diff --git a/images/README.md b/images/README.md index c9809e9..d8d20b4 100644 --- a/images/README.md +++ b/images/README.md @@ -13,7 +13,7 @@ image-build wrapper. | `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. | +| `ubuntu-24.04-desktop/smoke.py` | Candidate qualification for X11, the Driver session socket and user unit, native `list_apps`, live whole-desktop capture after launching Text Editor, 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: name → digest-pinned GHCR reference, upstream Incus `remote:alias`, or cluster-local `alias` in `image-build`, plus kind, OS, and defaults. | | `smoke.sh` | Router boot qualification, including the four `/opt/router/{nat,route,dhcp,wg}` helpers. | @@ -96,6 +96,15 @@ 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. +The daemon serves with `--no-overlay`: the synthetic agent-cursor overlay +freezes X root-window reads on this GNOME/Xorg session. Starting before the +first composite freezes a black frame; both Driver whole-desktop capture and +VNC are affected. Reproduced on Driver 0.28.1 and 0.28.2. Window capture and +input remain functional. Disabling the cosmetic overlay restores live capture +but removes its session-coloured pointer; re-enabling it reintroduces the defect. +The desktop smoke compares image data before and after launching Text Editor, +so it rejects a frozen frame even when that frame is not black. + See the [Phase 6 desktop spike report](../spikes/desktop/README.md) for direct Driver results, timing, screenshot scaling, VNC, and reboot evidence. 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 index f7dc9e7..6059482 100755 --- 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 @@ -9,7 +9,9 @@ export DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS:-unix:path=${XDG_RUN case "${1:-}" in cua-driver) - exec /usr/local/bin/cua-driver serve --socket "${XDG_RUNTIME_DIR}/cua-driver.sock" + # The cursor overlay freezes X root reads on GNOME/Xorg, including VNC. + # Disable that cosmetic overlay so whole-desktop capture stays live. + exec /usr/local/bin/cua-driver serve --no-overlay --socket "${XDG_RUNTIME_DIR}/cua-driver.sock" ;; x0vnc) # Only private sandbox networks and explicitly created human-viewer forwards diff --git a/images/ubuntu-24.04-desktop/smoke.py b/images/ubuntu-24.04-desktop/smoke.py index 4ffdef9..180edfc 100755 --- a/images/ubuntu-24.04-desktop/smoke.py +++ b/images/ubuntu-24.04-desktop/smoke.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Boot-test a split desktop VM for X11, cua-driver, and one list_apps call. +"""Boot-test a split desktop VM for X11, cua-driver, list_apps, and live capture. The instance is created with security.nesting=false. This script imports the candidate only when the fingerprint is absent, never touches shared aliases, @@ -32,6 +32,7 @@ GUEST_GID = "1000" X11_SOCKET = "/tmp/.X11-unix/X0" EDITOR_LAUNCH = "gnome-text-editor" +GUEST_DESKTOP_PNG = "/tmp/agentcompute-desktop-smoke.png" class Error(RuntimeError): @@ -119,6 +120,35 @@ def guest_exec( return incus(project, args, capture_output=True) +def guest_pull(remote: str, project: str, name: str, guest_path: str, target: Path) -> None: + pulled = incus( + project, + ["file", "pull", f"{remote}:{name}{guest_path}", str(target)], + capture_output=True, + ) + if pulled.returncode != 0: + die(f"pulling {guest_path} failed: {(pulled.stderr or pulled.stdout or '').strip()}") + + +def screenshot_pixels(raw: bytes) -> bytes: + """Compare PNG image data without timestamps or other ancillary metadata.""" + if not raw.startswith(b"\x89PNG\r\n\x1a\n"): + die("screenshot is not a PNG") + offset = 8 + pixels = bytearray() + while offset + 12 <= len(raw): + length = int.from_bytes(raw[offset : offset + 4], "big") + end = offset + 12 + length + if end > len(raw): + die("screenshot PNG contains a truncated chunk") + if raw[offset + 4 : offset + 8] == b"IDAT": + pixels.extend(raw[offset + 8 : end - 4]) + offset = end + if not pixels: + die("screenshot PNG contains no image data") + return bytes(pixels) + + def main(argv: list[str]) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--metadata", type=Path, required=True) @@ -297,6 +327,40 @@ def unit_active() -> bool: die("list_apps did not include gnome-text-editor") note(f"list_apps returned {len(apps)} apps including {EDITOR_LAUNCH}", log) + def capture(filename: str) -> bytes: + captured = guest_exec( + remote, project, name, + [ + DRIVER_BIN, "call", "--socket", DRIVER_SOCKET, + "--screenshot-out-file", GUEST_DESKTOP_PNG, + "get_desktop_state", "{}", + ], + user=True, + ) + if captured.returncode != 0: + die(f"get_desktop_state exited {captured.returncode}: {captured.stderr}") + target = evidence / filename + guest_pull(remote, project, name, GUEST_DESKTOP_PNG, target) + return screenshot_pixels(target.read_bytes()) + + before = capture("desktop-before.png") + launched = guest_exec( + remote, project, name, + [ + "systemd-run", "--user", "--collect", + "--unit=agentcompute-capture-smoke", EDITOR_LAUNCH, + ], + user=True, + ) + if launched.returncode != 0: + die(f"launching {EDITOR_LAUNCH} failed: {launched.stderr}") + wait_until( + min(deadline, time.time() + 30), + "whole-desktop pixels to change after launching Text Editor", + lambda: capture("desktop.png") != before, + ) + note("whole-desktop pixels changed after launching Text Editor", log) + result = { "fingerprint": fingerprint, "metadata": str(metadata), @@ -308,6 +372,7 @@ def unit_active() -> bool: "x11": True, "cua_driver_unit": "active", "list_apps": len(apps), + "desktop_capture_live": True, "security_nesting": "false", } (evidence / "result.json").write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")