From b7f3cf43db61251b779479588e09d1ede2bee9af Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 14 Sep 2026 07:53:19 -0700 Subject: [PATCH 1/2] feat(images): build and qualify the Ubuntu X11 desktop VM --- .github/workflows/images-validate.yml | 6 +- images/README.md | 80 ++++- images/build.py | 184 ++++++++-- images/catalog-pr.py | 62 +++- images/pins.yaml | 8 + images/publish.py | 15 +- .../ubuntu-24.04-desktop/distrobuilder.yaml | 282 +++++++++++++++ .../usr/lib/systemd/user/cua-driver.service | 13 + .../usr/lib/systemd/user/x0vncserver.service | 13 + .../libexec/agentcompute-desktop-session | 23 ++ images/ubuntu-24.04-desktop/smoke.py | 328 ++++++++++++++++++ spikes/desktop/README.md | 111 ++++++ 12 files changed, 1080 insertions(+), 45 deletions(-) create mode 100644 images/ubuntu-24.04-desktop/distrobuilder.yaml create mode 100644 images/ubuntu-24.04-desktop/files/usr/lib/systemd/user/cua-driver.service create mode 100644 images/ubuntu-24.04-desktop/files/usr/lib/systemd/user/x0vncserver.service create mode 100755 images/ubuntu-24.04-desktop/files/usr/local/libexec/agentcompute-desktop-session create mode 100755 images/ubuntu-24.04-desktop/smoke.py create mode 100644 spikes/desktop/README.md diff --git a/.github/workflows/images-validate.yml b/.github/workflows/images-validate.yml index 9f9e5b7..eb6f718 100644 --- a/.github/workflows/images-validate.yml +++ b/.github/workflows/images-validate.yml @@ -83,5 +83,7 @@ jobs: - name: Build router publisher run: go build -o "$RUNNER_TEMP/image-publish" ./cmd/image-publish - - name: Check the boot smoke script - run: bash -n images/smoke.sh + - name: Check the boot smoke scripts + run: | + bash -n images/smoke.sh + python3 -m py_compile images/runner/smoke.py images/ubuntu-24.04-desktop/smoke.py images/publish.py images/catalog-pr.py diff --git a/images/README.md b/images/README.md index 6e333e5..be32699 100644 --- a/images/README.md +++ b/images/README.md @@ -1,14 +1,17 @@ # images -Lab-built Incus images for agentcompute: the Alpine `router` system container -and two minimal Ubuntu 24.04 runner VMs. `runner` has no sudo grant; -`runner-publisher` permits only the root-owned image-build wrapper. +Lab-built Incus images for agentcompute: the Alpine `router` system container, +two minimal Ubuntu 24.04 runner VMs, and an Ubuntu 24.04 Xorg desktop VM. +`runner` has no sudo grant; `runner-publisher` permits only the root-owned +image-build wrapper. | Path | Role | | --- | --- | -| `pins.yaml` | Checksummed build tools, Alpine package closure, Ubuntu base and CA bootstrap package, fixed Ubuntu snapshot, Actions Runner, and upstream guest files. | +| `pins.yaml` | Checksummed build tools, Alpine package closure, Ubuntu base and CA bootstrap package, fixed Ubuntu snapshot, Actions Runner, Cua Driver, and upstream guest files. | | `router/distrobuilder.yaml` | The recipe. Installs only the pinned APKs from an offline seed (`--no-network`, empty `/etc/apk/repositories`), enables OpenRC `lxc` mode, and emits a unified tarball. | | `runner/distrobuilder.yaml` | Split VM recipe with `runner` and `publisher` variants, signed shim/GRUB, growroot, incus-agent generator, and `ttyS0` output. | +| `ubuntu-24.04-desktop/distrobuilder.yaml` | Split desktop VM recipe with Xorg, GDM automatic login, AT-SPI, Cua Driver, X0tigervnc, systemd-networkd, growroot, and signed shim/GRUB. | +| `ubuntu-24.04-desktop/smoke.py` | Candidate qualification for X11, the Driver session socket and user unit, native `list_apps`, disabled nesting, and owned-resource cleanup. | | `build.py` | `validate` (schema and pin checks, no credentials) and `build` (download-verify, compile distrobuilder from vendored source, assemble). PEP 723 script with `build.py.lock`. | | `catalog.yaml` | Startup catalog: image name → digest-pinned GHCR reference or upstream Incus `remote:alias`, kind, OS, defaults. | | `smoke.sh` | Shared six-tool router boot smoke used by image CI. | @@ -36,12 +39,13 @@ deletes anything it did not create. distrobuilder needs root and loop devices, not KVM. macOS cannot run it; `sandbox01` can. -For a runner VM, add `--image runner` or `--image runner-publisher`. Install -the VM assembly tools (`btrfs`, `qemu-img`, `sgdisk`, `mkfs.vfat`, `mkfs.ext4`, -`resize2fs`, `losetup`, `mount`, `rsync`, `blkid`, and `dpkg-deb`) first. -Distrobuilder requires `btrfs` even for an ext4 image; the publisher variant -bakes `btrfs-progs` alongside its other assembly tools. Output is `incus.tar.xz`, -`disk.qcow2`, and `metrics.json`; no nested virtualization is used. +For a VM, add `--image runner`, `--image runner-publisher`, or +`--image ubuntu-24.04-desktop`. Install the VM assembly tools (`btrfs`, +`qemu-img`, `sgdisk`, `mkfs.vfat`, `mkfs.ext4`, `resize2fs`, `losetup`, +`mount`, `rsync`, `blkid`, and `dpkg-deb`) first. Distrobuilder requires +`btrfs` even for an ext4 image; the publisher variant bakes `btrfs-progs` +alongside its other assembly tools. Output is `incus.tar.xz`, `disk.qcow2`, +and `metrics.json`; no nested virtualization is used. The guest files are copied byte-for-byte from the pinned incus-gh-runner v2.0.0 release and checked during validation. General runners inherit @@ -52,11 +56,38 @@ sudo command can run: actions-runner ALL=(root) NOPASSWD: /usr/local/sbin/agentcompute-build ``` -The wrapper accepts only a full lowercase commit SHA and one of the three +The wrapper accepts only a full lowercase commit SHA and one of the four image names. It discards job-supplied environment variables, uses a root-owned checkout, and refuses source outside public `origin/master` ancestry. It does not grant direct sudo access to shells, mount tools, or `qemu-img`. +### Desktop image + +The desktop recipe starts from the pinned Ubuntu base and snapshot +`20260911T000000Z`. It builds a 16 GiB amd64 VM with Xorg, automatic login as +`automation`, `gnome-text-editor`, AT-SPI, and X0tigervnc on guest TCP port +5900. Netplan explicitly selects `renderer: networkd`, and the recipe enables +`systemd-networkd`; this is the correction found after the first guest needed +the same setting applied in place. + +Cua Driver 0.28.1 is pinned to the full +`cua-driver-rs-0.28.1-linux-x86_64.tar.gz` archive with SHA-256 +`a068b6e477893b77ced74bceccf7db7483cf140e8d54150ce5849b6252b90bcf`. +The GitHub release is a prerelease accepted under the project-specific pin +exception. + +The graphical session runs a persistent user daemon at +`/run/user/1000/cua-driver.sock`. Host calls use a new one-shot +`cua-driver call --socket /run/user/1000/cua-driver.sock` process for each +native tool invocation. They run as UID 1000 from `/home/automation` with +`HOME=/home/automation` and `XDG_RUNTIME_DIR=/run/user/1000`. Native tool names +and JSON arguments pass through unchanged; the host does not add typed +wrappers for individual Driver tools. Screenshots travel through the binary +guest-file API and return as URLs rather than embedded image data. + +See the [Phase 6 desktop spike report](../spikes/desktop/README.md) for direct +Driver results, timing, screenshot scaling, VNC, and reboot evidence. + ## Publication and import The public `images-publish.yml` validates on a GitHub-hosted runner and @@ -184,6 +215,33 @@ with the router set (59 APKs, 13.2 MB downloaded). Scratch is sampled every 100 ms, so short peaks between samples are missed. +### Desktop Phase 6 + +Two local desktop builds used fresh work and output directories: + +| Build evidence | Download | Compile | Assemble | Peak RSS | Scratch high-water | `incus.tar.xz` | `disk.qcow2` | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| First image | 5.849 s | 31.670 s | 842.017 s | 507,180 KiB | 7,625,043,968 B | 656 B | 744,611,840 B | +| Corrected networkd rebuild | 5.824 s | 31.834 s | 392.512 s | 517,712 KiB | 7,621,808,128 B | 640 B | 745,013,248 B | + +Download includes verified retrieval of Go, vendored distrobuilder source, +Ubuntu base, the snapshot CA package, and the full Cua Driver archive. Compile +excludes download and assembly. Scratch was sampled every 100 ms. + +The direct token and reboot results in the spike report came from the first +image after repairing that guest's netplan in place. The corrected rebuild +subsequently passed `images/ubuntu-24.04-desktop/smoke.py` in `image-build`: +X11, the graphical user's active Driver service, and 234 native `list_apps` +entries. The smoke removed its own VM and imported image. + +Protected bootstrap PR #23 and private bake run 34854323245 completed +successfully. The desktop-aware publisher must be deployed before the +four-image bake. Desktop publication, verified fetch-back, live MCP acceptance, +and the public catalog update remain pending; no desktop GHCR digest is claimed. +Live MCP acceptance reached the server but was blocked before sandbox creation +by the full root filesystem on `ovncentral01`. Recovery is tracked separately +in the companion fleet change. + ### Reproducibility The three hand-driven tarballs decode to identical member sets, contents, diff --git a/images/build.py b/images/build.py index c260856..3f14573 100755 --- a/images/build.py +++ b/images/build.py @@ -10,11 +10,11 @@ Usage: uv run --locked --script images/build.py validate sudo env PATH="$PATH" uv run --locked --script images/build.py build \\ - --image router|runner|runner-publisher \\ + --image router|runner|runner-publisher|ubuntu-24.04-desktop \\ --work-dir --output-dir ---image defaults to router (unified tar.xz). runner and runner-publisher -emit split incus.tar.xz + disk.qcow2. +--image defaults to router (unified tar.xz). runner, runner-publisher, and +ubuntu-24.04-desktop emit split incus.tar.xz + disk.qcow2. """ from __future__ import annotations @@ -44,7 +44,11 @@ RECIPE_PATH = ROUTER_DIR / "distrobuilder.yaml" RUNNER_DIR = IMAGES / "runner" RUNNER_RECIPE_PATH = RUNNER_DIR / "distrobuilder.yaml" -IMAGE_NAMES = ("router", "runner", "runner-publisher") +DESKTOP_DIR = IMAGES / "ubuntu-24.04-desktop" +DESKTOP_RECIPE_PATH = DESKTOP_DIR / "distrobuilder.yaml" +IMAGE_NAMES = ("router", "runner", "runner-publisher", "ubuntu-24.04-desktop") +CUA_DRIVER_VERSION = "0.28.1" +CUA_DRIVER_SHA256 = "a068b6e477893b77ced74bceccf7db7483cf140e8d54150ce5849b6252b90bcf" PYYAML_VERSION = "6.0.3" DISTROBUILDER_TAGS = ( "containers_image_storage_stub,containers_image_docker_daemon_stub," @@ -60,6 +64,7 @@ "imgoci", "pyyaml", "runner", + "desktop", } SNAPSHOT_RE = re.compile(r"^[0-9]{8}T[0-9]{6}Z$") PROXY_HTTP = "http://10.10.10.14:3128" @@ -193,6 +198,7 @@ def load_pins() -> dict[str, Any]: if pyyaml.get("version") != PYYAML_VERSION: raise Error(f"pyyaml.version must be {PYYAML_VERSION}") load_runner_pins(pins) + load_desktop_pins(pins) return pins def load_runner_pins(pins: dict[str, Any]) -> None: @@ -263,6 +269,28 @@ def load_runner_pins(pins: dict[str, Any]) -> None: raise Error(f"runner.proxy.no_proxy must be {PROXY_NO}") +def load_desktop_pins(pins: dict[str, Any]) -> None: + desktop = require_mapping(pins.get("desktop"), "desktop") + extra_keys(desktop, {"cua_driver"}, "desktop") + cua = require_mapping(desktop.get("cua_driver"), "desktop.cua_driver") + extra_keys(cua, {"version", "url", "sha256"}, "desktop.cua_driver") + version = require_str(cua.get("version"), "desktop.cua_driver.version") + if version != CUA_DRIVER_VERSION: + raise Error(f"desktop.cua_driver.version must be {CUA_DRIVER_VERSION}") + url = require_https( + require_str(cua.get("url"), "desktop.cua_driver.url"), + "desktop.cua_driver.url", + ) + digest = require_sha256(cua.get("sha256"), "desktop.cua_driver.sha256") + if digest != CUA_DRIVER_SHA256: + raise Error("desktop.cua_driver.sha256 must be the approved 0.28.1 linux-x86_64 archive") + expected = f"cua-driver-rs-{version}-linux-x86_64.tar.gz" + if url.rsplit("/", 1)[-1] != expected: + raise Error(f"desktop.cua_driver.url filename must be {expected}") + if f"/cua-driver-rs-v{version}/" not in url: + raise Error("desktop.cua_driver.url must be the cua-driver-rs GitHub release tag") + + def load_runner_recipe() -> dict[str, Any]: recipe = require_mapping(load_yaml(RUNNER_RECIPE_PATH), str(RUNNER_RECIPE_PATH)) source = require_mapping(recipe.get("source"), "runner source") @@ -292,6 +320,63 @@ def load_runner_recipe() -> dict[str, Any]: return recipe +def load_desktop_recipe() -> dict[str, Any]: + recipe = require_mapping(load_yaml(DESKTOP_RECIPE_PATH), str(DESKTOP_RECIPE_PATH)) + source = require_mapping(recipe.get("source"), "desktop source") + url = require_str(source.get("url"), "desktop source.url") + parsed = urlparse(url) + if parsed.scheme != "file" or not parsed.path: + raise Error("desktop source.url must be a file:// seed path") + packages = require_mapping(recipe.get("packages"), "desktop packages") + if packages.get("manager") != "apt": + raise Error("desktop packages.manager must be apt") + if not require_bool(packages.get("update"), "desktop packages.update"): + raise Error("desktop packages.update must be true") + required = { + "ubuntu-desktop-minimal", + "ubuntu-session", + "gnome-text-editor", + "tigervnc-scraping-server", + "grub-efi-amd64-signed", + "shim-signed", + "cloud-initramfs-growroot", + } + named: set[str] = set() + for index, item in enumerate(require_list(packages.get("sets"), "desktop packages.sets")): + package_set = require_mapping(item, f"desktop packages.sets[{index}]") + for name in require_list(package_set.get("packages"), f"desktop packages.sets[{index}].packages"): + named.add(require_str(name, f"desktop packages.sets[{index}].packages[]")) + missing = sorted(required - named) + if missing: + raise Error("desktop recipe missing packages: " + ", ".join(missing)) + files = require_list(recipe.get("files"), "desktop files") + generators = {item.get("generator") for item in files if isinstance(item, dict)} + if "incus-agent" not in generators: + raise Error("desktop recipe must include the incus-agent generator") + fstab = next( + ( + item + for item in files + if isinstance(item, dict) and item.get("path") == "/etc/fstab" + ), + None, + ) + if not isinstance(fstab, dict) or "x-systemd.growfs" not in str(fstab.get("content") or ""): + raise Error("desktop /etc/fstab must set x-systemd.growfs") + gdm = next( + ( + item + for item in files + if isinstance(item, dict) and item.get("path") == "/etc/gdm3/custom.conf" + ), + None, + ) + content = str(gdm.get("content") if isinstance(gdm, dict) else "") + if "WaylandEnable=false" not in content or "AutomaticLogin=automation" not in content: + raise Error("desktop GDM config must disable Wayland and autologin automation") + return recipe + + def validate_guest_files(pins: dict[str, Any]) -> None: for entry in pins["runner"]["guest"]["files"]: path = RUNNER_DIR / entry["path"] @@ -319,6 +404,24 @@ def validate_guest_files(pins: dict[str, Any]) -> None: raise Error("publisher proxy.env does not match runner.proxy pins") +def validate_desktop_files() -> None: + overlay = ( + DESKTOP_DIR / "files/usr/local/libexec/agentcompute-desktop-session", + DESKTOP_DIR / "files/usr/lib/systemd/user/cua-driver.service", + DESKTOP_DIR / "files/usr/lib/systemd/user/x0vncserver.service", + ) + for path in overlay: + if not path.is_file(): + raise Error(f"missing desktop overlay file {path}") + wrapper = overlay[0].read_text(encoding="utf-8") + if "--socket" not in wrapper or "cua-driver.sock" not in wrapper: + raise Error("desktop session wrapper must pass an explicit cua-driver socket") + if "--permission-mode" in wrapper or "dangerously-bypass" in wrapper: + raise Error("desktop session wrapper must keep default standard Driver permissions") + if "-rfbport 5900" not in wrapper or "SecurityTypes None" not in wrapper: + raise Error("desktop VNC must use port 5900 on the private guest network") + + def recipe_package_filenames(recipe: dict[str, Any]) -> list[str]: packages = require_mapping(recipe.get("packages"), "recipe packages") @@ -387,6 +490,8 @@ def validate() -> dict[str, Any]: validate_package_closure(pins, recipe) load_runner_recipe() validate_guest_files(pins) + load_desktop_recipe() + validate_desktop_files() validate_catalog() return pins @@ -570,15 +675,21 @@ def host_proxy() -> str | None: def build_vm( pins: dict[str, Any], work: Path, output: Path, tools: dict[str, str], image: str ) -> dict[str, Any]: - variant = "publisher" if image == "runner-publisher" else "runner" + is_desktop = image == "ubuntu-24.04-desktop" + if image == "runner-publisher": + variant = "publisher" + elif is_desktop: + variant = "desktop" + else: + variant = "runner" + recipe_dir = DESKTOP_DIR if is_desktop else RUNNER_DIR + recipe_path = DESKTOP_RECIPE_PATH if is_desktop else RUNNER_RECIPE_PATH downloads = work / "downloads" downloads.mkdir(mode=0o700) download_started = time.monotonic() ubuntu = pins["runner"]["ubuntu"] - actions = pins["runner"]["actions_runner"] ca_certs = pins["runner"]["ca_certificates"] ubuntu_archive = downloads / ubuntu["url"].rsplit("/", 1)[-1] - runner_archive = downloads / actions["url"].rsplit("/", 1)[-1] go_archive = downloads / pins["go"]["url"].rsplit("/", 1)[-1] distro_archive = downloads / pins["distrobuilder"]["url"].rsplit("/", 1)[-1] ca_deb = downloads / ca_certs["url"].rsplit("/", 1)[-1] @@ -586,7 +697,14 @@ def build_vm( download(pins["distrobuilder"]["url"], distro_archive, pins["distrobuilder"]["sha256"]) download(ubuntu["url"], ubuntu_archive, ubuntu["sha256"]) download(ca_certs["url"], ca_deb, ca_certs["sha256"]) - download(actions["url"], runner_archive, actions["sha256"]) + if is_desktop: + cua = pins["desktop"]["cua_driver"] + extra_archive = downloads / cua["url"].rsplit("/", 1)[-1] + download(cua["url"], extra_archive, cua["sha256"]) + else: + actions = pins["runner"]["actions_runner"] + runner_archive = downloads / actions["url"].rsplit("/", 1)[-1] + download(actions["url"], runner_archive, actions["sha256"]) download_wall = round(time.monotonic() - download_started, 3) distro_bin, compile_wall, compile_rss = compile_distrobuilder(pins, work, downloads) @@ -627,13 +745,22 @@ def build_vm( ) cache = seed / "var" / "cache" / "agentcompute" cache.mkdir(parents=True, exist_ok=True) - seeded_runner = cache / "actions-runner.tar.gz" - try: - os.link(runner_archive, seeded_runner) - except OSError: - shutil.copyfile(runner_archive, seeded_runner, follow_symlinks=False) - if sha256_file(seeded_runner) != sha256_file(runner_archive): - raise Error("seed copy changed actions-runner archive bytes") + if is_desktop: + seeded = cache / "cua-driver.tar.gz" + try: + os.link(extra_archive, seeded) + except OSError: + shutil.copyfile(extra_archive, seeded, follow_symlinks=False) + if sha256_file(seeded) != sha256_file(extra_archive): + raise Error("seed copy changed cua-driver archive bytes") + else: + seeded_runner = cache / "actions-runner.tar.gz" + try: + os.link(runner_archive, seeded_runner) + except OSError: + shutil.copyfile(runner_archive, seeded_runner, follow_symlinks=False) + if sha256_file(seeded_runner) != sha256_file(runner_archive): + raise Error("seed copy changed actions-runner archive bytes") seed_tar = work / "seed.tar" run_checked(["tar", "-cf", str(seed_tar), "-C", str(seed), "."]) shutil.rmtree(seed) @@ -643,7 +770,7 @@ def build_vm( command = [ str(distro_bin), "build-incus", - str(RUNNER_RECIPE_PATH), + str(recipe_path), str(output), "--vm", "--type=split", @@ -661,7 +788,7 @@ def build_vm( peak_scratch = scratch_bytes(work) log_path = work / "build.log" with log_path.open("w", encoding="utf-8") as log: - process = subprocess.Popen(command, stdout=log, stderr=subprocess.STDOUT, cwd=RUNNER_DIR) + process = subprocess.Popen(command, stdout=log, stderr=subprocess.STDOUT, cwd=recipe_dir) while process.poll() is None: peak_scratch = max(peak_scratch, scratch_bytes(work)) time.sleep(0.1) @@ -685,16 +812,23 @@ def build_vm( virtual = int(info.get("virtual-size") or 0) if virtual <= 0: raise Error(f"{disk} has no virtual size") + download_includes = ( + "https fetch and sha256 of go, vendored distrobuilder source, " + "ubuntu-base, snapshot ca-certificates, and the Actions Runner archive" + ) + if is_desktop: + download_includes = ( + "https fetch and sha256 of go, vendored distrobuilder source, " + "ubuntu-base, snapshot ca-certificates, and the Cua Driver 0.28.1 " + "full linux-x86_64 archive (GitHub pre-release, user-approved pin)" + ) metrics = { "image": image, "variant": variant, "download_wall_seconds": download_wall, "compile_wall_seconds": compile_wall, "assemble_wall_seconds": assemble_wall, - "download_includes": ( - "https fetch and sha256 of go, vendored distrobuilder source, " - "ubuntu-base, snapshot ca-certificates, and the Actions Runner archive" - ), + "download_includes": download_includes, "compile_includes": ( "extract go+distrobuilder and go build -mod=vendor " f"-tags={DISTROBUILDER_TAGS}; excludes download and assemble" @@ -720,9 +854,13 @@ def build_vm( }, "tools": tools, "ubuntu_snapshot": ubuntu["snapshot"], - "actions_runner_version": actions["version"], - "guest_version": pins["runner"]["guest"]["version"], } + if is_desktop: + metrics["cua_driver_version"] = pins["desktop"]["cua_driver"]["version"] + metrics["cua_driver_prerelease"] = True + else: + metrics["actions_runner_version"] = actions["version"] + metrics["guest_version"] = pins["runner"]["guest"]["version"] (output / "metrics.json").write_text(json.dumps(metrics, indent=2) + "\n", encoding="utf-8") print(json.dumps(metrics, indent=2)) return metrics diff --git a/images/catalog-pr.py b/images/catalog-pr.py index 27256c7..0d09e48 100755 --- a/images/catalog-pr.py +++ b/images/catalog-pr.py @@ -13,6 +13,9 @@ REPO = "GilmanLab/agentcompute" NAMESPACE = "ghcr.io/gilmanlab/agentcompute" +IMAGES = ("router", "runner", "runner-publisher", "ubuntu-24.04-desktop") +DESKTOP_IMAGE = "ubuntu-24.04-desktop" +DESKTOP_CATALOG = "ubuntu/24.04/desktop" def capture(*args: str, cwd: Path | None = None) -> str: @@ -23,6 +26,49 @@ def run(*args: str, cwd: Path) -> None: subprocess.run(args, cwd=cwd, check=True) +def catalog_name(image: str) -> str: + if image == DESKTOP_IMAGE: + return DESKTOP_CATALOG + return image + + +def new_entry(image: str) -> dict: + if image == DESKTOP_IMAGE: + return { + "name": DESKTOP_CATALOG, + "os": "ubuntu", + "version": "24.04", + "kinds": ["vm"], + "kind": "vm", + "desktop": True, + "cpus": 4, + "memory_mb": 8192, + "disk_gb": 40, + } + return { + "name": image, + "os": "ubuntu", + "version": "24.04", + "kinds": ["vm"], + "kind": "vm", + "cpus": 4, + "memory_mb": 8192, + "disk_gb": 40, + } + + +def apply_desktop_contract(entry: dict) -> None: + entry["name"] = DESKTOP_CATALOG + entry["os"] = "ubuntu" + entry["version"] = "24.04" + entry["kinds"] = ["vm"] + entry["kind"] = "vm" + entry["desktop"] = True + entry["cpus"] = 4 + entry["memory_mb"] = 8192 + entry["disk_gb"] = 40 + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--sha", required=True) @@ -31,8 +77,8 @@ def main() -> None: if not re.fullmatch(r"[0-9a-f]{40}", args.sha): raise ValueError("invalid source SHA") releases = json.loads(args.releases.read_text()) - if {item["name"] for item in releases} != {"router", "runner", "runner-publisher"} or len(releases) != 3: - raise ValueError("all three qualified image releases are required") + if {item["name"] for item in releases} != set(IMAGES) or len(releases) != len(IMAGES): + raise ValueError("all four qualified image releases are required") for item in releases: if item["source_sha"] != args.sha or not re.fullmatch(r"sha256:[0-9a-f]{64}", item["digest"]): raise ValueError("invalid qualified release identity") @@ -58,13 +104,19 @@ def main() -> None: changed = False for item in releases: name = item["name"] - entry = entries.get(name) + entry_name = catalog_name(name) + entry = entries.get(entry_name) if entry is None: if name == "router": raise ValueError("router catalog entry is missing") - entry = {"name": name, "os": "ubuntu", "version": "24.04", "kinds": ["vm"], - "kind": "vm", "cpus": 4, "memory_mb": 8192, "disk_gb": 40} + entry = new_entry(name) catalog["images"].append(entry) + entries[entry_name] = entry + changed = True + if name == DESKTOP_IMAGE: + before = json.dumps(entry, sort_keys=True, default=str) + apply_desktop_contract(entry) + changed |= json.dumps(entry, sort_keys=True, default=str) != before reference = f"{NAMESPACE}/{name}@{item['digest']}" changed |= entry.get("reference") != reference entry["reference"] = reference diff --git a/images/pins.yaml b/images/pins.yaml index fc26033..47fbbb6 100644 --- a/images/pins.yaml +++ b/images/pins.yaml @@ -324,3 +324,11 @@ runner: proxy: http: http://10.10.10.14:3128 no_proxy: 10.10.10.14,127.0.0.1,localhost +# cua-driver-rs-v0.28.1 is a GitHub pre-release (monorepo latest-pointer +# policy). Exact user-approved pin of the full linux-x86_64 archive, not the +# binary-only asset and not the curl-pipe installer. +desktop: + cua_driver: + version: "0.28.1" + url: https://github.com/trycua/cua/releases/download/cua-driver-rs-v0.28.1/cua-driver-rs-0.28.1-linux-x86_64.tar.gz + sha256: a068b6e477893b77ced74bceccf7db7483cf140e8d54150ce5849b6252b90bcf diff --git a/images/publish.py b/images/publish.py index fa36ab8..e6310a9 100755 --- a/images/publish.py +++ b/images/publish.py @@ -14,7 +14,7 @@ import tempfile import time -IMAGES = ("router", "runner", "runner-publisher") +IMAGES = ("router", "runner", "runner-publisher", "ubuntu-24.04-desktop") REPOSITORY = "ghcr.io/gilmanlab/agentcompute" @@ -44,10 +44,17 @@ def qualify(name: str, output: Path, evidence: Path) -> None: "--suffix", f"ci-{os.environ['GITHUB_RUN_ID']}-{os.environ['GITHUB_RUN_ATTEMPT']}", "--log", str(evidence / "router-smoke.log")) (evidence / "router-smoke.json").write_text(smoke + "\n") - else: - run(sys.executable, "images/runner/smoke.py", "--metadata", str(output / "incus.tar.xz"), - "--disk", str(output / "disk.qcow2"), "--remote", "nas01", "--project", "image-build", + return + metadata = str(output / "incus.tar.xz") + disk = str(output / "disk.qcow2") + if name == "ubuntu-24.04-desktop": + run(sys.executable, "images/ubuntu-24.04-desktop/smoke.py", "--metadata", metadata, + "--disk", disk, "--remote", "nas01", "--project", "image-build", "--profile", "runner-smoke", "--evidence", str(evidence / f"{name}-smoke")) + return + run(sys.executable, "images/runner/smoke.py", "--metadata", metadata, + "--disk", disk, "--remote", "nas01", "--project", "image-build", + "--profile", "runner-smoke", "--evidence", str(evidence / f"{name}-smoke")) def bake(sha: str, evidence: Path, publisher: str) -> None: diff --git a/images/ubuntu-24.04-desktop/distrobuilder.yaml b/images/ubuntu-24.04-desktop/distrobuilder.yaml new file mode 100644 index 0000000..d911299 --- /dev/null +++ b/images/ubuntu-24.04-desktop/distrobuilder.yaml @@ -0,0 +1,282 @@ +image: + distribution: ubuntu + release: noble + architecture: amd64 + name: ubuntu-24.04-desktop + description: Ubuntu 24.04 Xorg desktop VM + variant: desktop + serial: phase6 + expiry: 3650d +source: + downloader: rootfs-http + url: file:///build/seed.tar +targets: + incus: + vm: + size: 17179869184 + filesystem: ext4 +packages: + manager: apt + update: true + cleanup: true + sets: + - action: install + flags: + - --no-install-recommends + packages: + - systemd + - systemd-sysv + - systemd-resolved + - dbus + - dbus-user-session + - linux-virtual + - grub-efi-amd64-signed + - shim-signed + - efibootmgr + - acpid + - netplan.io + - cloud-initramfs-growroot + - tzdata + - ca-certificates + - util-linux + - ubuntu-desktop-minimal + - ubuntu-session + - gnome-text-editor + - tigervnc-scraping-server + - libxi6 + - libxtst6 + - at-spi2-core + - dconf-cli +files: +- path: /etc/hostname + generator: hostname +- path: /etc/hosts + generator: hosts +- path: /etc/machine-id + generator: dump + content: uninitialized +- path: /var/lib/dbus/machine-id + generator: remove +- name: incus-agent + generator: incus-agent + types: + - vm +- path: /etc/fstab + generator: dump + types: + - vm + content: |- + LABEL=rootfs / ext4 defaults,x-systemd.growfs 0 1 + LABEL=UEFI /boot/efi vfat umask=0077 0 1 +- path: /etc/netplan/10-incus.yaml + generator: dump + mode: "0600" + types: + - vm + content: |- + network: + version: 2 + renderer: networkd + ethernets: + id0: + match: + name: "en*" + dhcp4: true + dhcp-identifier: mac +- path: /etc/default/grub.d/50-incus.cfg + generator: dump + types: + - vm + content: |- + GRUB_RECORDFAIL_TIMEOUT=0 + GRUB_TIMEOUT=0 + GRUB_CMDLINE_LINUX_DEFAULT="${GRUB_CMDLINE_LINUX_DEFAULT} console=tty1 console=ttyS0" + GRUB_TERMINAL=console +- path: /etc/gdm3/custom.conf + generator: dump + mode: "0644" + content: |- + [daemon] + WaylandEnable=false + AutomaticLoginEnable=true + AutomaticLogin=automation + DefaultSession=ubuntu-xorg.desktop +- path: /var/lib/AccountsService/users/automation + generator: dump + mode: "0644" + content: |- + [User] + Session=ubuntu-xorg + XSession=ubuntu-xorg + SystemAccount=false +- path: /etc/dconf/profile/user + generator: dump + mode: "0644" + content: |- + user-db:user + system-db:local + system-db:ibus +- path: /etc/dconf/db/local.d/00-automation + generator: dump + mode: "0644" + content: |- + [org/gnome/desktop/session] + idle-delay=uint32 0 + + [org/gnome/desktop/screensaver] + lock-enabled=false + idle-activation-enabled=false + lock-delay=uint32 0 + + [org/gnome/desktop/lockdown] + disable-lock-screen=true + + [org/gnome/settings-daemon/plugins/power] + sleep-inactive-ac-type='nothing' + sleep-inactive-battery-type='nothing' + idle-dim=false + + [org/gnome/shell] + welcome-dialog-last-shown-version='4294967295' +- path: /etc/dconf/db/local.d/locks/00-automation + generator: dump + mode: "0644" + content: |- + /org/gnome/desktop/session/idle-delay + /org/gnome/desktop/screensaver/lock-enabled + /org/gnome/desktop/screensaver/idle-activation-enabled + /org/gnome/desktop/lockdown/disable-lock-screen +- path: /etc/xdg/autostart/gnome-initial-setup-first-login.desktop + generator: dump + mode: "0644" + content: |- + [Desktop Entry] + Name=GNOME Initial Setup + Hidden=true +- path: /etc/xdg/autostart/gnome-welcome-tour.desktop + generator: dump + mode: "0644" + content: |- + [Desktop Entry] + Name=GNOME Welcome Tour + Hidden=true +- path: /etc/skel/.config/gnome-initial-setup-done + generator: dump + mode: "0644" + content: |- + yes +- generator: copy + source: files/usr/local/libexec/agentcompute-desktop-session + path: /usr/local/libexec/agentcompute-desktop-session + mode: "0755" +- generator: copy + source: files/usr/lib/systemd/user/cua-driver.service + path: /usr/lib/systemd/user/cua-driver.service + mode: "0644" +- generator: copy + source: files/usr/lib/systemd/user/x0vncserver.service + path: /usr/lib/systemd/user/x0vncserver.service + mode: "0644" +actions: +- trigger: post-unpack + action: |- + #!/bin/sh + set -eux + [ -d /run/systemd ] || exit 0 + command -v chattr >/dev/null 2>&1 || exit 0 + rm -Rf /run/systemd/system + chattr +i /run/systemd +- trigger: post-packages + action: |- + #!/bin/sh + set -eux + [ -d /run/systemd ] || exit 0 + command -v chattr >/dev/null 2>&1 || exit 0 + chattr -i /run/systemd +- trigger: post-packages + action: |- + #!/bin/sh + set -eux + umount -l /etc/resolv.conf || true + rm -f /etc/resolv.conf + ln -sf ../run/systemd/resolve/stub-resolv.conf /etc/resolv.conf + systemctl enable systemd-resolved + systemctl enable systemd-networkd + systemctl set-default graphical.target + systemctl enable gdm.service || systemctl enable gdm3.service + systemctl mask ua-auto-attach || true + systemctl mask snapd.service snapd.socket snapd.seeded.service || true + systemctl mask cloud-init.service cloud-init-local.service cloud-config.service cloud-final.service || true + systemctl mask ssh.service ssh.socket || true + systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target || true + for pkg in os-prober snapd cloud-init openssh-server; do + if dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null | grep -q 'install ok installed'; then + apt-get purge -y "$pkg" + fi + done + ln -sf /usr/share/zoneinfo/UTC /etc/localtime + echo UTC > /etc/timezone + rm -f /etc/*- +- trigger: post-packages + action: |- + #!/bin/sh + set -eux + if getent passwd 1000 >/dev/null; then + existing=$(getent passwd 1000 | cut -d: -f1) + [ "$existing" = automation ] + else + useradd --uid 1000 --user-group --create-home --home-dir /home/automation --shell /bin/bash automation + fi + for grp in video audio render input plugdev; do + getent group "$grp" >/dev/null || continue + usermod -aG "$grp" automation + done + passwd -d automation + install -d -m 0755 /home/automation/.config + printf 'yes\n' > /home/automation/.config/gnome-initial-setup-done + chown -R automation:automation /home/automation + install -d -m 0755 /tmp/cua-driver-extract + tar --extract --gzip --file /var/cache/agentcompute/cua-driver.tar.gz --directory /tmp/cua-driver-extract + if [ -f /tmp/cua-driver-extract/cua-driver ]; then + src=/tmp/cua-driver-extract + else + src=/tmp/cua-driver-extract/cua-driver-rs-0.28.1-linux-x86_64 + fi + [ -f "$src/cua-driver" ] + rm -rf /usr/local/lib/cua-driver + install -d -m 0755 /usr/local/lib + cp -a "$src" /usr/local/lib/cua-driver + chown -R root:root /usr/local/lib/cua-driver + chmod 0755 /usr/local/lib/cua-driver/cua-driver + if [ -f /usr/local/lib/cua-driver/cua-cursor-theme ]; then + chmod 0755 /usr/local/lib/cua-driver/cua-cursor-theme + fi + ln -sfn /usr/local/lib/cua-driver/cua-driver /usr/local/bin/cua-driver + rm -rf /tmp/cua-driver-extract /var/cache/agentcompute/cua-driver.tar.gz + rmdir /var/cache/agentcompute 2>/dev/null || true + echo uninitialized > /etc/machine-id + rm -f /var/lib/dbus/machine-id + rm -f /etc/apt/apt.conf.d/99agentcompute-build-proxy +- trigger: post-files + action: |- + #!/bin/sh + set -eux + dconf update + install -d -m 0755 /etc/systemd/user/graphical-session.target.wants + ln -sfn /usr/lib/systemd/user/cua-driver.service /etc/systemd/user/graphical-session.target.wants/cua-driver.service + ln -sfn /usr/lib/systemd/user/x0vncserver.service /etc/systemd/user/graphical-session.target.wants/x0vncserver.service + chown root:root /usr/local/libexec/agentcompute-desktop-session + chmod 0755 /usr/local/libexec/agentcompute-desktop-session +- trigger: post-files + action: |- + #!/bin/sh + set -eux + update-grub + grub-install --uefi-secure-boot --target=x86_64-efi --no-nvram --removable + grub-install --uefi-secure-boot --target=x86_64-efi --no-nvram + update-grub + sed -i "s#root=[^ ]*#root=${DISTROBUILDER_ROOT_UUID}#g" /boot/grub/grub.cfg + types: + - vm +mappings: + architecture_map: debian diff --git a/images/ubuntu-24.04-desktop/files/usr/lib/systemd/user/cua-driver.service b/images/ubuntu-24.04-desktop/files/usr/lib/systemd/user/cua-driver.service new file mode 100644 index 0000000..ad6d669 --- /dev/null +++ b/images/ubuntu-24.04-desktop/files/usr/lib/systemd/user/cua-driver.service @@ -0,0 +1,13 @@ +[Unit] +Description=Cua Driver daemon +After=graphical-session-pre.target +PartOf=graphical-session.target + +[Service] +Type=simple +ExecStart=/usr/local/libexec/agentcompute-desktop-session cua-driver +Restart=on-failure +RestartSec=2 + +[Install] +WantedBy=graphical-session.target diff --git a/images/ubuntu-24.04-desktop/files/usr/lib/systemd/user/x0vncserver.service b/images/ubuntu-24.04-desktop/files/usr/lib/systemd/user/x0vncserver.service new file mode 100644 index 0000000..1388726 --- /dev/null +++ b/images/ubuntu-24.04-desktop/files/usr/lib/systemd/user/x0vncserver.service @@ -0,0 +1,13 @@ +[Unit] +Description=x0vncserver on the graphical session +After=graphical-session-pre.target +PartOf=graphical-session.target + +[Service] +Type=simple +ExecStart=/usr/local/libexec/agentcompute-desktop-session x0vnc +Restart=on-failure +RestartSec=2 + +[Install] +WantedBy=graphical-session.target diff --git a/images/ubuntu-24.04-desktop/files/usr/local/libexec/agentcompute-desktop-session b/images/ubuntu-24.04-desktop/files/usr/local/libexec/agentcompute-desktop-session new file mode 100755 index 0000000..f7dc9e7 --- /dev/null +++ b/images/ubuntu-24.04-desktop/files/usr/local/libexec/agentcompute-desktop-session @@ -0,0 +1,23 @@ +#!/bin/sh +# GNOME imports its display environment into the user manager before the +# graphical-session target. Never select an unrelated X server's socket. +set -eu +: "${DISPLAY:?graphical session DISPLAY is required}" +: "${XDG_RUNTIME_DIR:?graphical session runtime directory is required}" +export XAUTHORITY="${XAUTHORITY:-${XDG_RUNTIME_DIR}/gdm/Xauthority}" +export DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS:-unix:path=${XDG_RUNTIME_DIR}/bus}" + +case "${1:-}" in +cua-driver) + exec /usr/local/bin/cua-driver serve --socket "${XDG_RUNTIME_DIR}/cua-driver.sock" + ;; +x0vnc) + # Only private sandbox networks and explicitly created human-viewer forwards + # may expose this listener. No reusable credential is baked into the image. + exec /usr/bin/X0tigervnc -display "$DISPLAY" -rfbport 5900 -SecurityTypes None -AlwaysShared + ;; +*) + printf 'agentcompute-desktop-session: usage: cua-driver|x0vnc\n' >&2 + exit 64 + ;; +esac diff --git a/images/ubuntu-24.04-desktop/smoke.py b/images/ubuntu-24.04-desktop/smoke.py new file mode 100755 index 0000000..4ffdef9 --- /dev/null +++ b/images/ubuntu-24.04-desktop/smoke.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +"""Boot-test a split desktop VM for X11, cua-driver, and one list_apps call. + +The instance is created with security.nesting=false. This script imports the +candidate only when the fingerprint is absent, never touches shared aliases, +and deletes only the instance and image this run created. + +Usage: + python3 images/ubuntu-24.04-desktop/smoke.py \\ + --metadata PATH/incus.tar.xz --disk PATH/disk.qcow2 \\ + --remote nas01 --project image-build --profile runner-smoke \\ + --evidence DIR +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +import sys +import time +from pathlib import Path + +DRIVER_BIN = "/usr/local/bin/cua-driver" +DRIVER_SOCKET = "/run/user/1000/cua-driver.sock" +GUEST_HOME = "/home/automation" +RUNTIME_DIR = "/run/user/1000" +GUEST_UID = "1000" +GUEST_GID = "1000" +X11_SOCKET = "/tmp/.X11-unix/X0" +EDITOR_LAUNCH = "gnome-text-editor" + + +class Error(RuntimeError): + """Smoke failed.""" + + +def note(message: str, log: Path) -> None: + line = f"desktop-smoke: {message}" + print(line, file=sys.stderr) + with log.open("a", encoding="utf-8") as handle: + handle.write(line + "\n") + + +def run(args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + return subprocess.run(args, text=True, **kwargs) # type: ignore[arg-type] + + +def incus(project: str, args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + return run(["incus", "--quiet", "--project", project, *args], **kwargs) + + +def die(message: str) -> None: + raise Error(message) + + +def wait_until(deadline: float, description: str, check: object) -> None: + while time.time() < deadline: + if check(): # type: ignore[misc] + return + time.sleep(2) + die(f"timed out waiting for {description}") + + +def instance_config(remote: str, project: str, name: str) -> dict[str, object]: + result = run( + ["incus", "query", f"{remote}:/1.0/instances/{name}?project={project}"], + capture_output=True, + ) + if result.returncode != 0: + die(f"instance query failed: {(result.stderr or result.stdout or '').strip()}") + try: + data = json.loads(result.stdout) + except json.JSONDecodeError as exc: + die(f"instance query returned non-JSON: {exc}") + if not isinstance(data, dict): + die("instance query returned a non-object") + return data + + +def nesting_enabled(config: dict[str, object]) -> bool: + value = config.get("config") + if not isinstance(value, dict): + return False + nesting = str(value.get("security.nesting") or "false").strip().lower() + return nesting not in {"false", "0", ""} + + +def guest_exec( + remote: str, + project: str, + name: str, + command: list[str], + *, + user: bool = False, +) -> subprocess.CompletedProcess[str]: + args = ["exec", f"{remote}:{name}"] + if user: + args.extend( + [ + "--user", + GUEST_UID, + "--group", + GUEST_GID, + "--cwd", + GUEST_HOME, + "--env", + f"HOME={GUEST_HOME}", + "--env", + f"XDG_RUNTIME_DIR={RUNTIME_DIR}", + "--env", + f"DBUS_SESSION_BUS_ADDRESS=unix:path={RUNTIME_DIR}/bus", + ] + ) + args.extend(["--", *command]) + return incus(project, args, capture_output=True) + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--metadata", type=Path, required=True) + parser.add_argument("--disk", type=Path, required=True) + parser.add_argument("--remote", required=True) + parser.add_argument("--project", required=True) + parser.add_argument("--profile", required=True) + parser.add_argument("--evidence", type=Path, required=True) + parser.add_argument("--timeout", type=int, default=720) + args = parser.parse_args(argv) + + metadata = args.metadata.expanduser().resolve() + disk = args.disk.expanduser().resolve() + evidence = args.evidence.expanduser() + if not evidence.is_absolute(): + evidence = Path.cwd() / evidence + remote = args.remote + project = args.project + profile = args.profile + timeout = args.timeout + + if project == "default": + die("--project must not be the default project") + if not metadata.is_file(): + die(f"metadata not found: {metadata}") + if not disk.is_file(): + die(f"disk not found: {disk}") + if timeout <= 0: + die("--timeout must be a positive number of seconds") + if shutil.which("incus") is None: + die("incus client not found on PATH") + + evidence.mkdir(parents=True, exist_ok=True) + log = evidence / "smoke.log" + log.write_text("", encoding="utf-8") + suffix = time.strftime("%Y%m%d%H%M%S", time.gmtime()) + f"-{os.getpid()}" + name = f"desktop-smoke-{suffix}" + if len(name) > 63: + die(f"derived name is longer than 63 characters: {name}") + + imported = False + created = False + fingerprint = "" + + def cleanup() -> None: + failures = 0 + if created: + note(f"deleting instance {name}", log) + result = incus(project, ["delete", "-f", f"{remote}:{name}"]) + failures += 0 if result.returncode == 0 else 1 + if imported and fingerprint: + note(f"deleting image {fingerprint}", log) + result = incus(project, ["image", "delete", f"{remote}:{fingerprint}"]) + failures += 0 if result.returncode == 0 else 1 + if failures: + die("cleanup failed; the project may need manual inspection") + + exit_code = 0 + try: + shown = incus(project, ["profile", "show", f"{remote}:{profile}"], capture_output=True) + if shown.returncode != 0: + die(f"profile {profile} is missing in {remote} project {project}") + + hasher = hashlib.sha256() + for artifact in (metadata, disk): + with artifact.open("rb") as source: + while block := source.read(1024 * 1024): + hasher.update(block) + fingerprint = hasher.hexdigest() + listed = incus(project, ["image", "list", f"{remote}:", "--format=json"], capture_output=True) + if listed.returncode: + die(f"image inventory failed: {listed.stderr}") + present = any(image["fingerprint"] == fingerprint for image in json.loads(listed.stdout)) + if not present: + note(f"importing {metadata.name} + {disk.name}", log) + imported_run = incus( + project, + ["image", "import", str(metadata), str(disk), f"{remote}:"], + capture_output=True, + ) + if imported_run.returncode != 0: + die(f"image import failed: {imported_run.stderr}") + imported = True + verified = incus(project, ["image", "info", f"{remote}:{fingerprint}"], capture_output=True) + if verified.returncode: + die("import did not produce the fingerprint of the verified metadata and disk") + note(f"verified fingerprint {fingerprint}; smoke owns image={imported}", log) + + note(f"creating {name} --vm --profile {profile}", log) + init = incus( + project, + [ + "init", + f"{remote}:{fingerprint}", + f"{remote}:{name}", + "--vm", + "--profile", + profile, + "-c", + "limits.cpu=4", + "-c", + "limits.memory=8192MiB", + ], + capture_output=True, + ) + if init.returncode != 0: + die(f"init failed: {(init.stderr or init.stdout or '').strip()}") + created = True + nested = incus( + project, + ["config", "set", f"{remote}:{name}", "security.nesting=false"], + capture_output=True, + ) + if nested.returncode != 0 and nesting_enabled(instance_config(remote, project, name)): + die(f"security.nesting=false rejected: {(nested.stderr or nested.stdout or '').strip()}") + if nesting_enabled(instance_config(remote, project, name)): + die("security.nesting must be false") + start = incus(project, ["start", f"{remote}:{name}"], capture_output=True) + if start.returncode != 0: + die(f"start failed: {(start.stderr or start.stdout or '').strip()}") + + deadline = time.time() + timeout + note("waiting for incus-agent", log) + + def agent_up() -> bool: + return guest_exec(remote, project, name, ["/bin/true"]).returncode == 0 + + wait_until(deadline, "incus-agent", agent_up) + + def x11_ready() -> bool: + return guest_exec(remote, project, name, ["test", "-S", X11_SOCKET]).returncode == 0 + + wait_until(deadline, "X11 display socket", x11_ready) + note("X11 display socket is present", log) + + def driver_socket_ready() -> bool: + return guest_exec(remote, project, name, ["test", "-S", DRIVER_SOCKET]).returncode == 0 + + wait_until(deadline, "cua-driver socket", driver_socket_ready) + note("cua-driver socket is present", log) + + def unit_active() -> bool: + result = guest_exec( + remote, + project, + name, + ["systemctl", "--user", "--machine=automation@", "is-active", "cua-driver.service"], + ) + return (result.stdout or "").strip() == "active" + + wait_until(deadline, "cua-driver user unit", unit_active) + note("cua-driver.service is active for automation", log) + + listed_apps = guest_exec( + remote, + project, + name, + [DRIVER_BIN, "call", "--socket", DRIVER_SOCKET, "list_apps", "{}"], + user=True, + ) + stdout = listed_apps.stdout or "" + (evidence / "list-apps.stdout").write_text(stdout, encoding="utf-8") + (evidence / "list-apps.stderr").write_text(listed_apps.stderr or "", encoding="utf-8") + if listed_apps.returncode != 0: + die(f"list_apps exited {listed_apps.returncode}: {stdout}{listed_apps.stderr}") + try: + payload = json.loads(stdout) + except json.JSONDecodeError: + die(f"list_apps returned non-JSON; refusing success: {stdout[:500]}") + if not isinstance(payload, dict): + die("list_apps JSON was not an object") + apps = payload.get("apps") + if not isinstance(apps, list) or not apps: + die("list_apps did not return a non-empty apps list") + if not any(isinstance(app, dict) and app.get("launch_path") == EDITOR_LAUNCH for app in apps): + die("list_apps did not include gnome-text-editor") + note(f"list_apps returned {len(apps)} apps including {EDITOR_LAUNCH}", log) + + result = { + "fingerprint": fingerprint, + "metadata": str(metadata), + "disk": str(disk), + "remote": remote, + "project": project, + "profile": profile, + "instance": name, + "x11": True, + "cua_driver_unit": "active", + "list_apps": len(apps), + "security_nesting": "false", + } + (evidence / "result.json").write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + print(json.dumps(result)) + except Error as exc: + note(str(exc), log) + exit_code = 1 + finally: + try: + cleanup() + except Error as exc: + note(str(exc), log) + exit_code = 1 + return exit_code + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/spikes/desktop/README.md b/spikes/desktop/README.md new file mode 100644 index 0000000..7bdee3b --- /dev/null +++ b/spikes/desktop/README.md @@ -0,0 +1,111 @@ +# Desktop Phase 6 spike + +**2026-09-14 — direct native probing passed on a repaired first-build guest, and the corrected rebuild passed its X11/Driver boot smoke. Live MCP acceptance is blocked by OVN infrastructure.** The native probe exercised Cua Driver 0.28.1 through its session socket, including a token-addressed GUI change, bounded screenshots, VNC, and restart recovery. + +## Evidence and scope + +The results use these evidence sources: + +| Evidence | What it establishes | +| --- | --- | +| `/tmp/agentcompute-desktop-phase6-image/metrics.json` | First local image build measurements and artifact hashes. | +| `/tmp/agentcompute-desktop-spike/` | Direct native Driver results from a guest booted from the first build after its netplan was repaired in place. Each operation has a report, stdout, stderr, and any PNG pulled from the guest. `retained-evidence.json` preserves the timing comparison, status checks, native 640-pixel capture, VNC checks, and restart timing. | +| `/tmp/agentcompute-desktop-phase6-final/metrics.json` | Corrected local rebuild measurements and artifact hashes. It is build evidence, not live Driver or MCP acceptance evidence. | +| `internal/desktop/testdata/` | Captured Driver catalog and result fixtures used by the host adapter, including the missing-`pid` diagnostic and successful click response. | +| `/tmp/agentcompute-desktop-corrected-smoke/` | Corrected image boot: X11, active Driver user service, 234 apps from `list_apps`, and cleanup of smoke-owned resources. | + +The first guest did not acquire its network until `/etc/netplan/10-incus.yaml` was repaired with `renderer: networkd`. The current recipe embeds that renderer and enables `systemd-networkd`. The corrected image passed the dedicated boot smoke with fingerprint `9d0da557210766289d59823a540050c3e7f328af66380974cc1ebd08e877bd11`; the token, screenshot, timing, and restart measurements below remain attributed to the repaired first guest. + +## Image and session contract + +The image is an amd64 Ubuntu 24.04 Xorg VM with a 16 GiB virtual disk, GDM automatic login as `automation`, `gnome-text-editor`, AT-SPI, Cua Driver, and X0tigervnc. The build uses the Ubuntu snapshot `20260911T000000Z`. + +Cua Driver is pinned to the full `cua-driver-rs-0.28.1-linux-x86_64.tar.gz` archive with SHA-256 `a068b6e477893b77ced74bceccf7db7483cf140e8d54150ce5849b6252b90bcf`. Version 0.28.1 is a GitHub prerelease accepted under the project-specific pin exception; it is not a stable GitHub release. + +The graphical user service keeps one Driver daemon attached to the Xorg session: + +```text +/usr/local/bin/cua-driver serve --socket /run/user/1000/cua-driver.sock +``` + +The host starts a separate one-shot CLI process for each native operation. It preserves native tool names and JSON arguments rather than adding typed wrappers for individual Driver tools. The effective execution identity is UID 1000, working directory `/home/automation`, with this command shape: + +```sh +HOME=/home/automation XDG_RUNTIME_DIR=/run/user/1000 \ + /usr/local/bin/cua-driver call \ + --socket /run/user/1000/cua-driver.sock \ + list_apps '{}' +``` + +Passing the empty JSON object positionally is required for these Incus exec calls; waiting for JSON on stdin leaves the exec websocket open. The retained status check found the daemon at the expected socket with PID 676 and standard permission mode. A nonexistent socket exited 1 and reported that the daemon was not running. + +## Native Driver results + +The probe launched `gnome-text-editor` as PID 1220 with window ID 31457284 and bounds 822 × 642. Its first `get_window_state` response had snapshot ID `s00000001`, 13 accessibility elements, and one `tab panel = "New Document"` entry. Element token `s00000001:5` identified the **New tab** button. + +Calling `click` with only that token printed `Missing required integer field: pid` even though the pinned `click` input schema has no required fields. The CLI exited 0 and returned plain text. The host therefore cannot equate a zero exit status with success: it accepts structured JSON or native `[OK]` text and otherwise fails closed. + +Calling `click` again with PID 1220 and the token returned structured JSON: + +```json +{ + "delivery": {"mode": "background"}, + "effect": "unverifiable", + "route": "accessibility" +} +``` + +That response records delivery, not the GUI effect. A separate `get_window_state` call returned snapshot ID `s00000002`, 19 elements, and two `tab panel = "New Document"` entries. The before and after PNGs also differ. The state and image snapshots, not `effect: "unverifiable"`, prove that the tab count changed from one to two across calls. + +Representative single-operation timings from the repaired guest: + +| Operation | Total | Driver exec | PNG pull | +| --- | ---: | ---: | ---: | +| Initial `list_apps` | 361 ms | 358 ms | — | +| `launch_app` | 777 ms | 777 ms | — | +| Before-click `get_window_state` with tree | 580 ms | 511 ms | 67 ms | +| Successful token `click` with PID | 308 ms | 307 ms | — | +| After-click `get_window_state` with tree | 744 ms | 685 ms | 58 ms | +| `get_desktop_state` | 208 ms | 161 ms | 45 ms | +| Native 640-pixel window capture | 281 ms | 207 ms | 73 ms | +| Post-restart `list_apps` | 347 ms | 343 ms | — | + +A four-call comparison on the maximized 1214 × 768 editor window showed the cost and response-size difference from omitting the accessibility tree: + +| `get_window_state` case | Driver exec | PNG pull | stdout | PNG | +| --- | ---: | ---: | ---: | ---: | +| Tree, default dimension | 282 ms | 59 ms | 11,665 B | 30,366 B | +| No tree, default dimension | 180 ms | 55 ms | 318 B | 30,366 B | +| Tree, `max_dimension=1280` | 216 ms | 67 ms | 11,665 B | 30,366 B | +| No tree, `max_dimension=1280` | 195 ms | 59 ms | 318 B | 30,366 B | + +These are individual calls, not throughput benchmarks. + +## Screenshot behavior + +Native `get_desktop_state` has no `max_dimension` field in the pinned Linux schema. Its direct capture returned a 1280 × 800 PNG and matching screen metadata. The host `desktop.screenshot` convenience applies a requested desktop bound after pulling the PNG. + +Native `get_window_state` does accept `max_dimension`. With `max_dimension=640`, the Driver returned a 640 × 500 PNG while preserving the original `window_bounds` width of 822. The host response uses that original coordinate width to report an image-to-Driver scale of `822 / 640 = 1.284375`, so pixel coordinates from the bounded image map back to the native window coordinate space. + +Screenshot bytes use the internal binary guest-file path, not the text exec path. Each call supplies a unique `--screenshot-out-file`, streams the PNG through the binary file API, removes the guest file, strips any embedded image payload from preserved JSON, and returns only a bearer URL plus dimensions and scale. URLs are served with `Cache-Control: no-store` and expire after at most five minutes or when the sandbox expires. Native structured JSON remains otherwise intact. + +## VNC and restart recovery + +The final recipe runs X0tigervnc on guest TCP port 5900 with no baked reusable credential. Access is limited by the private sandbox network or an explicitly created forward. + +The direct spike reached the management-network VNC endpoint at `10.10.40.65:5900`. It read the RFB banner `RFB 003.008\n` before restart and again after restart. Restart-to-Driver-ready took 12.117 seconds, after which `list_apps` returned structured JSON in 347 ms and X0tigervnc was running under a new PID. This proves recovery for the repaired first guest only. + +## Build measurements and release status + +Both local builds used fresh work and output directories. Download time includes the Go toolchain, vendored distrobuilder source, Ubuntu base, snapshot CA package, and full Cua Driver archive. Compile time excludes downloads and assembly. Scratch usage was sampled every 100 ms, so an interval peak can be missed. + +| Build evidence | Download | Compile | Assemble | Peak RSS | Scratch high-water | Metadata | Disk | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| First image | 5.849 s | 31.670 s | 842.017 s | 507,180 KiB | 7,625,043,968 B | 656 B | 744,611,840 B | +| Corrected rebuild | 5.824 s | 31.834 s | 392.512 s | 517,712 KiB | 7,621,808,128 B | 640 B | 745,013,248 B | + +The first image artifacts were `incus.tar.xz` SHA-256 `3174b0a6e76d6e1b3e601a7ffc761589615205fed0124bd1f300ed52aa612f54` and `disk.qcow2` SHA-256 `ed3cfd45045f459b5b17ac5782a2e8f17f10e06b5805719ccbdf27a04bd22f37`. The corrected artifacts were `incus.tar.xz` SHA-256 `d7e5e20009425f2d2164770797e84e5d99611d0fe0d5a0b0e719032aa5c9cd9d` and `disk.qcow2` SHA-256 `42c8bdd02f8991822ecdb2093d02b760f781ab21e301a15ed1466830c2d36c2c`. Both qcow2 files report a 17,179,869,184-byte virtual size. + +Protected bootstrap PR #23 and private bake run 34854323245 completed successfully. The desktop-aware publisher awaits deployment before the four-image bake. Desktop publication and catalog promotion remain pending; no desktop GHCR digest is claimed. + +The real MCP acceptance lane passed capability registration and reached sandbox creation. OVN rejected the create because `/var/lib/ovn/ovnnb_db.db` could not write: `ovncentral01` had filled its 20 GiB root filesystem, with approximately 19 GiB in logs. This is a Phase 5 infrastructure defect, not a desktop result. The companion fleet work preserves evidence before the explicitly approved log truncation and addresses recurrence. From 15d1b8cdc6b0efada01434ea9c1a3443b5e90549 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 14 Sep 2026 09:33:27 -0700 Subject: [PATCH 2/2] docs(desktop): record end-to-end MCP acceptance --- images/README.md | 17 ++++++++++++----- spikes/desktop/README.md | 11 +++++++++-- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/images/README.md b/images/README.md index be32699..12af9b2 100644 --- a/images/README.md +++ b/images/README.md @@ -236,11 +236,18 @@ entries. The smoke removed its own VM and imported image. Protected bootstrap PR #23 and private bake run 34854323245 completed successfully. The desktop-aware publisher must be deployed before the -four-image bake. Desktop publication, verified fetch-back, live MCP acceptance, -and the public catalog update remain pending; no desktop GHCR digest is claimed. -Live MCP acceptance reached the server but was blocked before sandbox creation -by the full root filesystem on `ovncentral01`. Recovery is tracked separately -in the companion fleet change. +four-image bake. Desktop publication, verified fetch-back, and the public +catalog update remain pending; no desktop GHCR digest is claimed. + +The corrected image passed the complete production-stdio MCP acceptance run +after OVN recovery: private-only client, desktop readiness, native `list_apps`, +PNG URL fetch/decode, one foreground token click changing one editor tab to +two, reboot recovery, and the VNC endpoint reported by `desktop.info`. +The representative program took 23.624 s; the full run took 158.45 s. +The running reaper returned 404 for the original screenshot 27.120 s after +the shortened sandbox expiry. See the spike report for the native background +delivery limitation and [fleet PR #20](https://github.com/GilmanLab/fleet/pull/20) +for the separately recovered stale-CA reconnect storm and active log limits. ### Reproducibility diff --git a/spikes/desktop/README.md b/spikes/desktop/README.md index 7bdee3b..3f356ac 100644 --- a/spikes/desktop/README.md +++ b/spikes/desktop/README.md @@ -1,6 +1,6 @@ # Desktop Phase 6 spike -**2026-09-14 — direct native probing passed on a repaired first-build guest, and the corrected rebuild passed its X11/Driver boot smoke. Live MCP acceptance is blocked by OVN infrastructure.** The native probe exercised Cua Driver 0.28.1 through its session socket, including a token-addressed GUI change, bounded screenshots, VNC, and restart recovery. +**2026-09-14 — the corrected image passed X11/Driver boot qualification and the complete production-stdio MCP acceptance scenario.** One-shot Driver calls retain token continuity through the persistent guest daemon. Screenshots remain URL-only; the adapter neither retries input nor silently changes delivery mode. ## Evidence and scope @@ -13,6 +13,7 @@ The results use these evidence sources: | `/tmp/agentcompute-desktop-phase6-final/metrics.json` | Corrected local rebuild measurements and artifact hashes. It is build evidence, not live Driver or MCP acceptance evidence. | | `internal/desktop/testdata/` | Captured Driver catalog and result fixtures used by the host adapter, including the missing-`pid` diagnostic and successful click response. | | `/tmp/agentcompute-desktop-corrected-smoke/` | Corrected image boot: X11, active Driver user service, 234 apps from `list_apps`, and cleanup of smoke-owned resources. | +| `/tmp/agentcompute-desktop-mcp-evidence/` | Before/after PNGs from the corrected image's real MCP token interaction. The full acceptance run passed in 158.45 s after OVN recovery. | The first guest did not acquire its network until `/etc/netplan/10-incus.yaml` was repaired with `renderer: networkd`. The current recipe embeds that renderer and enables `systemd-networkd`. The corrected image passed the dedicated boot smoke with fingerprint `9d0da557210766289d59823a540050c3e7f328af66380974cc1ebd08e877bd11`; the token, screenshot, timing, and restart measurements below remain attributed to the repaired first guest. @@ -57,6 +58,8 @@ Calling `click` again with PID 1220 and the token returned structured JSON: That response records delivery, not the GUI effect. A separate `get_window_state` call returned snapshot ID `s00000002`, 19 elements, and two `tab panel = "New Document"` entries. The before and after PNGs also differ. The state and image snapshots, not `effect: "unverifiable"`, prove that the tab count changed from one to two across calls. +The corrected image also completed this interaction through `desktop.call` in one MCP `execute` program. A background attempt exposed an upstream delivery limitation: `click` returned `effect: "unverifiable"` with `route: "global_input"` but the next snapshot still showed one tab. The acceptance program therefore supplies the native `window_id` and explicitly requests `delivery_mode: "foreground"`. Its single click returned the same unverifiable effect, routed through accessibility; subsequent tree and PNG observations proved two tabs. No host-side retry or implicit foreground fallback was added. + Representative single-operation timings from the repaired guest: | Operation | Total | Driver exec | PNG pull | @@ -95,6 +98,8 @@ The final recipe runs X0tigervnc on guest TCP port 5900 with no baked reusable c The direct spike reached the management-network VNC endpoint at `10.10.40.65:5900`. It read the RFB banner `RFB 003.008\n` before restart and again after restart. Restart-to-Driver-ready took 12.117 seconds, after which `list_apps` returned structured JSON in 347 ms and X0tigervnc was running under a new PID. This proves recovery for the repaired first guest only. +The corrected-image MCP acceptance created a separate viewer on the default OVN network, created a TCP forward, restarted the guest, and observed `desktop.info.ready == true` again. The address reported by `desktop.info.vnc`, `10.10.40.67:5900`, answered with `RFB 003.008\n`. The representative client retained only its private LAN NIC. Its complete create/wait/screenshot/list-apps program took 23.624 s. After shortening sandbox lifetime, the running reaper made the original screenshot URL return 404 at 27.120 s after expiry. + ## Build measurements and release status Both local builds used fresh work and output directories. Download time includes the Go toolchain, vendored distrobuilder source, Ubuntu base, snapshot CA package, and full Cua Driver archive. Compile time excludes downloads and assembly. Scratch usage was sampled every 100 ms, so an interval peak can be missed. @@ -108,4 +113,6 @@ The first image artifacts were `incus.tar.xz` SHA-256 `3174b0a6e76d6e1b3e601a7ff Protected bootstrap PR #23 and private bake run 34854323245 completed successfully. The desktop-aware publisher awaits deployment before the four-image bake. Desktop publication and catalog promotion remain pending; no desktop GHCR digest is claimed. -The real MCP acceptance lane passed capability registration and reached sandbox creation. OVN rejected the create because `/var/lib/ovn/ovnnb_db.db` could not write: `ovncentral01` had filled its 20 GiB root filesystem, with approximately 19 GiB in logs. This is a Phase 5 infrastructure defect, not a desktop result. The companion fleet work preserves evidence before the explicitly approved log truncation and addresses recurrence. +The original MCP blocker was a Phase 5 OVN outage: 19,307,134,976 bytes of logs filled central's 20 GiB root. The approved fleet recovery preserved complete signature counts and log boundaries before truncation, then recycled only the three Incus daemons retaining stale CA trust. No central database or northd process restarted. Logging limits are now active; details are in [fleet PR #20](https://github.com/GilmanLab/fleet/pull/20) and the central OVN runbook. + +Live acceptance also corrected two adapter boundaries. Native private Incus images need an image-access secret in the create request; the image existed even though the unauthenticated pull reported it missing. VNC forward discovery must use the same DHCP-lease fallback as forward creation because guest NIC state can briefly lack addresses after reboot. The passing run exercised both corrections without publishing the temporary image or delaying Driver readiness for networking.