Agent Zero AI framework

Persist Agent Zero Desktop lifecycle

Keep one Xpra Desktop iframe alive across canvas, modal, and keepalive hosts instead of unloading it during normal UI handoffs. Add intentional shutdown/restart state so explicit shutdown is treated as closed, not crashed. Add the desktop_shutdown Office API path, backend system-desktop shutdown cleanup, and an XFCE panel Shutdown Desktop launcher that requires a second click before writing the shutdown request marker. Hide unsafe logout, lock, and switch-user affordances and cover the lifecycle with focused tests.

Alessandro committed May 5, 2026 at 12:20 UTC 9390e42bcc5781f58e3f2d6aed95b5d50970405c
7 files changed +788 -47
plugins/_office/api/office_session.py
+9
@@ -63,6 +63,8 @@ class OfficeSession(ApiHandler):
63 return self._desktop_sync(input)
64 if action == "desktop_state":
65 return self._desktop_state(input)
66 + if action == "desktop_shutdown":
67 + return self._desktop_shutdown(input)
68 return {"ok": False, "error": f"Unsupported office session action: {action}"}
69
70 async def _open_document(self, doc: dict, input: dict, request: Request) -> dict:
@@ -196,6 +198,13 @@ class OfficeSession(ApiHandler):
198 include_screenshot = bool(input.get("include_screenshot") is True)
199 return libreoffice_desktop.get_manager().state(include_screenshot=include_screenshot)
200
201 + def _desktop_shutdown(self, input: dict) -> dict:
202 + save_first = input.get("save_first") is not False
203 + return libreoffice_desktop.get_manager().shutdown_system_desktop(
204 + save_first=save_first,
205 + source=str(input.get("source") or "api"),
206 + )
207 +
208 def _origin(self, request: Request) -> str:
209 origin = request.headers.get("Origin") or request.host_url.rstrip("/")
210 return origin.rstrip("/")
plugins/_office/extensions/webui/right-canvas-panels/office-panel.html
+1 -1
@@ -2,7 +2,7 @@
2 class="right-canvas-surface-panel office-canvas-surface"
3 data-surface-id="office"
4 x-show="$store.rightCanvas && $store.rightCanvas.isOpen && $store.rightCanvas.activeSurfaceId === 'office'"
5 - x-effect="$store.office?.setDesktopHostVisible?.($store.rightCanvas && $store.rightCanvas.isOpen && $store.rightCanvas.activeSurfaceId === 'office')"
5 + x-effect="(() => { const visible = Boolean($store.rightCanvas && $store.rightCanvas.isOpen && $store.rightCanvas.activeSurfaceId === 'office'); (globalThis.queueMicrotask || ((callback) => globalThis.setTimeout(callback, 0)))(() => $store.office?.setDesktopHostVisible?.(visible)); })()"
6 style="display: none;"
7 >
8 <x-component path="/plugins/_office/webui/office-panel.html"></x-component>
plugins/_office/helpers/libreoffice_desktop.py
+240 -9
@@ -50,6 +50,10 @@ HIDDEN_XFCE_MENU_ENTRIES = (
50 ("exo-web-browser.desktop", "Web Browser"),
51 ("xfce4-mail-reader.desktop", "Mail Reader"),
52 ("xfce4-web-browser.desktop", "Web Browser"),
53 + ("xfce4-session-logout.desktop", "Log Out"),
54 + ("xfce4-lock-screen.desktop", "Lock Screen"),
55 + ("xflock4.desktop", "Lock Screen"),
56 + ("xfce4-switch-user.desktop", "Switch User"),
57 )
58 DESKTOP_README_SOURCE = Path(__file__).resolve().parents[1] / "assets" / "desktop" / "README.md"
59 DESKTOP_FOLDER_LINKS = (
@@ -61,6 +65,9 @@ DESKTOP_FOLDER_LINKS = (
65 URL_INTENT_MAX_ITEMS = 50
66 URL_INTENT_MAX_LENGTH = 8192
67 URL_HANDLER_DESKTOP_ID = "agent-zero-browser.desktop"
68 +SHUTDOWN_HANDLER_DESKTOP_ID = "agent-zero-shutdown.desktop"
69 +SHUTDOWN_PANEL_LAUNCHER_ID = SHUTDOWN_HANDLER_DESKTOP_ID
70 +SHUTDOWN_CONFIRM_SECONDS = 8
71 OOR_NS = "http://openoffice.org/2001/registry"
72 XS_NS = "http://www.w3.org/2001/XMLSchema"
73 XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
@@ -197,6 +204,12 @@ class LibreOfficeDesktopManager:
204 except Exception:
205 pass
206 url_intents = self.claim_url_intents(session.session_id)
207 + shutdown_request = self.claim_shutdown_request(session.session_id)
208 + if shutdown_request:
209 + return self.shutdown_system_desktop(
210 + save_first=True,
211 + source=str(shutdown_request.get("source") or "tray"),
212 + )
213 doc = self._document_for_save(session, file_id)
214 if not doc:
215 return {
@@ -224,6 +237,50 @@ class LibreOfficeDesktopManager:
237 return []
238 return _claim_url_intents(session)
239
240 + def claim_shutdown_request(self, session_id: str = SYSTEM_SESSION_ID) -> dict[str, Any] | None:
241 + session = self.get(session_id) or self.get(SYSTEM_SESSION_ID)
242 + if not session:
243 + return None
244 + return _claim_shutdown_request(session)
245 +
246 + def shutdown_system_desktop(self, *, save_first: bool = True, source: str = "api") -> dict[str, Any]:
247 + with self._lock:
248 + session = self._sessions.get(SYSTEM_SESSION_ID)
249 + if not session:
250 + _remove_system_manifest()
251 + return {
252 + "ok": True,
253 + "closed": 0,
254 + "session_id": SYSTEM_SESSION_ID,
255 + "shutdown": True,
256 + "intentional_shutdown": True,
257 + "source": source,
258 + }
259 +
260 + save_result = None
261 + if save_first:
262 + try:
263 + save_result = self.save(session.session_id)
264 + except Exception as exc:
265 + save_result = {"ok": False, "error": str(exc)}
266 +
267 + with self._lock:
268 + if self._sessions.get(SYSTEM_SESSION_ID) is session:
269 + self._sessions.pop(SYSTEM_SESSION_ID, None)
270 + virtual_desktop.unregister_session(session.token)
271 + self._terminate_session(session)
272 + self._remove_manifest(session.session_id)
273 + _clear_shutdown_request(session)
274 + return {
275 + "ok": True,
276 + "closed": 1,
277 + "session_id": session.session_id,
278 + "shutdown": True,
279 + "intentional_shutdown": True,
280 + "source": source,
281 + "save": save_result,
282 + }
283 +
284 def retarget_document(self, file_id: str, doc: dict[str, Any]) -> dict[str, Any]:
285 session = self._find_by_file_id(file_id)
286 if not session:
@@ -643,6 +700,7 @@ class LibreOfficeDesktopManager:
700 applications_dir.mkdir(parents=True, exist_ok=True)
701
702 browser_bridge = _write_url_bridge_script(session)
703 + shutdown_bridge = _write_shutdown_bridge_script(session)
704 helpers_rc = config_dir / "xfce4" / "helpers.rc"
705 helpers_rc.parent.mkdir(parents=True, exist_ok=True)
706 helpers_rc.write_text(
@@ -672,6 +730,23 @@ class LibreOfficeDesktopManager:
730 mime_types=_url_handler_mime_types(),
731 no_display=True,
732 )
733 + _write_desktop_launcher(
734 + applications_dir / SHUTDOWN_HANDLER_DESKTOP_ID,
735 + name="Shutdown Desktop",
736 + exec_line=_desktop_exec(shutdown_bridge),
737 + icon="system-shutdown",
738 + categories="System;",
739 + try_exec=str(shutdown_bridge),
740 + no_display=True,
741 + )
742 + _write_desktop_launcher(
743 + config_dir / "xfce4" / "panel" / "launcher-9" / SHUTDOWN_HANDLER_DESKTOP_ID,
744 + name="Shutdown Desktop",
745 + exec_line=_desktop_exec(shutdown_bridge),
746 + icon="system-shutdown",
747 + categories="System;",
748 + try_exec=str(shutdown_bridge),
749 + )
750 _write_desktop_launcher(
751 desktop_dir / "Browser.desktop",
752 name="Browser",
@@ -737,7 +812,9 @@ class LibreOfficeDesktopManager:
812 ET.SubElement(plugins, "property", {"name": "plugin-6", "type": "string", "value": "separator"})
813 ET.SubElement(plugins, "property", {"name": "plugin-7", "type": "string", "value": "clock"})
814 ET.SubElement(plugins, "property", {"name": "plugin-8", "type": "string", "value": "separator"})
740 - ET.SubElement(plugins, "property", {"name": "plugin-9", "type": "string", "value": "actions"})
815 + shutdown = ET.SubElement(plugins, "property", {"name": "plugin-9", "type": "string", "value": "launcher"})
816 + shutdown_items = ET.SubElement(shutdown, "property", {"name": "items", "type": "array"})
817 + ET.SubElement(shutdown_items, "value", {"type": "string", "value": SHUTDOWN_PANEL_LAUNCHER_ID})
818
819 tree = ET.ElementTree(root)
820 try:
@@ -764,15 +841,7 @@ if command -v xfconf-query >/dev/null 2>&1; then
841 xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-filesystem -n -t bool -s false >/dev/null 2>&1 || true
842 xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-removable -n -t bool -s false >/dev/null 2>&1 || true
843 xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-trash -n -t bool -s false >/dev/null 2>&1 || true
767 - xfconf-query -c xfce4-panel -p /panels -n -a -t int -s 1 >/dev/null 2>&1 || true
768 - xfconf-query -c xfce4-panel -p /panels/panel-2 -r -R >/dev/null 2>&1 || true
769 - xfconf-query -c xfce4-panel -p /panels/panel-1/plugin-ids -r -R >/dev/null 2>&1 || true
770 - xfconf-query -c xfce4-panel -p /panels/panel-1/plugin-ids -n -a -t int -s 1 -t int -s 2 -t int -s 3 -t int -s 4 -t int -s 5 -t int -s 6 -t int -s 7 -t int -s 8 -t int -s 9 >/dev/null 2>&1 || true
771 - for plugin_id in $(seq 10 30); do
772 - xfconf-query -c xfce4-panel -p "/plugins/plugin-${plugin_id}" -r -R >/dev/null 2>&1 || true
773 - done
844 fi
775 -rm -rf "$XDG_CONFIG_HOME"/xfce4/panel/launcher-* 2>/dev/null || true
845 for launcher in "$HOME"/Desktop/*.desktop; do
846 [ -f "$launcher" ] || continue
847 chmod +x "$launcher" 2>/dev/null || true
@@ -1281,6 +1350,18 @@ def _url_bridge_lock_path(session: DesktopSession) -> Path:
1350 return _url_bridge_dir(session) / "browser-url-intents.lock"
1351
1352
1353 +def _shutdown_request_path(session: DesktopSession) -> Path:
1354 + return _url_bridge_dir(session) / "shutdown-request.json"
1355 +
1356 +
1357 +def _shutdown_arm_path(session: DesktopSession) -> Path:
1358 + return _url_bridge_dir(session) / "shutdown-request.arm.json"
1359 +
1360 +
1361 +def _shutdown_lock_path(session: DesktopSession) -> Path:
1362 + return _url_bridge_dir(session) / "shutdown-request.lock"
1363 +
1364 +
1365 def _write_url_bridge_script(session: DesktopSession) -> Path:
1366 bridge_dir = _url_bridge_dir(session)
1367 bridge_dir.mkdir(parents=True, exist_ok=True)
@@ -1331,6 +1412,117 @@ if __name__ == "__main__":
1412 return script
1413
1414
1415 +def _write_shutdown_bridge_script(session: DesktopSession) -> Path:
1416 + bridge_dir = _url_bridge_dir(session)
1417 + bridge_dir.mkdir(parents=True, exist_ok=True)
1418 + script = bridge_dir / "shutdown-desktop"
1419 + request = _shutdown_request_path(session)
1420 + arm = _shutdown_arm_path(session)
1421 + lock = _shutdown_lock_path(session)
1422 + script.write_text(
1423 + f"""#!/usr/bin/env python3
1424 +import fcntl
1425 +import json
1426 +import os
1427 +import shutil
1428 +import subprocess
1429 +import time
1430 +
1431 +REQUEST_PATH = {str(request)!r}
1432 +ARM_PATH = {str(arm)!r}
1433 +LOCK_PATH = {str(lock)!r}
1434 +CONFIRM_SECONDS = {SHUTDOWN_CONFIRM_SECONDS}
1435 +
1436 +
1437 +def notify(message, timeout=None):
1438 + if not os.environ.get("DISPLAY"):
1439 + return
1440 + xmessage = shutil.which("xmessage")
1441 + if not xmessage:
1442 + return
1443 + try:
1444 + subprocess.Popen(
1445 + [
1446 + xmessage,
1447 + "-buttons",
1448 + "",
1449 + "-timeout",
1450 + str(timeout or CONFIRM_SECONDS),
1451 + "-center",
1452 + message,
1453 + ],
1454 + stdin=subprocess.DEVNULL,
1455 + stdout=subprocess.DEVNULL,
1456 + stderr=subprocess.DEVNULL,
1457 + start_new_session=True,
1458 + )
1459 + except OSError:
1460 + pass
1461 +
1462 +
1463 +def read_arm(now):
1464 + try:
1465 + with open(ARM_PATH, "r", encoding="utf-8") as handle:
1466 + payload = json.load(handle)
1467 + except (OSError, json.JSONDecodeError):
1468 + return None
1469 + try:
1470 + created_at = float(payload.get("created_at"))
1471 + except (TypeError, ValueError):
1472 + return None
1473 + if now - created_at > CONFIRM_SECONDS:
1474 + return None
1475 + return created_at
1476 +
1477 +
1478 +def write_json_atomic(path, payload):
1479 + tmp_path = path + ".tmp"
1480 + with open(tmp_path, "w", encoding="utf-8") as handle:
1481 + json.dump(payload, handle, ensure_ascii=True)
1482 + handle.write("\\n")
1483 + handle.flush()
1484 + os.fsync(handle.fileno())
1485 + os.replace(tmp_path, path)
1486 +
1487 +
1488 +def main():
1489 + os.makedirs(os.path.dirname(REQUEST_PATH), exist_ok=True)
1490 + now = time.time()
1491 + with open(LOCK_PATH, "a+", encoding="utf-8") as lock_file:
1492 + fcntl.flock(lock_file, fcntl.LOCK_EX)
1493 + armed_at = read_arm(now)
1494 + if armed_at is None:
1495 + write_json_atomic(ARM_PATH, {{"created_at": now, "source": "tray"}})
1496 + notify(
1497 + f"Shutdown Desktop armed. Click Shutdown Desktop again within {{CONFIRM_SECONDS}} seconds to close it.",
1498 + CONFIRM_SECONDS,
1499 + )
1500 + return
1501 + try:
1502 + os.unlink(ARM_PATH)
1503 + except OSError:
1504 + pass
1505 + payload = {{
1506 + "created_at": now,
1507 + "armed_at": armed_at,
1508 + "source": "tray",
1509 + }}
1510 + write_json_atomic(REQUEST_PATH, payload)
1511 + notify("Shutting down Agent Zero Desktop.", 2)
1512 +
1513 +
1514 +if __name__ == "__main__":
1515 + main()
1516 +""",
1517 + encoding="utf-8",
1518 + )
1519 + try:
1520 + script.chmod(0o755)
1521 + except OSError:
1522 + pass
1523 + return script
1524 +
1525 +
1526 def _claim_url_intents(session: DesktopSession) -> list[dict[str, Any]]:
1527 queue = _url_bridge_queue_path(session)
1528 lock = _url_bridge_lock_path(session)
@@ -1374,6 +1566,45 @@ def _claim_url_intents(session: DesktopSession) -> list[dict[str, Any]]:
1566 return intents
1567
1568
1569 +def _claim_shutdown_request(session: DesktopSession) -> dict[str, Any] | None:
1570 + request = _shutdown_request_path(session)
1571 + if not request.exists():
1572 + return None
1573 + try:
1574 + raw = request.read_text(encoding="utf-8")
1575 + request.unlink(missing_ok=True)
1576 + except OSError:
1577 + return None
1578 + try:
1579 + payload = json.loads(raw)
1580 + except json.JSONDecodeError:
1581 + payload = {}
1582 + created_at = payload.get("created_at")
1583 + try:
1584 + created_at = float(created_at)
1585 + except (TypeError, ValueError):
1586 + created_at = time.time()
1587 + return {
1588 + "created_at": created_at,
1589 + "source": str(payload.get("source") or "tray"),
1590 + }
1591 +
1592 +
1593 +def _clear_shutdown_request(session: DesktopSession) -> None:
1594 + request = _shutdown_request_path(session)
1595 + arm = _shutdown_arm_path(session)
1596 + lock = _shutdown_lock_path(session)
1597 + request.unlink(missing_ok=True)
1598 + request.with_suffix(request.suffix + ".tmp").unlink(missing_ok=True)
1599 + arm.unlink(missing_ok=True)
1600 + arm.with_suffix(arm.suffix + ".tmp").unlink(missing_ok=True)
1601 + lock.unlink(missing_ok=True)
1602 +
1603 +
1604 +def _remove_system_manifest() -> None:
1605 + (SESSION_DIR / f"{SYSTEM_SESSION_ID}.json").unlink(missing_ok=True)
1606 +
1607 +
1608 def _url_handler_mime_types() -> tuple[str, ...]:
1609 return (
1610 "x-scheme-handler/http",
plugins/_office/webui/office-panel.html
+38 -14
@@ -103,16 +103,11 @@
103 <div class="office-editor-wrap" x-show="$store.office.session" style="display: none;">
104 <div class="office-editor-scroll" :class="{ 'is-desktop': $store.office.hasOfficialOffice(), 'is-source': $store.office.isMarkdown() }" @click.self="$store.office.focusEditor()">
105 <template x-if="$store.office.hasOfficialOffice()">
106 - <div class="office-desktop-wrap">
107 - <iframe
108 - class="office-desktop-frame"
109 - data-office-desktop-frame
110 - tabindex="0"
111 - :src="$store.office.officialOfficeUrl()"
112 - aria-label="Desktop"
113 - allow="clipboard-read; clipboard-write; autoplay"
114 - @load="$store.office.onDesktopFrameLoaded($event)"
115 - ></iframe>
106 + <div
107 + class="office-desktop-wrap"
108 + data-office-desktop-host
109 + x-init="$nextTick(() => $store.office.mountDesktopFrameHost($el))"
110 + >
111 </div>
112 </template>
113
@@ -130,6 +125,15 @@
125
126 </div>
127 </div>
128 +
129 + <div class="office-desktop-empty" x-show="$store.office.shouldShowDesktopEmptyState()" style="display: none;">
130 + <span class="material-symbols-outlined" aria-hidden="true">power_settings_new</span>
131 + <span class="office-desktop-empty-title">Desktop is shut down</span>
132 + <button type="button" class="office-icon-button office-command-button" @click="$store.office.restartDesktopSession()">
133 + <span class="material-symbols-outlined" aria-hidden="true">restart_alt</span>
134 + <span class="office-button-label">Restart Desktop</span>
135 + </button>
136 + </div>
137 </div>
138 </div>
139 </template>
@@ -460,10 +464,7 @@
464 flex: 1 1 auto;
465 min-height: 0;
466 overflow: hidden;
463 - background:
464 - linear-gradient(90deg, rgba(44, 123, 229, 0.05), transparent 38%),
465 - linear-gradient(180deg, rgba(44, 165, 141, 0.04), transparent 46%),
466 - #eef2f7;
467 + background: var(--color-background);
468 }
469
470 .office-body.is-source {
@@ -510,6 +511,29 @@
511 background: #1f2329;
512 }
513
514 + .office-desktop-empty {
515 + display: grid;
516 + flex: 1 1 auto;
517 + place-items: center;
518 + align-content: center;
519 + gap: 12px;
520 + min-width: 0;
521 + min-height: 0;
522 + padding: 24px;
523 + color: var(--color-text-secondary);
524 + text-align: center;
525 + }
526 +
527 + .office-desktop-empty > .material-symbols-outlined {
528 + font-size: 32px;
529 + color: color-mix(in srgb, var(--color-text) 68%, transparent);
530 + }
531 +
532 + .office-desktop-empty-title {
533 + font-size: 13px;
534 + font-weight: 700;
535 + }
536 +
537 .office-desktop-frame {
538 flex: 1 1 auto;
539 width: 100%;
plugins/_office/webui/office-store.js
+276 -19
@@ -19,6 +19,7 @@ const SYSTEM_DESKTOP_FILE_ID = "system-desktop";
19 const BROWSER_MODAL_PATH = "/plugins/_browser/webui/main.html";
20 const OFFICE_MODAL_PATH = "/plugins/_office/webui/main.html";
21 const URL_INTENT_PANEL_TIMEOUT_MS = 5000;
22 +const DESKTOP_SHUTDOWN_STORAGE_KEY = "a0.office.desktopShutdown";
23 const MAX_HISTORY = 80;
24
25 function currentContextId() {
@@ -236,8 +237,14 @@ const model = {
237 _desktopStarting: null,
238 _desktopUrlIntentBusy: false,
239 _desktopUrlIntentQueue: [],
240 + _desktopFrame: null,
241 + _desktopFrameHost: null,
242 + _desktopFrameLoadHandler: null,
243 + _desktopKeepaliveHost: null,
244 + _desktopIntentionalShutdown: false,
245
246 async init(element = null) {
247 + this.restoreDesktopShutdownState();
248 return await this.onMount(element, { mode: "canvas" });
249 },
250
@@ -254,12 +261,18 @@ const model = {
261 },
262
263 async onOpen(payload = {}) {
264 + this.restoreDesktopShutdownState();
265 await this.refresh();
266 if (payload?.path || payload?.file_id) {
267 await this.openSession({
268 path: payload.path || "",
269 file_id: payload.file_id || "",
270 });
271 + } else if (this._desktopIntentionalShutdown) {
272 + this.session = null;
273 + this.activeTabId = "";
274 + this.editorText = "";
275 + this.dirty = false;
276 } else {
277 await this.ensureDesktopSession({ select: !this.session });
278 }
@@ -271,6 +284,9 @@ const model = {
284 this._desktopHostVisible = false;
285 this.flushInput();
286 this.clearDesktopViewportSyncTimers();
287 + this.stopDesktopMonitor();
288 + this.stopDesktopKeyboardBridge();
289 + this.stopDesktopClipboardBridge();
290 this.unloadDesktopFrames();
291 },
292
@@ -282,6 +298,7 @@ const model = {
298 this.stopXpraDesktopPrime();
299 this.stopDesktopKeyboardBridge();
300 this.stopDesktopClipboardBridge();
301 + if (!this._desktopIntentionalShutdown) this.moveDesktopFrameToKeepalive();
302 this._floatingCleanup?.();
303 this._floatingCleanup = null;
304 if (this._mode === "modal") this._root = null;
@@ -297,7 +314,110 @@ const model = {
314 }
315 },
316
317 + restoreDesktopShutdownState() {
318 + try {
319 + this._desktopIntentionalShutdown = localStorage.getItem(DESKTOP_SHUTDOWN_STORAGE_KEY) === "1";
320 + } catch {
321 + this._desktopIntentionalShutdown = Boolean(this._desktopIntentionalShutdown);
322 + }
323 + },
324 +
325 + persistDesktopShutdownState() {
326 + try {
327 + if (this._desktopIntentionalShutdown) {
328 + localStorage.setItem(DESKTOP_SHUTDOWN_STORAGE_KEY, "1");
329 + } else {
330 + localStorage.removeItem(DESKTOP_SHUTDOWN_STORAGE_KEY);
331 + }
332 + } catch {
333 + // Shutdown state is still correct for this page even without storage.
334 + }
335 + },
336 +
337 + setDesktopIntentionalShutdown(value) {
338 + this._desktopIntentionalShutdown = Boolean(value);
339 + this.persistDesktopShutdownState();
340 + },
341 +
342 + isDesktopShutdown() {
343 + return Boolean(this._desktopIntentionalShutdown);
344 + },
345 +
346 + shouldShowDesktopEmptyState() {
347 + return Boolean(this._desktopIntentionalShutdown && !this.session);
348 + },
349 +
350 + async restartDesktopSession() {
351 + this.error = "";
352 + const session = await this.ensureDesktopSession({
353 + force: true,
354 + restart: true,
355 + select: true,
356 + message: "Restarting Agent Zero Desktop environment",
357 + });
358 + if (!session) {
359 + this.setDesktopIntentionalShutdown(true);
360 + return null;
361 + }
362 + this.restoreDesktopFrames();
363 + this.requestDesktopViewportSync({ force: true });
364 + return session;
365 + },
366 +
367 + async shutdownDesktop(options = {}) {
368 + this.loading = options.progress !== false;
369 + this.message = this.loading ? "Shutting down Desktop" : this.message;
370 + this.error = "";
371 + try {
372 + const response = await callOffice("desktop_shutdown", {
373 + save_first: options.saveFirst !== false,
374 + source: options.source || "ui",
375 + });
376 + await this.handleIntentionalDesktopShutdown(response);
377 + return response;
378 + } catch (error) {
379 + this.error = error instanceof Error ? error.message : String(error);
380 + return null;
381 + } finally {
382 + if (options.progress !== false) {
383 + this.loading = false;
384 + if (this.message === "Shutting down Desktop") this.message = "";
385 + }
386 + }
387 + },
388 +
389 + async handleIntentionalDesktopShutdown(response = {}) {
390 + this.setDesktopIntentionalShutdown(true);
391 + this.stopDesktopMonitor();
392 + this.stopDesktopResizeObserver();
393 + this.clearDesktopViewportSyncTimers();
394 + this.stopXpraDesktopPrime();
395 + this.stopDesktopKeyboardBridge();
396 + this.stopDesktopClipboardBridge();
397 + this.destroyDesktopFrame();
398 + const activeTabId = this.activeTabId;
399 + this.tabs = this.tabs.filter((tab) => !this.isDesktopSession(tab) && !this.hasOfficialOffice(tab));
400 + if (!this.tabs.some((tab) => tab.tab_id === activeTabId)) {
401 + this.session = null;
402 + this.activeTabId = "";
403 + this.editorText = "";
404 + this.dirty = false;
405 + this.resetHistory("");
406 + }
407 + this._desktopStarting = null;
408 + this._desktopHeartbeatMisses = 0;
409 + this.message = response?.source === "tray" ? "Desktop shut down from system tray" : "Desktop is shut down";
410 + await this.refresh();
411 + },
412 +
413 async ensureDesktopSession(options = {}) {
414 + if (this._desktopIntentionalShutdown && options.restart !== true) {
415 + return null;
416 + }
417 + if (options.restart === true) {
418 + this.setDesktopIntentionalShutdown(false);
419 + this.destroyDesktopFrame();
420 + }
421 const existing = this.tabs.find((tab) => this.isDesktopSession(tab));
422 if (existing && !options.force) {
423 if (options.select) this.selectTab(existing.tab_id, { focus: false });
@@ -323,6 +443,7 @@ const model = {
443 }
444 const response = await callOffice("desktop");
445 if (response?.ok === false) throw new Error(response.error || "Desktop session could not be opened.");
446 + this.setDesktopIntentionalShutdown(false);
447 const session = normalizeSession(response);
448 const existingIndex = this.tabs.findIndex((tab) => this.isDesktopSession(tab));
449 let desktopTabId = session.tab_id;
@@ -347,6 +468,7 @@ const model = {
468 } else {
469 this.updateDesktopMonitor();
470 }
471 + this.restoreDesktopFrames();
472 return { ...session, tab_id: desktopTabId };
473 } catch (error) {
474 this.error = error instanceof Error ? error.message : String(error);
@@ -434,6 +556,7 @@ const model = {
556 },
557
558 installDesktopDocumentSession(session) {
559 + this.setDesktopIntentionalShutdown(false);
560 this.tabs = this.tabs.filter((tab) => this.isVisibleOfficeTab(tab));
561 let desktopTab = this.tabs.find((tab) => this.isDesktopSession(tab));
562 if (!desktopTab) {
@@ -468,12 +591,19 @@ const model = {
591
592 selectTab(tabId, options = {}) {
593 const tab = this.tabs.find((item) => item.tab_id === tabId) || this.tabs[0] || null;
594 + if (this.hasOfficialOffice(this.session) && !this.hasOfficialOffice(tab)) {
595 + this.moveDesktopFrameToKeepalive();
596 + }
597 this.session = tab;
598 this.activeTabId = tab?.tab_id || "";
599 this.editorText = String(tab?.text || "");
600 this.dirty = Boolean(tab?.dirty);
601 this.resetHistory(this.editorText);
602 this.queueRender({ focus: Boolean(tab) && options.focus !== false });
603 + if (this.hasOfficialOffice(tab)) {
604 + this.restoreDesktopFrames();
605 + this.requestDesktopViewportSync({ force: true });
606 + }
607 this.updateDesktopMonitor();
608 },
609
@@ -898,9 +1028,11 @@ const model = {
1028 },
1029
1030 desktopFrames() {
901 - const frames = Array.from(document.querySelectorAll("[data-office-desktop-frame]"));
902 - const rootFrame = this._root?.querySelector?.("[data-office-desktop-frame]");
903 - if (rootFrame && !frames.includes(rootFrame)) frames.push(rootFrame);
1031 + const frames = [];
1032 + if (this._desktopFrame) frames.push(this._desktopFrame);
1033 + for (const frame of Array.from(document.querySelectorAll("[data-office-desktop-frame]"))) {
1034 + if (!frames.includes(frame)) frames.push(frame);
1035 + }
1036 return frames;
1037 },
1038
@@ -924,29 +1056,149 @@ const model = {
1056 })[0] || null;
1057 },
1058
1059 + isUsableDesktopHost(host) {
1060 + if (!host?.appendChild) return false;
1061 + const rect = host.getBoundingClientRect?.();
1062 + return Boolean(rect && rect.width >= 120 && rect.height >= 80);
1063 + },
1064 +
1065 + desktopHost(preferred = null) {
1066 + if (preferred?.matches?.("[data-office-desktop-host]")) return preferred;
1067 + const rootHost = this._root?.querySelector?.("[data-office-desktop-host]");
1068 + if (this.isUsableDesktopHost(rootHost)) return rootHost;
1069 + const hosts = Array.from(document.querySelectorAll("[data-office-desktop-host]"));
1070 + return hosts
1071 + .filter((host) => this.isUsableDesktopHost(host))
1072 + .sort((left, right) => {
1073 + const leftRect = left.getBoundingClientRect();
1074 + const rightRect = right.getBoundingClientRect();
1075 + return (rightRect.width * rightRect.height) - (leftRect.width * leftRect.height);
1076 + })[0] || rootHost || hosts[0] || null;
1077 + },
1078 +
1079 + ensureDesktopKeepaliveHost() {
1080 + if (this._desktopKeepaliveHost?.isConnected) return this._desktopKeepaliveHost;
1081 + const host = document.createElement("div");
1082 + host.className = "office-desktop-keepalive";
1083 + host.dataset.officeDesktopKeepalive = "true";
1084 + Object.assign(host.style, {
1085 + position: "fixed",
1086 + left: "-10000px",
1087 + top: "-10000px",
1088 + width: "720px",
1089 + height: "480px",
1090 + overflow: "hidden",
1091 + pointerEvents: "none",
1092 + visibility: "hidden",
1093 + });
1094 + document.body?.appendChild(host);
1095 + this._desktopKeepaliveHost = host;
1096 + return host;
1097 + },
1098 +
1099 + rememberDesktopFrameSize() {
1100 + const frame = this._desktopFrame;
1101 + const rect = frame?.getBoundingClientRect?.();
1102 + const hostRect = this._desktopFrameHost?.getBoundingClientRect?.();
1103 + const width = Math.round(rect?.width || hostRect?.width || 720);
1104 + const height = Math.round(rect?.height || hostRect?.height || 480);
1105 + const keepalive = this.ensureDesktopKeepaliveHost();
1106 + keepalive.style.width = `${Math.max(320, width)}px`;
1107 + keepalive.style.height = `${Math.max(220, height)}px`;
1108 + return keepalive;
1109 + },
1110 +
1111 + ensureDesktopFrame() {
1112 + if (this._desktopFrame) return this._desktopFrame;
1113 + const frame = document.createElement("iframe");
1114 + frame.className = "office-desktop-frame";
1115 + frame.dataset.officeDesktopFrame = "true";
1116 + frame.dataset.officePersistentDesktopFrame = "true";
1117 + frame.setAttribute("tabindex", "0");
1118 + frame.setAttribute("aria-label", "Desktop");
1119 + frame.setAttribute("allow", "clipboard-read; clipboard-write; autoplay");
1120 + this._desktopFrameLoadHandler = (event) => this.onDesktopFrameLoaded(event);
1121 + frame.addEventListener("load", this._desktopFrameLoadHandler);
1122 + this._desktopFrame = frame;
1123 + return frame;
1124 + },
1125 +
1126 + desktopFrameSrcMatches(frame, url) {
1127 + const current = frame?.getAttribute?.("src") || frame?.src || "";
1128 + if (!current && !url) return true;
1129 + try {
1130 + return new URL(current, window.location.href).href === new URL(url, window.location.href).href;
1131 + } catch {
1132 + return current === url;
1133 + }
1134 + },
1135 +
1136 + attachDesktopFrame(host = null) {
1137 + if (!this.hasOfficialOffice()) return false;
1138 + const target = this.desktopHost(host);
1139 + if (!target) return false;
1140 + const frame = this.ensureDesktopFrame();
1141 + if (frame.parentElement !== target) {
1142 + frame.parentElement?.removeAttribute?.("data-office-desktop-attached");
1143 + target.appendChild(frame);
1144 + }
1145 + target.dataset.officeDesktopAttached = "true";
1146 + if (this._desktopFrameHost !== target) this._desktopFrameHost = target;
1147 + const url = this.officialOfficeUrl();
1148 + if (url && !this.desktopFrameSrcMatches(frame, url)) {
1149 + frame.setAttribute("src", url);
1150 + }
1151 + return true;
1152 + },
1153 +
1154 + mountDesktopFrameHost(host = null) {
1155 + const attached = this.attachDesktopFrame(host);
1156 + if (attached && this.isDesktopHostVisible()) {
1157 + this.requestDesktopViewportSync({ force: true, frame: this._desktopFrame, followup: true });
1158 + }
1159 + return attached;
1160 + },
1161 +
1162 + moveDesktopFrameToKeepalive() {
1163 + const frame = this._desktopFrame;
1164 + if (!frame) return false;
1165 + const keepalive = this.rememberDesktopFrameSize();
1166 + if (frame.parentElement !== keepalive) {
1167 + frame.parentElement?.removeAttribute?.("data-office-desktop-attached");
1168 + keepalive.appendChild(frame);
1169 + }
1170 + this._desktopFrameHost = keepalive;
1171 + this._desktopKeyboardActive = false;
1172 + this.updateDesktopKeyboardCaptureState(frame);
1173 + return true;
1174 + },
1175 +
1176 + destroyDesktopFrame() {
1177 + const frame = this._desktopFrame;
1178 + if (!frame) return;
1179 + if (this._desktopFrameLoadHandler) {
1180 + frame.removeEventListener("load", this._desktopFrameLoadHandler);
1181 + }
1182 + frame.setAttribute("src", "about:blank");
1183 + frame.remove();
1184 + this._desktopFrame = null;
1185 + this._desktopFrameHost = null;
1186 + this._desktopFrameLoadHandler = null;
1187 + this._desktopBridgeReady = false;
1188 + this.updateDesktopKeyboardCaptureState();
1189 + this._desktopKeepaliveHost?.remove?.();
1190 + this._desktopKeepaliveHost = null;
1191 + },
1192 +
1193 unloadDesktopFrames() {
1194 this.stopDesktopResizeObserver();
1195 this.stopXpraDesktopPrime();
930 - for (const frame of this.desktopFrames()) {
931 - if (!frame?.getAttribute) continue;
932 - const current = frame.getAttribute("src") || "";
933 - if (!current || current === "about:blank") continue;
934 - frame.dataset.officeDesktopUnloaded = "true";
935 - frame.setAttribute("src", "about:blank");
936 - }
1196 + this.moveDesktopFrameToKeepalive();
1197 },
1198
1199 restoreDesktopFrames() {
1200 if (!this.isDesktopHostVisible()) return;
941 - const url = this.officialOfficeUrl();
942 - if (!url) return;
943 - for (const frame of this.desktopFrames()) {
944 - if (!frame?.getAttribute) continue;
945 - const current = frame.getAttribute("src") || "";
946 - if (current && current !== "about:blank" && frame.dataset.officeDesktopUnloaded !== "true") continue;
947 - delete frame.dataset.officeDesktopUnloaded;
948 - frame.setAttribute("src", url);
949 - }
1201 + this.attachDesktopFrame();
1202 },
1203
1204 afterDesktopHostShown() {
@@ -1908,6 +2160,10 @@ const model = {
2160 desktop_session_id: sessionId,
2161 file_id: this.session.file_id || "",
2162 });
2163 + if (response?.intentional_shutdown || response?.shutdown) {
2164 + await this.handleIntentionalDesktopShutdown(response);
2165 + return;
2166 + }
2167 if (response?.ok === false) throw new Error(response.error || "Desktop session closed.");
2168 this._desktopHeartbeatMisses = 0;
2169 await this.handleDesktopUrlIntents(response?.url_intents);
@@ -1945,6 +2201,7 @@ const model = {
2201 },
2202
2203 async handleOfficialOfficeClosed(tabId) {
2204 + if (this._desktopIntentionalShutdown) return;
2205 const tab = this.tabs.find((item) => item.tab_id === tabId);
2206 const hiddenDesktopDocument = !tab && this.session?.tab_id === tabId && this.isDesktopOfficeDocument(this.session)
2207 ? this.session
tests/test_office_canvas_setup.py
+31 -3
@@ -22,9 +22,12 @@ def test_document_canvas_uses_markdown_editor_and_official_libreoffice_desktop_f
22 assert "office-rich-editor" not in panel
23 assert "office-docx-pages" not in panel
24 assert "office-desktop-frame" in panel
25 - assert "data-office-desktop-frame" in panel
25 + assert "data-office-desktop-host" in panel
26 + assert 'x-init="$nextTick(() => $store.office.mountDesktopFrameHost($el))"' in panel
27 + assert 'x-effect="$store.office.attachDesktopFrame($el)"' not in panel
28 + assert "data-office-desktop-frame" in store
29 assert 'title="LibreOffice desktop"' not in panel
27 - assert 'aria-label="Desktop"' in panel
30 + assert 'frame.setAttribute("aria-label", "Desktop")' in store
31 assert "office-command-button" in panel
32 assert "office-button-label" in panel
33 assert "grid-template-columns: minmax(0, 1fr) auto auto auto" in panel
@@ -32,7 +35,7 @@ def test_document_canvas_uses_markdown_editor_and_official_libreoffice_desktop_f
35 assert ".modal-inner.office-modal .modal-scroll" in panel
36 assert "office-modal-resizer" in panel
37 assert "resize: both" not in panel
35 - assert 'tabindex="0"' in panel
38 + assert 'frame.setAttribute("tabindex", "0")' in store
39 assert "format_underlined" not in panel
40 assert "format_align_center" not in panel
41 assert "is-native-tile" not in panel
@@ -55,6 +58,7 @@ def test_document_canvas_uses_markdown_editor_and_official_libreoffice_desktop_f
58 assert "isDesktopHostVisible" in store
59 assert "clearDesktopViewportSyncTimers" in store
60 assert "setDesktopHostVisible" in canvas_panel
61 + assert "queueMicrotask" in canvas_panel
62 assert "Starting Agent Zero Desktop environment" in store
63 assert "handleOfficialOfficeClosed" in store
64 assert "ResizeObserver" in store
@@ -67,8 +71,22 @@ def test_document_canvas_uses_markdown_editor_and_official_libreoffice_desktop_f
71 assert "right-canvas-resize-end" in store
72 assert "isDesktopSession" in store
73 assert "desktopFrame" in store
74 + assert "attachDesktopFrame" in store
75 + assert "mountDesktopFrameHost" in store
76 + assert "desktopFrameSrcMatches" in store
77 + assert "moveDesktopFrameToKeepalive" in store
78 + assert "destroyDesktopFrame" in store
79 + assert "office-desktop-keepalive" in store
80 + assert "DESKTOP_SHUTDOWN_STORAGE_KEY" in store
81 + assert 'callOffice("desktop_shutdown"' in store
82 + assert "intentional_shutdown" in store
83 + assert "restartDesktopSession" in store
84 + assert "shouldShowDesktopEmptyState" in store
85 + assert "Restart Desktop" in panel
86 + assert "office-desktop-empty" in panel
87 assert "unloadDesktopFrames" in store
88 assert "restoreDesktopFrames" in store
89 + assert "officeDesktopUnloaded" not in store
90 assert "primeXpraDesktopFrame" in store
91 assert "normalizeXpraDesktopWindow" in store
92 assert "installXpraDesktopWheelBridge" in store
@@ -306,11 +324,21 @@ def test_official_libreoffice_desktop_route_and_packages_are_declared():
324 assert "DESKTOP_FOLDER_LINKS" in desktop
325 assert "HIDDEN_XPRA_DESKTOP_ENTRIES" in desktop
326 assert "HIDDEN_XFCE_MENU_ENTRIES" in desktop
327 + assert "SHUTDOWN_HANDLER_DESKTOP_ID" in desktop
328 + assert "SHUTDOWN_PANEL_LAUNCHER_ID" in desktop
329 + assert "SHUTDOWN_CONFIRM_SECONDS" in desktop
330 + assert "Shutdown Desktop" in desktop
331 + assert "shutdown-request.json" in desktop
332 + assert "shutdown-request.arm.json" in desktop
333 + assert "shutdown_system_desktop" in desktop
334 + assert "claim_shutdown_request" in desktop
335 assert "last-show-hidden" in desktop
336 assert "exo-mail-reader.desktop" in desktop
337 assert "exo-web-browser.desktop" in desktop
338 assert "xfce4-mail-reader.desktop" in desktop
339 assert "xfce4-web-browser.desktop" in desktop
340 + assert "xfce4-session-logout.desktop" in desktop
341 + assert "agent-zero-shutdown.desktop" in desktop
342 assert "libreoffice-gtk3" in install
343 assert "libreofficekit" not in install
344 assert "gir1.2-lokdocview" not in install
tests/test_office_document_store.py
+193 -1
@@ -4,6 +4,7 @@ import asyncio
4 import importlib.util
5 import json
6 import os
7 +import subprocess
8 import sys
9 import types
10 import zipfile
@@ -554,6 +555,51 @@ def test_office_session_desktop_state_action_defaults_without_screenshot(monkeyp
555 monkeypatch.delattr(api_package, "office_session", raising=False)
556
557
558 +def test_office_session_desktop_shutdown_action_calls_manager(monkeypatch):
559 + api_module = types.ModuleType("helpers.api")
560 +
561 + class ApiHandler:
562 + def __init__(self, app=None, thread_lock=None):
563 + self.app = app
564 + self.thread_lock = thread_lock
565 +
566 + api_module.ApiHandler = ApiHandler
567 + api_module.Request = object
568 + monkeypatch.setitem(sys.modules, "helpers.api", api_module)
569 + monkeypatch.delitem(sys.modules, "plugins._office.api.office_session", raising=False)
570 +
571 + from plugins._office.api import office_session
572 +
573 + calls = []
574 +
575 + class FakeManager:
576 + def shutdown_system_desktop(self, *, save_first=True, source="api"):
577 + calls.append({"save_first": save_first, "source": source})
578 + return {
579 + "ok": True,
580 + "closed": 1,
581 + "shutdown": True,
582 + "intentional_shutdown": True,
583 + "source": source,
584 + }
585 +
586 + monkeypatch.setattr(office_session.libreoffice_desktop, "get_manager", lambda: FakeManager())
587 + handler = office_session.OfficeSession(app=None, thread_lock=None)
588 + request = types.SimpleNamespace(headers={}, host_url="http://localhost:32080")
589 +
590 + result = asyncio.run(
591 + handler.process({"action": "desktop_shutdown", "save_first": False, "source": "ui"}, request),
592 + )
593 +
594 + assert result["ok"] is True
595 + assert result["intentional_shutdown"] is True
596 + assert calls == [{"save_first": False, "source": "ui"}]
597 + monkeypatch.delitem(sys.modules, "plugins._office.api.office_session", raising=False)
598 + api_package = sys.modules.get("plugins._office.api")
599 + if api_package is not None:
600 + monkeypatch.delattr(api_package, "office_session", raising=False)
601 +
602 +
603 def test_official_libreoffice_desktop_manager_opens_binary_session(office_state, tmp_path, monkeypatch):
604 class FakeProcess:
605 pid = 4242
@@ -704,7 +750,47 @@ def test_official_libreoffice_desktop_manager_opens_binary_session(office_state,
750 ).read_text(encoding="utf-8")
751 assert "panel-1" in panel_profile
752 assert "panel-2" not in panel_profile
707 - assert "launcher" not in panel_profile
753 + assert 'value="actions"' not in panel_profile
754 + assert 'value="launcher"' in panel_profile
755 + assert "agent-zero-shutdown.desktop" in panel_profile
756 + shutdown_app = (
757 + tmp_path
758 + / "desktop"
759 + / "profiles"
760 + / payload["session_id"]
761 + / ".local"
762 + / "share"
763 + / "applications"
764 + / "agent-zero-shutdown.desktop"
765 + ).read_text(encoding="utf-8")
766 + assert "Shutdown Desktop" in shutdown_app
767 + assert "shutdown-desktop" in shutdown_app
768 + shutdown_panel_launcher = (
769 + tmp_path
770 + / "desktop"
771 + / "profiles"
772 + / payload["session_id"]
773 + / ".config"
774 + / "xfce4"
775 + / "panel"
776 + / "launcher-9"
777 + / "agent-zero-shutdown.desktop"
778 + ).read_text(encoding="utf-8")
779 + assert "Shutdown Desktop" in shutdown_panel_launcher
780 + assert "shutdown-desktop" in shutdown_panel_launcher
781 + shutdown_script = (
782 + tmp_path
783 + / "desktop"
784 + / "profiles"
785 + / payload["session_id"]
786 + / ".agent-zero"
787 + / "shutdown-desktop"
788 + ).read_text(encoding="utf-8")
789 + assert "CONFIRM_SECONDS" in shutdown_script
790 + assert "ARM_PATH" in shutdown_script
791 + assert "Click Shutdown Desktop again" in shutdown_script
792 + assert "xmessage" in shutdown_script
793 + assert '"-buttons",' in shutdown_script
794 desktop_helper = (
795 PROJECT_ROOT / "plugins" / "_office" / "helpers" / "libreoffice_desktop.py"
796 ).read_text(encoding="utf-8")
@@ -731,11 +817,17 @@ def test_official_libreoffice_desktop_manager_opens_binary_session(office_state,
817 assert "agent-zero-settings.desktop" not in profile_script
818 assert "metadata::xfce-exe-checksum" in profile_script
819 assert "xfconf-query -c thunar -p /last-show-hidden" in profile_script
820 + assert "xfconf-query -c xfce4-panel" not in profile_script
821 + assert "launcher-*" not in profile_script
822 for filename in (
823 "exo-mail-reader.desktop",
824 "exo-web-browser.desktop",
825 "xfce4-mail-reader.desktop",
826 "xfce4-web-browser.desktop",
827 + "xfce4-session-logout.desktop",
828 + "xfce4-lock-screen.desktop",
829 + "xflock4.desktop",
830 + "xfce4-switch-user.desktop",
831 ):
832 entry = (
833 tmp_path
@@ -754,6 +846,106 @@ def test_official_libreoffice_desktop_manager_opens_binary_session(office_state,
846 assert manager.close(payload["session_id"], save_first=False)["persistent"] is True
847
848
849 +def test_shutdown_panel_launcher_requires_second_click(tmp_path):
850 + profile_dir = tmp_path / "desktop" / "profiles" / libreoffice_desktop.SYSTEM_SESSION_ID
851 + profile_dir.mkdir(parents=True)
852 + desktop_path = tmp_path / "workdir"
853 + desktop_path.mkdir()
854 + session = libreoffice_desktop.DesktopSession(
855 + session_id=libreoffice_desktop.SYSTEM_SESSION_ID,
856 + file_id=libreoffice_desktop.SYSTEM_FILE_ID,
857 + extension="desktop",
858 + path=str(desktop_path),
859 + title=libreoffice_desktop.SYSTEM_TITLE,
860 + display=libreoffice_desktop.DISPLAY_BASE,
861 + xpra_port=libreoffice_desktop.XPRA_PORT_BASE,
862 + token=libreoffice_desktop.SYSTEM_SESSION_ID,
863 + url="/desktop/session/agent-zero-desktop/index.html",
864 + profile_dir=profile_dir,
865 + )
866 + script = libreoffice_desktop._write_shutdown_bridge_script(session)
867 + request = libreoffice_desktop._shutdown_request_path(session)
868 + arm = libreoffice_desktop._shutdown_arm_path(session)
869 + env = dict(os.environ)
870 + env.pop("DISPLAY", None)
871 +
872 + subprocess.run([sys.executable, str(script)], check=True, env=env)
873 +
874 + assert arm.exists()
875 + assert not request.exists()
876 +
877 + subprocess.run([sys.executable, str(script)], check=True, env=env)
878 +
879 + payload = json.loads(request.read_text(encoding="utf-8"))
880 + assert payload["source"] == "tray"
881 + assert payload["armed_at"] <= payload["created_at"]
882 + assert not arm.exists()
883 +
884 +
885 +def test_libreoffice_desktop_sync_consumes_shutdown_marker(tmp_path, monkeypatch):
886 + class FakeProcess:
887 + pid = 5252
888 + terminated = False
889 +
890 + def poll(self):
891 + return None if not self.terminated else 0
892 +
893 + def terminate(self):
894 + self.terminated = True
895 +
896 + def wait(self, timeout=None):
897 + self.terminated = True
898 + return 0
899 +
900 + def kill(self):
901 + self.terminated = True
902 +
903 + monkeypatch.setattr(libreoffice_desktop, "STATE_DIR", tmp_path / "desktop")
904 + monkeypatch.setattr(libreoffice_desktop, "SESSION_DIR", tmp_path / "desktop" / "sessions")
905 + monkeypatch.setattr(libreoffice_desktop, "PROFILE_DIR", tmp_path / "desktop" / "profiles")
906 +
907 + profile_dir = tmp_path / "desktop" / "profiles" / libreoffice_desktop.SYSTEM_SESSION_ID
908 + profile_dir.mkdir(parents=True)
909 + desktop_path = tmp_path / "workdir"
910 + desktop_path.mkdir()
911 + session = libreoffice_desktop.DesktopSession(
912 + session_id=libreoffice_desktop.SYSTEM_SESSION_ID,
913 + file_id=libreoffice_desktop.SYSTEM_FILE_ID,
914 + extension="desktop",
915 + path=str(desktop_path),
916 + title=libreoffice_desktop.SYSTEM_TITLE,
917 + display=libreoffice_desktop.DISPLAY_BASE,
918 + xpra_port=libreoffice_desktop.XPRA_PORT_BASE,
919 + token=libreoffice_desktop.SYSTEM_SESSION_ID,
920 + url="/desktop/session/agent-zero-desktop/index.html",
921 + profile_dir=profile_dir,
922 + processes={"xpra": FakeProcess()},
923 + )
924 + manager = libreoffice_desktop.LibreOfficeDesktopManager()
925 + manager._sessions[session.session_id] = session
926 + manager._write_manifest(session)
927 + libreoffice_desktop._write_url_bridge_script(session)
928 + shutdown_request = libreoffice_desktop._shutdown_request_path(session)
929 + shutdown_request.write_text('{"source": "tray", "created_at": 123.0}\n', encoding="utf-8")
930 + save_calls = []
931 + monkeypatch.setattr(
932 + manager,
933 + "save",
934 + lambda session_id, file_id="": save_calls.append((session_id, file_id)) or {"ok": True},
935 + )
936 +
937 + result = manager.sync(session_id=session.session_id)
938 +
939 + assert result["ok"] is True
940 + assert result["intentional_shutdown"] is True
941 + assert result["source"] == "tray"
942 + assert result["closed"] == 1
943 + assert save_calls == [(libreoffice_desktop.SYSTEM_SESSION_ID, "")]
944 + assert not shutdown_request.exists()
945 + assert not (libreoffice_desktop.SESSION_DIR / f"{session.session_id}.json").exists()
946 + assert manager.get(session.session_id) is None
947 +
948 +
949 def test_libreoffice_desktop_cleanup_preserves_live_owner_manifest(tmp_path, monkeypatch):
950 session_dir = tmp_path / "sessions"
951 session_dir.mkdir()