Repair the pinned Xpra runtime stack

Install matching Xpra client packages and carry Kali rolling's ATK introspection package into snapshot-based image builds. Repair self-updated containers by installing the complete Xpra and GTK stack at the installed Xpra version.

Alessandro committed Aug 19, 2026 at 21:22 UTC b22a144bf59f15b1516084c9e7b88133ba92c8a9
6 files changed +163 -62
docker/run/AGENTS.md
+1 -1
@@ -19,7 +19,7 @@
19 - `BRANCH` is required for branch-based Docker builds.
20 - Preserve exposed ports for SSH, HTTP, and tunneled services unless docs and workflows are updated together.
21 - Keep the two-runtime Python model aligned with the root contract.
22 -- Keep runtime desktop packages on `kali-last-snapshot`; pin the verified Python 3.13-compatible LibreOffice and Xpra versions in `fs/ins/install_additional.sh` for both published architectures.
22 +- Keep runtime desktop packages on `kali-last-snapshot`; carry the rolling base's matching ATK introspection package into that transaction, then pin the verified Python 3.13-compatible LibreOffice and complete Xpra runtime versions in `fs/ins/install_additional.sh` for both published architectures.
23 - Do not bake secrets, local `.env` values, or user data into the image.
24 - Runtime startup must ensure `/a0/usr/uploads` exists before supervised services start.
25 - Runtime startup raises the soft open-file limit toward `A0_NOFILE_LIMIT` (default `65535`) before supervisord starts, bounded by the container hard limit.
docker/run/fs/ins/install_additional.sh
+13
@@ -33,10 +33,17 @@ LIBREOFFICE_PACKAGES=(
33 XPRA_PACKAGES=(
34 "xpra-common=$XPRA_VERSION"
35 "xpra-server=$XPRA_VERSION"
36 + "xpra-client=$XPRA_VERSION"
37 + "xpra-client-gtk3=$XPRA_VERSION"
38 "xpra-x11=$XPRA_VERSION"
39 "xpra-html5=$XPRA_HTML5_VERSION"
40 )
41
42 +apt-get update
43 +ATK_VERSION="$(dpkg-query -W -f='${Version}' libatk1.0-0t64)"
44 +ATK_GIR_PACKAGE="/tmp/gir1.2-atk-1.0_${ATK_VERSION}_${arch}.deb"
45 +(cd /tmp && apt-get download "gir1.2-atk-1.0=$ATK_VERSION")
46 +
47 for source in /etc/apt/sources.list /etc/apt/sources.list.d/kali.sources; do
48 [ ! -f "$source" ] || sed -i "s/kali-rolling/$KALI_SUITE/g" "$source"
49 done
@@ -54,6 +61,8 @@ Architectures: $arch
61 EOF
62 apt-get update
63 DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
64 + "$ATK_GIR_PACKAGE" \
65 + gir1.2-gtk-3.0 \
66 "${LIBREOFFICE_PACKAGES[@]}" \
67 "${XPRA_PACKAGES[@]}" \
68 xfce4-session \
@@ -66,7 +75,10 @@ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
75 libglib2.0-bin \
76 xfce4-terminal \
77 x11-xserver-utils \
78 + x11-utils \
79 + x11-apps \
80 xdotool \
81 + xclip \
82 xauth \
83 xvfb \
84 dbus-x11 \
@@ -78,4 +90,5 @@ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
90 fonts-noto-cjk \
91 fonts-noto-color-emoji
92
93 +rm -f "$ATK_GIR_PACKAGE"
94 rm -rf /var/lib/apt/lists/*
plugins/_desktop/AGENTS.md
+1
@@ -14,6 +14,7 @@
14 ## Local Contracts
15
16 - Preserve session startup, cleanup, and route protection for desktop access.
17 +- Keep the Xpra server, client modules, and GTK introspection runtime present as one compatible stack; Xpra shadow sessions import all three even when users connect only through HTML5.
18 - Keep desktop state injected into prompts accurate and bounded.
19 - Do not expose desktop routes without the expected auth protections.
20 - Keep Desktop host visibility tied to an attached modal or canvas host; modal cleanup may preserve the iframe in keepalive, but must not leave stale modal mode behind.
plugins/_desktop/hooks.py
+78 -43
@@ -3,6 +3,7 @@ from __future__ import annotations
3 import os
4 import shutil
5 import subprocess
6 +import tempfile
7 import threading
8 import time
9 import urllib.request
@@ -22,8 +23,22 @@ LIBREOFFICE_RUNTIME_PACKAGES = (
23 XPRA_SOURCE_FILE = Path("/etc/apt/sources.list.d/xpra.sources")
24 XPRA_KEYRING_FILE = Path("/usr/share/keyrings/xpra.asc")
25 XPRA_KEY_URL = "https://xpra.org/xpra.asc"
26 +XPRA_VERSION = "6.5.2-r0-1"
27 +GTK_RUNTIME_PACKAGE = "gir1.2-gtk-3.0"
28 +KALI_ROLLING_SOURCE = "deb http://http.kali.org/kali kali-rolling main contrib non-free non-free-firmware\n"
29 +XPRA_VERSIONED_RUNTIME_PACKAGES = frozenset(
30 + {
31 + "xpra-common",
32 + "xpra-server",
33 + "xpra-client",
34 + "xpra-client-gtk3",
35 + "xpra-x11",
36 + }
37 +)
38 RUNTIME_PACKAGES = (
39 *LIBREOFFICE_RUNTIME_PACKAGES,
40 + GTK_RUNTIME_PACKAGE,
41 + "xpra-common",
42 "xpra-server",
43 "xpra-client",
44 "xpra-client-gtk3",
@@ -54,10 +69,6 @@ RUNTIME_PACKAGES = (
69 "fonts-noto-cjk",
70 "fonts-noto-color-emoji",
71 )
57 -OPTIONAL_RUNTIME_PACKAGES = (
58 - "xpra-client",
59 - "xpra-client-gtk3",
60 -)
72 RETIRED_RUNTIME_PACKAGES = (
73 "firefox-esr",
74 )
@@ -202,6 +213,17 @@ def _package_installed(package: str) -> bool:
213 return result.returncode == 0 and "install ok installed" in result.stdout
214
215
216 +def _package_version(package: str) -> str:
217 + result = subprocess.run(
218 + ["dpkg-query", "-W", "-f=${Version}", package],
219 + check=False,
220 + text=True,
221 + capture_output=True,
222 + timeout=8,
223 + )
224 + return result.stdout.strip() if result.returncode == 0 else ""
225 +
226 +
227 def _purge_packages(
228 removed: list[str],
229 errors: list[str],
@@ -223,6 +245,8 @@ def _purge_packages(
245 def _ensure_runtime_dependencies(installed: list[str], errors: list[str]) -> None:
246 if os.geteuid() != 0 or not shutil.which("apt-get") or not shutil.which("dpkg-query"):
247 return
248 + if not _ensure_kali_gtk_runtime(installed, errors):
249 + return
250 missing = [package for package in RUNTIME_PACKAGES if not _package_installed(package)]
251 if not missing:
252 return
@@ -230,8 +254,7 @@ def _ensure_runtime_dependencies(installed: list[str], errors: list[str]) -> Non
254 if not _apt_update(errors):
255 return
256
233 - required_missing, optional_missing = _split_runtime_packages(missing)
234 - required_xpra_missing = [package for package in required_missing if package.startswith("xpra")]
257 + required_xpra_missing = [package for package in missing if package.startswith("xpra")]
258 if required_xpra_missing and not _package_candidates_available(required_xpra_missing):
259 previous_error_count = len(errors)
260 _ensure_xpra_repository(installed, errors)
@@ -240,47 +263,72 @@ def _ensure_runtime_dependencies(installed: list[str], errors: list[str]) -> Non
263 missing = [package for package in RUNTIME_PACKAGES if not _package_installed(package)]
264 if not missing:
265 return
243 - required_missing, optional_missing = _split_runtime_packages(missing)
266
245 - if required_missing and not _install_runtime_packages(required_missing, installed, errors):
246 - return
267 + if missing:
268 + _install_runtime_packages(missing, installed, errors)
269
248 - if optional_missing:
249 - optional_xpra_missing = [package for package in optional_missing if package.startswith("xpra")]
250 - if optional_xpra_missing and not _package_candidates_available(optional_xpra_missing):
251 - return
252 - _install_runtime_packages(optional_missing, installed, errors, optional=True)
270
271 +def _ensure_kali_gtk_runtime(installed: list[str], errors: list[str]) -> bool:
272 + if (
273 + GTK_RUNTIME_PACKAGE not in RUNTIME_PACKAGES
274 + or _package_installed(GTK_RUNTIME_PACKAGE)
275 + or _read_os_release().get("ID") != "kali"
276 + ):
277 + return True
278
255 -def _split_runtime_packages(packages: list[str]) -> tuple[list[str], list[str]]:
256 - optional = [package for package in packages if package in OPTIONAL_RUNTIME_PACKAGES]
257 - required = [package for package in packages if package not in OPTIONAL_RUNTIME_PACKAGES]
258 - return required, optional
279 + with tempfile.TemporaryDirectory(prefix="a0-desktop-apt-") as directory:
280 + source = Path(directory) / "kali-rolling.list"
281 + source.write_text(KALI_ROLLING_SOURCE, encoding="utf-8")
282 + options = [
283 + "-o",
284 + f"Dir::Etc::sourcelist={source}",
285 + "-o",
286 + "Dir::Etc::sourceparts=-",
287 + ]
288 + result = _run_apt_command(["apt-get", *options, "update"], timeout=300)
289 + if result.returncode == 0:
290 + result = _run_apt_command(
291 + [
292 + "apt-get",
293 + *options,
294 + "install",
295 + "-y",
296 + "--no-install-recommends",
297 + GTK_RUNTIME_PACKAGE,
298 + ],
299 + timeout=900,
300 + )
301 + if result.returncode == 0:
302 + installed.append(GTK_RUNTIME_PACKAGE)
303 + return True
304 + errors.append((result.stderr or result.stdout or "GTK runtime install failed").strip())
305 + return False
306
307
308 def _install_runtime_packages(
309 packages: list[str],
310 installed: list[str],
311 errors: list[str],
265 - *,
266 - optional: bool = False,
312 ) -> bool:
268 - result = _run_apt_command(["apt-get", "install", "-y", "--no-install-recommends", *packages], timeout=900)
313 + xpra_version = ""
314 + if any(package in XPRA_VERSIONED_RUNTIME_PACKAGES for package in packages):
315 + xpra_version = _package_version("xpra-common") or XPRA_VERSION
316 + package_specs = [
317 + f"{package}={xpra_version}" if package in XPRA_VERSIONED_RUNTIME_PACKAGES else package
318 + for package in packages
319 + ]
320 + result = _run_apt_command(
321 + ["apt-get", "install", "-y", "--no-install-recommends", *package_specs],
322 + timeout=900,
323 + )
324 if result.returncode == 0:
325 installed.extend(packages)
326 return True
327 output = (result.stderr or result.stdout or "apt-get install failed").strip()
273 - if optional and _is_xpra_codec_dependency_gap(output):
274 - return False
328 errors.append(output)
329 return False
330
331
279 -def _is_xpra_codec_dependency_gap(output: str) -> bool:
280 - normalized = output.lower()
281 - return "xpra-codecs" in normalized and "libvpx9" in normalized
282 -
283 -
332 def _apt_update(errors: list[str]) -> bool:
333 result = _run_apt_command(["apt-get", "update"], timeout=300)
334 if result.returncode == 0:
@@ -359,21 +407,8 @@ def _xpra_repository_source() -> str:
407 codename = os_release.get("VERSION_CODENAME", "")
408 arch = _dpkg_architecture()
409
362 - if os_id == "kali" and arch == "amd64":
363 - uri = "https://xpra.org/beta"
364 - suite = "sid"
365 - elif os_id == "kali":
366 - uri = "https://xpra.org"
367 - suite = "trixie"
368 - elif codename in {"sid", "forky"} and arch == "amd64":
369 - uri = "https://xpra.org/beta"
370 - suite = codename
371 - elif codename in {"sid", "forky"}:
372 - uri = "https://xpra.org"
373 - suite = "trixie"
374 - else:
375 - uri = "https://xpra.org"
376 - suite = codename or "trixie"
410 + uri = "https://xpra.org"
411 + suite = "trixie" if os_id == "kali" or codename in {"sid", "forky"} else codename or "trixie"
412
413 return (
414 f"Types: deb\n"
tests/test_browser_agent_regressions.py
+7
@@ -2357,6 +2357,7 @@ def test_browser_docker_installs_full_chromium_to_tmp_cache():
2357 assert "self.interactive_view.ensure_display()" in runtime
2358 assert " xvfb \\" in install_additional
2359 assert " xdotool \\" in install_additional
2360 + assert " xclip \\" in install_additional
2361
2362
2363 def test_browser_docker_pins_python_313_compatible_desktop_packages():
@@ -2370,8 +2371,14 @@ def test_browser_docker_pins_python_313_compatible_desktop_packages():
2371 assert 'XPRA_HTML5_VERSION="19-r1-1"' in install_additional
2372 assert 'XPRA_HTML5_VERSION="21-r1-1"' in install_additional
2373 assert '"python3-uno=$LIBREOFFICE_VERSION"' in install_additional
2374 + assert 'apt-get download "gir1.2-atk-1.0=$ATK_VERSION"' in install_additional
2375 + assert ' "$ATK_GIR_PACKAGE" \\' in install_additional
2376 + assert " gir1.2-gtk-3.0 \\" in install_additional
2377 + assert '"xpra-client=$XPRA_VERSION"' in install_additional
2378 + assert '"xpra-client-gtk3=$XPRA_VERSION"' in install_additional
2379 assert '"xpra-server=$XPRA_VERSION"' in install_additional
2380 assert '"xpra-html5=$XPRA_HTML5_VERSION"' in install_additional
2381 + assert install_additional.index("apt-get download") < install_additional.index('s/kali-rolling/$KALI_SUITE')
2382 assert "https://xpra.org/beta" not in install_additional
2383
2384
tests/test_office_document_store.py
+63 -18
@@ -1659,10 +1659,18 @@ def test_office_runtime_dependency_install_waits_out_apt_locks(monkeypatch):
1659
1660
1661 def test_desktop_runtime_packages_include_libreoffice_for_desktop_status():
1662 + install_additional = (
1663 + PROJECT_ROOT / "docker" / "run" / "fs" / "ins" / "install_additional.sh"
1664 + ).read_text(encoding="utf-8")
1665 +
1666 assert set(desktop_hooks.LIBREOFFICE_RUNTIME_PACKAGES).issubset(desktop_hooks.RUNTIME_PACKAGES)
1667 assert "libreoffice-writer" in desktop_hooks.RUNTIME_PACKAGES
1668 assert "libreoffice-calc" in desktop_hooks.RUNTIME_PACKAGES
1669 assert "libreoffice-impress" in desktop_hooks.RUNTIME_PACKAGES
1670 + assert desktop_hooks.GTK_RUNTIME_PACKAGE in desktop_hooks.RUNTIME_PACKAGES
1671 + assert "xpra-client" in desktop_hooks.RUNTIME_PACKAGES
1672 + assert "xpra-client-gtk3" in desktop_hooks.RUNTIME_PACKAGES
1673 + assert f'XPRA_VERSION="{desktop_hooks.XPRA_VERSION}"' in install_additional
1674
1675
1676 def test_desktop_cleanup_moves_retired_state_to_plugin_state(tmp_path, monkeypatch):
@@ -1756,8 +1764,8 @@ def test_cleanup_hook_enables_official_xpra_repo_when_kali_lacks_candidate(tmp_p
1764 assert errors == []
1765 assert installed == ["xpra"]
1766 assert keyring.read_bytes() == b"xpra-key"
1759 - assert "URIs: https://xpra.org/beta" in source.read_text(encoding="utf-8")
1760 - assert "Suites: sid" in source.read_text(encoding="utf-8")
1767 + assert "URIs: https://xpra.org\n" in source.read_text(encoding="utf-8")
1768 + assert "Suites: trixie" in source.read_text(encoding="utf-8")
1769 assert calls.count(["apt-get", "update"]) == 2
1770 assert calls[-1][:4] == ["apt-get", "install", "-y", "--no-install-recommends"]
1771
@@ -1803,24 +1811,19 @@ def test_cleanup_hook_uses_trixie_xpra_components_for_kali_arm64(tmp_path, monke
1811 assert "URIs: https://xpra.org\n" in source_text
1812 assert "Suites: trixie" in source_text
1813 assert "xpra" not in calls[-1]
1806 - assert calls[-1][-3:] == ["xpra-server", "xpra-x11", "xpra-html5"]
1814 + assert calls[-1][-3:] == [
1815 + f"xpra-server={desktop_hooks.XPRA_VERSION}",
1816 + f"xpra-x11={desktop_hooks.XPRA_VERSION}",
1817 + "xpra-html5",
1818 + ]
1819
1820
1809 -def test_cleanup_hook_skips_optional_xpra_client_codec_conflict(monkeypatch):
1821 +def test_cleanup_hook_installs_matching_xpra_client_stack(monkeypatch):
1822 calls = []
1823 installed_state = {
1812 - "xpra-server": True,
1824 "xpra-client": False,
1825 "xpra-client-gtk3": False,
1815 - "xpra-x11": True,
1816 - "xpra-html5": True,
1826 }
1818 - codec_error = (
1819 - "E: Unable to satisfy dependencies. Reached two conflicting assignments:\n"
1820 - " 1. xpra-codecs:arm64=6.4.3-r0-1 is selected for install\n"
1821 - " 2. xpra-codecs:arm64 Depends libvpx9 (>= 1.12.0)\n"
1822 - " but none of the choices are installable: [no choices]"
1823 - )
1827
1828 monkeypatch.setattr(desktop_hooks.os, "geteuid", lambda: 0)
1829 monkeypatch.setattr(
@@ -1831,16 +1834,18 @@ def test_cleanup_hook_skips_optional_xpra_client_codec_conflict(monkeypatch):
1834 monkeypatch.setattr(
1835 desktop_hooks,
1836 "RUNTIME_PACKAGES",
1834 - ("xpra-server", "xpra-client", "xpra-client-gtk3", "xpra-x11", "xpra-html5"),
1837 + ("xpra-client", "xpra-client-gtk3"),
1838 )
1839 monkeypatch.setattr(desktop_hooks, "_package_installed", lambda package: installed_state.get(package, False))
1840 + monkeypatch.setattr(desktop_hooks, "_package_version", lambda package: "6.5.2-r0-1")
1841
1842 def fake_run(command, **kwargs):
1843 calls.append(command)
1844 if command[:2] == ["apt-cache", "policy"]:
1841 - return types.SimpleNamespace(returncode=0, stdout="Candidate: 6.4.3-r0-1\n", stderr="")
1845 + return types.SimpleNamespace(returncode=0, stdout="Candidate: 6.5.3-r0-1\n", stderr="")
1846 if command[:2] == ["apt-get", "install"]:
1843 - return types.SimpleNamespace(returncode=100, stdout="", stderr=codec_error)
1847 + installed_state["xpra-client"] = True
1848 + installed_state["xpra-client-gtk3"] = True
1849 return types.SimpleNamespace(returncode=0, stdout="", stderr="")
1850
1851 monkeypatch.setattr(desktop_hooks.subprocess, "run", fake_run)
@@ -1849,9 +1854,49 @@ def test_cleanup_hook_skips_optional_xpra_client_codec_conflict(monkeypatch):
1854
1855 desktop_hooks._ensure_runtime_dependencies(installed, errors)
1856
1852 - assert installed == []
1857 + assert installed == ["xpra-client", "xpra-client-gtk3"]
1858 + assert errors == []
1859 + assert calls[-1][-2:] == ["xpra-client=6.5.2-r0-1", "xpra-client-gtk3=6.5.2-r0-1"]
1860 +
1861 +
1862 +def test_cleanup_hook_repairs_kali_gtk_from_rolling_source(monkeypatch):
1863 + calls = []
1864 + source_text = []
1865 + installed_state = {desktop_hooks.GTK_RUNTIME_PACKAGE: False}
1866 +
1867 + monkeypatch.setattr(desktop_hooks.os, "geteuid", lambda: 0)
1868 + monkeypatch.setattr(
1869 + desktop_hooks.shutil,
1870 + "which",
1871 + lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query"} else "",
1872 + )
1873 + monkeypatch.setattr(desktop_hooks, "RUNTIME_PACKAGES", (desktop_hooks.GTK_RUNTIME_PACKAGE,))
1874 + monkeypatch.setattr(desktop_hooks, "_read_os_release", lambda: {"ID": "kali"})
1875 + monkeypatch.setattr(desktop_hooks, "_package_installed", lambda package: installed_state.get(package, False))
1876 +
1877 + def fake_run(command, **kwargs):
1878 + calls.append(command)
1879 + source_option = next(
1880 + (item for item in command if item.startswith("Dir::Etc::sourcelist=")),
1881 + "",
1882 + )
1883 + if source_option:
1884 + source_text.append(Path(source_option.split("=", 1)[1]).read_text(encoding="utf-8"))
1885 + if "install" in command:
1886 + installed_state[desktop_hooks.GTK_RUNTIME_PACKAGE] = True
1887 + return types.SimpleNamespace(returncode=0, stdout="", stderr="")
1888 +
1889 + monkeypatch.setattr(desktop_hooks.subprocess, "run", fake_run)
1890 + installed = []
1891 + errors = []
1892 +
1893 + desktop_hooks._ensure_runtime_dependencies(installed, errors)
1894 +
1895 + assert installed == [desktop_hooks.GTK_RUNTIME_PACKAGE]
1896 assert errors == []
1854 - assert calls[-1][-2:] == ["xpra-client", "xpra-client-gtk3"]
1897 + assert source_text == [desktop_hooks.KALI_ROLLING_SOURCE, desktop_hooks.KALI_ROLLING_SOURCE]
1898 + assert calls[0][-1] == "update"
1899 + assert calls[1][-2:] == ["--no-install-recommends", desktop_hooks.GTK_RUNTIME_PACKAGE]
1900
1901
1902 def test_cleanup_hook_reports_required_xpra_codec_conflict(monkeypatch):