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