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] = [