Add refactor guardrails and runtime docs

Cover the modal/surface boundary, Desktop ownership, Office document-only behavior, explicit Desktop opens, plugin-owned runtime paths, renamed skills, connector ownership rules, Browser context handoff, and Playwright cache stability. Update operator docs to match the retained Docker Playwright install path.

Alessandro committed May 7, 2026 at 00:15 UTC d637f3c2b68edfffd6e84e102b4f934abaa8d3e1
7 files changed +916 -716
docs/guides/troubleshooting.md
+1 -1
@@ -27,7 +27,7 @@ Refer to the [Choosing your LLMs](../setup/installation.md#installing-and-using-
27 Use **Settings → Backup & Restore** and avoid mapping the entire `/a0` directory. See [How to update Agent Zero](../setup/installation.md#how-to-update-agent-zero).
28
29 **8. My browser tool fails or says Playwright is missing. What now?**
30 -The built-in browser is provided by the `_browser` plugin and the direct `browser` tool. **Docker:** the Chromium headless shell is shipped preinstalled (typically under `/a0/tmp/playwright`). **Local development:** if the binary is missing, `ensure_playwright_binary()` in `plugins/_browser/helpers/playwright.py` runs `playwright install chromium --only-shell` into `tmp/playwright` on first browser use (you may see UI notifications). To install ahead of time, run `PLAYWRIGHT_BROWSERS_PATH=tmp/playwright playwright install chromium --only-shell` after `pip install -r requirements.txt`. If you prefer an external browser stack, use MCP alternatives such as Browser OS, Chrome DevTools, or Playwright MCP. See [MCP Setup](mcp-setup.md).
30 +The built-in browser is provided by the `_browser` plugin and the direct `browser` tool. **Docker:** the Chromium headless shell is shipped preinstalled (typically under `/a0/usr/plugins/_browser/playwright`). **Local development:** if the binary is missing, `ensure_playwright_binary()` in `plugins/_browser/helpers/playwright.py` runs `playwright install chromium --only-shell` into `usr/plugins/_browser/playwright` on first browser use (you may see UI notifications). To install ahead of time, run `PLAYWRIGHT_BROWSERS_PATH=usr/plugins/_browser/playwright playwright install chromium --only-shell` after `pip install -r requirements.txt`. If you prefer an external browser stack, use MCP alternatives such as Browser OS, Chrome DevTools, or Playwright MCP. See [MCP Setup](mcp-setup.md).
31
32 **9. My secrets disappeared after a backup restore.**
33 Secrets are stored in `/a0/usr/secrets.env` and are not always included in backup archives. Copy them manually.
docs/setup/dev-setup.md
+2 -2
@@ -67,9 +67,9 @@ Now when you select one of the python files in the project, you should see prope
67 3. Install dependencies. Run these two commands in the terminal:
68 ```bash
69 pip install -r requirements.txt
70 -PLAYWRIGHT_BROWSERS_PATH=tmp/playwright playwright install chromium --only-shell
70 +PLAYWRIGHT_BROWSERS_PATH=usr/plugins/_browser/playwright playwright install chromium --only-shell
71 ```
72 -The first command installs Python dependencies. The second installs the Chromium headless shell into `tmp/playwright` ahead of time (same path in Docker: `/a0/tmp/playwright`). If you skip the second command, **local development** still downloads the shell on first browser use through `ensure_playwright_binary()` in `plugins/_browser/helpers/playwright.py`. Pre-installing avoids that wait. **Docker** images ship the shell preinstalled; runtime install is for local dev when the binary is missing.
72 +The first command installs Python dependencies. The second installs the Chromium headless shell into `usr/plugins/_browser/playwright` ahead of time (same path in Docker: `/a0/usr/plugins/_browser/playwright`). If you skip the second command, **local development** still downloads the shell on first browser use through `ensure_playwright_binary()` in `plugins/_browser/helpers/playwright.py`. Pre-installing avoids that wait. **Docker** images ship the shell preinstalled; runtime install is for local dev when the binary is missing.
73 Errors in the code editor caused by missing packages should now be gone. If not, try reloading the window.
74
75
tests/test_browser_agent_regressions.py
+231 -45
@@ -258,6 +258,32 @@ def test_browser_launch_config_uses_full_chromium_for_all_sessions(tmp_path):
258
259
260 def test_browser_playwright_cache_uses_persistent_usr_path(monkeypatch, tmp_path):
261 + monkeypatch.delenv("A0_BROWSER_PLAYWRIGHT_CACHE_DIR", raising=False)
262 + monkeypatch.setattr(
263 + browser_playwright_module.files,
264 + "get_abs_path",
265 + lambda *parts: str(tmp_path.joinpath(*parts)),
266 + )
267 + browser_binary = (
268 + tmp_path
269 + / "usr"
270 + / "plugins"
271 + / "_browser"
272 + / "playwright"
273 + / "chromium-1169"
274 + / "chrome-linux"
275 + / "chrome"
276 + )
277 + browser_binary.parent.mkdir(parents=True)
278 + browser_binary.write_text("#!/bin/sh\n", encoding="utf-8")
279 +
280 + assert get_playwright_cache_dir() == str(
281 + tmp_path / "usr" / "plugins" / "_browser" / "playwright"
282 + )
283 + assert get_playwright_binary() == browser_binary
284 +
285 +
286 +def test_browser_playwright_cache_falls_back_to_existing_legacy_install(monkeypatch, tmp_path):
287 monkeypatch.delenv("A0_BROWSER_PLAYWRIGHT_CACHE_DIR", raising=False)
288 monkeypatch.setattr(
289 browser_playwright_module.files,
@@ -275,9 +301,11 @@ def test_browser_playwright_cache_uses_persistent_usr_path(monkeypatch, tmp_path
301 legacy_binary.parent.mkdir(parents=True)
302 legacy_binary.write_text("#!/bin/sh\n", encoding="utf-8")
303
278 - assert get_playwright_cache_dir() == str(
279 - tmp_path / "usr" / "plugins" / "_browser" / "playwright"
280 - )
304 + assert browser_playwright_module.get_playwright_cache_dirs() == [
305 + tmp_path / "usr" / "plugins" / "_browser" / "playwright",
306 + tmp_path / "usr" / "browser" / "playwright",
307 + tmp_path / "tmp" / "playwright",
308 + ]
309 assert get_playwright_binary() == legacy_binary
310
311
@@ -288,7 +316,7 @@ def test_browser_extension_storage_uses_plugin_user_path(monkeypatch, tmp_path):
316 lambda *parts: str(tmp_path.joinpath(*parts)),
317 )
318
291 - assert get_extensions_root() == tmp_path / "usr" / "plugins" / "_browser" / "extensions"
319 + assert get_extensions_root() == tmp_path / "usr" / "_browser" / "extensions"
320
321
322 def test_browser_extension_manager_uninstalls_only_managed_extensions(monkeypatch, tmp_path):
@@ -472,7 +500,8 @@ def test_browser_viewer_creates_chat_when_no_context_is_selected():
500 assert "chatsStore.setSelected?.(contextId)" in js
501 assert "this.contextId = existingContextId;" in js
502 assert "this.contextId = contextId;" in js
475 - assert "let targetContextId = requestedContextId;" in js
503 + assert "let targetContextId = requestedContextId" in js
504 + assert "|| this.resolveContextId();" in js
505 assert "targetContextId = await this.ensureContextId();" in js
506 assert "contextId: targetContextId" in js
507 assert "No active chat context is selected." not in js
@@ -496,6 +525,30 @@ def test_browser_canvas_startup_waits_for_raw_viewport_settle():
525 assert "isCurrentSurfaceOpen(surfaceSequence)" in js
526 assert "isCanvasSurfaceVisible(element)" in js
527 assert "scheduleViewportSyncForSurface" in js
528 + assert "const targetChanged = Boolean(" in js
529 + assert "if (this.frameSrc && !targetChanged)" in js
530 + assert "this.cancelFrameRender();" in js
531 + assert "this.resetRenderedFrame();" in js
532 +
533 +
534 +def test_browser_surface_handoffs_keep_existing_frame_until_replacement_arrives():
535 + js = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js").read_text(
536 + encoding="utf-8"
537 + )
538 + prepare_start = js.index("prepareSurfaceOpen(nextMode")
539 + prepare_block = js[prepare_start: js.index("resetViewportTracking()", prepare_start)]
540 + viewport_start = js.index("resetRenderedFrameIfViewportChanged(viewport =")
541 + viewport_block = js[viewport_start: js.index("async waitForSurfaceViewport", viewport_start)]
542 + clear_start = js.index("clearRenderedFrameIfViewportChanged()")
543 + clear_block = js[clear_start: js.index("beginCommand()", clear_start)]
544 +
545 + assert "modeChanged" not in prepare_block
546 + assert "if (this.frameSrc && !targetChanged)" in prepare_block
547 + assert "this.resetRenderedFrame();" in prepare_block
548 + assert "this.cancelFrameRender();" in viewport_block
549 + assert "this.resetRenderedFrame();" not in viewport_block
550 + assert "this.cancelFrameRender();" in clear_block
551 + assert "this.resetRenderedFrame();" not in clear_block
552
553
554 def test_browser_canvas_surface_open_waits_for_visible_panel():
@@ -595,7 +648,7 @@ def test_browser_entry_points_prefer_canvas_and_modal_dock_handoff():
648 / "_browser"
649 / "extensions"
650 / "webui"
598 - / "right_canvas_register_surfaces"
651 + / "surfaces_register"
652 / "register-browser.js"
653 ).read_text(encoding="utf-8")
654 browser_store = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js").read_text(
@@ -605,10 +658,11 @@ def test_browser_entry_points_prefer_canvas_and_modal_dock_handoff():
658 encoding="utf-8"
659 )
660 modals_js = (PROJECT_ROOT / "webui" / "js" / "modals.js").read_text(encoding="utf-8")
661 + surfaces_js = (PROJECT_ROOT / "webui" / "js" / "surfaces.js").read_text(encoding="utf-8")
662
663 assert "Open Browser" in button_html
610 - assert "$store.rightCanvas ? $store.rightCanvas.open('browser')" in button_html
611 - assert "window.ensureModalOpen ? window.ensureModalOpen('/plugins/_browser/webui/main.html')" in button_html
664 + assert "import('/js/surfaces.js')" in button_html
665 + assert "open('browser')" in button_html
666 assert "$store.rightCanvas.toggle('browser')" not in button_html
667 assert 'defaultOpenMode: "modal"' not in register_js
668 assert "beginDockHandoff()" in register_js
@@ -623,11 +677,9 @@ def test_browser_entry_points_prefer_canvas_and_modal_dock_handoff():
677 assert "await surface.cancelDockHandoff?.(payload)" in canvas_store
678 assert "async closeDockSourceModal" in canvas_store
679
626 - assert "sourceModalPath: modal.path" in modals_js
627 - assert "closeSourceModal: async () =>" in modals_js
628 - assert "const closed = await closeModal(modal.path)" in modals_js
629 - assert "const fallbackClosed = await closeModal()" in modals_js
630 - assert "button.disabled = true" in modals_js
680 + assert "dock(metadata.surfaceId" in surfaces_js
681 + assert "button.disabled = true" in surfaces_js
682 + assert "dockSurface(metadata.surfaceId" not in modals_js
683
684 assert "beginSurfaceHandoff()" in browser_store
685 assert "finishSurfaceHandoff()" in browser_store
@@ -635,28 +687,36 @@ def test_browser_entry_points_prefer_canvas_and_modal_dock_handoff():
687 assert "releaseSurfaceBindings()" in browser_store
688 assert "this.releaseSurfaceBindings();" in browser_store
689
638 - assert "async function openBrowserCanvas" in tool_handler
639 - assert 'await rightCanvasStore.open("browser", payload);' in tool_handler
640 - assert "window.ensureModalOpen" in tool_handler
641 - assert "window.openModal" in tool_handler
690 + assert "async function openBrowserSurface" in tool_handler
691 + assert 'await openSurface("browser", payload);' in tool_handler
692 + assert "rightCanvasStore" not in tool_handler
693 + assert "window.ensureModalOpen" not in tool_handler
694 + assert "window.openModal" not in tool_handler
695 assert "function syncOpenBrowserCanvas" in tool_handler
696 assert "async function syncOpenBrowserCanvas" in after_loop_handler
697 assert "syncBrowserResultsIntoOpenCanvas" in after_loop_handler
698 + assert '${contextId || ""}' in tool_handler
699 + assert "contextId || \"\"" in after_loop_handler
700 + assert '"context_id"' in after_loop_handler
701 + assert '"contextId"' in after_loop_handler
702 + assert "rightCanvasStore" not in after_loop_handler
703 assert "window.ensureModalOpen" not in after_loop_handler
704 assert "window.openModal" not in after_loop_handler
705
706 for js in (tool_handler, after_loop_handler):
707 assert "openBrowserModal" not in js
708 assert "isBrowserCanvasAlreadyOpen" in js
651 - assert "rightCanvasStore?.isOpen" in js
652 - assert 'rightCanvasStore?.activeSurfaceId === "browser"' in js
709 + assert '[data-surface-id="browser"].is-active .browser-panel' in js
710 assert "autoOpenBrowserCanvas" not in js
711 assert "autoOpenedBrowsers" not in js
712 assert "syncedBrowserCanvases" in js
713 assert "const FOCUS_ACTIONS = new Set" in js
714 assert "FOCUS_ACTIONS.has(action)" in js
715
659 - for js in (tool_handler, after_loop_handler, register_js, browser_store, modals_js):
716 + assert 'id: "browser"' in surfaces_js
717 + assert "/plugins/_browser/webui/main.html" in surfaces_js
718 +
719 + for js in (tool_handler, after_loop_handler, register_js, browser_store, modals_js, surfaces_js):
720 assert "globalThis.Alpine" not in js
721 assert "Alpine?.store" not in js
722 assert "Alpine.store" not in js
@@ -671,9 +731,23 @@ def test_browser_and_desktop_surface_buttons_remember_latest_window_mode():
731 )
732 modals_js = (PROJECT_ROOT / "webui" / "js" / "modals.js").read_text(encoding="utf-8")
733 modals_css = (PROJECT_ROOT / "webui" / "css" / "modals.css").read_text(encoding="utf-8")
674 - surface_button_block = modals_js[
675 - modals_js.index("function createModalSurfaceButton"):
676 - modals_js.index("function configureModalSurfaceSwitcher")
734 + surfaces_js = (PROJECT_ROOT / "webui" / "js" / "surfaces.js").read_text(encoding="utf-8")
735 + surfaces_css = (PROJECT_ROOT / "webui" / "css" / "surfaces.css").read_text(encoding="utf-8")
736 + close_block = canvas_store[
737 + canvas_store.index("async close()"):
738 + canvas_store.index("async dockSurface")
739 + ]
740 + undock_block = canvas_store[
741 + canvas_store.index("async undockSurface"):
742 + canvas_store.index("async openModalSurface")
743 + ]
744 + open_modal_block = canvas_store[
745 + canvas_store.index("async openModalSurface"):
746 + canvas_store.index("async undockActiveSurface")
747 + ]
748 + surface_button_block = surfaces_js[
749 + surfaces_js.index("function createModalSurfaceButton"):
750 + surfaces_js.index("function configureModalSurfaceSwitcher")
751 ]
752
753 assert "surfaceModes: {}" in canvas_store
@@ -685,38 +759,57 @@ def test_browser_and_desktop_surface_buttons_remember_latest_window_mode():
759 assert "isSurfaceVisible(id)" in canvas_store
760 assert "async openLatest(surfaceId" in canvas_store
761 assert "async openModalSurface(surfaceId" in canvas_store
688 - assert "this.recordSurfaceMode(targetId, SURFACE_MODE_CANVAS" in canvas_store
689 - assert "this.recordSurfaceMode(targetId, SURFACE_MODE_MODAL)" in canvas_store
762 + assert "this.recordSurfaceMode(targetId, SURFACE_MODE_DOCKED" in canvas_store
763 + assert "this.recordSurfaceMode(targetId, SURFACE_MODE_FLOATING)" in canvas_store
764 assert "surfaceModes: this.surfaceModes" in canvas_store
765 assert "normalizeSurfaceMode(mode)" in canvas_store
766 + assert "migratePersistedSurfaceState" in canvas_store
767 + assert "this.mountedSurfaces = {}" not in close_block
768 + assert "surface?.close" not in close_block
769 + assert "this.mountedSurfaces = {}" not in undock_block
770 + assert "failed to close while undocking" not in undock_block
771 + assert "this.mountedSurfaces = {}" not in open_modal_block
772 + assert "failed to close before modal open" not in open_modal_block
773
774 assert '@click="$store.rightCanvas.openLatest(surface.id)"' in canvas_html
775 assert '@click="$store.rightCanvas.open(surface.id)"' in canvas_html
776
696 - assert 'rightCanvasStore.recordSurfaceMode?.(metadata.surfaceId, "modal")' in modals_js
697 - assert "configureModalSurfaceSwitcher" in modals_js
698 - assert "modal-surface-switcher" in modals_js
699 - assert "modal-surface-button" in modals_js
700 - assert "SINGLE_VISIBLE_MODAL_SURFACE_PATHS" in modals_js
701 - assert "modal-surface-parked" in modals_js
702 - assert "parkSiblingSurfaceModals(activeModal)" in modals_js
777 + assert "recordMode(metadata.surfaceId, SURFACE_MODE_FLOATING)" in surfaces_js
778 + assert "configureModalSurfaceSwitcher" in surfaces_js
779 + assert "surface-switcher" in surfaces_js
780 + assert "surface-button" in surfaces_js
781 + assert "SINGLE_VISIBLE_MODAL_SURFACE_PATHS" not in modals_js
782 + assert "modal-surface-parked" in surfaces_js
783 + assert "parkSiblingSurfaceModals(activeModal)" in surfaces_js
784 assert "activateModal(modal)" in modals_js
785 + assert "closeSurfaceGroupModals" not in modals_js
786 + assert "closeSurfaceGroupModals" in surfaces_js
787 + assert "const closed = await closeSurfaceGroupModals()" in surfaces_js
788 + assert "globalThis.closeSurfaceGroupModals = closeSurfaceGroupModals" in surfaces_js
789 assert "button.title = title" not in modals_js
790 assert "button.title = metadata.title" not in modals_js
706 - assert "rightCanvasStore.panelSurfaces" in modals_js
707 - assert 'rightCanvasStore.recordSurfaceMode?.(surface.id, "modal")' in modals_js
791 + assert "rightCanvasStore.panelSurfaces" not in modals_js
792 + assert 'await recordMode(normalizedId, SURFACE_MODE_FLOATING)' in surfaces_js
793 assert "const openPromise = ensureModalOpen(targetModalPath)" in surface_button_block
794 assert "await closeModal(modal.path)" not in surface_button_block
795 assert "modalRequiresExplicitClose" in modals_js
711 - assert '"plugins/_browser/webui/main.html"' in modals_js
712 - assert '"plugins/_office/webui/main.html"' in modals_js
796 + assert "modalSurfaceMetadata" not in modals_js
797 + assert "modal-content-loaded" in modals_js
798 + assert '"plugins/_browser/webui/main.html"' not in modals_js
799 + assert '"plugins/_office/webui/main.html"' not in modals_js
800 assert "&& !modalRequiresExplicitClose(newModal)" in modals_js
801 assert "if (modalRequiresExplicitClose(modalStack[modalStack.length - 1])) return;" in modals_js
715 - assert ".modal-surface-switcher" in modals_css
716 - assert ".modal-surface-button.is-active" in modals_css
717 - assert ".modal-surface-image" in modals_css
718 - assert ".modal.modal-surface-parked" in modals_css
719 - assert "grid-auto-flow: column" in modals_css
802 + assert ".modal-surface-switcher" not in modals_css
803 + assert ".surface-switcher" in surfaces_css
804 + assert ".surface-button" in surfaces_css
805 + assert ".modal-surface-button.is-active" in surfaces_css
806 + assert ".modal-surface-image" in surfaces_css
807 + assert ".modal.modal-surface-parked" in surfaces_css
808 + assert "grid-auto-flow: column" in surfaces_css
809 + assert 'id: "browser"' in surfaces_js
810 + assert 'id: "desktop"' in surfaces_js
811 + assert "/plugins/_browser/webui/main.html" in surfaces_js
812 + assert "/plugins/_desktop/webui/main.html" in surfaces_js
813
814
815 def test_browser_tool_does_not_auto_open_canvas_policy_is_documented():
@@ -730,8 +823,8 @@ def test_browser_tool_does_not_auto_open_canvas_policy_is_documented():
823 encoding="utf-8"
824 )
825
733 - assert "must not open the right canvas automatically" in prompt
734 - assert "Use the tool headlessly unless the user opens the Browser canvas" in prompt
826 + assert "must not open a Browser surface automatically" in prompt
827 + assert "Use the tool headlessly unless the user opens the Browser surface" in prompt
828 assert "optional visible WebUI viewer" in prompt
829 assert "screenshot" in prompt
830 assert "vision_load" in prompt
@@ -741,7 +834,7 @@ def test_browser_tool_does_not_auto_open_canvas_policy_is_documented():
834 assert "browser-forms" in prompt
835 assert "does not automatically load screenshots" in prompt
836 assert "already open" in config
744 - assert "already-open Browser canvas" in config_html
837 + assert "already-open Browser surface" in config_html
838
839
840 def test_browser_forms_skill_is_plugin_owned_and_discoverable():
@@ -849,6 +942,12 @@ def test_browser_viewer_uses_tabs_for_session_switching():
942 assert "activeBrowserContextId" in browser_store
943 assert "sameBrowserTab" in browser_store
944 assert "applyBrowserListing" in browser_store
945 + assert "syncViewerToSelectedContext(selectedContextId)" in browser_store
946 + assert "async syncViewerToSelectedContext" in browser_store
947 + assert "isVisibleBrowserSurface()" in browser_store
948 + assert "firstBrowserInContext(selectedContextId)" in browser_store
949 + assert "requestedContextId && requestedContextId !== inFlightContextId" in browser_store
950 + assert "create_browser: Boolean(options.createBrowser || options.create_browser)" in browser_store
951 assert "browserTabTooltip(browser)" in browser_store
952 assert "browserChatTitle(browser = {})" in browser_store
953 assert "contextId.slice" not in browser_store
@@ -1560,9 +1659,12 @@ async def test_browser_viewer_subscribe_unregisters_stream(monkeypatch):
1659 return {"id": 1, "state": {"id": 1, "currentUrl": "about:blank"}}
1660 raise AssertionError(method)
1661
1662 + fake_runtime = FakeRuntime()
1663 +
1664 async def fake_get_runtime(context_id, create=True):
1665 assert context_id == "ctx"
1565 - return FakeRuntime()
1666 + assert create is False
1667 + return fake_runtime
1668
1669 monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
1670 monkeypatch.setattr(
@@ -1584,6 +1686,8 @@ async def test_browser_viewer_subscribe_unregisters_stream(monkeypatch):
1686 )
1687
1688 assert result["context_id"] == "ctx"
1689 + assert result["active_browser_id"] is None
1690 + assert fake_runtime.opened is False
1691 assert ("sid-1", "ctx") in ws_browser_module.WsBrowser._streams
1692
1693 await handler.on_disconnect("sid-1")
@@ -1591,6 +1695,88 @@ async def test_browser_viewer_subscribe_unregisters_stream(monkeypatch):
1695 assert ("sid-1", "ctx") not in ws_browser_module.WsBrowser._streams
1696
1697
1698 +@pytest.mark.anyio
1699 +async def test_browser_viewer_subscribe_can_create_blank_tab_when_requested(monkeypatch):
1700 + class FakeRuntime:
1701 + def __init__(self) -> None:
1702 + self.opened = False
1703 +
1704 + async def call(self, method, *args):
1705 + if method == "list":
1706 + if self.opened:
1707 + return {
1708 + "browsers": [{"id": 1, "currentUrl": "about:blank", "title": ""}],
1709 + "last_interacted_browser_id": 1,
1710 + }
1711 + return {"browsers": [], "last_interacted_browser_id": None}
1712 + if method == "open":
1713 + self.opened = True
1714 + return {"id": 1, "state": {"id": 1, "currentUrl": "about:blank"}}
1715 + raise AssertionError(method)
1716 +
1717 + fake_runtime = FakeRuntime()
1718 +
1719 + async def fake_get_runtime(context_id, create=True):
1720 + assert context_id == "ctx"
1721 + assert create is True
1722 + return fake_runtime
1723 +
1724 + monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
1725 + monkeypatch.setattr(
1726 + ws_browser_module.AgentContext,
1727 + "get",
1728 + staticmethod(lambda context_id: SimpleNamespace(id=context_id)),
1729 + )
1730 +
1731 + handler = ws_browser_module.WsBrowser(
1732 + SimpleNamespace(),
1733 + threading.RLock(),
1734 + manager=None,
1735 + )
1736 +
1737 + result = await handler.process(
1738 + "browser_viewer_subscribe",
1739 + {"context_id": "ctx", "create_browser": True},
1740 + "sid-create",
1741 + )
1742 +
1743 + assert result["active_browser_id"] == 1
1744 + assert fake_runtime.opened is True
1745 +
1746 + await handler.on_disconnect("sid-create")
1747 +
1748 +
1749 +@pytest.mark.anyio
1750 +async def test_browser_viewer_subscribe_without_runtime_does_not_create_runtime(monkeypatch):
1751 + async def fake_get_runtime(context_id, create=True):
1752 + assert context_id == "ctx"
1753 + assert create is False
1754 + return None
1755 +
1756 + monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
1757 + monkeypatch.setattr(
1758 + ws_browser_module.AgentContext,
1759 + "get",
1760 + staticmethod(lambda context_id: SimpleNamespace(id=context_id)),
1761 + )
1762 +
1763 + handler = ws_browser_module.WsBrowser(
1764 + SimpleNamespace(),
1765 + threading.RLock(),
1766 + manager=None,
1767 + )
1768 +
1769 + result = await handler.process(
1770 + "browser_viewer_subscribe",
1771 + {"context_id": "ctx"},
1772 + "sid-empty",
1773 + )
1774 +
1775 + assert result["active_browser_id"] is None
1776 + assert result["browsers"] == []
1777 + assert ("sid-empty", "ctx") not in ws_browser_module.WsBrowser._streams
1778 +
1779 +
1780 @pytest.mark.anyio
1781 async def test_browser_runtime_sessions_are_context_qualified(monkeypatch):
1782 class FakeRuntime:
tests/test_office_canvas_setup.py
+271 -525
@@ -6,536 +6,282 @@ from pathlib import Path
6 PROJECT_ROOT = Path(__file__).resolve().parents[1]
7
8
9 -def test_document_canvas_uses_markdown_editor_and_official_libreoffice_desktop_frame():
10 - panel = (PROJECT_ROOT / "plugins" / "_office" / "webui" / "office-panel.html").read_text(
11 - encoding="utf-8",
9 +def read(*parts: str) -> str:
10 + return (PROJECT_ROOT.joinpath(*parts)).read_text(encoding="utf-8")
11 +
12 +
13 +def test_modals_are_generic_and_surfaces_own_live_surface_paths():
14 + modals_js = read("webui", "js", "modals.js")
15 + modals_css = read("webui", "css", "modals.css")
16 + surfaces_js = read("webui", "js", "surfaces.js")
17 + surfaces_css = read("webui", "css", "surfaces.css")
18 +
19 + for forbidden in (
20 + "right-canvas-store",
21 + "/plugins/_browser",
22 + "/plugins/_office",
23 + "/plugins/_desktop",
24 + "SINGLE_VISIBLE_MODAL_SURFACE_PATHS",
25 + "data-canvas",
26 + "surface-window",
27 + ):
28 + assert forbidden not in modals_js
29 +
30 + assert "modalStack" in modals_js
31 + assert 'const backdrop = document.createElement("div")' in modals_js
32 + assert "backdrop.style.display" in modals_js
33 + assert "modalSurfaceMetadata" not in modals_js
34 + assert "modal-content-loaded" in modals_js
35 + assert ".surface-floating" not in modals_css
36 + assert ".surface-switcher" not in modals_css
37 +
38 + assert "CORE_SURFACES" in surfaces_js
39 + assert "modalSurfaceMetadata" in surfaces_js
40 + assert "closeSurfaceGroupModals" in surfaces_js
41 + assert 'id: "browser"' in surfaces_js
42 + assert 'id: "desktop"' in surfaces_js
43 + assert "/plugins/_browser/webui/main.html" in surfaces_js
44 + assert "/plugins/_desktop/webui/main.html" in surfaces_js
45 + assert "LEGACY_SURFACE_IDS" in surfaces_js
46 + assert '["office", "desktop"]' in surfaces_js
47 + assert "htmlDataset.surfaceId" in surfaces_js
48 + assert "htmlDataset.canvasSurface" in surfaces_js
49 + assert ".surface-modal" in surfaces_css
50 + assert ".surface-floating" in surfaces_css
51 + assert ".surface-resize-handle" in surfaces_css
52 + assert ".surface-switcher" in surfaces_css
53 + assert "surface-window" not in surfaces_js + surfaces_css
54 +
55 +
56 +def test_right_canvas_uses_desktop_surface_id_and_migrates_legacy_office_state():
57 + canvas_store = read("webui", "components", "canvas", "right-canvas-store.js")
58 + desktop_register = read(
59 + "plugins",
60 + "_desktop",
61 + "extensions",
62 + "webui",
63 + "surfaces_register",
64 + "register-desktop.js",
65 )
13 - store = (PROJECT_ROOT / "plugins" / "_office" / "webui" / "office-store.js").read_text(
14 - encoding="utf-8",
66 + desktop_panel = read(
67 + "plugins",
68 + "_desktop",
69 + "extensions",
70 + "webui",
71 + "right-canvas-panels",
72 + "desktop-panel.html",
73 )
16 - canvas_panel = (
17 - PROJECT_ROOT / "plugins" / "_office" / "extensions" / "webui" / "right-canvas-panels" / "office-panel.html"
18 - ).read_text(encoding="utf-8")
19 -
20 - assert "office-source-editor" in panel
21 - assert "data-office-source" in panel
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-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
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
34 - assert "flex-wrap: nowrap" in panel
35 - assert ".modal-inner.office-modal .modal-scroll" in panel
36 - assert "office-modal-resizer" in panel
37 - assert "resize: both" not 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
42 - assert "hasOfficialOffice()" in panel
43 - assert 'title="Rename"' in panel
44 - assert "@click=\"$store.office.renameActiveFile()\"" in panel
45 - assert "office_save" in store
46 - assert "desktop_save" in store
47 - assert "openRenameModal" in store
48 - assert 'callOffice("renamed"' in store
49 - assert "performRename" in store
50 - assert "payload.text" in store
51 - assert "handleActiveFileRenamed" in store
52 - assert "--office-zoom" not in panel
53 - assert "zoom: 1" not in store
54 - assert 'callOffice("desktop")' in store
55 - assert "ensureDesktopSession" in store
56 - assert 'await this.onOpen({ source: "modal" });' in store
57 - assert "setDesktopHostVisible" in store
58 - assert "isDesktopHostVisible" in store
59 - assert "clearDesktopViewportSyncTimers" in store
60 - assert "setDesktopHostVisible" in canvas_panel
61 - assert "queueMicrotask" in canvas_panel
62 - assert "isSurfaceRendered('office')" in canvas_panel
63 - assert "isSurfaceVisible('office')" in canvas_panel
64 - assert "canvas.isSurfaceMounted?.(\"office\")" in store
65 - assert "Starting Agent Zero Desktop environment" in store
66 - assert "handleOfficialOfficeClosed" in store
67 - assert "ResizeObserver" in store
68 - assert "_desktopResizeSuspended" in store
69 - assert "_desktopResizePending" in store
70 - assert "_desktopResizePendingKey" in store
71 - assert "_desktopViewportSyncTimers" in store
72 - assert "shouldDeferDesktopResize" in store
73 - assert "right-canvas-resize-start" in store
74 - assert "right-canvas-resize-end" in store
75 - assert "isDesktopSession" in store
76 - assert "desktopFrame" in store
77 - assert "attachDesktopFrame" in store
78 - assert "mountDesktopFrameHost" in store
79 - assert "desktopFrameSrcMatches" in store
80 - assert "moveDesktopFrameToKeepalive" in store
81 - assert "destroyDesktopFrame" in store
82 - assert "office-desktop-keepalive" in store
83 - assert "DESKTOP_SHUTDOWN_STORAGE_KEY" in store
84 - assert 'callOffice("desktop_shutdown"' in store
85 - assert "intentional_shutdown" in store
86 - assert "restartDesktopSession" in store
87 - assert "shouldShowDesktopEmptyState" in store
88 - assert "Restart Desktop" in panel
89 - assert "office-desktop-empty" in panel
90 - assert "unloadDesktopFrames" in store
91 - assert "restoreDesktopFrames" in store
92 - assert "officeDesktopUnloaded" not in store
93 - assert "primeXpraDesktopFrame" in store
94 - assert "normalizeXpraDesktopWindow" in store
95 - assert "installXpraDesktopWheelBridge" in store
96 - assert "installXpraDesktopAgentBridge" in store
97 - assert "agentZeroDesktop" in store
98 - assert 'callOffice("desktop_state"' in store
99 - assert "desktopToClient" in store
100 - assert "clientToDesktop" in store
101 - assert "requestRefresh" in store
102 - assert "_desktopBridgeReady" in store
103 - assert "_desktopKeyboardCaptureState" in store
104 - assert "installXpraDesktopKeyboardBridge" in store
105 - assert "focusDesktopFrame" in store
106 - assert "_desktopFocusInProgress" in store
107 - assert "if (this._desktopFocusInProgress) return" in store
108 - assert "_desktopKeyboardActive" in store
109 - assert "isEditableInputTarget" in store
110 - assert "reloadDesktopFrame" in store
111 - assert 'result?.reload' in store
112 - assert "a0_reload" in store
113 - assert "const DESKTOP_RESIZE_DELAY_MS = 80" in store
114 - assert "requestServerResize: false" in store
115 - assert "requestRefresh: false" in store
116 - assert "_desktopResizeTarget" in store
117 - assert "requestDesktopViewportSync" in store
118 - assert "syncDesktopViewport" in store
119 - assert "options.serverResize !== false" in store
120 - assert "serverResize: true" in store
121 - assert "server_is_desktop = true" in store
122 - assert "server_resize_exact = true" in store
123 - assert "_set_decorated?.(false)" in store
124 - assert "topoffset = 0" in store
125 - assert ".undecorated" in store
126 - assert "a0-xpra-desktop-frame-css" in store
127 - assert "installXpraDesktopFramePatches" in store
128 - assert "installXpraDesktopClientPatches" in store
129 - assert "patchedNoWindowList" in store
130 - assert "patchedAddWindowListItem" in store
131 - assert "patchedScreenResized" in store
132 - assert "__a0AllowScreenResize" in store
133 - assert "_desktopHeartbeatTimer" in store
134 - assert "office-modal-focus-button" in store
135 - assert "focusButton.title" not in store
136 - assert "officialOfficeUrl" in store
137 - assert 'parsed.searchParams.set("offscreen", secureContext ? "true" : "false")' in store
138 - assert 'parsed.searchParams.set("clipboard_poll", secureContext ? "true" : "false")' in store
139 - assert "hasOfficialOffice" in store
140 - assert "isOfficeSocketData" in store
141 - assert "office_command" not in store
142 - assert "office_key" not in store
143 - assert "office_mouse" not in store
144 - assert ".uno:Bold" not in store
145 - assert "nativeTilesToHtml" not in store
146 - assert "editorContainsFocus" in store
147 - assert "_focusAttempts" in store
148 - assert "_nativeEventQueue" not in store
149 - assert "await this.awaitNativeEvents()" not in store
150 - assert "<p><br></p>" not in store
151 - assert "setupTitle()" not in panel
152 - assert "Setup in progress" not in store
153 - assert "office-log" not in panel
154 - assert "New Writer document" in panel
155 - assert "DOCX</span>" not in panel
156 - assert "$store.office.create('document', 'odt')" in panel
157 - assert "$store.office.create('spreadsheet', 'ods')" in panel
158 - assert "$store.office.create('presentation', 'odp')" in panel
159 -
160 -
161 -def test_desktop_xpra_canvas_scroll_is_forwarded_to_the_remote_session():
162 - store = (PROJECT_ROOT / "plugins" / "_office" / "webui" / "office-store.js").read_text(
163 - encoding="utf-8",
74 + desktop_new_menu = read(
75 + "plugins",
76 + "_desktop",
77 + "extensions",
78 + "webui",
79 + "right-canvas-toolbar-start",
80 + "desktop-new-menu.html",
81 )
82 + right_canvas_css = read("webui", "components", "canvas", "right-canvas.css")
83 + desktop_web_panel = read("plugins", "_desktop", "webui", "desktop-panel.html")
84
166 - assert "canvas.addEventListener(\"wheel\"" in store
167 - assert "mouse_scroll_cb(normalizedEvent, xpraWindow)" in store
168 - assert "stopImmediatePropagation" in store
169 - assert "{ passive: false, capture: true }" in store
170 - assert "xpraDesktopWheelEvent" in store
171 - assert "deltaMode: { value: 0 }" in store
172 - assert "wheelDeltaY" in store
173 - assert "getModifierState: { value: getModifierState }" in store
85 + assert 'await callJsExtensions("surfaces_register", this);' in canvas_store
86 + assert 'await callJsExtensions("right_canvas_register_surfaces", this);' in canvas_store
87 + assert "migratePersistedSurfaceState" in canvas_store
88 + assert "normalizeSurfaceId" in canvas_store
89 + assert "const saved = migratePersistedSurfaceState(JSON.parse" in canvas_store
90 + assert 'id: "desktop"' in desktop_register
91 + assert 'modalPath: "/plugins/_desktop/webui/main.html"' in desktop_register
92 + assert 'data-surface-id="desktop"' in desktop_panel
93 + assert "isSurfaceVisible('desktop')" in desktop_panel
94 + assert "right-canvas-desktop-actions" in desktop_new_menu
95 + assert "isSurfaceActive('desktop')" in desktop_new_menu
96 + assert "runNewMenuAction('writer')" in desktop_new_menu
97 + assert "runNewMenuAction('spreadsheet')" in desktop_new_menu
98 + assert "runNewMenuAction('presentation')" in desktop_new_menu
99 + assert ".right-canvas-header" in right_canvas_css
100 + assert "overflow: visible;" in right_canvas_css
101 + assert ".right-canvas-toolbar" in right_canvas_css
102 + assert ".right-canvas-desktop-actions .office-new-menu" in desktop_web_panel
103 + assert "z-index: 4000;" in desktop_web_panel
104 + assert not (PROJECT_ROOT / "plugins" / "_office" / "extensions" / "webui" / "right_canvas_register_surfaces" / "register-office.js").exists()
105 + assert not (PROJECT_ROOT / "plugins" / "_office" / "extensions" / "webui" / "right-canvas-panels" / "office-panel.html").exists()
106
107
176 -def test_office_surface_filters_tabs_to_desktop_and_markdown_without_dashboard():
177 - panel = (PROJECT_ROOT / "plugins" / "_office" / "webui" / "office-panel.html").read_text(
178 - encoding="utf-8",
179 - )
180 - store = (PROJECT_ROOT / "plugins" / "_office" / "webui" / "office-store.js").read_text(
181 - encoding="utf-8",
182 - )
108 +def test_browser_surface_restores_focus_mode_chrome():
109 + browser_store = read("plugins", "_browser", "webui", "browser-store.js")
110 + browser_panel = read("plugins", "_browser", "webui", "browser-panel.html")
111 +
112 + assert "browser-modal-focus-button" in browser_store
113 + assert "is-focus-mode" in browser_store
114 + assert "fullscreen_exit" in browser_store
115 + assert "Focus mode" in browser_store
116 + assert "Restore size" in browser_store
117 + assert ".modal-inner.browser-modal.is-focus-mode" in browser_panel
118 +
119 +
120 +def test_office_frontend_is_document_only_and_does_not_import_browser_or_desktop_runtime_code():
121 + office_store = read("plugins", "_office", "webui", "office-store.js")
122 + office_panel = read("plugins", "_office", "webui", "office-panel.html")
123 + office_modal = read("plugins", "_office", "webui", "main.html")
124 +
125 + assert "/plugins/_browser" not in office_store
126 + assert "right-canvas-store" not in office_store
127 + assert "handleUrlIntent" not in office_store
128 + assert "ensureDesktopSession" not in office_store
129 + assert "desktop_save" not in office_store
130 + assert "desktop_sync" not in office_store
131 + assert "desktop_state" not in office_store
132 + assert "desktop_shutdown" not in office_store
133 + assert "Xpra" not in office_store
134 + assert "xpra" not in office_store
135 + assert "data-office-desktop-host" not in office_panel
136 + assert "office-desktop-frame" not in office_panel
137 + assert "Restart Desktop" not in office_panel
138 + assert "data-surface-id" not in office_modal
139 + assert "modal-no-backdrop" not in office_modal
140 + assert "data-canvas-surface" not in office_modal
141 +
142 + assert "office-source-editor" in office_panel
143 + assert "data-office-source" in office_panel
144 + assert "openRenameModal" in office_store
145 + assert "office_save" in office_store
146 + assert 'callOffice("renamed"' in office_store
147 + assert "requires_desktop" in office_store
148 + assert "openSurface(\"desktop\"" in office_store
149 +
150 +
151 +def test_desktop_plugin_owns_routes_runtime_surface_and_state_paths():
152 + desktop_plugin = PROJECT_ROOT / "plugins" / "_desktop"
153 + assert (desktop_plugin / "plugin.yaml").exists()
154 + assert (desktop_plugin / "api" / "desktop_session.py").exists()
155 + assert (desktop_plugin / "helpers" / "desktop_session.py").exists()
156 + assert (desktop_plugin / "helpers" / "desktop_state.py").exists()
157 + assert (desktop_plugin / "skills" / "linux-desktop" / "scripts" / "desktopctl.sh").exists()
158
184 - assert "office-card-grid" not in panel
185 - assert "office-document-card" not in panel
186 - assert "visibleTabs()" in panel
187 - assert "openCards()" not in panel
188 - assert "recentCards()" not in panel
189 - assert "office-editor-head" not in panel
190 - assert "office-recent-row" not in panel
191 - assert "open_documents" not in store
192 - assert "installDesktopDocumentSession" in store
193 - assert "isDesktopOfficeDocument" in store
194 - assert "isVisibleOfficeTab" in store
195 - assert "return this.tabs.filter((tab) => this.isVisibleOfficeTab(tab));" in store
196 -
197 - file_browser_store = (
198 - PROJECT_ROOT / "webui" / "components" / "modals" / "file-browser" / "file-browser-store.js"
199 - ).read_text(encoding="utf-8")
200 -
201 - assert "renameAfterConfirm" in file_browser_store
202 - assert "renamePerformAction" in file_browser_store
203 - assert "renameValidateName" in file_browser_store
204 - assert "options.onRenamed" in file_browser_store
205 - assert "options.performRename" in file_browser_store
206 - assert "options.validateName" in file_browser_store
207 -
208 -
209 -def test_right_canvas_surface_is_branded_as_desktop():
210 - surface = (
211 - PROJECT_ROOT
212 - / "plugins"
213 - / "_office"
214 - / "extensions"
215 - / "webui"
216 - / "right_canvas_register_surfaces"
217 - / "register-office.js"
218 - ).read_text(encoding="utf-8")
219 - handler = (
220 - PROJECT_ROOT
221 - / "plugins"
222 - / "_office"
223 - / "extensions"
224 - / "webui"
225 - / "get_tool_message_handler"
226 - / "document-artifact-handler.js"
227 - ).read_text(encoding="utf-8")
228 - document_actions = (
229 - PROJECT_ROOT
230 - / "plugins"
231 - / "_office"
232 - / "extensions"
233 - / "webui"
234 - / "lib"
235 - / "document-actions.js"
236 - ).read_text(encoding="utf-8")
237 -
238 - assert 'title: "Desktop"' in surface
239 - assert 'icon: "desktop_windows"' in surface
240 - assert "buildDocumentFileActionButtons(document)" in handler
241 - assert "Open in canvas" in document_actions
242 - assert "downloadDocument" in document_actions
243 - assert "/api/download_work_dir_file?path=" in document_actions
244 - assert "source: \"message-action\"" in document_actions
245 -
246 -
247 -def test_official_libreoffice_desktop_route_and_packages_are_declared():
248 - routes = (PROJECT_ROOT / "helpers" / "virtual_desktop_routes.py").read_text(encoding="utf-8")
249 - primitive = (PROJECT_ROOT / "helpers" / "virtual_desktop.py").read_text(encoding="utf-8")
250 - desktop = (
251 - PROJECT_ROOT / "plugins" / "_office" / "helpers" / "libreoffice_desktop.py"
252 - ).read_text(encoding="utf-8")
253 - install = (PROJECT_ROOT / "docker" / "run" / "fs" / "ins" / "install_additional.sh").read_text(
254 - encoding="utf-8",
159 + desktop_startup = read("plugins", "_desktop", "extensions", "python", "startup_migration", "_20_desktop_routes.py")
160 + desktop_api = read("plugins", "_desktop", "api", "desktop_session.py")
161 + desktop_session = read("plugins", "_desktop", "helpers", "desktop_session.py")
162 + desktop_state = read("plugins", "_desktop", "helpers", "desktop_state.py")
163 + desktop_store = read("plugins", "_desktop", "webui", "desktop-store.js")
164 + desktop_main = read("plugins", "_desktop", "webui", "main.html")
165 + desktop_web_panel = read("plugins", "_desktop", "webui", "desktop-panel.html")
166 +
167 + assert "virtual_desktop_routes.install_route_hooks()" in desktop_startup
168 + assert 'action in {"open_document", "document"}' in desktop_api
169 + assert 'callJsonApi("/plugins/_desktop/desktop_session"' in desktop_store
170 + assert 'callDesktop("open_document"' in desktop_store
171 + assert 'callOffice("create"' in desktop_store
172 + assert "open_in_desktop: isOfficialExtension(fmt)" in desktop_store
173 + assert "__a0XpraOffsetWarnPatched" in desktop_store
174 + assert "window does not fit in canvas, offsets" in desktop_store
175 + assert "decode error packet" in desktop_store
176 + assert 'data-surface-id="desktop"' in desktop_main
177 + assert "virtual_desktop.session_url" in desktop_session
178 + assert 'owner="desktop"' in desktop_session
179 + assert 'STATE_DIR = Path(files.get_abs_path("usr", "_desktop"))' in desktop_session
180 + assert 'STATE_DIR = BASE_DIR / "usr" / "_desktop"' in desktop_state
181 + assert "> x-component > div[x-data] > .office-panel" in desktop_web_panel
182 + assert ".office-state-line > span:not(.material-symbols-outlined)" in desktop_web_panel
183 +
184 + assert not (PROJECT_ROOT / "plugins" / "_office" / "helpers" / "desktop_state.py").exists()
185 + assert not (PROJECT_ROOT / "plugins" / "_office" / "helpers" / "libreoffice_desktop_routes.py").exists()
186 + assert not (PROJECT_ROOT / "plugins" / "_office" / "assets" / "desktop").exists()
187 +
188 +
189 +def test_plugin_owned_runtime_state_paths_are_declared():
190 + office_documents = read("plugins", "_office", "helpers", "document_store.py")
191 + browser_playwright = read("plugins", "_browser", "helpers", "playwright.py")
192 + browser_extensions = read("plugins", "_browser", "helpers", "extension_manager.py")
193 + docker_playwright = read("docker", "run", "fs", "ins", "install_playwright.sh")
194 +
195 + assert 'PLUGIN_NAME = "_office"' in office_documents
196 + assert 'STATE_DIR = Path(files.get_abs_path("usr", PLUGIN_NAME, "documents"))' in office_documents
197 + assert 'PLAYWRIGHT_CACHE_DIR = ("usr", "plugins", "_browser", "playwright")' in browser_playwright
198 + assert "Path(files.get_abs_path(*PLAYWRIGHT_CACHE_DIR))" in browser_playwright
199 + assert "Path(files.get_abs_path(*EXTENSIONS_ROOT_DIR))" in browser_extensions
200 + assert "PLAYWRIGHT_BROWSERS_PATH=/a0/usr/plugins/_browser/playwright" in docker_playwright
201 +
202 +
203 +def test_document_artifacts_only_open_desktop_from_explicit_document_ui_requests():
204 + auto_open = read(
205 + "plugins",
206 + "_office",
207 + "extensions",
208 + "webui",
209 + "set_messages_after_loop",
210 + "auto-open-document-results.js",
211 )
256 - linux_desktop_skill = (
257 - PROJECT_ROOT / "plugins" / "_office" / "skills" / "linux-desktop" / "SKILL.md"
258 - ).read_text(encoding="utf-8")
259 - linux_desktopctl = (
260 - PROJECT_ROOT / "plugins" / "_office" / "skills" / "linux-desktop" / "scripts" / "desktopctl.sh"
261 - ).read_text(encoding="utf-8")
262 - desktop_state_helper = (
263 - PROJECT_ROOT / "plugins" / "_office" / "helpers" / "desktop_state.py"
264 - ).read_text(encoding="utf-8")
265 - hooks_py = (PROJECT_ROOT / "plugins" / "_office" / "hooks.py").read_text(encoding="utf-8")
266 - linux_calc_helper = (
267 - PROJECT_ROOT / "plugins" / "_office" / "skills" / "linux-desktop" / "scripts" / "calc_set_cell.py"
268 - ).read_text(encoding="utf-8")
269 -
270 - assert 'Mount("/desktop"' in routes
271 - assert 'Mount("/libreoffice"' not in routes
272 - assert "http.client.HTTPConnection" in routes
273 - assert "WSConnection" in routes
274 - assert "/session/" in routes
275 - assert "resize_session" in routes
276 - assert "resize_display" in primitive
277 - assert "DEFAULT_HEIGHT = 900" in primitive
278 - assert "MAX_WIDTH = 1920" in primitive
279 - assert "MAX_HEIGHT = 1080" in primitive
280 - assert "xrandr" in primitive
281 - assert "xpra-x11" in primitive
282 - assert "xpramenu" in primitive
283 - assert "floating_menu" in primitive
284 - assert '"file_transfer": "true"' in primitive
285 - assert '"sound": "false"' in primitive
286 - assert '"encoding": "jpeg"' in primitive
287 - assert '"quality": "85"' in primitive
288 - assert '"speed": "80"' in primitive
289 - assert '"printing": "true"' in primitive
290 - assert '"offscreen": "true"' in primitive
291 - assert "xpra" in desktop
292 - assert "xpra-html5" in desktop
293 - assert "Xvfb" in desktop
294 - assert "xfce4-session" in desktop
295 - assert "DISPLAY_START_TIMEOUT_SECONDS" in desktop
296 - assert '"shadow"' in desktop
297 - assert "--resize-display=yes" in desktop
298 - assert "--tray=no" in desktop
299 - assert "--system-tray=no" in desktop
300 - assert "--file-transfer=yes" in desktop
301 - assert "--open-files=no" in desktop
302 - assert "--open-url=no" in desktop
303 - assert "--printing=yes" in desktop
304 - assert "--cursors=no" not in desktop
305 - assert "--audio=no" in desktop
306 - assert "--speaker=off" in desktop
307 - assert "--microphone=off" in desktop
308 - assert "--encoding=jpeg" in desktop
309 - assert "--quality=85" in desktop
310 - assert "--speed=80" in desktop
311 - assert "_restart_xpra_shadow(session)" not in desktop
312 - assert 'result["reload"] = True' not in desktop
313 - assert "MAX_SCREEN_WIDTH}x{MAX_SCREEN_HEIGHT}x24" in desktop
314 - assert '"-ac"' in desktop
315 - assert "SYSTEM_TITLE = \"Desktop\"" in desktop
316 - assert "title=\"Desktop\"" in desktop
317 - assert "--log-file=xpra.log" in desktop
318 - assert "virtual_desktop.session_url" in desktop
319 - assert "xsetroot" in desktop
320 - assert "BLOCKING_DIALOG_TITLES" in desktop
321 - assert "xfce4-terminal" in desktop
322 - assert "thunar" in desktop
323 - assert "Browser.desktop" in desktop
324 - assert "Files.desktop" in desktop
325 - assert "org.xfce.terminal" in desktop
326 - assert "org.xfce.settings.manager" in desktop
327 - assert "firefox-esr" not in desktop
328 - assert "xfce4-settings-manager" in desktop
329 - assert "metadata::xfce-exe-checksum" in desktop
330 - assert "DESKTOP_FOLDER_LINKS" in desktop
331 - assert "HIDDEN_XPRA_DESKTOP_ENTRIES" in desktop
332 - assert "HIDDEN_XFCE_MENU_ENTRIES" in desktop
333 - assert "SHUTDOWN_HANDLER_DESKTOP_ID" in desktop
334 - assert "SHUTDOWN_PANEL_LAUNCHER_ID" in desktop
335 - assert "SHUTDOWN_CONFIRM_SECONDS" in desktop
336 - assert "Shutdown Desktop" in desktop
337 - assert "shutdown-request.json" in desktop
338 - assert "shutdown-request.arm.json" in desktop
339 - assert "shutdown_system_desktop" in desktop
340 - assert "claim_shutdown_request" in desktop
341 - assert "last-show-hidden" in desktop
342 - assert "exo-mail-reader.desktop" in desktop
343 - assert "exo-web-browser.desktop" in desktop
344 - assert "xfce4-mail-reader.desktop" in desktop
345 - assert "xfce4-web-browser.desktop" in desktop
346 - assert "xfce4-session-logout.desktop" in desktop
347 - assert "agent-zero-shutdown.desktop" in desktop
348 - assert "libreoffice-gtk3" in install
349 - assert "libreofficekit" not in install
350 - assert "gir1.2-lokdocview" not in install
351 - assert "python3-gi" not in install
352 - assert "xpra" in install
353 - assert "xpra-x11" in install
354 - assert "xpra-html5" in install
355 - assert "xfce4-session" in install
356 - assert "thunar" in install
357 - assert "libglib2.0-bin" in install
358 - assert "xfce4-terminal" in install
359 - assert "firefox-esr" not in install
360 - assert "pulseaudio" not in install
361 - assert "x11-xserver-utils" in install
362 - assert "xauth" in install
363 - assert "Linux Desktop Interface" in linux_desktop_skill
364 - assert "Use the external Agent Zero Browser" in linux_desktop_skill
365 - assert "/a0/usr/workdir" in linux_desktop_skill
366 - assert "/a0/usr/projects" in linux_desktop_skill
367 - assert "desktopctl.sh" in linux_desktop_skill
368 - assert "/a0/plugins/_office/skills/linux-desktop/scripts/desktopctl.sh" in linux_desktop_skill
369 - assert "calc-set-cell" in linux_desktop_skill
370 - assert "Clicks are explicitly last resort" in linux_desktop_skill or "clicks are explicitly last resort" in linux_desktop_skill
371 - assert "fresh Desktop observation" in linux_desktop_skill
372 - assert "observe --json --screenshot" in linux_desktop_skill
373 - assert "Terminal And CLI Agent Verification" in linux_desktop_skill
374 - assert "Do not report from an earlier screenshot path" in linux_desktop_skill
375 - assert "screenshot path returned by that final observation" in linux_desktop_skill
376 - assert "Never paste natural-language text into that shell prompt" in linux_desktop_skill
377 - assert "command not found" in linux_desktop_skill
378 - assert "TARGET_CLI=\"example-cli-agent\"" in linux_desktop_skill
379 - assert "FALLBACK_CMD" in linux_desktop_skill
380 - assert "@openai/codex" not in linux_desktop_skill
381 - assert "xdotool" in linux_desktopctl
382 - assert "agent-zero-desktop" in linux_desktopctl
383 - assert "launch_app" in linux_desktopctl
384 - assert "paste_key_for_active_window" in linux_desktopctl
385 - assert "active_window_is_terminal" in linux_desktopctl
386 - assert "WM_CLASS" in linux_desktopctl
387 - for command in (
388 - "state)",
389 - "observe)",
390 - "screenshot)",
391 - "active-window)",
392 - "geometry)",
393 - "wait-window)",
394 - "scroll)",
395 - "drag)",
396 - "right-click)",
397 - "paste-text)",
398 - "sequence)",
399 - ):
400 - assert command in linux_desktopctl
401 - assert "calc_set_cell.py" in linux_desktopctl
402 - assert "collect_state" in desktop_state_helper
403 - assert "compact_prompt_context" in desktop_state_helper
404 - assert "fresh final" in desktop_state_helper
405 - assert "xwd" in desktop_state_helper
406 - assert "PIL" in desktop_state_helper
407 - assert '"x11-utils"' in hooks_py
408 - assert '"x11-apps"' in hooks_py
409 - assert '"xclip"' in hooks_py
410 - assert '"python3-pil"' in hooks_py
411 - assert "wait_for_document" in linux_calc_helper
412 - assert "document.store()" in linux_calc_helper
413 - assert "read_xlsx_cell" in linux_calc_helper
414 - assert "DisposedException" in linux_calc_helper
415 -
416 -
417 -def test_right_canvas_requires_explicit_open_and_is_absent_on_mobile():
418 - canvas_store = (
419 - PROJECT_ROOT / "webui" / "components" / "canvas" / "right-canvas-store.js"
420 - ).read_text(encoding="utf-8")
421 - canvas_html = (
422 - PROJECT_ROOT / "webui" / "components" / "canvas" / "right-canvas.html"
423 - ).read_text(encoding="utf-8")
424 - canvas_css = (
425 - PROJECT_ROOT / "webui" / "components" / "canvas" / "right-canvas.css"
426 - ).read_text(encoding="utf-8")
427 - handler = (
428 - PROJECT_ROOT
429 - / "plugins"
430 - / "_office"
431 - / "extensions"
432 - / "webui"
433 - / "get_tool_message_handler"
434 - / "document-artifact-handler.js"
435 - ).read_text(encoding="utf-8")
436 - after_loop = (
437 - PROJECT_ROOT
438 - / "plugins"
439 - / "_office"
440 - / "extensions"
441 - / "webui"
442 - / "set_messages_after_loop"
443 - / "auto-open-document-results.js"
444 - ).read_text(encoding="utf-8")
445 -
446 - init_registration = canvas_store.index('await callJsExtensions("right_canvas_register_surfaces", this);')
447 - init_ensure = canvas_store.index("this.ensureActiveSurface();", init_registration)
448 - register_surface = canvas_store.index("registerSurface(surface)")
449 - register_guard = canvas_store.index("if (!this._registering)", register_surface)
450 - guarded_ensure = canvas_store.index("this.ensureActiveSurface();", register_guard)
451 - open_surface = canvas_store.index("async open", register_surface)
452 -
453 - assert init_registration < init_ensure
454 - assert register_surface < register_guard < guarded_ensure < open_surface
455 - assert "right-canvas-resize-start" in canvas_store
456 - assert "right-canvas-resize-end" in canvas_store
457 - assert "dispatchResizeEvent" in canvas_store
458 - assert "this.isOpen = false;" in canvas_store
459 - assert "wasMobileMode && this.width < MIN_WIDTH" in canvas_store
460 - assert "const MIN_WIDTH = 0" in canvas_store
461 - assert "const MAX_WIDTH" not in canvas_store
462 - assert "0.58" not in canvas_store
463 - assert "min(900px, 58vw)" not in canvas_css
464 - assert "max-width: none" in canvas_css
465 - assert "if (this.isMobileMode && !surface.actionOnly)" in canvas_store
466 - assert "if (this.isMobileMode)" in canvas_store
467 - assert "shouldRender()" in canvas_store
468 - assert "$store.rightCanvas.shouldRender()" in canvas_html
469 - assert 'title="Open as window"' in canvas_html
470 - assert 'title="Close canvas"' in canvas_html
471 - assert 'aria-label="Close canvas"' in canvas_html
472 - assert "@click=\"$store.rightCanvas.close()\"" in canvas_html
473 - assert canvas_html.index('title="Open as window"') < canvas_html.index('title="Close canvas"')
474 - assert "body.right-canvas-mobile-mode .right-canvas" in canvas_css
475 - assert "display: none !important" in canvas_css
476 - assert "autoOpenOfficeCanvas" not in handler
477 - assert "isOfficeCanvasAlreadyOpen" in after_loop
478 - assert 'canvas?.isOpen && canvas?.activeSurfaceId === "office"' in after_loop
479 - assert "office.openSession?.(" in after_loop
480 - assert 'source: "tool-result-sync"' in after_loop
481 - assert 'rightCanvas.open' not in after_loop
482 -
483 -
484 -def test_office_skills_preserve_markdown_first_and_opt_in_desktop_policy():
485 - office_skill = (
486 - PROJECT_ROOT / "plugins" / "_office" / "skills" / "office-artifacts" / "SKILL.md"
487 - ).read_text(encoding="utf-8")
488 - desktop_skill = (
489 - PROJECT_ROOT / "plugins" / "_office" / "skills" / "linux-desktop" / "SKILL.md"
490 - ).read_text(encoding="utf-8")
491 - markdown_skill = (
492 - PROJECT_ROOT / "plugins" / "_office" / "skills" / "markdown-documents" / "SKILL.md"
493 - ).read_text(encoding="utf-8")
494 - word_skill = (
495 - PROJECT_ROOT / "plugins" / "_office" / "skills" / "word-documents" / "SKILL.md"
496 - ).read_text(encoding="utf-8")
497 - excel_skill = (
498 - PROJECT_ROOT / "plugins" / "_office" / "skills" / "excel-workbooks" / "SKILL.md"
499 - ).read_text(encoding="utf-8")
500 - presentation_skill = (
501 - PROJECT_ROOT / "plugins" / "_office" / "skills" / "presentation-decks" / "SKILL.md"
502 - ).read_text(encoding="utf-8")
503 -
504 - assert "ODF is first-class" in office_skill
505 - assert "DOCX, XLSX, or PPTX only" in office_skill
506 - assert "custom document canvas" in office_skill
507 - assert "must not open the canvas automatically" in office_skill
508 - assert "Download and Open in canvas actions" in office_skill
509 - assert "method: \"create\"" in office_skill
510 - assert "The Desktop is opt-in" in desktop_skill
511 - assert "coordinate clicks only as a last resort" in desktop_skill
512 - assert "After any GUI action, verify" in desktop_skill
513 - assert "custom Markdown editor" in desktop_skill
514 - assert "Never open the Desktop/canvas automatically" in desktop_skill
515 - assert "persistent Desktop runtime during initial startup" in desktop_skill
516 - assert '"format": "md"' in markdown_skill
517 - assert "never open the canvas automatically" in markdown_skill
518 - assert '"format": "odt"' in word_skill
519 - assert "DOCX only" in word_skill
520 - assert "must not open the canvas automatically" in word_skill
521 - assert '"format": "ods"' in excel_skill
522 - assert "For a blank workbook request" in excel_skill
523 - assert "must not open the canvas automatically" in excel_skill
524 - assert '"format": "odp"' in presentation_skill
525 - assert "must not open the canvas automatically" in presentation_skill
526 -
527 -
528 -def test_office_extra_prompt_includes_existing_desktop_state_without_opening_canvas():
529 - canvas_context = (
530 - PROJECT_ROOT / "plugins" / "_office" / "helpers" / "canvas_context.py"
531 - ).read_text(encoding="utf-8")
532 - prompt = (
533 - PROJECT_ROOT / "plugins" / "_office" / "prompts" / "agent.extras.office_canvas.md"
534 - ).read_text(encoding="utf-8")
535 -
536 - assert "build_desktop_context" in canvas_context
537 - assert "session_manifest_exists" in canvas_context
538 - assert "collect_state(include_screenshot=False)" in canvas_context
539 - assert "compact_prompt_context" in canvas_context
540 - assert "ensure_system_desktop" not in canvas_context
541 - assert "[DOCUMENT CANVAS]" in prompt
212 + document_actions = read("plugins", "_office", "extensions", "webui", "lib", "document-actions.js")
213 + document_tool = read("plugins", "_office", "tools", "document_artifact.py")
214 + office_api = read("plugins", "_office", "api", "office_session.py")
215 +
216 + assert 'openSurface("desktop"' in auto_open
217 + assert "isExplicitDocumentUiRequest(payload)" in auto_open
218 + assert 'action === "open"' in auto_open
219 + assert "open_in_canvas" in auto_open
220 + assert "open_in_desktop" in auto_open
221 + assert 'surfaces.open("desktop"' not in auto_open
222 + assert "rightCanvas.open" not in auto_open
223 + assert "globalThis.Alpine" not in auto_open
224 + assert "syncDocumentResultsIntoOpenOfficeModal" in auto_open
225 + assert "isOfficeCanvas" not in auto_open
226 + assert "officeStore" in auto_open
227 + assert "openDocumentInDesktop" in document_actions
228 + assert "openDocumentArtifact" in document_actions
229 + assert "ensureModalOpen" in document_actions
230 + assert "Open Document" in document_actions
231 + assert 'openSurface("desktop"' in document_actions
232 + assert "Edit in Writer" in document_actions
233 + assert "Edit in Calc" in document_actions
234 + assert "Edit in Impress" in document_actions
235 + assert "open_in_canvas: bool = False" in document_tool
236 + assert '"open_in_canvas": bool(open_in_canvas)' in document_tool
237 + assert '"open_in_desktop": bool(open_in_desktop)' in document_tool
238 + assert '"requires_desktop": True' in office_api
239 + assert 'input.get("open_in_desktop") is not True' in office_api
240 +
241 +
242 +def test_office_and_desktop_skills_are_rehomed_and_renamed():
243 + office_skills = PROJECT_ROOT / "plugins" / "_office" / "skills"
244 + desktop_skills = PROJECT_ROOT / "plugins" / "_desktop" / "skills"
245 +
246 + assert not (office_skills / "linux-desktop").exists()
247 + assert (desktop_skills / "linux-desktop" / "SKILL.md").exists()
248 + assert not (office_skills / "office-artifacts").exists()
249 + assert not (office_skills / "word-documents").exists()
250 + assert not (office_skills / "excel-workbooks").exists()
251 + assert not (office_skills / "presentation-decks").exists()
252 +
253 + expected = {
254 + "document-artifacts": office_skills / "document-artifacts" / "SKILL.md",
255 + "writer-documents": office_skills / "writer-documents" / "SKILL.md",
256 + "calc-spreadsheets": office_skills / "calc-spreadsheets" / "SKILL.md",
257 + "impress-presentations": office_skills / "impress-presentations" / "SKILL.md",
258 + "markdown-documents": office_skills / "markdown-documents" / "SKILL.md",
259 + }
260 + for name, path in expected.items():
261 + text = path.read_text(encoding="utf-8")
262 + assert f"name: {name}" in text
263 +
264 + desktop_skill = (desktop_skills / "linux-desktop" / "SKILL.md").read_text(encoding="utf-8")
265 + desktopctl = (desktop_skills / "linux-desktop" / "scripts" / "desktopctl.sh").read_text(encoding="utf-8")
266 + assert "/a0/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh" in desktop_skill
267 + assert "Open in Desktop action" in desktop_skill
268 + assert "$BASE_DIR/usr/_desktop/profiles/$SESSION" in desktopctl
269 + assert "$BASE_DIR/usr/_desktop/sessions/$SESSION.json" in desktopctl
270 +
271 +
272 +def test_skill_catalog_and_connector_boundaries_are_static_guarded():
273 + skills_py = read("helpers", "skills.py")
274 + connector_list = read("plugins", "_a0_connector", "api", "v1", "skills_list.py")
275 + connector_delete = read("plugins", "_a0_connector", "api", "v1", "skills_delete.py")
276 +
277 + assert "RENAMED_SKILLS" not in skills_py
278 + assert "RENAMED_SKILL_PATHS" not in skills_py
279 + assert "_migrate_skill_name" not in skills_py
280 + assert "_migrate_skill_path" not in skills_py
281 + assert "Built-in plugin skills cannot be deleted" in skills_py
282 + assert "list_skill_catalog" in connector_list
283 + assert "list_skills(" not in connector_list
284 + assert '"origin": skill["origin"]' in connector_list
285 + assert "list_skill_catalog" in connector_delete
286 + assert 'match.get("origin") not in {"User", "Project"}' in connector_delete
287 + assert "only user or project skills can be deleted" in connector_delete
tests/test_office_desktop_state.py
+1 -1
@@ -10,7 +10,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
10 if str(PROJECT_ROOT) not in sys.path:
11 sys.path.insert(0, str(PROJECT_ROOT))
12
13 -from plugins._office.helpers import desktop_state
13 +from plugins._desktop.helpers import desktop_state
14
15
16 def _completed(command, returncode=0, stdout="", stderr=""):
tests/test_office_document_store.py
+319 -136
@@ -19,12 +19,13 @@ if str(PROJECT_ROOT) not in sys.path:
19 sys.path.insert(0, str(PROJECT_ROOT))
20
21 from plugins._office import hooks
22 +from plugins._desktop import hooks as desktop_hooks
23 +from plugins._desktop.helpers import desktop_session
24 from plugins._office.helpers import (
25 artifact_editor,
26 canvas_context,
27 document_store,
28 libreoffice,
27 - libreoffice_desktop,
29 markdown_sessions,
30 )
31
@@ -312,8 +313,8 @@ def test_odf_is_advertised_and_docx_remains_explicit_compatibility(office_state)
313 assert "ODF is first-class for LibreOffice" in prompt
314 assert "DOCX/XLSX/PPTX are compatibility formats" in prompt
315 assert "`method` is accepted as an alias for action" in prompt
315 - assert "they do not open the canvas automatically" in prompt
316 - assert "Download and Open in canvas message actions" in prompt
316 + assert "they do not open a surface automatically" in prompt
317 + assert "explicit Download, Open Document, or Desktop edit message actions" in prompt
318 doc = document_store.create_document("document", "Use ODT", "odt", "")
319 assert doc["extension"] == "odt"
320
@@ -450,7 +451,7 @@ def test_thunar_defaults_preserve_existing_profile_settings(tmp_path):
451 encoding="utf-8",
452 )
453
453 - libreoffice_desktop._write_thunar_defaults(thunar_xml)
454 + desktop_session._write_thunar_defaults(thunar_xml)
455
456 root = ET.parse(thunar_xml).getroot()
457 values = {child.get("name"): child.get("value") for child in root.findall("property")}
@@ -459,14 +460,14 @@ def test_thunar_defaults_preserve_existing_profile_settings(tmp_path):
460 assert values["last-show-hidden"] == "true"
461
462
462 -def test_official_libreoffice_desktop_status_and_url_contract(tmp_path, monkeypatch):
463 +def test_official_desktop_session_status_and_url_contract(tmp_path, monkeypatch):
464 xpra_html = tmp_path / "xpra" / "www"
465 xpra_html.mkdir(parents=True)
466 (xpra_html / "index.html").write_text("xpra", encoding="utf-8")
467
467 - monkeypatch.setattr(libreoffice_desktop.libreoffice, "find_soffice", lambda: "/usr/bin/soffice")
468 + monkeypatch.setattr(desktop_session.libreoffice, "find_soffice", lambda: "/usr/bin/soffice")
469 monkeypatch.setattr(
469 - libreoffice_desktop.shutil,
470 + desktop_session.shutil,
471 "which",
472 lambda name: f"/usr/bin/{name}"
473 if name
@@ -484,11 +485,11 @@ def test_official_libreoffice_desktop_status_and_url_contract(tmp_path, monkeypa
485 }
486 else "",
487 )
487 - monkeypatch.setattr(libreoffice_desktop.virtual_desktop, "XPRA_HTML_ROOT_CANDIDATES", (xpra_html,))
488 - monkeypatch.setattr(libreoffice_desktop.virtual_desktop, "_package_installed", lambda package: True)
488 + monkeypatch.setattr(desktop_session.virtual_desktop, "XPRA_HTML_ROOT_CANDIDATES", (xpra_html,))
489 + monkeypatch.setattr(desktop_session.virtual_desktop, "_package_installed", lambda package: True)
490
490 - status = libreoffice_desktop.collect_desktop_status()
491 - url = libreoffice_desktop._xpra_url("abc123")
491 + status = desktop_session.collect_desktop_status()
492 + url = desktop_session._xpra_url("abc123")
493
494 assert status["healthy"] is True
495 assert status["xpra_html_root"] == str(xpra_html)
@@ -504,6 +505,21 @@ def test_official_libreoffice_desktop_status_and_url_contract(tmp_path, monkeypa
505 assert "printing=true" in url
506
507
508 +def test_desktop_gateway_patches_xpra_menu_script():
509 + source = (PROJECT_ROOT / "helpers" / "virtual_desktop_routes.py").read_text(encoding="utf-8")
510 +
511 + assert "XPRA_MENU_CUSTOM_PATCH" in source
512 + assert 'upstream_path.endswith("/js/MenuCustom.js")' in source
513 + assert "window.noWindowList" in source
514 + assert "__a0SafeWindowList" in source
515 + assert "XPRA_WINDOW_OFFSET_WARNING_PATCH" in source
516 + assert "XPRA_WINDOW_SCRIPT_PATCH" in source
517 + assert "a0_desktop_patch=20260506" in source
518 + assert 'upstream_path.endswith("/index.html")' in source
519 + assert 'upstream_path.endswith("/js/Window.js")' in source
520 + assert "window does not fit in canvas, offsets:" in source
521 +
522 +
523 def test_office_session_desktop_state_action_defaults_without_screenshot(monkeypatch):
524 api_module = types.ModuleType("helpers.api")
525
@@ -527,7 +543,7 @@ def test_office_session_desktop_state_action_defaults_without_screenshot(monkeyp
543 return {
544 "ok": True,
545 "display": ":120",
530 - "profile_dir": "/a0/tmp/_office/desktop/profiles/agent-zero-desktop",
546 + "profile_dir": "/a0/usr/_desktop/profiles/agent-zero-desktop",
547 "size": {"width": 1440, "height": 900},
548 "pointer": {"x": 0, "y": 0, "screen": 0, "window": 0},
549 "active_window": None,
@@ -537,7 +553,7 @@ def test_office_session_desktop_state_action_defaults_without_screenshot(monkeyp
553 "errors": [],
554 }
555
540 - monkeypatch.setattr(office_session.libreoffice_desktop, "get_manager", lambda: FakeManager())
556 + monkeypatch.setattr(office_session.desktop_session, "get_manager", lambda: FakeManager())
557 handler = office_session.OfficeSession(app=None, thread_lock=None)
558 request = types.SimpleNamespace(headers={}, host_url="http://localhost:32080")
559
@@ -583,7 +599,7 @@ def test_office_session_desktop_shutdown_action_calls_manager(monkeypatch):
599 "source": source,
600 }
601
586 - monkeypatch.setattr(office_session.libreoffice_desktop, "get_manager", lambda: FakeManager())
602 + monkeypatch.setattr(office_session.desktop_session, "get_manager", lambda: FakeManager())
603 handler = office_session.OfficeSession(app=None, thread_lock=None)
604 request = types.SimpleNamespace(headers={}, host_url="http://localhost:32080")
605
@@ -600,7 +616,50 @@ def test_office_session_desktop_shutdown_action_calls_manager(monkeypatch):
616 monkeypatch.delattr(api_package, "office_session", raising=False)
617
618
603 -def test_official_libreoffice_desktop_manager_opens_binary_session(office_state, tmp_path, monkeypatch):
619 +def test_office_binary_open_requires_explicit_desktop_without_cold_session(office_state, monkeypatch):
620 + api_module = types.ModuleType("helpers.api")
621 +
622 + class ApiHandler:
623 + def __init__(self, app=None, thread_lock=None):
624 + self.app = app
625 + self.thread_lock = thread_lock
626 +
627 + api_module.ApiHandler = ApiHandler
628 + api_module.Request = object
629 + monkeypatch.setitem(sys.modules, "helpers.api", api_module)
630 + monkeypatch.delitem(sys.modules, "plugins._office.api.office_session", raising=False)
631 +
632 + from plugins._office.api import office_session
633 +
634 + doc = document_store.create_document("document", "Cold Memo", "odt", "No surprise Desktop.")
635 +
636 + def forbidden_session(*_args, **_kwargs):
637 + raise AssertionError("cold binary open must not create a store session")
638 +
639 + class ForbiddenManager:
640 + def open(self, *_args, **_kwargs):
641 + raise AssertionError("cold binary open must not open Desktop")
642 +
643 + monkeypatch.setattr(office_session.document_store, "create_session", forbidden_session)
644 + monkeypatch.setattr(office_session.desktop_session, "get_manager", lambda: ForbiddenManager())
645 +
646 + handler = office_session.OfficeSession(app=None, thread_lock=None)
647 + request = types.SimpleNamespace(headers={}, host_url="http://localhost:32080")
648 + result = asyncio.run(handler.process({"action": "open", "file_id": doc["file_id"]}, request))
649 +
650 + assert result["ok"] is True
651 + assert result["requires_desktop"] is True
652 + assert result["file_id"] == doc["file_id"]
653 + assert "session_id" not in result
654 + assert "store_session_id" not in result
655 +
656 + monkeypatch.delitem(sys.modules, "plugins._office.api.office_session", raising=False)
657 + api_package = sys.modules.get("plugins._office.api")
658 + if api_package is not None:
659 + monkeypatch.delattr(api_package, "office_session", raising=False)
660 +
661 +
662 +def test_official_desktop_session_manager_opens_binary_session(office_state, tmp_path, monkeypatch):
663 class FakeProcess:
664 pid = 4242
665
@@ -616,21 +675,21 @@ def test_official_libreoffice_desktop_manager_opens_binary_session(office_state,
675 def kill(self):
676 return None
677
619 - monkeypatch.setattr(libreoffice_desktop, "STATE_DIR", tmp_path / "desktop")
620 - monkeypatch.setattr(libreoffice_desktop, "SESSION_DIR", tmp_path / "desktop" / "sessions")
621 - monkeypatch.setattr(libreoffice_desktop, "PROFILE_DIR", tmp_path / "desktop" / "profiles")
622 - monkeypatch.setattr(libreoffice_desktop, "collect_desktop_status", lambda: {"healthy": True, "message": "ok"})
623 - monkeypatch.setattr(libreoffice_desktop.libreoffice, "find_soffice", lambda: "/usr/bin/soffice")
624 - monkeypatch.setattr(libreoffice_desktop, "_port_is_free", lambda port: True)
625 - monkeypatch.setattr(libreoffice_desktop.virtual_desktop, "has_window", lambda **kwargs: True)
626 - real_get_abs_path = libreoffice_desktop.files.get_abs_path
678 + monkeypatch.setattr(desktop_session, "STATE_DIR", tmp_path / "desktop")
679 + monkeypatch.setattr(desktop_session, "SESSION_DIR", tmp_path / "desktop" / "sessions")
680 + monkeypatch.setattr(desktop_session, "PROFILE_DIR", tmp_path / "desktop" / "profiles")
681 + monkeypatch.setattr(desktop_session, "collect_desktop_status", lambda: {"healthy": True, "message": "ok"})
682 + monkeypatch.setattr(desktop_session.libreoffice, "find_soffice", lambda: "/usr/bin/soffice")
683 + monkeypatch.setattr(desktop_session, "_port_is_free", lambda port: True)
684 + monkeypatch.setattr(desktop_session.virtual_desktop, "has_window", lambda **kwargs: True)
685 + real_get_abs_path = desktop_session.files.get_abs_path
686
687 def fake_get_abs_path(*parts):
688 if parts and parts[0] == "usr":
689 return str(tmp_path.joinpath(*parts))
690 return real_get_abs_path(*parts)
691
633 - monkeypatch.setattr(libreoffice_desktop.files, "get_abs_path", fake_get_abs_path)
692 + monkeypatch.setattr(desktop_session.files, "get_abs_path", fake_get_abs_path)
693
694 def fake_spawn(self, session):
695 session.profile_dir.mkdir(parents=True, exist_ok=True)
@@ -639,11 +698,11 @@ def test_official_libreoffice_desktop_manager_opens_binary_session(office_state,
698 def fake_open_document(self, session, doc):
699 session.processes[f"soffice-{doc['file_id']}"] = FakeProcess()
700
642 - monkeypatch.setattr(libreoffice_desktop.LibreOfficeDesktopManager, "_spawn_desktop_locked", fake_spawn)
643 - monkeypatch.setattr(libreoffice_desktop.LibreOfficeDesktopManager, "_open_document_locked", fake_open_document)
701 + monkeypatch.setattr(desktop_session.DesktopSessionManager, "_spawn_desktop_locked", fake_spawn)
702 + monkeypatch.setattr(desktop_session.DesktopSessionManager, "_open_document_locked", fake_open_document)
703
704 doc = document_store.create_document("spreadsheet", "Official Sheet", "ods", "Name,Value\nA,1")
646 - manager = libreoffice_desktop.LibreOfficeDesktopManager()
705 + manager = desktop_session.DesktopSessionManager()
706 payload = manager.open(doc)
707
708 assert payload["available"] is True
@@ -792,7 +851,7 @@ def test_official_libreoffice_desktop_manager_opens_binary_session(office_state,
851 assert "xmessage" in shutdown_script
852 assert '"-buttons",' in shutdown_script
853 desktop_helper = (
795 - PROJECT_ROOT / "plugins" / "_office" / "helpers" / "libreoffice_desktop.py"
854 + PROJECT_ROOT / "plugins" / "_desktop" / "helpers" / "desktop_session.py"
855 ).read_text(encoding="utf-8")
856 assert "_refresh_xfce_desktop" in desktop_helper
857 assert "DBUS_SESSION_BUS_ADDRESS" in desktop_helper
@@ -803,7 +862,7 @@ def test_official_libreoffice_desktop_manager_opens_binary_session(office_state,
862 / payload["session_id"]
863 / ".config"
864 / "autostart"
806 - / "agent-zero-office-desktop.desktop"
865 + / "agent-zero-desktop.desktop"
866 )
867 assert "prepare-xfce-profile.sh" in autostart.read_text(encoding="utf-8")
868 profile_script = (
@@ -841,31 +900,31 @@ def test_official_libreoffice_desktop_manager_opens_binary_session(office_state,
900 ).read_text(encoding="utf-8")
901 assert "NoDisplay=true" in entry
902 assert "Hidden=true" in entry
844 - assert manager.proxy_for_token(payload["token"]) == ("127.0.0.1", libreoffice_desktop.XPRA_PORT_BASE)
903 + assert manager.proxy_for_token(payload["token"]) == ("127.0.0.1", desktop_session.XPRA_PORT_BASE)
904 assert manager.close(payload["session_id"], save_first=False)["closed"] == 0
905 assert manager.close(payload["session_id"], save_first=False)["persistent"] is True
906
907
908 def test_shutdown_panel_launcher_requires_second_click(tmp_path):
850 - profile_dir = tmp_path / "desktop" / "profiles" / libreoffice_desktop.SYSTEM_SESSION_ID
909 + profile_dir = tmp_path / "desktop" / "profiles" / desktop_session.SYSTEM_SESSION_ID
910 profile_dir.mkdir(parents=True)
911 desktop_path = tmp_path / "workdir"
912 desktop_path.mkdir()
854 - session = libreoffice_desktop.DesktopSession(
855 - session_id=libreoffice_desktop.SYSTEM_SESSION_ID,
856 - file_id=libreoffice_desktop.SYSTEM_FILE_ID,
913 + session = desktop_session.DesktopSession(
914 + session_id=desktop_session.SYSTEM_SESSION_ID,
915 + file_id=desktop_session.SYSTEM_FILE_ID,
916 extension="desktop",
917 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,
918 + title=desktop_session.SYSTEM_TITLE,
919 + display=desktop_session.DISPLAY_BASE,
920 + xpra_port=desktop_session.XPRA_PORT_BASE,
921 + token=desktop_session.SYSTEM_SESSION_ID,
922 url="/desktop/session/agent-zero-desktop/index.html",
923 profile_dir=profile_dir,
924 )
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)
925 + script = desktop_session._write_shutdown_bridge_script(session)
926 + request = desktop_session._shutdown_request_path(session)
927 + arm = desktop_session._shutdown_arm_path(session)
928 env = dict(os.environ)
929 env.pop("DISPLAY", None)
930
@@ -882,7 +941,7 @@ def test_shutdown_panel_launcher_requires_second_click(tmp_path):
941 assert not arm.exists()
942
943
885 -def test_libreoffice_desktop_sync_consumes_shutdown_marker(tmp_path, monkeypatch):
944 +def test_desktop_session_sync_consumes_shutdown_marker(tmp_path, monkeypatch):
945 class FakeProcess:
946 pid = 5252
947 terminated = False
@@ -900,32 +959,32 @@ def test_libreoffice_desktop_sync_consumes_shutdown_marker(tmp_path, monkeypatch
959 def kill(self):
960 self.terminated = True
961
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")
962 + monkeypatch.setattr(desktop_session, "STATE_DIR", tmp_path / "desktop")
963 + monkeypatch.setattr(desktop_session, "SESSION_DIR", tmp_path / "desktop" / "sessions")
964 + monkeypatch.setattr(desktop_session, "PROFILE_DIR", tmp_path / "desktop" / "profiles")
965
907 - profile_dir = tmp_path / "desktop" / "profiles" / libreoffice_desktop.SYSTEM_SESSION_ID
966 + profile_dir = tmp_path / "desktop" / "profiles" / desktop_session.SYSTEM_SESSION_ID
967 profile_dir.mkdir(parents=True)
968 desktop_path = tmp_path / "workdir"
969 desktop_path.mkdir()
911 - session = libreoffice_desktop.DesktopSession(
912 - session_id=libreoffice_desktop.SYSTEM_SESSION_ID,
913 - file_id=libreoffice_desktop.SYSTEM_FILE_ID,
970 + session = desktop_session.DesktopSession(
971 + session_id=desktop_session.SYSTEM_SESSION_ID,
972 + file_id=desktop_session.SYSTEM_FILE_ID,
973 extension="desktop",
974 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,
975 + title=desktop_session.SYSTEM_TITLE,
976 + display=desktop_session.DISPLAY_BASE,
977 + xpra_port=desktop_session.XPRA_PORT_BASE,
978 + token=desktop_session.SYSTEM_SESSION_ID,
979 url="/desktop/session/agent-zero-desktop/index.html",
980 profile_dir=profile_dir,
981 processes={"xpra": FakeProcess()},
982 )
924 - manager = libreoffice_desktop.LibreOfficeDesktopManager()
983 + manager = desktop_session.DesktopSessionManager()
984 manager._sessions[session.session_id] = session
985 manager._write_manifest(session)
927 - libreoffice_desktop._write_url_bridge_script(session)
928 - shutdown_request = libreoffice_desktop._shutdown_request_path(session)
986 + desktop_session._write_url_bridge_script(session)
987 + shutdown_request = desktop_session._shutdown_request_path(session)
988 shutdown_request.write_text('{"source": "tray", "created_at": 123.0}\n', encoding="utf-8")
989 save_calls = []
990 monkeypatch.setattr(
@@ -940,56 +999,177 @@ def test_libreoffice_desktop_sync_consumes_shutdown_marker(tmp_path, monkeypatch
999 assert result["intentional_shutdown"] is True
1000 assert result["source"] == "tray"
1001 assert result["closed"] == 1
943 - assert save_calls == [(libreoffice_desktop.SYSTEM_SESSION_ID, "")]
1002 + assert save_calls == [(desktop_session.SYSTEM_SESSION_ID, "")]
1003 assert not shutdown_request.exists()
945 - assert not (libreoffice_desktop.SESSION_DIR / f"{session.session_id}.json").exists()
1004 + assert not (desktop_session.SESSION_DIR / f"{session.session_id}.json").exists()
1005 assert manager.get(session.session_id) is None
1006
1007
949 -def test_libreoffice_desktop_cleanup_preserves_live_owner_manifest(tmp_path, monkeypatch):
1008 +def test_desktop_session_cleanup_preserves_live_owner_manifest(tmp_path, monkeypatch):
1009 session_dir = tmp_path / "sessions"
1010 + legacy_session_dir = tmp_path / "legacy-sessions"
1011 session_dir.mkdir()
1012 + legacy_session_dir.mkdir()
1013 manifest = session_dir / "live.json"
1014 manifest.write_text(
1015 json.dumps({"owner_pid": os.getpid(), "pids": {"xpra": 987654}}),
1016 encoding="utf-8",
1017 )
957 - monkeypatch.setattr(libreoffice_desktop, "SESSION_DIR", session_dir)
1018 + legacy_manifest = legacy_session_dir / "stale.json"
1019 + legacy_manifest.write_text(
1020 + json.dumps({"owner_pid": 987650, "pids": {"xpra": 987651, "xfce": 987652}}),
1021 + encoding="utf-8",
1022 + )
1023 + monkeypatch.setattr(desktop_session, "SESSION_DIR", session_dir)
1024 + monkeypatch.setattr(desktop_session, "LEGACY_SESSION_DIRS", (legacy_session_dir,))
1025 + killed = []
1026 +
1027 + def fake_kill_pid(pid):
1028 + killed.append(pid)
1029 + return True
1030 +
1031 monkeypatch.setattr(
959 - libreoffice_desktop,
1032 + desktop_session,
1033 "_kill_pid",
961 - lambda _pid: pytest.fail("cleanup should not kill a desktop owned by a live UI process"),
1034 + fake_kill_pid,
1035 )
1036
964 - result = libreoffice_desktop.cleanup_stale_runtime_state()
1037 + result = desktop_session.cleanup_stale_runtime_state()
1038
966 - assert result["killed"] == []
1039 + assert result["killed"] == [987651, 987652]
1040 + assert killed == [987651, 987652]
1041 assert manifest.exists()
1042 + assert not legacy_manifest.exists()
1043
1044
970 -def test_libreoffice_desktop_removes_stale_lock_file(tmp_path):
1045 +def test_desktop_session_removes_stale_lock_file(tmp_path):
1046 doc_path = tmp_path / "Deck.pptx"
1047 doc_path.write_text("pptx", encoding="utf-8")
1048 lock_path = tmp_path / ".~lock.Deck.pptx#"
1049 lock_path.write_text("stale", encoding="utf-8")
975 - session = libreoffice_desktop.DesktopSession(
1050 + session = desktop_session.DesktopSession(
1051 session_id="session",
1052 file_id="file",
1053 extension="pptx",
1054 path=str(doc_path),
1055 title=doc_path.name,
981 - display=libreoffice_desktop.DISPLAY_BASE,
982 - xpra_port=libreoffice_desktop.XPRA_PORT_BASE,
1056 + display=desktop_session.DISPLAY_BASE,
1057 + xpra_port=desktop_session.XPRA_PORT_BASE,
1058 token="token",
1059 url="/desktop/session/token/index.html",
1060 profile_dir=tmp_path / "profile",
1061 )
1062
988 - libreoffice_desktop.LibreOfficeDesktopManager()._remove_stale_lock_file(session)
1063 + desktop_session.DesktopSessionManager()._remove_stale_lock_file(session)
1064
1065 assert not lock_path.exists()
1066
1067
1068 +def _isolate_office_cleanup_hook(monkeypatch, tmp_path):
1069 + monkeypatch.setattr(hooks, "RETIRED_WEB_APT_SOURCE_FILE", tmp_path / "missing.sources")
1070 + monkeypatch.setattr(hooks, "RETIRED_WEB_APT_KEYRING_FILE", tmp_path / "missing.gpg")
1071 + monkeypatch.setattr(hooks, "RETIRED_WEB_SUPERVISOR_FILE", tmp_path / "missing.conf")
1072 + monkeypatch.setattr(hooks, "RETIRED_WEB_RUNTIME_DIRS", [])
1073 + monkeypatch.setattr(hooks, "CLEANUP_MARKER", tmp_path / "state" / "cleanup.done")
1074 + monkeypatch.setattr(hooks, "_installed_packages", lambda packages: [])
1075 + monkeypatch.setattr(hooks, "_kill_old_processes", lambda errors: None)
1076 + monkeypatch.setattr(hooks, "_ensure_runtime_dependencies", lambda installed, errors: None)
1077 + monkeypatch.setattr(hooks, "_purge_packages", lambda removed, errors, **kwargs: None)
1078 + monkeypatch.setattr(hooks.shutil, "which", lambda name: "")
1079 +
1080 +
1081 +def test_cleanup_hook_migrates_legacy_document_state_without_removing_source(tmp_path, monkeypatch):
1082 + _isolate_office_cleanup_hook(monkeypatch, tmp_path)
1083 + legacy_documents = tmp_path / "usr" / "plugins" / "_office" / "documents"
1084 + document_state = tmp_path / "usr" / "_office" / "documents"
1085 + legacy_documents.mkdir(parents=True)
1086 + (legacy_documents / "documents.sqlite3").write_text("legacy-db\n", encoding="utf-8")
1087 + (legacy_documents / "backups").mkdir()
1088 + (legacy_documents / "backups" / "draft.md").write_text("backup\n", encoding="utf-8")
1089 +
1090 + monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", document_state)
1091 + monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [legacy_documents])
1092 + monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, warnings, errors: None)
1093 +
1094 + result = hooks.cleanup_stale_runtime_state(force=True)
1095 +
1096 + assert result["ok"] is True
1097 + assert result["migrated"] == [f"{legacy_documents} -> {document_state}"]
1098 + assert (legacy_documents / "documents.sqlite3").exists()
1099 + assert (document_state / "documents.sqlite3").read_text(encoding="utf-8") == "legacy-db\n"
1100 + assert (document_state / "backups" / "draft.md").read_text(encoding="utf-8") == "backup\n"
1101 +
1102 +
1103 +def test_cleanup_hook_prefers_existing_new_document_state_without_merge(tmp_path, monkeypatch):
1104 + _isolate_office_cleanup_hook(monkeypatch, tmp_path)
1105 + legacy_documents = tmp_path / "legacy-documents"
1106 + document_state = tmp_path / "usr" / "_office" / "documents"
1107 + legacy_documents.mkdir(parents=True)
1108 + document_state.mkdir(parents=True)
1109 + (legacy_documents / "documents.sqlite3").write_text("legacy-db\n", encoding="utf-8")
1110 + (document_state / "documents.sqlite3").write_text("new-db\n", encoding="utf-8")
1111 +
1112 + monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", document_state)
1113 + monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [legacy_documents])
1114 + monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, warnings, errors: None)
1115 +
1116 + result = hooks.cleanup_stale_runtime_state(force=True)
1117 +
1118 + assert result["ok"] is True
1119 + assert result["migrated"] == []
1120 + assert result["warnings"] == [
1121 + f"Legacy Office document state left in place because {document_state} already exists: {legacy_documents}"
1122 + ]
1123 + assert (legacy_documents / "documents.sqlite3").read_text(encoding="utf-8") == "legacy-db\n"
1124 + assert (document_state / "documents.sqlite3").read_text(encoding="utf-8") == "new-db\n"
1125 +
1126 +
1127 +def test_office_hook_desktop_compat_forwards_runtime_result(monkeypatch):
1128 + monkeypatch.setattr(
1129 + desktop_hooks,
1130 + "cleanup_stale_runtime_state",
1131 + lambda: {
1132 + "installed": ["xpra-server"],
1133 + "removed": ["firefox-esr"],
1134 + "warnings": ["desktop warning"],
1135 + "errors": ["desktop error"],
1136 + },
1137 + )
1138 + installed = []
1139 + removed = []
1140 + warnings = []
1141 + errors = []
1142 +
1143 + hooks._ensure_desktop_runtime_compat(installed, removed, warnings, errors)
1144 +
1145 + assert installed == ["xpra-server"]
1146 + assert removed == ["firefox-esr"]
1147 + assert warnings == ["desktop warning"]
1148 + assert errors == ["desktop error"]
1149 +
1150 +
1151 +def test_cleanup_hook_delegates_desktop_runtime_for_legacy_self_update(tmp_path, monkeypatch):
1152 + _isolate_office_cleanup_hook(monkeypatch, tmp_path)
1153 + monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", tmp_path / "usr" / "_office" / "documents")
1154 + monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [])
1155 + calls = []
1156 +
1157 + def fake_desktop_compat(installed, removed, warnings, errors):
1158 + calls.append("desktop")
1159 + installed.append("xpra-server")
1160 + removed.append("firefox-esr")
1161 + warnings.append("desktop runtime prepared through office compatibility hook")
1162 +
1163 + monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", fake_desktop_compat)
1164 +
1165 + result = hooks.cleanup_stale_runtime_state(force=True)
1166 +
1167 + assert calls == ["desktop"]
1168 + assert result["installed"] == ["xpra-server"]
1169 + assert result["removed"] == ["firefox-esr"]
1170 + assert result["warnings"] == ["desktop runtime prepared through office compatibility hook"]
1171 +
1172 +
1173 def test_cleanup_hook_removes_stale_runtime_state_idempotently(tmp_path, monkeypatch):
1174 source = tmp_path / "sources.list.d" / "retired.sources"
1175 keyring = tmp_path / "keyrings" / "retired.gpg"
@@ -1003,17 +1183,20 @@ def test_cleanup_hook_removes_stale_runtime_state_idempotently(tmp_path, monkeyp
1183 (runtime_dir / "nested").mkdir(parents=True, exist_ok=True)
1184 (runtime_dir / "nested" / "state.txt").write_text("old\n", encoding="utf-8")
1185
1006 - monkeypatch.setattr(hooks, "APT_SOURCE_FILE", source)
1007 - monkeypatch.setattr(hooks, "APT_KEYRING_FILE", keyring)
1008 - monkeypatch.setattr(hooks, "SUPERVISOR_FILE", supervisor)
1009 - monkeypatch.setattr(hooks, "RUNTIME_DIRS", [runtime_dir])
1186 + monkeypatch.setattr(hooks, "RETIRED_WEB_APT_SOURCE_FILE", source)
1187 + monkeypatch.setattr(hooks, "RETIRED_WEB_APT_KEYRING_FILE", keyring)
1188 + monkeypatch.setattr(hooks, "RETIRED_WEB_SUPERVISOR_FILE", supervisor)
1189 + monkeypatch.setattr(hooks, "RETIRED_WEB_RUNTIME_DIRS", [runtime_dir])
1190 monkeypatch.setattr(hooks, "CLEANUP_MARKER", marker)
1191 + monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", tmp_path / "usr" / "_office" / "documents")
1192 + monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [])
1193 monkeypatch.setattr(hooks, "_installed_packages", lambda packages: [])
1194 monkeypatch.setattr(hooks, "_kill_old_processes", lambda errors: None)
1195 + monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, warnings, errors: None)
1196
1197 def fake_ensure(installed, errors):
1198 assert not source.exists()
1016 - installed.append("xpra")
1199 + installed.append("libreoffice-core")
1200
1201 def fake_purge(removed, errors, **kwargs):
1202 return None
@@ -1026,7 +1209,7 @@ def test_cleanup_hook_removes_stale_runtime_state_idempotently(tmp_path, monkeyp
1209 skipped = hooks.cleanup_stale_runtime_state()
1210
1211 assert first["ok"] is True
1029 - assert first["installed"] == ["xpra"]
1212 + assert first["installed"] == ["libreoffice-core"]
1213 assert second["ok"] is True
1214 assert skipped["skipped"] is True
1215 assert not source.exists()
@@ -1037,12 +1220,8 @@ def test_cleanup_hook_removes_stale_runtime_state_idempotently(tmp_path, monkeyp
1220
1221
1222 def test_office_startup_defers_persistent_desktop_runtime(monkeypatch):
1040 - calls = []
1223 cleanup_calls = []
1224 started_threads = []
1043 - routes_module = types.ModuleType("plugins._office.helpers.libreoffice_desktop_routes")
1044 - routes_module.install_route_hooks = lambda: calls.append("routes")
1045 - monkeypatch.setitem(sys.modules, "plugins._office.helpers.libreoffice_desktop_routes", routes_module)
1225 monkeypatch.delitem(
1226 sys.modules,
1227 "plugins._office.extensions.python.startup_migration._20_office_routes",
@@ -1073,12 +1252,11 @@ def test_office_startup_defers_persistent_desktop_runtime(monkeypatch):
1252
1253 office_startup.OfficeStartupCleanup(agent=None).execute()
1254
1076 - assert calls == ["routes"]
1255 assert cleanup_calls == []
1256 assert len(started_threads) == 1
1079 - assert started_threads[0].name == "a0-office-runtime-preparation"
1257 + assert started_threads[0].name == "a0-office-document-runtime-preparation"
1258 assert started_threads[0].daemon is True
1081 - assert not hasattr(office_startup, "libreoffice_desktop")
1259 + assert not hasattr(office_startup, "desktop_session")
1260
1261 started_threads[0].target()
1262 assert cleanup_calls == ["cleanup"]
@@ -1089,14 +1267,17 @@ def test_cleanup_hook_reruns_when_stale_packages_exist_after_old_marker(tmp_path
1267 marker.parent.mkdir(parents=True)
1268 marker.write_text("old\n", encoding="utf-8")
1269
1092 - monkeypatch.setattr(hooks, "APT_SOURCE_FILE", tmp_path / "missing.sources")
1093 - monkeypatch.setattr(hooks, "APT_KEYRING_FILE", tmp_path / "missing.gpg")
1094 - monkeypatch.setattr(hooks, "SUPERVISOR_FILE", tmp_path / "missing.conf")
1095 - monkeypatch.setattr(hooks, "RUNTIME_DIRS", [])
1270 + monkeypatch.setattr(hooks, "RETIRED_WEB_APT_SOURCE_FILE", tmp_path / "missing.sources")
1271 + monkeypatch.setattr(hooks, "RETIRED_WEB_APT_KEYRING_FILE", tmp_path / "missing.gpg")
1272 + monkeypatch.setattr(hooks, "RETIRED_WEB_SUPERVISOR_FILE", tmp_path / "missing.conf")
1273 + monkeypatch.setattr(hooks, "RETIRED_WEB_RUNTIME_DIRS", [])
1274 monkeypatch.setattr(hooks, "CLEANUP_MARKER", marker)
1275 + monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", tmp_path / "usr" / "_office" / "documents")
1276 + monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [])
1277 monkeypatch.setattr(hooks, "_installed_packages", lambda packages: ["coolwsd"])
1278 monkeypatch.setattr(hooks, "_ensure_runtime_dependencies", lambda installed, errors: None)
1279 monkeypatch.setattr(hooks, "_kill_old_processes", lambda errors: None)
1280 + monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, warnings, errors: None)
1281
1282 def fake_purge(removed, errors, **kwargs):
1283 removed.extend(kwargs["installed_packages"])
@@ -1115,19 +1296,21 @@ def test_cleanup_hook_removes_retired_supervisor_program_after_marker(tmp_path,
1296 marker.write_text("ok\n", encoding="utf-8")
1297 calls = []
1298
1118 - monkeypatch.setattr(hooks, "APT_SOURCE_FILE", tmp_path / "missing.sources")
1119 - monkeypatch.setattr(hooks, "APT_KEYRING_FILE", tmp_path / "missing.gpg")
1120 - monkeypatch.setattr(hooks, "SUPERVISOR_FILE", tmp_path / "missing.conf")
1121 - monkeypatch.setattr(hooks, "RUNTIME_DIRS", [])
1299 + monkeypatch.setattr(hooks, "RETIRED_WEB_APT_SOURCE_FILE", tmp_path / "missing.sources")
1300 + monkeypatch.setattr(hooks, "RETIRED_WEB_APT_KEYRING_FILE", tmp_path / "missing.gpg")
1301 + monkeypatch.setattr(hooks, "RETIRED_WEB_SUPERVISOR_FILE", tmp_path / "missing.conf")
1302 + monkeypatch.setattr(hooks, "RETIRED_WEB_RUNTIME_DIRS", [])
1303 monkeypatch.setattr(hooks, "CLEANUP_MARKER", marker)
1304 + monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", tmp_path / "usr" / "_office" / "documents")
1305 + monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [])
1306 monkeypatch.setattr(hooks, "_installed_packages", lambda packages: [])
1307 monkeypatch.setattr(hooks, "_ensure_runtime_dependencies", lambda installed, errors: None)
1125 - monkeypatch.setattr(hooks, "_cleanup_desktop_sessions", lambda errors: None)
1308 + monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, warnings, errors: None)
1309 monkeypatch.setattr(hooks.shutil, "which", lambda name: "/usr/bin/supervisorctl" if name == "supervisorctl" else "")
1310
1311 def fake_supervisorctl(*args):
1312 calls.append(args)
1130 - if args == ("status", hooks.SUPERVISOR_PROGRAM):
1313 + if args == ("status", hooks.RETIRED_WEB_SUPERVISOR_PROGRAM):
1314 return types.SimpleNamespace(
1315 returncode=0,
1316 stdout="a0_office_collabora BACKOFF can't find command\n",
@@ -1143,22 +1326,22 @@ def test_cleanup_hook_removes_retired_supervisor_program_after_marker(tmp_path,
1326 assert result["skipped"] is True
1327 assert result["errors"] == []
1328 assert calls == [
1146 - ("status", hooks.SUPERVISOR_PROGRAM),
1147 - ("stop", hooks.SUPERVISOR_PROGRAM),
1148 - ("remove", hooks.SUPERVISOR_PROGRAM),
1329 + ("status", hooks.RETIRED_WEB_SUPERVISOR_PROGRAM),
1330 + ("stop", hooks.RETIRED_WEB_SUPERVISOR_PROGRAM),
1331 + ("remove", hooks.RETIRED_WEB_SUPERVISOR_PROGRAM),
1332 ("reread",),
1333 ("update",),
1334 ]
1335
1336
1154 -def test_cleanup_hook_installs_missing_libreoffice_desktop_dependencies(monkeypatch):
1337 +def test_cleanup_hook_installs_missing_desktop_session_dependencies(monkeypatch):
1338 calls = []
1339 installed_state = {"xpra": False}
1340
1158 - monkeypatch.setattr(hooks.os, "geteuid", lambda: 0)
1159 - monkeypatch.setattr(hooks.shutil, "which", lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query"} else "")
1160 - monkeypatch.setattr(hooks, "RUNTIME_PACKAGES", ("xpra",))
1161 - monkeypatch.setattr(hooks, "_package_installed", lambda package: installed_state.get(package, False))
1341 + monkeypatch.setattr(desktop_hooks.os, "geteuid", lambda: 0)
1342 + monkeypatch.setattr(desktop_hooks.shutil, "which", lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query"} else "")
1343 + monkeypatch.setattr(desktop_hooks, "RUNTIME_PACKAGES", ("xpra",))
1344 + monkeypatch.setattr(desktop_hooks, "_package_installed", lambda package: installed_state.get(package, False))
1345
1346 def fake_run(command, **kwargs):
1347 calls.append(command)
@@ -1166,11 +1349,11 @@ def test_cleanup_hook_installs_missing_libreoffice_desktop_dependencies(monkeypa
1349 installed_state["xpra"] = True
1350 return types.SimpleNamespace(returncode=0, stdout="", stderr="")
1351
1169 - monkeypatch.setattr(hooks.subprocess, "run", fake_run)
1352 + monkeypatch.setattr(desktop_hooks.subprocess, "run", fake_run)
1353 installed = []
1354 errors = []
1355
1173 - hooks._ensure_runtime_dependencies(installed, errors)
1356 + desktop_hooks._ensure_runtime_dependencies(installed, errors)
1357
1358 assert installed == ["xpra"]
1359 assert errors == []
@@ -1184,19 +1367,19 @@ def test_cleanup_hook_enables_official_xpra_repo_when_kali_lacks_candidate(tmp_p
1367 keyring = tmp_path / "keyrings" / "xpra.asc"
1368 source = tmp_path / "sources.list.d" / "xpra.sources"
1369
1187 - monkeypatch.setattr(hooks.os, "geteuid", lambda: 0)
1370 + monkeypatch.setattr(desktop_hooks.os, "geteuid", lambda: 0)
1371 monkeypatch.setattr(
1189 - hooks.shutil,
1372 + desktop_hooks.shutil,
1373 "which",
1374 lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query", "apt-cache"} else "",
1375 )
1193 - monkeypatch.setattr(hooks, "RUNTIME_PACKAGES", ("xpra",))
1194 - monkeypatch.setattr(hooks, "XPRA_KEYRING_FILE", keyring)
1195 - monkeypatch.setattr(hooks, "XPRA_SOURCE_FILE", source)
1196 - monkeypatch.setattr(hooks, "_download", lambda url: b"xpra-key")
1197 - monkeypatch.setattr(hooks, "_read_os_release", lambda: {"ID": "kali", "VERSION_CODENAME": "kali-rolling"})
1198 - monkeypatch.setattr(hooks, "_dpkg_architecture", lambda: "amd64")
1199 - monkeypatch.setattr(hooks, "_package_installed", lambda package: installed_state.get(package, False))
1376 + monkeypatch.setattr(desktop_hooks, "RUNTIME_PACKAGES", ("xpra",))
1377 + monkeypatch.setattr(desktop_hooks, "XPRA_KEYRING_FILE", keyring)
1378 + monkeypatch.setattr(desktop_hooks, "XPRA_SOURCE_FILE", source)
1379 + monkeypatch.setattr(desktop_hooks, "_download", lambda url: b"xpra-key")
1380 + monkeypatch.setattr(desktop_hooks, "_read_os_release", lambda: {"ID": "kali", "VERSION_CODENAME": "kali-rolling"})
1381 + monkeypatch.setattr(desktop_hooks, "_dpkg_architecture", lambda: "amd64")
1382 + monkeypatch.setattr(desktop_hooks, "_package_installed", lambda package: installed_state.get(package, False))
1383
1384 def fake_run(command, **kwargs):
1385 calls.append(command)
@@ -1206,11 +1389,11 @@ def test_cleanup_hook_enables_official_xpra_repo_when_kali_lacks_candidate(tmp_p
1389 installed_state["xpra"] = True
1390 return types.SimpleNamespace(returncode=0, stdout="", stderr="")
1391
1209 - monkeypatch.setattr(hooks.subprocess, "run", fake_run)
1392 + monkeypatch.setattr(desktop_hooks.subprocess, "run", fake_run)
1393 installed = []
1394 errors = []
1395
1213 - hooks._ensure_runtime_dependencies(installed, errors)
1396 + desktop_hooks._ensure_runtime_dependencies(installed, errors)
1397
1398 assert errors == []
1399 assert installed == ["xpra"]
@@ -1227,19 +1410,19 @@ def test_cleanup_hook_uses_trixie_xpra_components_for_kali_arm64(tmp_path, monke
1410 keyring = tmp_path / "keyrings" / "xpra.asc"
1411 source = tmp_path / "sources.list.d" / "xpra.sources"
1412
1230 - monkeypatch.setattr(hooks.os, "geteuid", lambda: 0)
1413 + monkeypatch.setattr(desktop_hooks.os, "geteuid", lambda: 0)
1414 monkeypatch.setattr(
1232 - hooks.shutil,
1415 + desktop_hooks.shutil,
1416 "which",
1417 lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query", "apt-cache"} else "",
1418 )
1236 - monkeypatch.setattr(hooks, "RUNTIME_PACKAGES", ("xpra-server", "xpra-x11", "xpra-html5"))
1237 - monkeypatch.setattr(hooks, "XPRA_KEYRING_FILE", keyring)
1238 - monkeypatch.setattr(hooks, "XPRA_SOURCE_FILE", source)
1239 - monkeypatch.setattr(hooks, "_download", lambda url: b"xpra-key")
1240 - monkeypatch.setattr(hooks, "_read_os_release", lambda: {"ID": "kali", "VERSION_CODENAME": "kali-rolling"})
1241 - monkeypatch.setattr(hooks, "_dpkg_architecture", lambda: "arm64")
1242 - monkeypatch.setattr(hooks, "_package_installed", lambda package: installed_state.get(package, False))
1419 + monkeypatch.setattr(desktop_hooks, "RUNTIME_PACKAGES", ("xpra-server", "xpra-x11", "xpra-html5"))
1420 + monkeypatch.setattr(desktop_hooks, "XPRA_KEYRING_FILE", keyring)
1421 + monkeypatch.setattr(desktop_hooks, "XPRA_SOURCE_FILE", source)
1422 + monkeypatch.setattr(desktop_hooks, "_download", lambda url: b"xpra-key")
1423 + monkeypatch.setattr(desktop_hooks, "_read_os_release", lambda: {"ID": "kali", "VERSION_CODENAME": "kali-rolling"})
1424 + monkeypatch.setattr(desktop_hooks, "_dpkg_architecture", lambda: "arm64")
1425 + monkeypatch.setattr(desktop_hooks, "_package_installed", lambda package: installed_state.get(package, False))
1426
1427 def fake_run(command, **kwargs):
1428 calls.append(command)
@@ -1250,11 +1433,11 @@ def test_cleanup_hook_uses_trixie_xpra_components_for_kali_arm64(tmp_path, monke
1433 installed_state[package] = True
1434 return types.SimpleNamespace(returncode=0, stdout="", stderr="")
1435
1253 - monkeypatch.setattr(hooks.subprocess, "run", fake_run)
1436 + monkeypatch.setattr(desktop_hooks.subprocess, "run", fake_run)
1437 installed = []
1438 errors = []
1439
1257 - hooks._ensure_runtime_dependencies(installed, errors)
1440 + desktop_hooks._ensure_runtime_dependencies(installed, errors)
1441
1442 assert errors == []
1443 assert installed == ["xpra-server", "xpra-x11", "xpra-html5"]
@@ -1281,18 +1464,18 @@ def test_cleanup_hook_skips_optional_xpra_client_codec_conflict(monkeypatch):
1464 " but none of the choices are installable: [no choices]"
1465 )
1466
1284 - monkeypatch.setattr(hooks.os, "geteuid", lambda: 0)
1467 + monkeypatch.setattr(desktop_hooks.os, "geteuid", lambda: 0)
1468 monkeypatch.setattr(
1286 - hooks.shutil,
1469 + desktop_hooks.shutil,
1470 "which",
1471 lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query", "apt-cache"} else "",
1472 )
1473 monkeypatch.setattr(
1291 - hooks,
1474 + desktop_hooks,
1475 "RUNTIME_PACKAGES",
1476 ("xpra-server", "xpra-client", "xpra-client-gtk3", "xpra-x11", "xpra-html5"),
1477 )
1295 - monkeypatch.setattr(hooks, "_package_installed", lambda package: installed_state.get(package, False))
1478 + monkeypatch.setattr(desktop_hooks, "_package_installed", lambda package: installed_state.get(package, False))
1479
1480 def fake_run(command, **kwargs):
1481 calls.append(command)
@@ -1302,11 +1485,11 @@ def test_cleanup_hook_skips_optional_xpra_client_codec_conflict(monkeypatch):
1485 return types.SimpleNamespace(returncode=100, stdout="", stderr=codec_error)
1486 return types.SimpleNamespace(returncode=0, stdout="", stderr="")
1487
1305 - monkeypatch.setattr(hooks.subprocess, "run", fake_run)
1488 + monkeypatch.setattr(desktop_hooks.subprocess, "run", fake_run)
1489 installed = []
1490 errors = []
1491
1309 - hooks._ensure_runtime_dependencies(installed, errors)
1492 + desktop_hooks._ensure_runtime_dependencies(installed, errors)
1493
1494 assert installed == []
1495 assert errors == []
@@ -1321,14 +1504,14 @@ def test_cleanup_hook_reports_required_xpra_codec_conflict(monkeypatch):
1504 " but none of the choices are installable: [no choices]"
1505 )
1506
1324 - monkeypatch.setattr(hooks.os, "geteuid", lambda: 0)
1507 + monkeypatch.setattr(desktop_hooks.os, "geteuid", lambda: 0)
1508 monkeypatch.setattr(
1326 - hooks.shutil,
1509 + desktop_hooks.shutil,
1510 "which",
1511 lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query", "apt-cache"} else "",
1512 )
1330 - monkeypatch.setattr(hooks, "RUNTIME_PACKAGES", ("xpra-server",))
1331 - monkeypatch.setattr(hooks, "_package_installed", lambda package: False)
1513 + monkeypatch.setattr(desktop_hooks, "RUNTIME_PACKAGES", ("xpra-server",))
1514 + monkeypatch.setattr(desktop_hooks, "_package_installed", lambda package: False)
1515
1516 def fake_run(command, **kwargs):
1517 if command[:2] == ["apt-cache", "policy"]:
@@ -1337,11 +1520,11 @@ def test_cleanup_hook_reports_required_xpra_codec_conflict(monkeypatch):
1520 return types.SimpleNamespace(returncode=100, stdout="", stderr=codec_error)
1521 return types.SimpleNamespace(returncode=0, stdout="", stderr="")
1522
1340 - monkeypatch.setattr(hooks.subprocess, "run", fake_run)
1523 + monkeypatch.setattr(desktop_hooks.subprocess, "run", fake_run)
1524 installed = []
1525 errors = []
1526
1344 - hooks._ensure_runtime_dependencies(installed, errors)
1527 + desktop_hooks._ensure_runtime_dependencies(installed, errors)
1528
1529 assert installed == []
1530 assert errors == [codec_error]
tests/test_skills_runtime.py
+91 -6
@@ -8,6 +8,15 @@ import pytest
8
9 PROJECT_ROOT = Path(__file__).resolve().parents[1]
10 SKILLS_HELPER_PATH = PROJECT_ROOT / "helpers" / "skills.py"
11 +HELPER_STUB_MODULES = (
12 + "helpers",
13 + "helpers.files",
14 + "helpers.projects",
15 + "helpers.plugins",
16 + "helpers.subagents",
17 + "helpers.file_tree",
18 + "helpers.runtime",
19 +)
20
21
22 def _register_helpers_stubs():
@@ -61,13 +70,22 @@ def _register_helpers_stubs():
70
71
72 def _load_skills_helper_module():
73 + missing = object()
74 + original_modules = {name: sys.modules.get(name, missing) for name in HELPER_STUB_MODULES}
75 _register_helpers_stubs()
65 - spec = importlib.util.spec_from_file_location("test_skills_helper_module", SKILLS_HELPER_PATH)
66 - module = importlib.util.module_from_spec(spec)
67 - assert spec and spec.loader
68 - sys.modules[spec.name] = module
69 - spec.loader.exec_module(module)
70 - return module
76 + try:
77 + spec = importlib.util.spec_from_file_location("test_skills_helper_module", SKILLS_HELPER_PATH)
78 + module = importlib.util.module_from_spec(spec)
79 + assert spec and spec.loader
80 + sys.modules[spec.name] = module
81 + spec.loader.exec_module(module)
82 + return module
83 + finally:
84 + for name, original in original_modules.items():
85 + if original is missing:
86 + sys.modules.pop(name, None)
87 + else:
88 + sys.modules[name] = original
89
90
91 runtime = _load_skills_helper_module()
@@ -191,6 +209,73 @@ def test_loaded_skill_entries_come_from_agent_data():
209 ]
210
211
212 +def test_skill_runtime_does_not_alias_old_office_skill_references():
213 + entries = runtime.normalize_active_skills(
214 + [
215 + "office-artifacts",
216 + {"name": "word-documents"},
217 + {"path": "/a0/plugins/_office/skills/excel-workbooks"},
218 + {"name": "Desktop", "path": "/a0/plugins/_office/skills/linux-desktop"},
219 + "presentation-decks",
220 + ]
221 + )
222 +
223 + assert entries == [
224 + {"name": "office-artifacts"},
225 + {"name": "word-documents"},
226 + {"path": "/a0/plugins/_office/skills/excel-workbooks"},
227 + {"name": "Desktop", "path": "/a0/plugins/_office/skills/linux-desktop"},
228 + {"name": "presentation-decks"},
229 + ]
230 +
231 + agent = DummyAgent()
232 + agent.data[runtime.AGENT_DATA_NAME_LOADED_SKILLS] = [
233 + "office-artifacts",
234 + "word-documents",
235 + "excel-workbooks",
236 + "presentation-decks",
237 + ]
238 +
239 + assert runtime.get_loaded_skill_entries(agent) == [
240 + {"name": "office-artifacts"},
241 + {"name": "word-documents"},
242 + {"name": "excel-workbooks"},
243 + {"name": "presentation-decks"},
244 + ]
245 +
246 + assert runtime.unload_agent_skill(agent, {"name": "office-artifacts"}) is True
247 + assert agent.data[runtime.AGENT_DATA_NAME_LOADED_SKILLS] == [
248 + "word-documents",
249 + "excel-workbooks",
250 + "presentation-decks",
251 + ]
252 +
253 +
254 +def test_builtin_plugin_skill_delete_is_rejected_before_filesystem_delete():
255 + with pytest.raises(PermissionError, match="Built-in plugin skills cannot be deleted"):
256 + runtime.delete_skill("/a0/plugins/_office/skills/document-artifacts")
257 +
258 +
259 +def test_invalid_skill_frontmatter_reports_yaml_errors():
260 + frontmatter, errors = runtime.parse_frontmatter("name: [unterminated\n")
261 +
262 + assert frontmatter == {}
263 + assert errors
264 + assert errors[0].startswith("Invalid YAML frontmatter")
265 +
266 +
267 +def test_a0_manage_plugin_skill_frontmatter_is_valid_yaml():
268 + text = (PROJECT_ROOT / "skills" / "a0-manage-plugin" / "SKILL.md").read_text(
269 + encoding="utf-8"
270 + )
271 +
272 + frontmatter, body, errors = runtime.split_frontmatter(text)
273 +
274 + assert errors == []
275 + assert frontmatter["name"] == "a0-manage-plugin"
276 + assert "Agent Zero Plugin Management" in body
277 +
278 +
279 def test_unload_agent_skill_removes_loaded_skill_by_name():
280 agent = DummyAgent()
281 agent.data[runtime.AGENT_DATA_NAME_LOADED_SKILLS] = [