From ef8d9fcaa85830aa3096cf56fef8da99be4f31f0 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Tue, 15 Sep 2026 19:42:11 -0700 Subject: [PATCH 1/2] fix(images): serve the desktop Driver without its cursor overlay The Driver's agent-cursor overlay maps a full-screen, override-redirect, depth-24 window with save-unders. While it is mapped, every X root-window read on this GNOME/Xorg session returns the frame from the moment the overlay appeared instead of live pixels, and the user unit starts before Mutter's first composite, so get_desktop_state and x0vncserver both serve a solid black 1280x800 frame for the life of the guest. Per-window captures read redirected window pixmaps and stayed correct, which is why only whole-desktop capture broke. Reproduced on the deployed image at boot with Driver 0.28.1 and 0.28.2, so this is an upstream defect rather than a pin bump; unmapping the overlay restores live pixels immediately. Serve with upstream's own --no-overlay switch, keep the reviewed 0.28.1 pin, guard the flag in build.py, and make the desktop smoke fail a flat screenshot so a regression cannot be published again. --- images/README.md | 22 ++- images/build.py | 5 + .../libexec/agentcompute-desktop-session | 11 +- images/ubuntu-24.04-desktop/smoke.py | 126 +++++++++++++++++- 4 files changed, 161 insertions(+), 3 deletions(-) diff --git a/images/README.md b/images/README.md index c9809e9..8281486 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`, a whole-desktop screenshot that is not a flat frame, 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,26 @@ 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 Driver's agent-cursor overlay maps a +full-screen, override-redirect, depth-24 window with save-unders; while that +window is mapped, every X root-window read on this GNOME/Xorg session returns +the frame from the moment the overlay appeared instead of live pixels. The user +unit starts before Mutter's first composite, so that frozen frame is solid +black: `get_desktop_state` and the x0vncserver view both show a black 1280x800 +screen for the life of the guest, while per-window captures keep working +because they read redirected window pixmaps. Unmapping the overlay restores +live pixels within a frame. Reproduced on Driver 0.28.1 and 0.28.2, so this is +an upstream defect and not a pin-bump fix; `--no-overlay` is upstream's own +documented switch for the daemon that owns the overlay. + +The cost is the synthetic agent cursor: pointer actions no longer draw a +session-coloured cursor for a human watching over VNC. Input itself is +unaffected — clicks, typing, and window captures behave exactly as before. +Anyone who re-enables the overlay, by dropping the flag or by starting a second +daemon without it, reintroduces the black desktop for that guest; `build.py` +refuses a wrapper that serves without `--no-overlay`, and the desktop smoke +fails a flat screenshot. + 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/build.py b/images/build.py index 3f14573..2d67385 100755 --- a/images/build.py +++ b/images/build.py @@ -416,6 +416,11 @@ def validate_desktop_files() -> None: 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 "serve --no-overlay" not in wrapper: + raise Error( + "desktop session wrapper must serve with --no-overlay; the Driver's " + "agent-cursor overlay freezes every root-window read on GNOME/Xorg" + ) 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: 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..6adc073 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,16 @@ 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" + # --no-overlay: the Driver's agent-cursor overlay maps a full-screen, + # override-redirect, depth-24 window with save-unders. On this GNOME/Xorg + # session every root-window read is then served from that window's stale + # save-under instead of the live screen, so get_desktop_state and + # x0vncserver both return the frame from the moment the overlay appeared — + # solid black, because the daemon starts before Mutter's first composite. + # Per-window captures read redirected window pixmaps and stay correct, + # which is why only whole-desktop capture broke. Reproduced on 0.28.1 and + # 0.28.2; unmapping the overlay restores live pixels immediately. + 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..2bef8ab 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, @@ -19,9 +19,11 @@ import json import os import shutil +import struct import subprocess import sys import time +import zlib from pathlib import Path DRIVER_BIN = "/usr/local/bin/cua-driver" @@ -32,6 +34,12 @@ GUEST_GID = "1000" X11_SOCKET = "/tmp/.X11-unix/X0" EDITOR_LAUNCH = "gnome-text-editor" +GUEST_DESKTOP_PNG = "/tmp/agentcompute-desktop-smoke.png" +# A frozen or unpainted X screen reads as one flat colour. The live GNOME +# desktop has thousands of distinct colours in the wallpaper alone, so any +# sample this dull means whole-desktop capture is broken. +MIN_DESKTOP_COLORS = 16 +DESKTOP_SAMPLE_STEP = 4 class Error(RuntimeError): @@ -119,6 +127,91 @@ 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 png_rows(raw: bytes) -> tuple[int, int, int, bytes]: + """Return width, height, bytes per pixel, and unfiltered 8-bit samples.""" + if raw[:8] != b"\x89PNG\r\n\x1a\n": + die("screenshot is not a PNG") + offset = 8 + header: tuple[int, ...] = () + compressed = bytearray() + while offset + 8 <= len(raw): + length = int.from_bytes(raw[offset : offset + 4], "big") + kind = raw[offset + 4 : offset + 8] + body = raw[offset + 8 : offset + 8 + length] + if kind == b"IHDR": + header = struct.unpack(">IIBBBBB", body) + elif kind == b"IDAT": + compressed += body + offset += 12 + length + if not header: + die("screenshot PNG has no IHDR") + width, height, depth, colour, _, _, interlace = header + channels = {0: 1, 2: 3, 4: 2, 6: 4}.get(colour) + if channels is None or depth != 8 or interlace != 0: + die(f"screenshot PNG is depth {depth} colour type {colour} interlace {interlace}") + stride = width * channels + data = zlib.decompress(bytes(compressed)) + out = bytearray(height * stride) + previous = bytearray(stride) + position = 0 + for row in range(height): + method = data[position] + position += 1 + line = bytearray(data[position : position + stride]) + position += stride + if method == 1: + for index in range(channels, stride): + line[index] = (line[index] + line[index - channels]) & 0xFF + elif method == 2: + for index in range(stride): + line[index] = (line[index] + previous[index]) & 0xFF + elif method == 3: + for index in range(stride): + left = line[index - channels] if index >= channels else 0 + line[index] = (line[index] + ((left + previous[index]) >> 1)) & 0xFF + elif method == 4: + for index in range(stride): + left = line[index - channels] if index >= channels else 0 + up = previous[index] + corner = previous[index - channels] if index >= channels else 0 + estimate = left + up - corner + da, db, dc = abs(estimate - left), abs(estimate - up), abs(estimate - corner) + if da <= db and da <= dc: + predictor = left + elif db <= dc: + predictor = up + else: + predictor = corner + line[index] = (line[index] + predictor) & 0xFF + elif method != 0: + die(f"screenshot PNG row {row} uses filter {method}") + out[row * stride : (row + 1) * stride] = line + previous = line + return width, height, channels, bytes(out) + + +def distinct_colors(raw: bytes) -> int: + width, height, channels, pixels = png_rows(raw) + stride = width * channels + seen: set[bytes] = set() + for row in range(0, height, DESKTOP_SAMPLE_STEP): + line = pixels[row * stride : (row + 1) * stride] + for column in range(0, width, DESKTOP_SAMPLE_STEP): + start = column * channels + seen.add(line[start : start + 3]) + return len(seen) + + def main(argv: list[str]) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--metadata", type=Path, required=True) @@ -297,6 +390,36 @@ 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) + captured = guest_exec( + remote, + project, + name, + [ + DRIVER_BIN, + "call", + "--socket", + DRIVER_SOCKET, + "--screenshot-out-file", + GUEST_DESKTOP_PNG, + "get_desktop_state", + "{}", + ], + user=True, + ) + (evidence / "desktop-state.stdout").write_text(captured.stdout or "", encoding="utf-8") + (evidence / "desktop-state.stderr").write_text(captured.stderr or "", encoding="utf-8") + if captured.returncode != 0: + die(f"get_desktop_state exited {captured.returncode}: {captured.stderr}") + desktop_png = evidence / "desktop.png" + guest_pull(remote, project, name, GUEST_DESKTOP_PNG, desktop_png) + colors = distinct_colors(desktop_png.read_bytes()) + if colors < MIN_DESKTOP_COLORS: + die( + f"whole-desktop screenshot holds {colors} distinct colours; the X screen " + "is unpainted or frozen (see images/README.md on the Driver cursor overlay)" + ) + note(f"whole-desktop screenshot holds {colors} distinct colours", log) + result = { "fingerprint": fingerprint, "metadata": str(metadata), @@ -308,6 +431,7 @@ def unit_active() -> bool: "x11": True, "cua_driver_unit": "active", "list_apps": len(apps), + "desktop_screenshot_colors": colors, "security_nesting": "false", } (evidence / "result.json").write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") From 08ae468f89b7be0eaac41701c7505ea9b3b18e0e Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Tue, 15 Sep 2026 19:50:12 -0700 Subject: [PATCH 2/2] test(images): qualify live desktop pixels rather than flat-frame heuristics --- images/README.md | 29 ++-- images/build.py | 5 - .../libexec/agentcompute-desktop-session | 11 +- images/ubuntu-24.04-desktop/smoke.py | 147 ++++++------------ 4 files changed, 55 insertions(+), 137 deletions(-) diff --git a/images/README.md b/images/README.md index 8281486..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`, a whole-desktop screenshot that is not a flat frame, 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,25 +96,14 @@ 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 Driver's agent-cursor overlay maps a -full-screen, override-redirect, depth-24 window with save-unders; while that -window is mapped, every X root-window read on this GNOME/Xorg session returns -the frame from the moment the overlay appeared instead of live pixels. The user -unit starts before Mutter's first composite, so that frozen frame is solid -black: `get_desktop_state` and the x0vncserver view both show a black 1280x800 -screen for the life of the guest, while per-window captures keep working -because they read redirected window pixmaps. Unmapping the overlay restores -live pixels within a frame. Reproduced on Driver 0.28.1 and 0.28.2, so this is -an upstream defect and not a pin-bump fix; `--no-overlay` is upstream's own -documented switch for the daemon that owns the overlay. - -The cost is the synthetic agent cursor: pointer actions no longer draw a -session-coloured cursor for a human watching over VNC. Input itself is -unaffected — clicks, typing, and window captures behave exactly as before. -Anyone who re-enables the overlay, by dropping the flag or by starting a second -daemon without it, reintroduces the black desktop for that guest; `build.py` -refuses a wrapper that serves without `--no-overlay`, and the desktop smoke -fails a flat screenshot. +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/build.py b/images/build.py index 2d67385..3f14573 100755 --- a/images/build.py +++ b/images/build.py @@ -416,11 +416,6 @@ def validate_desktop_files() -> None: 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 "serve --no-overlay" not in wrapper: - raise Error( - "desktop session wrapper must serve with --no-overlay; the Driver's " - "agent-cursor overlay freezes every root-window read on GNOME/Xorg" - ) 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: 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 6adc073..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,15 +9,8 @@ export DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS:-unix:path=${XDG_RUN case "${1:-}" in cua-driver) - # --no-overlay: the Driver's agent-cursor overlay maps a full-screen, - # override-redirect, depth-24 window with save-unders. On this GNOME/Xorg - # session every root-window read is then served from that window's stale - # save-under instead of the live screen, so get_desktop_state and - # x0vncserver both return the frame from the moment the overlay appeared — - # solid black, because the daemon starts before Mutter's first composite. - # Per-window captures read redirected window pixmaps and stay correct, - # which is why only whole-desktop capture broke. Reproduced on 0.28.1 and - # 0.28.2; unmapping the overlay restores live pixels immediately. + # 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) diff --git a/images/ubuntu-24.04-desktop/smoke.py b/images/ubuntu-24.04-desktop/smoke.py index 2bef8ab..180edfc 100755 --- a/images/ubuntu-24.04-desktop/smoke.py +++ b/images/ubuntu-24.04-desktop/smoke.py @@ -19,11 +19,9 @@ import json import os import shutil -import struct import subprocess import sys import time -import zlib from pathlib import Path DRIVER_BIN = "/usr/local/bin/cua-driver" @@ -35,11 +33,6 @@ X11_SOCKET = "/tmp/.X11-unix/X0" EDITOR_LAUNCH = "gnome-text-editor" GUEST_DESKTOP_PNG = "/tmp/agentcompute-desktop-smoke.png" -# A frozen or unpainted X screen reads as one flat colour. The live GNOME -# desktop has thousands of distinct colours in the wallpaper alone, so any -# sample this dull means whole-desktop capture is broken. -MIN_DESKTOP_COLORS = 16 -DESKTOP_SAMPLE_STEP = 4 class Error(RuntimeError): @@ -137,79 +130,23 @@ def guest_pull(remote: str, project: str, name: str, guest_path: str, target: Pa die(f"pulling {guest_path} failed: {(pulled.stderr or pulled.stdout or '').strip()}") -def png_rows(raw: bytes) -> tuple[int, int, int, bytes]: - """Return width, height, bytes per pixel, and unfiltered 8-bit samples.""" - if raw[:8] != b"\x89PNG\r\n\x1a\n": +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 - header: tuple[int, ...] = () - compressed = bytearray() - while offset + 8 <= len(raw): + pixels = bytearray() + while offset + 12 <= len(raw): length = int.from_bytes(raw[offset : offset + 4], "big") - kind = raw[offset + 4 : offset + 8] - body = raw[offset + 8 : offset + 8 + length] - if kind == b"IHDR": - header = struct.unpack(">IIBBBBB", body) - elif kind == b"IDAT": - compressed += body - offset += 12 + length - if not header: - die("screenshot PNG has no IHDR") - width, height, depth, colour, _, _, interlace = header - channels = {0: 1, 2: 3, 4: 2, 6: 4}.get(colour) - if channels is None or depth != 8 or interlace != 0: - die(f"screenshot PNG is depth {depth} colour type {colour} interlace {interlace}") - stride = width * channels - data = zlib.decompress(bytes(compressed)) - out = bytearray(height * stride) - previous = bytearray(stride) - position = 0 - for row in range(height): - method = data[position] - position += 1 - line = bytearray(data[position : position + stride]) - position += stride - if method == 1: - for index in range(channels, stride): - line[index] = (line[index] + line[index - channels]) & 0xFF - elif method == 2: - for index in range(stride): - line[index] = (line[index] + previous[index]) & 0xFF - elif method == 3: - for index in range(stride): - left = line[index - channels] if index >= channels else 0 - line[index] = (line[index] + ((left + previous[index]) >> 1)) & 0xFF - elif method == 4: - for index in range(stride): - left = line[index - channels] if index >= channels else 0 - up = previous[index] - corner = previous[index - channels] if index >= channels else 0 - estimate = left + up - corner - da, db, dc = abs(estimate - left), abs(estimate - up), abs(estimate - corner) - if da <= db and da <= dc: - predictor = left - elif db <= dc: - predictor = up - else: - predictor = corner - line[index] = (line[index] + predictor) & 0xFF - elif method != 0: - die(f"screenshot PNG row {row} uses filter {method}") - out[row * stride : (row + 1) * stride] = line - previous = line - return width, height, channels, bytes(out) - - -def distinct_colors(raw: bytes) -> int: - width, height, channels, pixels = png_rows(raw) - stride = width * channels - seen: set[bytes] = set() - for row in range(0, height, DESKTOP_SAMPLE_STEP): - line = pixels[row * stride : (row + 1) * stride] - for column in range(0, width, DESKTOP_SAMPLE_STEP): - start = column * channels - seen.add(line[start : start + 3]) - return len(seen) + 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: @@ -390,35 +327,39 @@ 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) - captured = guest_exec( - remote, - project, - name, + 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, [ - DRIVER_BIN, - "call", - "--socket", - DRIVER_SOCKET, - "--screenshot-out-file", - GUEST_DESKTOP_PNG, - "get_desktop_state", - "{}", + "systemd-run", "--user", "--collect", + "--unit=agentcompute-capture-smoke", EDITOR_LAUNCH, ], user=True, ) - (evidence / "desktop-state.stdout").write_text(captured.stdout or "", encoding="utf-8") - (evidence / "desktop-state.stderr").write_text(captured.stderr or "", encoding="utf-8") - if captured.returncode != 0: - die(f"get_desktop_state exited {captured.returncode}: {captured.stderr}") - desktop_png = evidence / "desktop.png" - guest_pull(remote, project, name, GUEST_DESKTOP_PNG, desktop_png) - colors = distinct_colors(desktop_png.read_bytes()) - if colors < MIN_DESKTOP_COLORS: - die( - f"whole-desktop screenshot holds {colors} distinct colours; the X screen " - "is unpainted or frozen (see images/README.md on the Driver cursor overlay)" - ) - note(f"whole-desktop screenshot holds {colors} distinct colours", log) + 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, @@ -431,7 +372,7 @@ def unit_active() -> bool: "x11": True, "cua_driver_unit": "active", "list_apps": len(apps), - "desktop_screenshot_colors": colors, + "desktop_capture_live": True, "security_nesting": "false", } (evidence / "result.json").write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")