Make Desktop screenshots ephemeral by default

Route in-process Xpra/Desktop screenshot observations through context-scoped ephemeral image refs with vision_load payloads, matching the privacy posture of computer-use and browser screenshots. Keep desktopctl shell observations path-based with aggressive pruning so image payloads are not printed into shell logs, and preserve explicit screenshot paths as durable user-owned artifacts.

Alessandro committed May 22, 2026 at 10:21 UTC c1bdde057c58adc04dd63ff511bd2eb5f4160015
8 files changed +222 -36
plugins/_desktop/extensions/python/message_loop_prompts_after/_55_include_desktop_state.py
+2 -1
@@ -7,7 +7,8 @@ from plugins._desktop.helpers import prompt_context
7
8 class IncludeDesktopState(Extension):
9 async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
10 - context = prompt_context.build_context()
10 + context_id = str(getattr(getattr(self.agent, "context", None), "id", "") or "")
11 + context = prompt_context.build_context(context_id=context_id)
12 if not context:
13 loop_data.extras_temporary.pop("desktop_state", None)
14 return
plugins/_desktop/helpers/desktop_state.py
+148 -6
@@ -6,11 +6,14 @@ import os
6 import re
7 import shutil
8 import subprocess
9 +import sys
10 import time
11 from pathlib import Path
12 from typing import Any
13
14 PROJECT_ROOT = Path(__file__).resolve().parents[3]
15 +if str(PROJECT_ROOT) not in sys.path:
16 + sys.path.insert(0, str(PROJECT_ROOT))
17
18
19 SESSION_ID = "agent-zero-desktop"
@@ -20,9 +23,10 @@ STATE_DIR = BASE_DIR / "usr" / "plugins" / PLUGIN_NAME
23 RETIRED_STATE_DIR = BASE_DIR / "usr" / PLUGIN_NAME
24 SESSION_DIR = STATE_DIR / "sessions"
25 PROFILE_DIR = STATE_DIR / "profiles"
23 -SCREENSHOT_DIR = STATE_DIR / "screenshots"
26 +SCREENSHOT_DIR = Path(os.environ.get("A0_DESKTOP_SCREENSHOT_DIR") or BASE_DIR / "tmp" / "desktop" / "screenshots")
27 RECENT_SCREENSHOT_SECONDS = 600
28 _SAFE_CONTEXT_RE = re.compile(r"[^a-zA-Z0-9_.-]+")
29 +_SCREENSHOT_SUFFIXES = {".png", ".jpg", ".jpeg", ".xwd"}
30
31
32 def session_manifest_path(session_id: str = SESSION_ID) -> Path:
@@ -47,6 +51,7 @@ def collect_state(
51 include_screenshot: bool = False,
52 screenshot_path: str | Path | None = None,
53 context_id: str = "",
54 + screenshot_transport: str = "ephemeral",
55 ) -> dict[str, Any]:
56 errors: list[str] = []
57 env_info = resolve_environment(errors=errors)
@@ -72,9 +77,11 @@ def collect_state(
77 path=screenshot_path,
78 errors=errors,
79 context_id=context_id,
80 + transport=screenshot_transport,
81 )
82
83 return stable_state(
84 + context_id=context_id,
85 display=display,
86 profile_dir=profile_dir,
87 size=size,
@@ -94,6 +101,7 @@ def capture_screenshot(
101 path: str | Path | None = None,
102 errors: list[str] | None = None,
103 context_id: str = "",
104 + transport: str = "ephemeral",
105 ) -> dict[str, Any]:
106 local_errors = errors if errors is not None else []
107 capabilities = capabilities or collect_capabilities()
@@ -109,12 +117,18 @@ def capture_screenshot(
117 local_errors.append(message)
118 return {"ok": False, "path": "", "format": "", "captured_at": "", "error": message}
119
120 + explicit_path = path is not None and str(path).strip() != ""
121 + ephemeral_ref = not explicit_path and str(transport or "").strip().lower() != "path"
122 screenshot_dir = context_screenshot_dir(context_id)
113 - screenshot_dir.mkdir(parents=True, exist_ok=True)
123 + if not explicit_path:
124 + prune_context_screenshots(context_id=context_id)
125 + screenshot_dir.mkdir(parents=True, exist_ok=True)
126 timestamp = time.strftime("%Y%m%d-%H%M%S")
115 - target = Path(path) if path else screenshot_dir / f"desktop-{timestamp}.png"
127 + millis = int((time.time() % 1) * 1000)
128 + target = Path(path) if explicit_path else screenshot_dir / f"desktop-{timestamp}-{millis:03d}.png"
129 target.parent.mkdir(parents=True, exist_ok=True)
130 raw_path = target.with_suffix(".xwd")
131 + safe_context = _safe_context_id(context_id)
132
133 result = run([xwd, "-root", "-silent", "-out", str(raw_path)], env=env, timeout=8)
134 if result.returncode != 0:
@@ -124,12 +138,16 @@ def capture_screenshot(
138 return {"ok": False, "path": "", "format": "", "captured_at": "", "error": detail}
139
140 if target.suffix.lower() == ".xwd":
141 + if not explicit_path:
142 + prune_context_screenshots(context_id=context_id, keep_path=raw_path)
143 return {
144 "ok": True,
145 "path": str(raw_path),
146 "format": "xwd",
147 "captured_at": iso_now(),
148 "recent": True,
149 + "ephemeral": not explicit_path,
150 + "context_id": safe_context,
151 "error": "",
152 }
153
@@ -141,6 +159,16 @@ def capture_screenshot(
159 width = int(image.width)
160 height = int(image.height)
161 raw_path.unlink(missing_ok=True)
162 + if ephemeral_ref:
163 + return ephemeral_screenshot_result(
164 + target,
165 + context_id=context_id,
166 + image_format=target.suffix.lower().lstrip(".") or "png",
167 + width=width,
168 + height=height,
169 + )
170 + if not explicit_path:
171 + prune_context_screenshots(context_id=context_id, keep_path=target)
172 return {
173 "ok": True,
174 "path": str(target),
@@ -149,12 +177,24 @@ def capture_screenshot(
177 "height": height,
178 "captured_at": iso_now(),
179 "recent": True,
180 + "ephemeral": not explicit_path,
181 + "context_id": safe_context,
182 "error": "",
183 }
184 except Exception as exc:
185 try:
186 converted = convert_xwd_to_image(raw_path, target)
187 raw_path.unlink(missing_ok=True)
188 + if ephemeral_ref:
189 + return ephemeral_screenshot_result(
190 + target,
191 + context_id=context_id,
192 + image_format=target.suffix.lower().lstrip(".") or "png",
193 + width=converted["width"],
194 + height=converted["height"],
195 + )
196 + if not explicit_path:
197 + prune_context_screenshots(context_id=context_id, keep_path=target)
198 return {
199 "ok": True,
200 "path": str(target),
@@ -163,17 +203,34 @@ def capture_screenshot(
203 "height": converted["height"],
204 "captured_at": iso_now(),
205 "recent": True,
206 + "ephemeral": not explicit_path,
207 + "context_id": safe_context,
208 "error": "",
209 }
210 except Exception as fallback_exc:
211 message = f"Pillow could not convert the XWD screenshot: {exc}; fallback parser failed: {fallback_exc}"
212 local_errors.append(message)
213 + if ephemeral_ref:
214 + raw_path.unlink(missing_ok=True)
215 + target.unlink(missing_ok=True)
216 + return {
217 + "ok": False,
218 + "path": "",
219 + "format": "",
220 + "captured_at": iso_now(),
221 + "recent": False,
222 + "ephemeral": True,
223 + "context_id": safe_context,
224 + "error": message,
225 + }
226 return {
227 "ok": True,
228 "path": str(raw_path),
229 "format": "xwd",
230 "captured_at": iso_now(),
231 "recent": True,
232 + "ephemeral": not explicit_path,
233 + "context_id": safe_context,
234 "error": message,
235 }
236
@@ -518,17 +575,21 @@ def parse_xprop(output: str) -> dict[str, str]:
575
576
577 def latest_screenshot(*, context_id: str = "") -> dict[str, Any]:
578 + prune_context_screenshots(context_id=context_id, max_age_seconds=RECENT_SCREENSHOT_SECONDS)
579 screenshot_dir = context_screenshot_dir(context_id)
580 if not screenshot_dir.exists():
581 return {"ok": False, "path": "", "format": "", "captured_at": "", "recent": False}
582 candidates = [
583 path
584 for path in screenshot_dir.iterdir()
527 - if path.is_file() and path.suffix.lower() in {".png", ".jpg", ".jpeg", ".xwd"}
585 + if path.is_file() and path.suffix.lower() in _SCREENSHOT_SUFFIXES
586 ]
587 if not candidates:
588 return {"ok": False, "path": "", "format": "", "captured_at": "", "recent": False}
589 latest = max(candidates, key=lambda item: item.stat().st_mtime)
590 + for candidate in candidates:
591 + if candidate != latest:
592 + candidate.unlink(missing_ok=True)
593 age = max(0.0, time.time() - latest.stat().st_mtime)
594 return {
595 "ok": True,
@@ -536,6 +597,8 @@ def latest_screenshot(*, context_id: str = "") -> dict[str, Any]:
597 "format": latest.suffix.lower().lstrip("."),
598 "captured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(latest.stat().st_mtime)),
599 "recent": age <= RECENT_SCREENSHOT_SECONDS,
600 + "ephemeral": True,
601 + "context_id": _safe_context_id(context_id),
602 }
603
604
@@ -543,6 +606,7 @@ def stable_state(
606 *,
607 display: str,
608 profile_dir: str,
609 + context_id: str = "",
610 size: dict[str, int] | None = None,
611 pointer: dict[str, int] | None = None,
612 active_window: dict[str, Any] | None = None,
@@ -554,6 +618,7 @@ def stable_state(
618 clean_errors = [str(error) for error in errors or [] if str(error)]
619 return {
620 "ok": not clean_errors,
621 + "context_id": _safe_context_id(context_id),
622 "display": display,
623 "profile_dir": profile_dir,
624 "size": size or {"width": 0, "height": 0},
@@ -594,9 +659,15 @@ def compact_prompt_context(state: dict[str, Any] | None = None) -> str:
659 lines.append("- visible=" + "; ".join(visible))
660 screenshot = state.get("screenshot") or {}
661 if screenshot.get("recent") and screenshot.get("path"):
597 - lines.append(f"- recent_screenshot={screenshot['path']}")
662 + ephemeral = " ephemeral" if screenshot.get("ephemeral") else ""
663 + lines.append(f"- recent_screenshot={screenshot['path']}{ephemeral}")
664 + context_id = str(state.get("context_id") or "").strip()
665 + if context_id:
666 + lines.append(f"- screenshot_context={context_id}")
667 + context_arg = f" --context-id {context_id}" if context_id else ""
668 lines.append(
599 - "- next=plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh observe --json --screenshot "
669 + "- next=plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh observe --json --screenshot"
670 + f"{context_arg} "
671 "before any coordinate action; prefer focus/key/paste/save/app-native helpers first."
672 )
673 lines.append(
@@ -667,6 +738,75 @@ def image_height(path: Path) -> int:
738 return 0
739
740
741 +def ephemeral_screenshot_result(
742 + path: Path,
743 + *,
744 + context_id: str = "",
745 + image_format: str = "png",
746 + width: int = 0,
747 + height: int = 0,
748 +) -> dict[str, Any]:
749 + from helpers import ephemeral_images
750 +
751 + mime = "image/jpeg" if image_format.lower() in {"jpg", "jpeg"} else "image/png"
752 + safe_context = _safe_context_id(context_id)
753 + ref = ephemeral_images.put_image_bytes(
754 + context_id=str(context_id or "").strip(),
755 + mime=mime,
756 + payload=path.read_bytes(),
757 + name=path.name,
758 + )
759 + path.unlink(missing_ok=True)
760 + prune_context_screenshots(context_id=context_id)
761 + return {
762 + "ok": True,
763 + "path": "",
764 + "format": image_format,
765 + "mime": mime,
766 + "width": width,
767 + "height": height,
768 + "captured_at": iso_now(),
769 + "recent": True,
770 + "ephemeral": True,
771 + "ephemeral_ref": ref,
772 + "context_id": safe_context,
773 + "vision_load": {
774 + "tool_name": "vision_load",
775 + "tool_args": {"paths": [ref]},
776 + },
777 + "error": "",
778 + }
779 +
780 +
781 +def prune_context_screenshots(
782 + *,
783 + context_id: str = "",
784 + keep_path: Path | None = None,
785 + max_age_seconds: float | None = None,
786 +) -> None:
787 + screenshot_dir = context_screenshot_dir(context_id)
788 + if not screenshot_dir.exists():
789 + return
790 + keep = keep_path.resolve(strict=False) if keep_path else None
791 + now = time.time()
792 + for candidate in screenshot_dir.iterdir():
793 + if not candidate.is_file() or candidate.suffix.lower() not in _SCREENSHOT_SUFFIXES:
794 + continue
795 + if keep is not None and candidate.resolve(strict=False) == keep:
796 + continue
797 + if max_age_seconds is not None:
798 + try:
799 + if now - candidate.stat().st_mtime <= max_age_seconds:
800 + continue
801 + except OSError:
802 + pass
803 + candidate.unlink(missing_ok=True)
804 + try:
805 + screenshot_dir.rmdir()
806 + except OSError:
807 + pass
808 +
809 +
810 def iso_now() -> str:
811 return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
812
@@ -696,6 +836,7 @@ def main(argv: list[str] | None = None) -> int:
836 payload = collect_state(
837 include_screenshot=bool(args.screenshot),
838 context_id=str(args.context_id or ""),
839 + screenshot_transport="path",
840 )
841 print(json.dumps(payload, sort_keys=True))
842 return 0 if payload.get("ok") else 1
@@ -709,6 +850,7 @@ def main(argv: list[str] | None = None) -> int:
850 path=args.path,
851 errors=errors,
852 context_id=str(args.context_id or ""),
853 + transport="path",
854 )
855 if args.json:
856 print(json.dumps(payload, sort_keys=True))
plugins/_desktop/helpers/prompt_context.py
+2 -2
@@ -3,12 +3,12 @@ from __future__ import annotations
3 from plugins._desktop.helpers import desktop_state
4
5
6 -def build_context() -> str:
6 +def build_context(context_id: str = "") -> str:
7 if not desktop_state.session_manifest_exists():
8 return ""
9 try:
10 return desktop_state.compact_prompt_context(
11 - desktop_state.collect_state(include_screenshot=False),
11 + desktop_state.collect_state(include_screenshot=False, context_id=context_id),
12 )
13 except Exception as exc:
14 return (
plugins/_desktop/hooks.py
+7 -22
@@ -89,7 +89,7 @@ def cleanup_stale_runtime_state(force: bool = False) -> dict[str, Any]:
89 errors: list[str] = []
90
91 _migrate_retired_plugin_state(migrated, warnings, errors)
92 - _migrate_unscoped_screenshots(migrated, warnings, errors)
92 + _remove_persisted_screenshots(warnings, errors)
93
94 retired_packages = _installed_packages(RETIRED_RUNTIME_PACKAGES)
95 if retired_packages:
@@ -142,33 +142,18 @@ def _migrate_retired_plugin_state(
142 )
143
144
145 -def _migrate_unscoped_screenshots(
146 - migrated: list[str],
145 +def _remove_persisted_screenshots(
146 warnings: list[str],
147 errors: list[str],
148 ) -> None:
149 screenshots_dir = STATE_DIR / "screenshots"
150 if not screenshots_dir.exists():
151 return
153 - legacy_screenshots = [
154 - path
155 - for path in screenshots_dir.iterdir()
156 - if path.is_file() and path.suffix.lower() in {".png", ".jpg", ".jpeg", ".xwd"}
157 - ]
158 - if not legacy_screenshots:
159 - return
160 -
161 - context_dir = screenshots_dir / "default"
162 - context_dir.mkdir(parents=True, exist_ok=True)
163 - for screenshot in legacy_screenshots:
164 - state_migration.migrate_retired_state_tree(
165 - source=screenshot,
166 - destination=context_dir / screenshot.name,
167 - owner="Desktop screenshot",
168 - migrated=migrated,
169 - warnings=warnings,
170 - errors=errors,
171 - )
152 + try:
153 + shutil.rmtree(screenshots_dir)
154 + warnings.append(f"Removed retired persistent Desktop screenshots: {screenshots_dir}")
155 + except Exception as exc:
156 + errors.append(f"Failed to remove retired persistent Desktop screenshots at {screenshots_dir}: {exc}")
157
158
159 def _begin_runtime_preparation() -> None:
plugins/_desktop/skills/linux-desktop/SKILL.md
+2 -2
@@ -30,7 +30,7 @@ The Desktop is an observe-act-verify control surface. Use this decision hierarch
30 3. Prefer launcher commands, window focus, keyboard shortcuts, menus, paste, and save commands.
31 4. Use coordinate clicks only as a last resort, and only after a fresh Desktop observation.
32 5. After any GUI action, verify through Desktop state, active window titles, screenshots, saved file state, or exported output.
33 -6. For terminal or CLI-agent work, verify against a fresh final `observe --json --screenshot` captured after the command has finished or visibly returned to an input prompt. Do not report from an earlier screenshot path.
33 +6. For terminal or CLI-agent work, verify against a fresh final `observe --json --screenshot` captured after the command has finished or visibly returned to an input prompt. Agent-facing Desktop screenshots are ephemeral refs; `desktopctl` shell observations return temporary context paths. Do not report from an earlier screenshot path.
34
35 Keep these standing rules:
36
@@ -60,7 +60,7 @@ $DESKTOP key ctrl+s
60
61 The script targets the persistent `agent-zero-desktop` X display, sets `DISPLAY`, `XAUTHORITY`, and `HOME` to the XFCE profile, then uses `xdotool` for input. Startup normally prepares this session. If `check` fails during explicit Desktop work, report that the Desktop runtime is not ready instead of installing packages ad hoc.
62
63 -If `observe --json --screenshot` shows a reachable display, visible Desktop/window entries, and a fresh screenshot, the Desktop is usable even when `active_window` is `null`; a bare XFCE desktop can have no active application window. Treat missing screenshots, missing display, or unavailable `xdotool`/`xwd` as blockers and stop with the specific readiness message instead of repeating clicks or inventing a fallback.
63 +If `observe --json --screenshot` shows a reachable display, visible Desktop/window entries, and a fresh screenshot, the Desktop is usable even when `active_window` is `null`; a bare XFCE desktop can have no active application window. Treat missing screenshots, missing display, or unavailable `xdotool`/`xwd` as blockers and stop with the specific readiness message instead of repeating clicks or inventing a fallback. Use any returned shell screenshot path promptly; only the latest temporary context screenshot is retained.
64
65 For direct app launches without coordinates:
66
plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh
+1 -1
@@ -60,7 +60,7 @@ Commands:
60 observe --json [--screenshot] [--context-id ID]
61 Return structured state, optionally with a fresh screenshot.
62 screenshot [PATH] [--context-id ID]
63 - Capture the Desktop to PATH, or to the default screenshot directory.
63 + Capture the Desktop to PATH, or to the temporary context screenshot directory.
64 active-window Print the active window name.
65 geometry PATTERN Print the first matching visible window geometry.
66 wait-window PATTERN Wait for a visible matching window and print its id.
tests/test_office_desktop_state.py
+58 -1
@@ -185,11 +185,12 @@ def test_desktop_state_screenshot_capture_uses_xwd_and_pillow_when_available(tmp
185 assert screenshot["ok"] is True
186 assert screenshot["path"] == str(tmp_path / "shot.png")
187 assert screenshot["format"] == "png"
188 + assert screenshot["ephemeral"] is False
189 assert (tmp_path / "shot.png").read_bytes() == b"png"
190 assert not (tmp_path / "shot.xwd").exists()
191
192
192 -def test_desktop_state_default_screenshot_path_is_context_scoped(tmp_path, monkeypatch):
193 +def test_desktop_state_shell_screenshot_path_is_context_scoped(tmp_path, monkeypatch):
194 monkeypatch.setattr(desktop_state, "SCREENSHOT_DIR", tmp_path)
195 capabilities = {"xwd": "/usr/bin/xwd"}
196 env = {"DISPLAY": ":120"}
@@ -221,19 +222,75 @@ def test_desktop_state_default_screenshot_path_is_context_scoped(tmp_path, monke
222 monkeypatch.setattr(desktop_state, "run", fake_run)
223 monkeypatch.setitem(sys.modules, "PIL", pil_module)
224 monkeypatch.setitem(sys.modules, "PIL.Image", image_module)
225 + stale_path = tmp_path / "ctx_id" / "stale.png"
226 + stale_path.parent.mkdir(parents=True)
227 + stale_path.write_bytes(b"stale")
228
229 screenshot = desktop_state.capture_screenshot(
230 env,
231 capabilities,
232 errors=[],
233 context_id="ctx/id",
234 + transport="path",
235 )
236
237 path = Path(screenshot["path"])
238 assert screenshot["ok"] is True
239 + assert screenshot["ephemeral"] is True
240 + assert screenshot["context_id"] == "ctx_id"
241 assert path.parent == tmp_path / "ctx_id"
242 assert path.name.startswith("desktop-")
243 assert desktop_state.latest_screenshot(context_id="ctx/id")["path"] == str(path)
244 + assert not stale_path.exists()
245 +
246 +
247 +def test_desktop_state_default_screenshot_returns_ephemeral_ref(tmp_path, monkeypatch):
248 + monkeypatch.setattr(desktop_state, "SCREENSHOT_DIR", tmp_path)
249 + capabilities = {"xwd": "/usr/bin/xwd"}
250 + env = {"DISPLAY": ":120"}
251 +
252 + def fake_run(command, *, env, timeout):
253 + raw_path = Path(command[command.index("-out") + 1])
254 + raw_path.write_bytes(b"xwd")
255 + return _completed(command)
256 +
257 + image_module = types.ModuleType("PIL.Image")
258 +
259 + class FakeImage:
260 + width = 320
261 + height = 240
262 +
263 + def __enter__(self):
264 + return self
265 +
266 + def __exit__(self, *_args):
267 + return False
268 +
269 + def save(self, target):
270 + Path(target).write_bytes(b"png")
271 +
272 + image_module.open = lambda _path: FakeImage()
273 + pil_module = types.ModuleType("PIL")
274 + pil_module.Image = image_module
275 +
276 + monkeypatch.setattr(desktop_state, "run", fake_run)
277 + monkeypatch.setitem(sys.modules, "PIL", pil_module)
278 + monkeypatch.setitem(sys.modules, "PIL.Image", image_module)
279 +
280 + screenshot = desktop_state.capture_screenshot(
281 + env,
282 + capabilities,
283 + errors=[],
284 + context_id="ctx/id",
285 + )
286 +
287 + assert screenshot["ok"] is True
288 + assert screenshot["path"] == ""
289 + assert screenshot["ephemeral"] is True
290 + assert screenshot["ephemeral_ref"].startswith("a0-ephemeral-image://")
291 + assert screenshot["vision_load"]["tool_args"]["paths"] == [screenshot["ephemeral_ref"]]
292 + assert screenshot["context_id"] == "ctx_id"
293 + assert not (tmp_path / "ctx_id").exists()
294
295
296 def test_xwd_fallback_parser_handles_truecolor_pixels(tmp_path, monkeypatch):
tests/test_office_document_store.py
+2 -1
@@ -1631,7 +1631,8 @@ def test_desktop_cleanup_moves_retired_state_to_plugin_state(tmp_path, monkeypat
1631 assert result["ok"] is True
1632 assert (plugin_state / "profiles" / "agent-zero-desktop" / "profile.txt").read_text(encoding="utf-8") == "profile\n"
1633 assert (plugin_state / "sessions" / "agent-zero-desktop.json").read_text(encoding="utf-8") == "{}\n"
1634 - assert (plugin_state / "screenshots" / "default" / "desktop.png").read_bytes() == b"png"
1634 + assert not (plugin_state / "screenshots").exists()
1635 + assert any("Removed retired persistent Desktop screenshots" in warning for warning in result["warnings"])
1636 assert not retired_state.exists()
1637
1638