main
py 1,965 lines 79.3 KB
Raw
1 from __future__ import annotations
2
3 import asyncio
4 import importlib.util
5 import json
6 import os
7 import subprocess
8 import sys
9 import types
10 import zipfile
11 import xml.etree.ElementTree as ET
12 from pathlib import Path
13
14 import pytest
15
16 PROJECT_ROOT = Path(__file__).resolve().parents[1]
17 if str(PROJECT_ROOT) not in sys.path:
18 sys.path.insert(0, str(PROJECT_ROOT))
19
20 from helpers import system_packages
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._editor.helpers import (
25 markdown_sessions as editor_markdown_sessions,
26 open_files_context,
27 )
28 from plugins._editor.api.editor_session import EditorSession
29 from plugins._office.helpers import (
30 artifact_editor,
31 canvas_context,
32 document_store,
33 libreoffice,
34 )
35
36
37 @pytest.fixture
38 def office_state(tmp_path, monkeypatch):
39 state = tmp_path / "state"
40 backups = state / "backups"
41 workdir = tmp_path / "workdir"
42 documents = workdir / "documents"
43 projects_parent = tmp_path / "projects"
44
45 monkeypatch.setattr(document_store, "STATE_DIR", state)
46 monkeypatch.setattr(document_store, "DB_PATH", state / "documents.sqlite3")
47 monkeypatch.setattr(document_store, "BACKUP_DIR", backups)
48 monkeypatch.setattr(document_store, "WORKDIR", workdir)
49 monkeypatch.setattr(document_store, "DOCUMENTS_DIR", documents)
50 settings_helpers = types.SimpleNamespace(get_settings=lambda: {"workdir_path": str(workdir)})
51 project_helpers = types.SimpleNamespace(
52 get_context_project_name=lambda context: None,
53 get_project_folder=lambda name: str(projects_parent / name),
54 get_projects_parent_folder=lambda: str(projects_parent),
55 )
56 monkeypatch.setattr(document_store, "_settings", lambda: settings_helpers)
57 monkeypatch.setattr(document_store, "_projects", lambda: project_helpers)
58 monkeypatch.setattr(
59 editor_markdown_sessions,
60 "_manager",
61 editor_markdown_sessions.MarkdownSessionManager(),
62 raising=False,
63 )
64
65 workdir.mkdir(parents=True, exist_ok=True)
66 documents.mkdir(parents=True, exist_ok=True)
67 projects_parent.mkdir(parents=True, exist_ok=True)
68 document_store.ensure_dirs()
69 return types.SimpleNamespace(
70 state=state,
71 backups=backups,
72 workdir=workdir,
73 documents=documents,
74 projects_parent=projects_parent,
75 project_helpers=project_helpers,
76 )
77
78
79 def test_document_store_create_defaults_to_markdown(office_state):
80 doc = document_store.create_document("document", "Research Note", content="A precise note.")
81
82 assert doc["extension"] == "md"
83 assert Path(doc["path"]).parent == office_state.workdir
84 assert Path(doc["path"]).read_text(encoding="utf-8").startswith("# Research Note")
85
86
87 def test_text_files_register_as_editor_documents(office_state):
88 path = office_state.workdir / "plain-note.txt"
89 path.write_text("Plain text belongs in the Editor surface.\n", encoding="utf-8")
90
91 doc = document_store.register_document(path)
92
93 assert doc["extension"] == "txt"
94 assert "txt" in document_store.EDITOR_TEXT_EXTENSIONS
95 assert "txt" not in desktop_session.OFFICIAL_EXTENSIONS
96
97
98 def test_file_browser_can_register_runtime_root_markdown(office_state, monkeypatch):
99 runtime_root = office_state.state.parent / "runtime-root"
100 runtime_root.mkdir()
101 path = runtime_root / "AGENTS.md"
102 path.write_text("# Runtime Instructions\n", encoding="utf-8")
103 monkeypatch.setattr(document_store.files, "get_base_dir", lambda: str(runtime_root))
104
105 with pytest.raises(PermissionError, match="active project or workdir"):
106 document_store.register_document(path)
107
108 doc = document_store.register_document(path, allow_base_dir=True)
109
110 assert doc["basename"] == "AGENTS.md"
111 assert doc["path"] == str(path)
112
113
114 def test_editor_file_browser_source_opens_runtime_root_markdown(office_state, monkeypatch):
115 runtime_root = office_state.state.parent / "runtime-root"
116 runtime_root.mkdir()
117 path = runtime_root / "AGENTS.md"
118 path.write_text("# Runtime Instructions\n", encoding="utf-8")
119 monkeypatch.setattr(document_store.files, "get_base_dir", lambda: str(runtime_root))
120 handler = EditorSession(app=None, thread_lock=None)
121 request = types.SimpleNamespace(headers={}, host_url="http://localhost/")
122
123 blocked = asyncio.run(handler.process({"action": "open", "path": str(path)}, request))
124 opened = asyncio.run(handler.process({
125 "action": "open",
126 "path": str(path),
127 "source": "file-browser",
128 }, request))
129
130 assert blocked["ok"] is False
131 assert "active project or workdir" in blocked["error"]
132 assert opened["ok"] is True
133 assert opened["title"] == "AGENTS.md"
134 assert opened["text"] == "# Runtime Instructions\n"
135
136
137 @pytest.mark.parametrize(
138 ("kind", "title", "fmt", "expected_name"),
139 [
140 ("document", "real-chat-canvas-smoke.md", "md", "real-chat-canvas-smoke.md"),
141 ("document", "Board Memo.ODT", "odt", "Board Memo.odt"),
142 ("spreadsheet", "Budget.ods", "ods", "Budget.ods"),
143 ("presentation", "Roadmap.odp", "odp", "Roadmap.odp"),
144 ],
145 )
146 def test_create_document_does_not_duplicate_matching_extension(office_state, kind, title, fmt, expected_name):
147 doc = document_store.create_document(kind, title, fmt, content="Smoke")
148
149 assert Path(doc["path"]).name == expected_name
150
151
152 def test_explicit_docx_creates_valid_word_package(office_state):
153 doc = document_store.create_document("document", "Board Memo", "docx", "A careful memo.")
154
155 assert doc["extension"] == "docx"
156 assert Path(doc["path"]).parent == office_state.documents
157 assert libreoffice.validate_docx(doc["path"])["ok"] is True
158 with zipfile.ZipFile(doc["path"]) as archive:
159 assert "word/document.xml" in archive.namelist()
160
161
162 def test_odf_formats_create_valid_libreoffice_packages(office_state):
163 writer = document_store.create_document("document", "Board Memo", "odt", "A careful memo.")
164 sheet = document_store.create_document("spreadsheet", "Budget", "ods", "Name,Amount\nPlatform,1000")
165 deck = document_store.create_document("presentation", "Roadmap", "odp", "Roadmap\nLaunch sequence")
166
167 assert writer["extension"] == "odt"
168 assert sheet["extension"] == "ods"
169 assert deck["extension"] == "odp"
170 assert Path(writer["path"]).parent == office_state.documents
171 assert libreoffice.validate_odf(writer["path"])["ok"] is True
172 assert libreoffice.validate_odf(sheet["path"])["ok"] is True
173 assert libreoffice.validate_odf(deck["path"])["ok"] is True
174 assert artifact_editor.read_artifact(writer)["text"].startswith("Board Memo")
175 assert artifact_editor.read_artifact(sheet)["sheets"][0]["preview_rows"][1][1] == 1000
176 assert artifact_editor.read_artifact(deck)["slides"][0]["title"] == "Roadmap"
177
178
179 def test_blank_docx_includes_editable_body_paragraph(office_state):
180 doc = document_store.create_document("document", "Blank Memo", "docx", "")
181 with zipfile.ZipFile(doc["path"]) as archive:
182 xml = archive.read("word/document.xml").decode("utf-8")
183 root = ET.fromstring(xml)
184
185 word_ns = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
186 assert len(list(root.iter(f"{{{word_ns}}}p"))) >= 2
187 assert 'xml:space="preserve">&#160;</w:t>' in xml
188
189
190 def test_odf_and_ooxml_creation_and_direct_edits_still_work(office_state):
191 odt = document_store.create_document("document", "Writer Memo", "odt", "Old phrase")
192 updated_odt, odt_payload = artifact_editor.edit_artifact(
193 odt,
194 operation="replace_text",
195 find="Old phrase",
196 replace="New phrase",
197 )
198 odt_read = artifact_editor.read_artifact(updated_odt)
199
200 assert odt_payload["changed"] is True
201 assert "New phrase" in odt_read["text"]
202
203 ods = document_store.create_document(
204 "spreadsheet",
205 "Budget ODS",
206 "ods",
207 "Name,Amount\nPlatform,1000",
208 )
209 updated_ods, ods_payload = artifact_editor.edit_artifact(
210 ods,
211 operation="set_cells",
212 cells={"Sheet1!B2": 12500, "Sheet1!A3": "Research", "Sheet1!B3": 4700},
213 )
214 ods_read = artifact_editor.read_artifact(updated_ods)
215 ods_rows = ods_read["sheets"][0]["preview_rows"]
216
217 assert ods_payload["changed"] is True
218 assert ods_rows[1][1] == 12500
219 assert ods_rows[2][0] == "Research"
220
221
222 def test_office_artifact_helpers_reject_markdown_inputs(office_state):
223 doc = document_store.create_document("document", "Append Shapes", "md", "# Title\n\nBase")
224
225 with pytest.raises(ValueError, match="use text_editor"):
226 artifact_editor.read_artifact(doc)
227 with pytest.raises(ValueError, match="use text_editor"):
228 artifact_editor.edit_artifact(doc, operation="set_text", content="# Updated")
229
230
231 def test_office_artifact_direct_edits_cover_presentations_spreadsheets_and_decks(office_state):
232
233 odp = document_store.create_document(
234 "presentation",
235 "Roadmap ODP",
236 "odp",
237 "Roadmap\nLaunch sequence\n\n---\n\nNext\nPolish rollout",
238 )
239 updated_odp, odp_payload = artifact_editor.edit_artifact(
240 odp,
241 operation="set_slides",
242 slides=[
243 {"title": "Now", "bullets": ["Stabilize"]},
244 {"title": "Next", "bullets": ["Polish"]},
245 ],
246 )
247 odp_read = artifact_editor.read_artifact(updated_odp)
248
249 assert odp_payload["changed"] is True
250 assert odp_read["slide_count"] == 2
251 assert odp_read["slides"][1]["title"] == "Next"
252
253 sheet = document_store.create_document(
254 "spreadsheet",
255 "Budget",
256 "xlsx",
257 "Name,Amount\nPlatform,1000",
258 )
259 updated_sheet, sheet_payload = artifact_editor.edit_artifact(
260 sheet,
261 operation="set_cells",
262 cells={"Sheet1!B2": 12500, "Sheet1!A3": "Research", "Sheet1!B3": 4700},
263 )
264 sheet_read = artifact_editor.read_artifact(updated_sheet)
265 rows = sheet_read["sheets"][0]["preview_rows"]
266
267 assert sheet_payload["changed"] is True
268 assert rows[1][1] == 12500
269 assert rows[2][0] == "Research"
270
271 deck = document_store.create_document(
272 "presentation",
273 "Roadmap",
274 "pptx",
275 "Roadmap\nLaunch sequence\n\n---\n\nNext\nPolish rollout",
276 )
277 created_deck_read = artifact_editor.read_artifact(deck)
278 with zipfile.ZipFile(deck["path"]) as archive:
279 created_slide_names = [name for name in archive.namelist() if name.startswith("ppt/slides/slide") and name.endswith(".xml")]
280
281 assert created_deck_read["slide_count"] == 2
282 assert created_deck_read["slides"][0]["title"] == "Roadmap"
283 assert created_deck_read["slides"][1]["title"] == "Next"
284 assert len(created_slide_names) == 2
285
286 updated_deck, deck_payload = artifact_editor.edit_artifact(
287 deck,
288 operation="set_slides",
289 slides=[
290 {"title": "Now", "bullets": ["Stabilize"]},
291 {"title": "Next", "bullets": ["Polish"]},
292 ],
293 )
294 deck_read = artifact_editor.read_artifact(updated_deck)
295
296 assert deck_payload["changed"] is True
297 assert deck_read["slide_count"] == 2
298 assert deck_read["slides"][1]["title"] == "Next"
299
300
301 def test_ods_direct_edit_preserves_rows_beyond_preview_window_and_blank_separators(office_state):
302 rows = [["Row", "Value"], ["alpha", 1], [], ["separator-survives", 2]]
303 rows.extend([[f"item-{index}", index] for index in range(4, 96)])
304 doc = document_store.create_document("spreadsheet", "Long ODS", "ods", "")
305 updated, payload = artifact_editor.edit_artifact(
306 doc,
307 operation="set_rows",
308 rows=rows,
309 )
310 updated, payload = artifact_editor.edit_artifact(
311 updated,
312 operation="set_cells",
313 cells={"Sheet1!B90": 9000},
314 )
315 parsed = artifact_editor._ods_sheets_from_bytes(Path(updated["path"]).read_bytes(), max_rows=120, max_cols=10)
316
317 assert payload["changed"] is True
318 assert parsed[0]["rows"][2] == []
319 assert parsed[0]["rows"][3][0] == "separator-survives"
320 assert parsed[0]["rows"][89][1] == 9000
321
322
323 def test_office_artifact_creates_ods_with_action_contract(office_state, monkeypatch):
324 tool_module = types.ModuleType("helpers.tool")
325
326 class Response:
327 def __init__(self, message, break_loop, additional=None):
328 self.message = message
329 self.break_loop = break_loop
330 self.additional = additional
331
332 class Tool:
333 def __init__(self, agent, name, method, args, message, loop_data, **kwargs):
334 self.agent = agent
335 self.name = name
336 self.method = method
337 self.args = args
338 self.message = message
339 self.loop_data = loop_data
340
341 tool_module.Response = Response
342 tool_module.Tool = Tool
343 monkeypatch.setitem(sys.modules, "helpers.tool", tool_module)
344 spec = importlib.util.spec_from_file_location(
345 "test_office_artifact_tool",
346 PROJECT_ROOT / "plugins" / "_office" / "tools" / "office_artifact.py",
347 )
348 office_artifact_module = importlib.util.module_from_spec(spec)
349 assert spec and spec.loader
350 spec.loader.exec_module(office_artifact_module)
351 OfficeArtifact = office_artifact_module.OfficeArtifact
352
353 tool = OfficeArtifact(
354 agent=None,
355 name="office_artifact",
356 method=None,
357 args={},
358 message="",
359 loop_data=None,
360 )
361
362 response = asyncio.run(
363 tool.execute(
364 action="create",
365 kind="document",
366 title="New Calc Workbook",
367 format="ods",
368 content="Sheet1\n",
369 )
370 )
371 payload = json.loads(response.message)
372
373 assert payload["action"] == "create"
374 assert payload["document"]["extension"] == "ods"
375 assert Path(payload["document"]["path"]).name == "New Calc Workbook.ods"
376 assert Path(document_store._path_from_a0(payload["document"]["path"])).exists()
377
378
379 def test_odf_is_advertised_and_docx_remains_explicit_compatibility(office_state):
380 prompt = (PROJECT_ROOT / "plugins" / "_office" / "prompts" / "agent.system.tool.office_artifact.md").read_text(
381 encoding="utf-8",
382 )
383
384 assert "formats: odt ods odp docx xlsx pptx" in prompt
385 assert "use `text_editor` for Markdown and plain text files" in prompt
386 assert "ODF is first-class for LibreOffice" in prompt
387 assert "DOCX/XLSX/PPTX are compatibility formats" in prompt
388 assert "`method` is accepted as an alias for action" not in prompt
389 assert "they do not open a surface automatically" in prompt
390 assert "do not write faux UI action labels" in prompt
391 assert '"Open document" or "Download file"' in prompt
392 assert "explicit Download, Open Document, or Desktop edit message actions" not in prompt
393 doc = document_store.create_document("document", "Use ODT", "odt", "")
394 assert doc["extension"] == "odt"
395
396
397 def test_project_scoped_creation_uses_active_project_root(office_state, monkeypatch):
398 project_root = office_state.projects_parent / "apollo"
399 project_root.mkdir(parents=True, exist_ok=True)
400 context = object()
401 agent_module = types.SimpleNamespace(
402 AgentContext=types.SimpleNamespace(get=staticmethod(lambda context_id: context))
403 )
404
405 monkeypatch.setitem(sys.modules, "agent", agent_module)
406 monkeypatch.setattr(office_state.project_helpers, "get_context_project_name", lambda active_context: "apollo")
407 monkeypatch.setattr(office_state.project_helpers, "get_project_folder", lambda name: str(project_root))
408
409 markdown = document_store.create_document("document", "Project Note", "md", "Scoped.", context_id="ctx-project")
410 odt = document_store.create_document("document", "Project Memo", "odt", "Scoped.", context_id="ctx-project")
411
412 assert Path(markdown["path"]).parent == project_root
413 assert Path(odt["path"]).parent == project_root / "documents"
414
415
416 def test_non_project_creation_uses_configured_workdir(office_state):
417 markdown = document_store.create_document("document", "Workdir Note", content="Plain.")
418 spreadsheet = document_store.create_document("spreadsheet", "Workdir Sheet", "ods", "Name,Value")
419
420 assert markdown["extension"] == "md"
421 assert Path(markdown["path"]).parent == office_state.workdir
422 assert Path(spreadsheet["path"]).parent == office_state.documents
423
424
425 def test_sessions_and_canvas_context_are_neutral(office_state):
426 doc = document_store.create_document("document", "Canvas Context", "odt", "Private body text.")
427 session = document_store.create_session(doc["file_id"], "user-a", "write", "http://localhost:32080")
428
429 open_docs = document_store.get_open_documents()
430 context = canvas_context.build_context()
431
432 assert open_docs[0]["file_id"] == doc["file_id"]
433 assert "Office artifacts" in context
434 assert "Private body text" not in context
435 assert document_store.close_session(session_id=session["session_id"]) == 1
436 assert document_store.get_open_documents() == []
437
438
439 def test_editor_open_files_are_scoped_to_active_context(office_state):
440 first = document_store.create_document("document", "First Editor Note", "md", "First private body.")
441 second = document_store.create_document("document", "Second Editor Note", "md", "Second private body.")
442 manager = editor_markdown_sessions.get_manager()
443
444 first_session = manager.open(first, context_id="ctx-a")
445 second_session = manager.open(second, context_id="ctx-b")
446 manager.input(first_session["session_id"], text="Unsaved ctx-a text")
447 reopened_first = manager.open(first, context_id="ctx-a")
448
449 ctx_a_files = manager.list_open("ctx-a")
450 ctx_b_files = manager.list_open("ctx-b")
451 prompt_context = open_files_context.build_context("ctx-a")
452
453 assert reopened_first["session_id"] == first_session["session_id"]
454 assert reopened_first["text"] == "Unsaved ctx-a text"
455 assert [item["file_id"] for item in ctx_a_files] == [first["file_id"]]
456 assert [item["file_id"] for item in ctx_b_files] == [second["file_id"]]
457 assert ctx_a_files[0]["dirty"] is True
458 assert ctx_a_files[0]["active"] is True
459 assert ctx_a_files[0]["open_sessions"] == 1
460 assert "First Editor Note.md" in prompt_context
461 assert "Second Editor Note.md" not in prompt_context
462 assert "First private body" not in prompt_context
463 assert "Unsaved ctx-a text" not in prompt_context
464
465
466 def test_markdown_save_tracks_version_history(office_state):
467 doc = document_store.create_document("document", "Versioned", "md", "First")
468 updated = document_store.write_markdown(doc["file_id"], "# Versioned\n\nSecond\n")
469 history = document_store.version_history(doc["file_id"])
470
471 assert updated["version"] == 2
472 assert history
473 assert Path(updated["path"]).read_text(encoding="utf-8").endswith("Second\n")
474
475
476 def test_document_path_update_preserves_file_id_after_rename(office_state):
477 doc = document_store.create_document("document", "Rename Me", "md", "Body")
478 original = Path(doc["path"])
479 renamed = original.with_name("Renamed.md")
480 original.rename(renamed)
481
482 updated = document_store.update_document_path(doc["file_id"], renamed)
483
484 assert updated["file_id"] == doc["file_id"]
485 assert updated["basename"] == "Renamed.md"
486 assert updated["path"] == str(renamed)
487 assert document_store.get_document(doc["file_id"])["path"] == str(renamed)
488
489
490 def test_document_rename_materializes_missing_markdown_with_editor_text(office_state):
491 doc = document_store.create_document("document", "Unsaved Draft", "md", "Seed")
492 original = Path(doc["path"])
493 original.unlink()
494 renamed = original.with_name("Renamed Draft.md")
495
496 updated = document_store.rename_document(
497 doc["file_id"],
498 renamed,
499 content="# Renamed Draft\n\nCanvas text",
500 )
501
502 assert updated["file_id"] == doc["file_id"]
503 assert updated["basename"] == "Renamed Draft.md"
504 assert updated["path"] == str(renamed)
505 assert renamed.read_text(encoding="utf-8") == "# Renamed Draft\n\nCanvas text"
506
507
508 def test_document_rename_saves_dirty_markdown_and_removes_original(office_state):
509 doc = document_store.create_document("document", "Dirty Rename", "md", "Old")
510 original = Path(doc["path"])
511 renamed = original.with_name("Clean Rename.md")
512
513 updated = document_store.rename_document(
514 doc["file_id"],
515 renamed,
516 content="# Clean Rename\n\nFresh text",
517 )
518
519 assert updated["version"] == 2
520 assert not original.exists()
521 assert renamed.read_text(encoding="utf-8") == "# Clean Rename\n\nFresh text"
522
523
524 def test_text_session_save_as_creates_new_file_without_mutating_original(office_state):
525 manager = editor_markdown_sessions.MarkdownSessionManager()
526 doc = document_store.create_document("document", "Original Note", "md", "# Original Note\n")
527 original = Path(doc["path"])
528 session = manager.open(doc, context_id="ctx-a")
529 target = office_state.workdir / "notes" / "Saved Copy.txt"
530
531 result = manager.save_as(
532 session["session_id"],
533 str(target),
534 text="Saved Copy\n\nExact body\n",
535 )
536
537 saved_session = manager._sessions[session["session_id"]]
538 assert result["ok"] is True
539 assert result["document"]["file_id"] != doc["file_id"]
540 assert result["previous_file_id"] == doc["file_id"]
541 assert saved_session.file_id == result["document"]["file_id"]
542 assert saved_session.path == str(target)
543 assert saved_session.extension == "txt"
544 assert saved_session.dirty is False
545 assert original.read_text(encoding="utf-8") == "# Original Note"
546 assert target.read_text(encoding="utf-8") == "Saved Copy\n\nExact body\n"
547
548
549 def test_editor_session_opens_and_saves_txt_documents(office_state):
550 manager = editor_markdown_sessions.MarkdownSessionManager()
551 doc = document_store.create_document("document", "Plain Note", "txt", "First line")
552 session = manager.open(doc, context_id="ctx-a")
553
554 assert session["extension"] == "txt"
555 assert session["text"] == "First line"
556
557 result = manager.save(session["session_id"], text="Second line\n")
558
559 assert result["ok"] is True
560 assert result["document"]["extension"] == "txt"
561 assert Path(result["document"]["path"]).read_text(encoding="utf-8") == "Second line\n"
562
563
564 def test_refresh_open_markdown_session_reloads_external_file_edits(office_state):
565 manager = editor_markdown_sessions.MarkdownSessionManager()
566 doc = document_store.create_document("document", "External Refresh", "md", "First")
567 session = manager.open(doc, context_id="ctx-a")
568
569 Path(doc["path"]).write_text("# External Refresh\n\nSecond\n", encoding="utf-8")
570 refreshed = manager.open(doc, context_id="ctx-a", refresh=True)
571
572 assert refreshed["session_id"] == session["session_id"]
573 assert refreshed["text"] == "# External Refresh\n\nSecond\n"
574 assert manager._sessions[session["session_id"]].dirty is False
575
576
577 def test_refresh_open_markdown_session_preserves_dirty_editor_text(office_state):
578 manager = editor_markdown_sessions.MarkdownSessionManager()
579 doc = document_store.create_document("document", "Dirty External Refresh", "md", "First")
580 session = manager.open(doc, context_id="ctx-a")
581 manager.input(session["session_id"], text="Unsaved editor text")
582
583 Path(doc["path"]).write_text("External disk text\n", encoding="utf-8")
584 refreshed = manager.open(doc, context_id="ctx-a", refresh=True)
585
586 assert refreshed["text"] == "Unsaved editor text"
587 assert manager._sessions[session["session_id"]].dirty is True
588
589
590 def test_external_text_editor_mutation_refreshes_clean_open_markdown_session(office_state):
591 manager = editor_markdown_sessions.MarkdownSessionManager()
592 doc = document_store.create_document("document", "Synced External Edit", "md", "First")
593 session = manager.open(doc, context_id="ctx-a")
594
595 Path(doc["path"]).write_text("# Synced External Edit\n\nSecond\n", encoding="utf-8")
596 result = manager.sync_external_file_mutations([doc["path"]])
597
598 assert result["matched"] == 1
599 assert manager._sessions[session["session_id"]].text == "# Synced External Edit\n\nSecond\n"
600 assert manager._sessions[session["session_id"]].dirty is False
601 assert manager._sessions[session["session_id"]].external_modified is False
602
603
604 def test_external_text_editor_mutation_marks_dirty_markdown_session_pending(office_state):
605 manager = editor_markdown_sessions.MarkdownSessionManager()
606 doc = document_store.create_document("document", "Dirty Synced External Edit", "md", "First")
607 session = manager.open(doc, context_id="ctx-a")
608 manager.input(session["session_id"], text="Unsaved editor text")
609
610 Path(doc["path"]).write_text("External disk text\n", encoding="utf-8")
611 result = manager.sync_external_file_mutations([doc["path"]])
612
613 dirty_session = manager._sessions[session["session_id"]]
614 assert result["matched"] == 1
615 assert dirty_session.text == "Unsaved editor text"
616 assert dirty_session.dirty is True
617 assert dirty_session.external_modified is True
618
619
620 def test_markdown_editor_save_rejects_stale_canvas_overwrite(office_state):
621 manager = editor_markdown_sessions.MarkdownSessionManager()
622 doc = document_store.create_document("document", "Stale Save Guard", "md", "First")
623 session = manager.open(doc, context_id="ctx-a")
624
625 Path(doc["path"]).write_text("# Stale Save Guard\n\nExternal newer text\n", encoding="utf-8")
626 result = manager.save(session["session_id"], text="# Stale Save Guard\n\nOlder canvas text\n")
627
628 assert result["ok"] is False
629 assert result["code"] == "external_change_conflict"
630 assert "External newer text" in Path(doc["path"]).read_text(encoding="utf-8")
631 assert "Older canvas text" not in Path(doc["path"]).read_text(encoding="utf-8")
632
633
634 def test_markdown_session_rejects_office_binaries(office_state):
635 manager = editor_markdown_sessions.MarkdownSessionManager()
636 doc = document_store.create_document("document", "Desktop Only", "odt", "Native text")
637
638 with pytest.raises(ValueError, match="Open .odt files in the Desktop"):
639 manager.open(doc)
640
641
642 def test_thunar_defaults_preserve_existing_profile_settings(tmp_path):
643 thunar_xml = tmp_path / "thunar.xml"
644 thunar_xml.write_text(
645 """<?xml version="1.1" encoding="UTF-8"?>
646 <channel name="thunar" version="1.0">
647 <property name="last-view" type="string" value="ThunarDetailsView"/>
648 <property name="last-window-width" type="int" value="900"/>
649 <property name="last-show-hidden" type="bool" value="false"/>
650 </channel>
651 """,
652 encoding="utf-8",
653 )
654
655 desktop_session._write_thunar_defaults(thunar_xml)
656
657 root = ET.parse(thunar_xml).getroot()
658 values = {child.get("name"): child.get("value") for child in root.findall("property")}
659 assert values["last-view"] == "ThunarDetailsView"
660 assert values["last-window-width"] == "900"
661 assert values["last-show-hidden"] == "true"
662
663
664 def test_official_desktop_session_status_and_url_contract(tmp_path, monkeypatch):
665 xpra_html = tmp_path / "xpra" / "www"
666 xpra_html.mkdir(parents=True)
667 (xpra_html / "index.html").write_text("xpra", encoding="utf-8")
668
669 monkeypatch.setattr(desktop_session.libreoffice, "find_soffice", lambda: "/usr/bin/soffice")
670 monkeypatch.setattr(
671 desktop_session.shutil,
672 "which",
673 lambda name: f"/usr/bin/{name}"
674 if name
675 in {
676 "xpra",
677 "Xvfb",
678 "xfce4-session",
679 "dbus-launch",
680 "xrandr",
681 "xdotool",
682 "thunar",
683 "xfce4-terminal",
684 "xfce4-settings-manager",
685 "gio",
686 }
687 else "",
688 )
689 monkeypatch.setattr(desktop_session.virtual_desktop, "XPRA_HTML_ROOT_CANDIDATES", (xpra_html,))
690 monkeypatch.setattr(desktop_session.virtual_desktop, "_package_installed", lambda package: True)
691
692 status = desktop_session.collect_desktop_status()
693 url = desktop_session._xpra_url("abc123")
694
695 assert status["healthy"] is True
696 assert status["xpra_html_root"] == str(xpra_html)
697 assert url.startswith("/desktop/session/abc123/index.html?")
698 assert "path=%2Fdesktop%2Fsession%2Fabc123%2F" in url
699 assert "xpramenu=false" in url
700 assert "floating_menu=false" in url
701 assert "file_transfer=true" in url
702 assert "sound=false" in url
703 assert "encoding=jpeg" in url
704 assert "quality=85" in url
705 assert "speed=80" in url
706 assert "printing=true" in url
707
708
709 def test_desktop_status_reports_installing_during_runtime_preparation(monkeypatch):
710 monkeypatch.setattr(
711 desktop_session.virtual_desktop,
712 "collect_status",
713 lambda: {
714 "binaries": {},
715 "packages": {},
716 "xpra_html_root": "",
717 },
718 )
719 monkeypatch.setattr(desktop_session.libreoffice, "find_soffice", lambda: "")
720 monkeypatch.setattr(desktop_session.shutil, "which", lambda _name: "")
721 monkeypatch.setattr(
722 desktop_session,
723 "_runtime_preparation_status",
724 lambda: {"preparing": True, "active_count": 1, "started_at": 123.0},
725 )
726
727 status = desktop_session.collect_desktop_status()
728
729 assert status["healthy"] is False
730 assert status["installing"] is True
731 assert status["state"] == "installing"
732 assert status["message"].startswith("Installing Agent Zero Desktop runtime dependencies")
733 assert "soffice" in status["missing"]
734
735
736 def test_desktop_gateway_patches_xpra_menu_script():
737 source = (PROJECT_ROOT / "helpers" / "virtual_desktop_routes.py").read_text(encoding="utf-8")
738
739 assert "XPRA_MENU_CUSTOM_PATCH" in source
740 assert 'upstream_path.endswith("/js/MenuCustom.js")' in source
741 assert "window.noWindowList" in source
742 assert "__a0SafeWindowList" in source
743 assert "XPRA_WINDOW_OFFSET_WARNING_PATCH" in source
744 assert "XPRA_WINDOW_SCRIPT_PATCH" in source
745 assert "a0_desktop_patch=20260506" in source
746 assert 'upstream_path.endswith("/index.html")' in source
747 assert 'upstream_path.endswith("/js/Window.js")' in source
748 assert "window does not fit in canvas, offsets:" in source
749
750
751 def test_office_binary_open_requires_explicit_desktop_without_cold_session(office_state, monkeypatch):
752 api_module = types.ModuleType("helpers.api")
753
754 class ApiHandler:
755 def __init__(self, app=None, thread_lock=None):
756 self.app = app
757 self.thread_lock = thread_lock
758
759 api_module.ApiHandler = ApiHandler
760 api_module.Request = object
761 monkeypatch.setitem(sys.modules, "helpers.api", api_module)
762 monkeypatch.delitem(sys.modules, "plugins._office.api.office_session", raising=False)
763
764 from plugins._office.api import office_session
765
766 doc = document_store.create_document("document", "Cold Memo", "odt", "No surprise Desktop.")
767
768 def forbidden_session(*_args, **_kwargs):
769 raise AssertionError("cold binary open must not create a store session")
770
771 class ForbiddenManager:
772 def open(self, *_args, **_kwargs):
773 raise AssertionError("cold binary open must not open Desktop")
774
775 monkeypatch.setattr(office_session.document_store, "create_session", forbidden_session)
776 monkeypatch.setattr(office_session.desktop_session, "get_manager", lambda: ForbiddenManager())
777
778 handler = office_session.OfficeSession(app=None, thread_lock=None)
779 request = types.SimpleNamespace(headers={}, host_url="http://localhost:32080")
780 result = asyncio.run(handler.process({"action": "open", "file_id": doc["file_id"]}, request))
781
782 assert result["ok"] is True
783 assert result["requires_desktop"] is True
784 assert result["file_id"] == doc["file_id"]
785 assert "session_id" not in result
786 assert "store_session_id" not in result
787
788 monkeypatch.delitem(sys.modules, "plugins._office.api.office_session", raising=False)
789 api_package = sys.modules.get("plugins._office.api")
790 if api_package is not None:
791 monkeypatch.delattr(api_package, "office_session", raising=False)
792
793
794 def test_official_desktop_session_manager_opens_binary_session(office_state, tmp_path, monkeypatch):
795 class FakeProcess:
796 pid = 4242
797
798 def poll(self):
799 return None
800
801 def terminate(self):
802 return None
803
804 def wait(self, timeout=None):
805 return 0
806
807 def kill(self):
808 return None
809
810 monkeypatch.setattr(desktop_session, "STATE_DIR", tmp_path / "desktop")
811 monkeypatch.setattr(desktop_session, "SESSION_DIR", tmp_path / "desktop" / "sessions")
812 monkeypatch.setattr(desktop_session, "PROFILE_DIR", tmp_path / "desktop" / "profiles")
813 monkeypatch.setattr(desktop_session, "collect_desktop_status", lambda: {"healthy": True, "message": "ok"})
814 monkeypatch.setattr(desktop_session.libreoffice, "find_soffice", lambda: "/usr/bin/soffice")
815 monkeypatch.setattr(desktop_session, "_port_is_free", lambda port: True)
816 monkeypatch.setattr(desktop_session.virtual_desktop, "has_window", lambda **kwargs: True)
817 real_get_abs_path = desktop_session.files.get_abs_path
818
819 def fake_get_abs_path(*parts):
820 if parts and parts[0] == "usr":
821 return str(tmp_path.joinpath(*parts))
822 return real_get_abs_path(*parts)
823
824 monkeypatch.setattr(desktop_session.files, "get_abs_path", fake_get_abs_path)
825
826 def fake_spawn(self, session):
827 session.profile_dir.mkdir(parents=True, exist_ok=True)
828 session.processes["xpra"] = FakeProcess()
829
830 def fake_open_document(self, session, doc):
831 session.processes[f"soffice-{doc['file_id']}"] = FakeProcess()
832
833 monkeypatch.setattr(desktop_session.DesktopSessionManager, "_spawn_desktop_locked", fake_spawn)
834 monkeypatch.setattr(desktop_session.DesktopSessionManager, "_open_document_locked", fake_open_document)
835
836 doc = document_store.create_document("spreadsheet", "Official Sheet", "ods", "Name,Value\nA,1")
837 manager = desktop_session.DesktopSessionManager()
838 payload = manager.open(doc)
839
840 assert payload["available"] is True
841 assert payload["extension"] == "ods"
842 assert payload["url"].startswith("/desktop/session/")
843 registry = tmp_path / "desktop" / "profiles" / payload["session_id"] / "user" / "registrymodifications.xcu"
844 registry_text = registry.read_text(encoding="utf-8")
845 assert "ooSetupInstCompleted" in registry_text
846 assert "FirstRun" in registry_text
847 assert "Office.Paths/Variables" in registry_text
848 assert "Office.Paths:NamedPath['Work']" in registry_text
849 assert office_state.workdir.as_uri() in registry_text
850 writer_launcher = tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / "LibreOffice Writer.desktop"
851 writer_text = writer_launcher.read_text(encoding="utf-8")
852 assert "--writer" in writer_text
853 assert f"Path={office_state.workdir}" in writer_text
854 assert "X-XFCE-Trusted=true" in writer_text
855 terminal_launcher = tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / "Terminal.desktop"
856 files_launcher = tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / "Files.desktop"
857 settings_launcher = tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / "Settings.desktop"
858 terminal_text = terminal_launcher.read_text(encoding="utf-8")
859 settings_text = settings_launcher.read_text(encoding="utf-8")
860 browser_launcher = tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / "Browser.desktop"
861 browser_text = browser_launcher.read_text(encoding="utf-8")
862 assert "xfce4-terminal" in terminal_text
863 assert "org.xfce.terminal" in terminal_text
864 assert not files_launcher.exists()
865 assert "open-url" in browser_text
866 assert "firefox" not in browser_text.lower()
867 assert "xfce4-settings-manager" in settings_text
868 assert "org.xfce.settings.manager" in settings_text
869 link_targets = {
870 "Projects": "usr/projects",
871 "Skills": "usr/skills",
872 "Agents": "usr/agents",
873 "Downloads": "usr/downloads",
874 }
875 workdir_link = tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / "Workdir"
876 assert workdir_link.is_symlink()
877 assert workdir_link.resolve() == office_state.workdir
878 for link_name, target in link_targets.items():
879 link = tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / link_name
880 assert link.is_symlink()
881 assert str(link.resolve()).endswith(target)
882 xpra_override = (
883 tmp_path
884 / "desktop"
885 / "profiles"
886 / payload["session_id"]
887 / ".local"
888 / "share"
889 / "applications"
890 / "xpra-gui.desktop"
891 )
892 assert "Hidden=true" in xpra_override.read_text(encoding="utf-8")
893 desktop_profile = (
894 tmp_path
895 / "desktop"
896 / "profiles"
897 / payload["session_id"]
898 / ".config"
899 / "xfce4"
900 / "xfconf"
901 / "xfce-perchannel-xml"
902 / "xfce4-desktop.xml"
903 )
904 desktop_profile_text = desktop_profile.read_text(encoding="utf-8")
905 assert "desktop-icons" in desktop_profile_text
906 assert "image-path" in desktop_profile_text
907 assert "usr/downloads" in desktop_profile_text
908 thunar_profile = (
909 tmp_path
910 / "desktop"
911 / "profiles"
912 / payload["session_id"]
913 / ".config"
914 / "xfce4"
915 / "xfconf"
916 / "xfce-perchannel-xml"
917 / "thunar.xml"
918 ).read_text(encoding="utf-8")
919 assert 'name="last-show-hidden" type="bool" value="true"' in thunar_profile
920 user_dirs = (
921 tmp_path
922 / "desktop"
923 / "profiles"
924 / payload["session_id"]
925 / ".config"
926 / "user-dirs.dirs"
927 ).read_text(encoding="utf-8")
928 assert 'XDG_PICTURES_DIR="' in user_dirs
929 assert "usr/downloads" in user_dirs
930 assert f'XDG_DOCUMENTS_DIR="{office_state.workdir}"' in user_dirs
931 panel_profile = (
932 tmp_path
933 / "desktop"
934 / "profiles"
935 / payload["session_id"]
936 / ".config"
937 / "xfce4"
938 / "xfconf"
939 / "xfce-perchannel-xml"
940 / "xfce4-panel.xml"
941 ).read_text(encoding="utf-8")
942 assert "panel-1" in panel_profile
943 assert "panel-2" not in panel_profile
944 assert 'value="actions"' not in panel_profile
945 assert 'value="launcher"' in panel_profile
946 assert "agent-zero-shutdown.desktop" in panel_profile
947 shutdown_app = (
948 tmp_path
949 / "desktop"
950 / "profiles"
951 / payload["session_id"]
952 / ".local"
953 / "share"
954 / "applications"
955 / "agent-zero-shutdown.desktop"
956 ).read_text(encoding="utf-8")
957 assert "Shutdown Desktop" in shutdown_app
958 assert "shutdown-desktop" in shutdown_app
959 shutdown_panel_launcher = (
960 tmp_path
961 / "desktop"
962 / "profiles"
963 / payload["session_id"]
964 / ".config"
965 / "xfce4"
966 / "panel"
967 / "launcher-9"
968 / "agent-zero-shutdown.desktop"
969 ).read_text(encoding="utf-8")
970 assert "Shutdown Desktop" in shutdown_panel_launcher
971 assert "shutdown-desktop" in shutdown_panel_launcher
972 shutdown_script = (
973 tmp_path
974 / "desktop"
975 / "profiles"
976 / payload["session_id"]
977 / ".agent-zero"
978 / "shutdown-desktop"
979 ).read_text(encoding="utf-8")
980 assert "CONFIRM_SECONDS" in shutdown_script
981 assert "ARM_PATH" in shutdown_script
982 assert "Click Shutdown Desktop again" in shutdown_script
983 assert "xmessage" in shutdown_script
984 assert '"-buttons",' in shutdown_script
985 desktop_helper = (
986 PROJECT_ROOT / "plugins" / "_desktop" / "helpers" / "desktop_session.py"
987 ).read_text(encoding="utf-8")
988 assert "_refresh_xfce_desktop" in desktop_helper
989 assert "DBUS_SESSION_BUS_ADDRESS" in desktop_helper
990 autostart = (
991 tmp_path
992 / "desktop"
993 / "profiles"
994 / payload["session_id"]
995 / ".config"
996 / "autostart"
997 / "agent-zero-desktop.desktop"
998 )
999 assert "prepare-xfce-profile.sh" in autostart.read_text(encoding="utf-8")
1000 profile_script = (
1001 tmp_path
1002 / "desktop"
1003 / "profiles"
1004 / payload["session_id"]
1005 / "prepare-xfce-profile.sh"
1006 ).read_text(encoding="utf-8")
1007 assert '"$HOME"/Desktop/*.desktop' in profile_script
1008 assert "agent-zero-settings.desktop" not in profile_script
1009 assert "metadata::xfce-exe-checksum" in profile_script
1010 assert "xfconf-query -c thunar -p /last-show-hidden" in profile_script
1011 assert "xfconf-query -c xfce4-panel" not in profile_script
1012 assert "launcher-*" not in profile_script
1013 for filename in (
1014 "exo-mail-reader.desktop",
1015 "exo-web-browser.desktop",
1016 "xfce4-mail-reader.desktop",
1017 "xfce4-web-browser.desktop",
1018 "xfce4-session-logout.desktop",
1019 "xfce4-lock-screen.desktop",
1020 "xflock4.desktop",
1021 "xfce4-switch-user.desktop",
1022 ):
1023 entry = (
1024 tmp_path
1025 / "desktop"
1026 / "profiles"
1027 / payload["session_id"]
1028 / ".local"
1029 / "share"
1030 / "applications"
1031 / filename
1032 ).read_text(encoding="utf-8")
1033 assert "NoDisplay=true" in entry
1034 assert "Hidden=true" in entry
1035 assert manager.proxy_for_token(payload["token"]) == ("127.0.0.1", desktop_session.XPRA_PORT_BASE)
1036 assert manager.close(payload["session_id"], save_first=False)["closed"] == 0
1037 assert manager.close(payload["session_id"], save_first=False)["persistent"] is True
1038
1039
1040 def test_desktop_save_targets_requested_libreoffice_window(office_state, tmp_path, monkeypatch):
1041 doc = document_store.create_document("spreadsheet", "Targeted Save", "ods", "Name,Value\nA,1")
1042 session = desktop_session.DesktopSession(
1043 session_id=desktop_session.SYSTEM_SESSION_ID,
1044 file_id=doc["file_id"],
1045 extension=doc["extension"],
1046 path=doc["path"],
1047 title=doc["basename"],
1048 display=desktop_session.DISPLAY_BASE,
1049 xpra_port=desktop_session.XPRA_PORT_BASE,
1050 token=desktop_session.SYSTEM_SESSION_ID,
1051 url="/desktop/session/agent-zero-desktop/index.html",
1052 profile_dir=tmp_path / "profile",
1053 processes={"xpra": types.SimpleNamespace(poll=lambda: None)},
1054 )
1055 manager = desktop_session.DesktopSessionManager()
1056 manager._sessions[session.session_id] = session
1057 commands = []
1058
1059 monkeypatch.setattr(desktop_session.shutil, "which", lambda name: f"/usr/bin/{name}")
1060 monkeypatch.setattr(desktop_session.time, "sleep", lambda _seconds: None)
1061
1062 def fake_run(command, **_kwargs):
1063 commands.append(command)
1064 if command[1] == "search":
1065 assert command == ["/usr/bin/xdotool", "search", "--onlyvisible", "--class", "libreoffice"]
1066 return subprocess.CompletedProcess(command, 0, "222\n", "")
1067 if command[1] == "getwindowname":
1068 return subprocess.CompletedProcess(command, 0, f"{doc['basename']} — LibreOffice Calc\n", "")
1069 Path(doc["path"]).write_bytes(Path(doc["path"]).read_bytes() + b"changed")
1070 return subprocess.CompletedProcess(command, 0, "", "")
1071
1072 monkeypatch.setattr(desktop_session.subprocess, "run", fake_run)
1073
1074 result = manager.save(session.session_id, doc["file_id"])
1075
1076 assert result["ok"] is True
1077 assert result["changed"] is True
1078 assert commands[-1] == [
1079 "/usr/bin/xdotool",
1080 "windowactivate",
1081 "--sync",
1082 "222",
1083 "key",
1084 "--clearmodifiers",
1085 "ctrl+s",
1086 ]
1087
1088 session.file_id = desktop_session.SYSTEM_FILE_ID
1089 command_count = len(commands)
1090 assert manager.save(session.session_id)["changed"] is False
1091 assert len(commands) == command_count
1092
1093
1094 def test_desktop_startup_waiters_probe_display_and_reject_dead_xfce(tmp_path, monkeypatch):
1095 session = desktop_session.DesktopSession(
1096 session_id=desktop_session.SYSTEM_SESSION_ID,
1097 file_id=desktop_session.SYSTEM_FILE_ID,
1098 extension="desktop",
1099 path=str(tmp_path),
1100 title=desktop_session.SYSTEM_TITLE,
1101 display=desktop_session.DISPLAY_BASE,
1102 xpra_port=desktop_session.XPRA_PORT_BASE,
1103 token=desktop_session.SYSTEM_SESSION_ID,
1104 url="/desktop/session/agent-zero-desktop/index.html",
1105 profile_dir=tmp_path / "profile",
1106 processes={"xvfb": types.SimpleNamespace(poll=lambda: None)},
1107 )
1108 manager = desktop_session.DesktopSessionManager()
1109 probes = iter((None, (1920, 1080)))
1110 monkeypatch.setattr(
1111 desktop_session.virtual_desktop,
1112 "current_display_size",
1113 lambda *_args, **_kwargs: next(probes),
1114 )
1115 monkeypatch.setattr(desktop_session.time, "sleep", lambda _seconds: None)
1116
1117 manager._wait_for_display(session)
1118
1119 session.processes = {"xfce": types.SimpleNamespace(poll=lambda: 1)}
1120 with pytest.raises(RuntimeError, match="XFCE desktop session exited"):
1121 manager._wait_for_xfce(session)
1122
1123
1124 def test_desktop_manifest_is_replaced_atomically(tmp_path, monkeypatch):
1125 session_dir = tmp_path / "sessions"
1126 session_dir.mkdir()
1127 manifest = session_dir / f"{desktop_session.SYSTEM_SESSION_ID}.json"
1128 manifest.write_text('{"old": true}', encoding="utf-8")
1129 session = desktop_session.DesktopSession(
1130 session_id=desktop_session.SYSTEM_SESSION_ID,
1131 file_id=desktop_session.SYSTEM_FILE_ID,
1132 extension="desktop",
1133 path=str(tmp_path),
1134 title=desktop_session.SYSTEM_TITLE,
1135 display=desktop_session.DISPLAY_BASE,
1136 xpra_port=desktop_session.XPRA_PORT_BASE,
1137 token=desktop_session.SYSTEM_SESSION_ID,
1138 url="/desktop/session/agent-zero-desktop/index.html",
1139 profile_dir=tmp_path / "profile",
1140 )
1141 real_replace = os.replace
1142
1143 def assert_atomic_replace(source, destination):
1144 assert json.loads(Path(destination).read_text(encoding="utf-8")) == {"old": True}
1145 assert json.loads(Path(source).read_text(encoding="utf-8"))["display"] == session.display
1146 real_replace(source, destination)
1147
1148 monkeypatch.setattr(desktop_session, "SESSION_DIR", session_dir)
1149 monkeypatch.setattr(desktop_session.os, "replace", assert_atomic_replace)
1150
1151 desktop_session.DesktopSessionManager()._write_manifest(session)
1152
1153 assert json.loads(manifest.read_text(encoding="utf-8"))["session_id"] == session.session_id
1154 assert list(session_dir.glob(".*.tmp")) == []
1155
1156
1157 def test_shutdown_panel_launcher_requires_second_click(tmp_path):
1158 profile_dir = tmp_path / "desktop" / "profiles" / desktop_session.SYSTEM_SESSION_ID
1159 profile_dir.mkdir(parents=True)
1160 desktop_path = tmp_path / "workdir"
1161 desktop_path.mkdir()
1162 session = desktop_session.DesktopSession(
1163 session_id=desktop_session.SYSTEM_SESSION_ID,
1164 file_id=desktop_session.SYSTEM_FILE_ID,
1165 extension="desktop",
1166 path=str(desktop_path),
1167 title=desktop_session.SYSTEM_TITLE,
1168 display=desktop_session.DISPLAY_BASE,
1169 xpra_port=desktop_session.XPRA_PORT_BASE,
1170 token=desktop_session.SYSTEM_SESSION_ID,
1171 url="/desktop/session/agent-zero-desktop/index.html",
1172 profile_dir=profile_dir,
1173 )
1174 script = desktop_session._write_shutdown_bridge_script(session)
1175 request = desktop_session._shutdown_request_path(session)
1176 arm = desktop_session._shutdown_arm_path(session)
1177 env = dict(os.environ)
1178 env.pop("DISPLAY", None)
1179
1180 subprocess.run([sys.executable, str(script)], check=True, env=env)
1181
1182 assert arm.exists()
1183 assert not request.exists()
1184
1185 subprocess.run([sys.executable, str(script)], check=True, env=env)
1186
1187 payload = json.loads(request.read_text(encoding="utf-8"))
1188 assert payload["source"] == "tray"
1189 assert payload["armed_at"] <= payload["created_at"]
1190 assert not arm.exists()
1191
1192
1193 def test_desktop_session_sync_consumes_shutdown_marker(tmp_path, monkeypatch):
1194 class FakeProcess:
1195 pid = 5252
1196 terminated = False
1197
1198 def poll(self):
1199 return None if not self.terminated else 0
1200
1201 def terminate(self):
1202 self.terminated = True
1203
1204 def wait(self, timeout=None):
1205 self.terminated = True
1206 return 0
1207
1208 def kill(self):
1209 self.terminated = True
1210
1211 monkeypatch.setattr(desktop_session, "STATE_DIR", tmp_path / "desktop")
1212 monkeypatch.setattr(desktop_session, "SESSION_DIR", tmp_path / "desktop" / "sessions")
1213 monkeypatch.setattr(desktop_session, "PROFILE_DIR", tmp_path / "desktop" / "profiles")
1214
1215 profile_dir = tmp_path / "desktop" / "profiles" / desktop_session.SYSTEM_SESSION_ID
1216 profile_dir.mkdir(parents=True)
1217 desktop_path = tmp_path / "workdir"
1218 desktop_path.mkdir()
1219 session = desktop_session.DesktopSession(
1220 session_id=desktop_session.SYSTEM_SESSION_ID,
1221 file_id=desktop_session.SYSTEM_FILE_ID,
1222 extension="desktop",
1223 path=str(desktop_path),
1224 title=desktop_session.SYSTEM_TITLE,
1225 display=desktop_session.DISPLAY_BASE,
1226 xpra_port=desktop_session.XPRA_PORT_BASE,
1227 token=desktop_session.SYSTEM_SESSION_ID,
1228 url="/desktop/session/agent-zero-desktop/index.html",
1229 profile_dir=profile_dir,
1230 processes={"xpra": FakeProcess()},
1231 )
1232 manager = desktop_session.DesktopSessionManager()
1233 manager._sessions[session.session_id] = session
1234 manager._write_manifest(session)
1235 desktop_session._write_url_bridge_script(session)
1236 shutdown_request = desktop_session._shutdown_request_path(session)
1237 shutdown_request.write_text('{"source": "tray", "created_at": 123.0}\n', encoding="utf-8")
1238 save_calls = []
1239 monkeypatch.setattr(
1240 manager,
1241 "save",
1242 lambda session_id, file_id="": save_calls.append((session_id, file_id)) or {"ok": True},
1243 )
1244
1245 result = manager.sync(session_id=session.session_id)
1246
1247 assert result["ok"] is True
1248 assert result["intentional_shutdown"] is True
1249 assert result["source"] == "tray"
1250 assert result["closed"] == 1
1251 assert save_calls == [(desktop_session.SYSTEM_SESSION_ID, "")]
1252 assert not shutdown_request.exists()
1253 assert not (desktop_session.SESSION_DIR / f"{session.session_id}.json").exists()
1254 assert manager.get(session.session_id) is None
1255
1256
1257 def test_desktop_session_cleanup_preserves_live_owner_manifest(tmp_path, monkeypatch):
1258 session_dir = tmp_path / "sessions"
1259 legacy_session_dir = tmp_path / "legacy-sessions"
1260 session_dir.mkdir()
1261 legacy_session_dir.mkdir()
1262 manifest = session_dir / "live.json"
1263 manifest.write_text(
1264 json.dumps({"owner_pid": os.getpid(), "pids": {"xpra": 987654}}),
1265 encoding="utf-8",
1266 )
1267 legacy_manifest = legacy_session_dir / "stale.json"
1268 legacy_manifest.write_text(
1269 json.dumps({"owner_pid": 987650, "pids": {"xpra": 987651, "xfce": 987652}}),
1270 encoding="utf-8",
1271 )
1272 monkeypatch.setattr(desktop_session, "SESSION_DIR", session_dir)
1273 monkeypatch.setattr(desktop_session, "LEGACY_SESSION_DIRS", (legacy_session_dir,))
1274 killed = []
1275
1276 def fake_kill_pid(pid):
1277 killed.append(pid)
1278 return True
1279
1280 monkeypatch.setattr(
1281 desktop_session,
1282 "_kill_pid",
1283 fake_kill_pid,
1284 )
1285
1286 result = desktop_session.cleanup_stale_runtime_state()
1287
1288 assert result["killed"] == [987651, 987652]
1289 assert killed == [987651, 987652]
1290 assert manifest.exists()
1291 assert not legacy_manifest.exists()
1292
1293
1294 def test_desktop_session_removes_stale_lock_file(tmp_path):
1295 doc_path = tmp_path / "Deck.pptx"
1296 doc_path.write_text("pptx", encoding="utf-8")
1297 lock_path = tmp_path / ".~lock.Deck.pptx#"
1298 lock_path.write_text("stale", encoding="utf-8")
1299 session = desktop_session.DesktopSession(
1300 session_id="session",
1301 file_id="file",
1302 extension="pptx",
1303 path=str(doc_path),
1304 title=doc_path.name,
1305 display=desktop_session.DISPLAY_BASE,
1306 xpra_port=desktop_session.XPRA_PORT_BASE,
1307 token="token",
1308 url="/desktop/session/token/index.html",
1309 profile_dir=tmp_path / "profile",
1310 )
1311
1312 desktop_session.DesktopSessionManager()._remove_stale_lock_file(session)
1313
1314 assert not lock_path.exists()
1315
1316
1317 def _isolate_office_cleanup_hook(monkeypatch, tmp_path):
1318 state_dir = tmp_path / "usr" / "plugins" / "_office"
1319 retired_state_dir = tmp_path / "usr" / "_office"
1320 monkeypatch.setattr(hooks, "RETIRED_WEB_APT_SOURCE_FILE", tmp_path / "missing.sources")
1321 monkeypatch.setattr(hooks, "RETIRED_WEB_APT_KEYRING_FILE", tmp_path / "missing.gpg")
1322 monkeypatch.setattr(hooks, "RETIRED_WEB_SUPERVISOR_FILE", tmp_path / "missing.conf")
1323 monkeypatch.setattr(hooks, "RETIRED_WEB_RUNTIME_DIRS", [])
1324 monkeypatch.setattr(hooks, "STATE_DIR", state_dir)
1325 monkeypatch.setattr(hooks, "RETIRED_STATE_DIR", retired_state_dir)
1326 monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", state_dir / "documents")
1327 monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [])
1328 monkeypatch.setattr(hooks, "CLEANUP_MARKER", state_dir / "cleanup.done")
1329 monkeypatch.setattr(hooks, "_installed_retired_web_packages", lambda: [])
1330 monkeypatch.setattr(hooks, "_installed_packages", lambda packages: [])
1331 monkeypatch.setattr(hooks, "_kill_old_processes", lambda errors: None)
1332 monkeypatch.setattr(hooks, "_ensure_runtime_dependencies", lambda installed, errors: None)
1333 monkeypatch.setattr(hooks, "_purge_packages", lambda removed, errors, **kwargs: None)
1334 monkeypatch.setattr(hooks.shutil, "which", lambda name: "")
1335
1336
1337 def test_cleanup_hook_moves_retired_office_state_to_plugin_state(tmp_path, monkeypatch):
1338 _isolate_office_cleanup_hook(monkeypatch, tmp_path)
1339 retired_state = tmp_path / "usr" / "_office"
1340 plugin_state = tmp_path / "usr" / "plugins" / "_office"
1341 (retired_state / "documents" / "backups").mkdir(parents=True)
1342 (retired_state / "documents" / "documents.sqlite3").write_text("db\n", encoding="utf-8")
1343 (retired_state / "documents" / "backups" / "draft.md").write_text("backup\n", encoding="utf-8")
1344 (retired_state / "stale-cleanup-v3.done").write_text("ok\n", encoding="utf-8")
1345 plugin_state.mkdir(parents=True)
1346
1347 result = hooks.cleanup_stale_runtime_state(force=True)
1348
1349 assert result["ok"] is True
1350 assert (plugin_state / "documents" / "documents.sqlite3").read_text(encoding="utf-8") == "db\n"
1351 assert (plugin_state / "documents" / "backups" / "draft.md").read_text(encoding="utf-8") == "backup\n"
1352 assert (plugin_state / "stale-cleanup-v3.done").read_text(encoding="utf-8") == "ok\n"
1353 assert not retired_state.exists()
1354
1355
1356 def test_cleanup_hook_migrates_legacy_document_state_without_removing_source(tmp_path, monkeypatch):
1357 _isolate_office_cleanup_hook(monkeypatch, tmp_path)
1358 legacy_documents = tmp_path / "usr" / "state" / "_office" / "documents"
1359 document_state = tmp_path / "usr" / "plugins" / "_office" / "documents"
1360 legacy_documents.mkdir(parents=True)
1361 (legacy_documents / "documents.sqlite3").write_text("legacy-db\n", encoding="utf-8")
1362 (legacy_documents / "backups").mkdir()
1363 (legacy_documents / "backups" / "draft.md").write_text("backup\n", encoding="utf-8")
1364
1365 monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", document_state)
1366 monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [legacy_documents])
1367 result = hooks.cleanup_stale_runtime_state(force=True)
1368
1369 assert result["ok"] is True
1370 assert result["migrated"] == [f"{legacy_documents} -> {document_state}"]
1371 assert (legacy_documents / "documents.sqlite3").exists()
1372 assert (document_state / "documents.sqlite3").read_text(encoding="utf-8") == "legacy-db\n"
1373 assert (document_state / "backups" / "draft.md").read_text(encoding="utf-8") == "backup\n"
1374
1375
1376 def test_cleanup_hook_prefers_existing_new_document_state_without_merge(tmp_path, monkeypatch):
1377 _isolate_office_cleanup_hook(monkeypatch, tmp_path)
1378 legacy_documents = tmp_path / "legacy-documents"
1379 document_state = tmp_path / "usr" / "plugins" / "_office" / "documents"
1380 legacy_documents.mkdir(parents=True)
1381 document_state.mkdir(parents=True)
1382 (legacy_documents / "documents.sqlite3").write_text("legacy-db\n", encoding="utf-8")
1383 (document_state / "documents.sqlite3").write_text("new-db\n", encoding="utf-8")
1384
1385 monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", document_state)
1386 monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [legacy_documents])
1387 result = hooks.cleanup_stale_runtime_state(force=True)
1388
1389 assert result["ok"] is True
1390 assert result["migrated"] == []
1391 assert result["warnings"] == [
1392 f"Legacy Office document state left in place because {document_state} already exists: {legacy_documents}"
1393 ]
1394 assert (legacy_documents / "documents.sqlite3").read_text(encoding="utf-8") == "legacy-db\n"
1395 assert (document_state / "documents.sqlite3").read_text(encoding="utf-8") == "new-db\n"
1396
1397
1398 def test_cleanup_hook_targets_legacy_collabora_runtime_artifacts():
1399 assert Path("/opt/cool") in hooks.RETIRED_WEB_RUNTIME_DIRS
1400 assert Path("/opt/collaboraoffice") in hooks.RETIRED_WEB_RUNTIME_DIRS
1401 assert {
1402 "collaboraoffice-ure",
1403 "collaboraofficebasis-core",
1404 "collaboraofficebasis-ooofonts",
1405 }.issubset(hooks.RETIRED_WEB_PACKAGES)
1406
1407
1408 def test_installed_retired_web_packages_discovers_collabora_split_packages(monkeypatch):
1409 monkeypatch.setattr(
1410 hooks.shutil,
1411 "which",
1412 lambda name: "/usr/bin/dpkg-query" if name == "dpkg-query" else "",
1413 )
1414 monkeypatch.setattr(
1415 hooks,
1416 "_package_installed",
1417 lambda package: package in {"coolwsd", "collaboraofficebasis-core"},
1418 )
1419
1420 def fake_run(command, **kwargs):
1421 assert command == ["dpkg-query", "-W", "-f=${binary:Package}\t${Status}\n", "collaboraoffice*"]
1422 return types.SimpleNamespace(
1423 returncode=0,
1424 stdout=(
1425 "collaboraofficebasis-core\tinstall ok installed\n"
1426 "collaboraofficebasis-extra-future\tinstall ok installed\n"
1427 "collaboraofficebasis-config-files\tdeinstall ok config-files\n"
1428 "notcollaboraoffice\tinstall ok installed\n"
1429 ),
1430 stderr="",
1431 )
1432
1433 monkeypatch.setattr(hooks.subprocess, "run", fake_run)
1434
1435 packages = hooks._installed_retired_web_packages()
1436
1437 assert packages == [
1438 "coolwsd",
1439 "collaboraofficebasis-core",
1440 "collaboraofficebasis-extra-future",
1441 ]
1442
1443
1444 def test_cleanup_hook_removes_stale_runtime_state_idempotently(tmp_path, monkeypatch):
1445 source = tmp_path / "sources.list.d" / "retired.sources"
1446 keyring = tmp_path / "keyrings" / "retired.gpg"
1447 supervisor = tmp_path / "supervisor" / "retired.conf"
1448 runtime_dir = tmp_path / "runtime"
1449 legacy_cool_dir = tmp_path / "opt" / "cool"
1450 legacy_collabora_dir = tmp_path / "opt" / "collaboraoffice"
1451 marker = tmp_path / "state" / "cleanup.done"
1452
1453 for path in (source, keyring, supervisor):
1454 path.parent.mkdir(parents=True, exist_ok=True)
1455 path.write_text("old\n", encoding="utf-8")
1456 (runtime_dir / "nested").mkdir(parents=True, exist_ok=True)
1457 (runtime_dir / "nested" / "state.txt").write_text("old\n", encoding="utf-8")
1458 (legacy_cool_dir / "state").mkdir(parents=True, exist_ok=True)
1459 (legacy_cool_dir / "state" / "cool.txt").write_text("old\n", encoding="utf-8")
1460 (legacy_collabora_dir / "program").mkdir(parents=True, exist_ok=True)
1461 (legacy_collabora_dir / "program" / "office.txt").write_text("old\n", encoding="utf-8")
1462
1463 monkeypatch.setattr(hooks, "RETIRED_WEB_APT_SOURCE_FILE", source)
1464 monkeypatch.setattr(hooks, "RETIRED_WEB_APT_KEYRING_FILE", keyring)
1465 monkeypatch.setattr(hooks, "RETIRED_WEB_SUPERVISOR_FILE", supervisor)
1466 monkeypatch.setattr(hooks, "RETIRED_WEB_RUNTIME_DIRS", [runtime_dir, legacy_cool_dir, legacy_collabora_dir])
1467 monkeypatch.setattr(hooks, "CLEANUP_MARKER", marker)
1468 monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", tmp_path / "usr" / "plugins" / "_office" / "documents")
1469 monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [])
1470 monkeypatch.setattr(hooks, "_installed_retired_web_packages", lambda: [])
1471 monkeypatch.setattr(hooks, "_installed_packages", lambda packages: [])
1472 monkeypatch.setattr(hooks, "_kill_old_processes", lambda errors: None)
1473 def fake_ensure(installed, errors):
1474 assert not source.exists()
1475 installed.append("libreoffice-core")
1476
1477 def fake_purge(removed, errors, **kwargs):
1478 return None
1479
1480 monkeypatch.setattr(hooks, "_ensure_runtime_dependencies", fake_ensure)
1481 monkeypatch.setattr(hooks, "_purge_packages", fake_purge)
1482
1483 first = hooks.cleanup_stale_runtime_state(force=True)
1484 second = hooks.cleanup_stale_runtime_state(force=True)
1485 skipped = hooks.cleanup_stale_runtime_state()
1486
1487 assert first["ok"] is True
1488 assert first["installed"] == ["libreoffice-core"]
1489 assert second["ok"] is True
1490 assert skipped["skipped"] is True
1491 assert not source.exists()
1492 assert not keyring.exists()
1493 assert not supervisor.exists()
1494 assert not runtime_dir.exists()
1495 assert not legacy_cool_dir.exists()
1496 assert not legacy_collabora_dir.exists()
1497 assert marker.exists()
1498
1499
1500 def test_office_startup_defers_persistent_desktop_runtime(monkeypatch):
1501 cleanup_calls = []
1502 started_threads = []
1503 monkeypatch.delitem(
1504 sys.modules,
1505 "plugins._office.extensions.python.startup_migration._20_office_routes",
1506 raising=False,
1507 )
1508
1509 from plugins._office.extensions.python.startup_migration import _20_office_routes as office_startup
1510
1511 monkeypatch.setattr(
1512 office_startup.hooks,
1513 "cleanup_stale_runtime_state",
1514 lambda: cleanup_calls.append("cleanup") or {"ok": True, "errors": [], "installed": [], "removed": []},
1515 )
1516
1517 class FakeThread:
1518 def __init__(self, *, target, name, daemon):
1519 self.target = target
1520 self.name = name
1521 self.daemon = daemon
1522
1523 def is_alive(self):
1524 return False
1525
1526 def start(self):
1527 started_threads.append(self)
1528
1529 monkeypatch.setattr(office_startup.threading, "Thread", FakeThread)
1530
1531 office_startup.OfficeStartupCleanup(agent=None).execute()
1532
1533 assert cleanup_calls == []
1534 assert len(started_threads) == 1
1535 assert started_threads[0].name == "a0-office-document-runtime-preparation"
1536 assert started_threads[0].daemon is True
1537 assert not hasattr(office_startup, "desktop_session")
1538
1539 started_threads[0].target()
1540 assert cleanup_calls == ["cleanup"]
1541
1542
1543 def test_cleanup_hook_reruns_when_stale_packages_exist_after_old_marker(tmp_path, monkeypatch):
1544 marker = tmp_path / "state" / "cleanup.done"
1545 marker.parent.mkdir(parents=True)
1546 marker.write_text("old\n", encoding="utf-8")
1547
1548 monkeypatch.setattr(hooks, "RETIRED_WEB_APT_SOURCE_FILE", tmp_path / "missing.sources")
1549 monkeypatch.setattr(hooks, "RETIRED_WEB_APT_KEYRING_FILE", tmp_path / "missing.gpg")
1550 monkeypatch.setattr(hooks, "RETIRED_WEB_SUPERVISOR_FILE", tmp_path / "missing.conf")
1551 monkeypatch.setattr(hooks, "RETIRED_WEB_RUNTIME_DIRS", [])
1552 monkeypatch.setattr(hooks, "CLEANUP_MARKER", marker)
1553 monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", tmp_path / "usr" / "plugins" / "_office" / "documents")
1554 monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [])
1555 retired_web_packages = [
1556 "coolwsd",
1557 "collaboraoffice-ure",
1558 "collaboraofficebasis-core",
1559 "collaboraofficebasis-ooofonts",
1560 ]
1561 monkeypatch.setattr(hooks, "_installed_retired_web_packages", lambda: retired_web_packages)
1562 monkeypatch.setattr(hooks, "_installed_packages", lambda packages: [])
1563 monkeypatch.setattr(hooks, "_ensure_runtime_dependencies", lambda installed, errors: None)
1564 monkeypatch.setattr(hooks, "_kill_old_processes", lambda errors: None)
1565 def fake_purge(removed, errors, **kwargs):
1566 removed.extend(kwargs["installed_packages"])
1567
1568 monkeypatch.setattr(hooks, "_purge_packages", fake_purge)
1569
1570 result = hooks.cleanup_stale_runtime_state()
1571
1572 assert result["skipped"] is False
1573 assert result["removed"] == retired_web_packages
1574
1575
1576 def test_cleanup_hook_removes_retired_supervisor_program_after_marker(tmp_path, monkeypatch):
1577 marker = tmp_path / "state" / "cleanup.done"
1578 marker.parent.mkdir(parents=True)
1579 marker.write_text("ok\n", encoding="utf-8")
1580 calls = []
1581
1582 monkeypatch.setattr(hooks, "RETIRED_WEB_APT_SOURCE_FILE", tmp_path / "missing.sources")
1583 monkeypatch.setattr(hooks, "RETIRED_WEB_APT_KEYRING_FILE", tmp_path / "missing.gpg")
1584 monkeypatch.setattr(hooks, "RETIRED_WEB_SUPERVISOR_FILE", tmp_path / "missing.conf")
1585 monkeypatch.setattr(hooks, "RETIRED_WEB_RUNTIME_DIRS", [])
1586 monkeypatch.setattr(hooks, "CLEANUP_MARKER", marker)
1587 monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", tmp_path / "usr" / "plugins" / "_office" / "documents")
1588 monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [])
1589 monkeypatch.setattr(hooks, "_installed_retired_web_packages", lambda: [])
1590 monkeypatch.setattr(hooks, "_installed_packages", lambda packages: [])
1591 monkeypatch.setattr(hooks, "_ensure_runtime_dependencies", lambda installed, errors: None)
1592 monkeypatch.setattr(hooks.shutil, "which", lambda name: "/usr/bin/supervisorctl" if name == "supervisorctl" else "")
1593
1594 def fake_supervisorctl(*args):
1595 calls.append(args)
1596 if args == ("status", hooks.RETIRED_WEB_SUPERVISOR_PROGRAM):
1597 return types.SimpleNamespace(
1598 returncode=0,
1599 stdout="a0_office_collabora BACKOFF can't find command\n",
1600 stderr="",
1601 )
1602 return types.SimpleNamespace(returncode=0, stdout="", stderr="")
1603
1604 monkeypatch.setattr(hooks, "_supervisorctl", fake_supervisorctl)
1605
1606 result = hooks.cleanup_stale_runtime_state()
1607
1608 assert result["ok"] is True
1609 assert result["skipped"] is True
1610 assert result["errors"] == []
1611 assert calls == [
1612 ("status", hooks.RETIRED_WEB_SUPERVISOR_PROGRAM),
1613 ("stop", hooks.RETIRED_WEB_SUPERVISOR_PROGRAM),
1614 ("remove", hooks.RETIRED_WEB_SUPERVISOR_PROGRAM),
1615 ("reread",),
1616 ("update",),
1617 ]
1618
1619
1620 def test_office_runtime_dependency_install_waits_out_apt_locks(monkeypatch):
1621 calls = []
1622 installed_state = {"libreoffice-core": False}
1623 update_attempts = {"count": 0}
1624
1625 monkeypatch.setattr(hooks.os, "geteuid", lambda: 0)
1626 monkeypatch.setattr(
1627 hooks.shutil,
1628 "which",
1629 lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query"} else "",
1630 )
1631 monkeypatch.setattr(hooks, "RUNTIME_PACKAGES", ("libreoffice-core",))
1632 monkeypatch.setattr(hooks, "_package_installed", lambda package: installed_state.get(package, False))
1633 monkeypatch.setattr(system_packages.time, "sleep", lambda _seconds: None)
1634
1635 def fake_run(command, **kwargs):
1636 calls.append(command)
1637 if command == ["apt-get", "update"]:
1638 update_attempts["count"] += 1
1639 if update_attempts["count"] == 1:
1640 return types.SimpleNamespace(
1641 returncode=100,
1642 stdout="",
1643 stderr="E: Could not get lock /var/lib/apt/lists/lock. It is held by process 1829 (apt-get)",
1644 )
1645 if command[:2] == ["apt-get", "install"]:
1646 installed_state["libreoffice-core"] = True
1647 return types.SimpleNamespace(returncode=0, stdout="", stderr="")
1648
1649 monkeypatch.setattr(hooks.subprocess, "run", fake_run)
1650 installed = []
1651 errors = []
1652
1653 hooks._ensure_runtime_dependencies(installed, errors)
1654
1655 assert errors == []
1656 assert installed == ["libreoffice-core"]
1657 assert calls[:2] == [["apt-get", "update"], ["apt-get", "update"]]
1658 assert calls[2][:4] == ["apt-get", "install", "-y", "--no-install-recommends"]
1659
1660
1661 def test_desktop_runtime_packages_include_libreoffice_for_desktop_status():
1662 install_additional = (
1663 PROJECT_ROOT / "docker" / "run" / "fs" / "ins" / "install_additional.sh"
1664 ).read_text(encoding="utf-8")
1665
1666 assert set(desktop_hooks.LIBREOFFICE_RUNTIME_PACKAGES).issubset(desktop_hooks.RUNTIME_PACKAGES)
1667 assert "libreoffice-writer" in desktop_hooks.RUNTIME_PACKAGES
1668 assert "libreoffice-calc" in desktop_hooks.RUNTIME_PACKAGES
1669 assert "libreoffice-impress" in desktop_hooks.RUNTIME_PACKAGES
1670 assert desktop_hooks.GTK_RUNTIME_PACKAGE in desktop_hooks.RUNTIME_PACKAGES
1671 assert "xpra-client" in desktop_hooks.RUNTIME_PACKAGES
1672 assert "xpra-client-gtk3" in desktop_hooks.RUNTIME_PACKAGES
1673 assert f'XPRA_VERSION="{desktop_hooks.XPRA_VERSION}"' in install_additional
1674
1675
1676 def test_desktop_cleanup_moves_retired_state_to_plugin_state(tmp_path, monkeypatch):
1677 retired_state = tmp_path / "usr" / "_desktop"
1678 plugin_state = tmp_path / "usr" / "plugins" / "_desktop"
1679 (retired_state / "profiles" / "agent-zero-desktop").mkdir(parents=True)
1680 (retired_state / "profiles" / "agent-zero-desktop" / "profile.txt").write_text("profile\n", encoding="utf-8")
1681 (retired_state / "sessions").mkdir()
1682 (retired_state / "sessions" / "agent-zero-desktop.json").write_text("{}\n", encoding="utf-8")
1683 (retired_state / "screenshots").mkdir()
1684 (retired_state / "screenshots" / "desktop.png").write_bytes(b"png")
1685 plugin_state.mkdir(parents=True)
1686
1687 monkeypatch.setattr(desktop_hooks, "STATE_DIR", plugin_state)
1688 monkeypatch.setattr(desktop_hooks, "RETIRED_STATE_DIR", retired_state)
1689 monkeypatch.setattr(desktop_hooks, "_installed_packages", lambda packages: [])
1690 monkeypatch.setattr(desktop_hooks, "_ensure_runtime_dependencies", lambda installed, errors: None)
1691 monkeypatch.setattr(desktop_hooks, "_cleanup_desktop_sessions", lambda errors: None)
1692
1693 result = desktop_hooks.cleanup_stale_runtime_state(force=True)
1694
1695 assert result["ok"] is True
1696 assert (plugin_state / "profiles" / "agent-zero-desktop" / "profile.txt").read_text(encoding="utf-8") == "profile\n"
1697 assert (plugin_state / "sessions" / "agent-zero-desktop.json").read_text(encoding="utf-8") == "{}\n"
1698 assert not (plugin_state / "screenshots").exists()
1699 assert any("Removed retired persistent Desktop screenshots" in warning for warning in result["warnings"])
1700 assert not retired_state.exists()
1701
1702
1703 def test_cleanup_hook_installs_missing_desktop_session_dependencies(monkeypatch):
1704 calls = []
1705 installed_state = {"xpra": False}
1706
1707 monkeypatch.setattr(desktop_hooks.os, "geteuid", lambda: 0)
1708 monkeypatch.setattr(desktop_hooks.shutil, "which", lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query"} else "")
1709 monkeypatch.setattr(desktop_hooks, "RUNTIME_PACKAGES", ("xpra",))
1710 monkeypatch.setattr(desktop_hooks, "_package_installed", lambda package: installed_state.get(package, False))
1711
1712 def fake_run(command, **kwargs):
1713 calls.append(command)
1714 if command[:2] == ["apt-get", "install"]:
1715 installed_state["xpra"] = True
1716 return types.SimpleNamespace(returncode=0, stdout="", stderr="")
1717
1718 monkeypatch.setattr(desktop_hooks.subprocess, "run", fake_run)
1719 installed = []
1720 errors = []
1721
1722 desktop_hooks._ensure_runtime_dependencies(installed, errors)
1723
1724 assert installed == ["xpra"]
1725 assert errors == []
1726 assert calls[0] == ["apt-get", "update"]
1727 assert calls[1][:4] == ["apt-get", "install", "-y", "--no-install-recommends"]
1728
1729
1730 def test_cleanup_hook_enables_official_xpra_repo_when_kali_lacks_candidate(tmp_path, monkeypatch):
1731 calls = []
1732 installed_state = {"xpra": False, "ca-certificates": True}
1733 keyring = tmp_path / "keyrings" / "xpra.asc"
1734 source = tmp_path / "sources.list.d" / "xpra.sources"
1735
1736 monkeypatch.setattr(desktop_hooks.os, "geteuid", lambda: 0)
1737 monkeypatch.setattr(
1738 desktop_hooks.shutil,
1739 "which",
1740 lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query", "apt-cache"} else "",
1741 )
1742 monkeypatch.setattr(desktop_hooks, "RUNTIME_PACKAGES", ("xpra",))
1743 monkeypatch.setattr(desktop_hooks, "XPRA_KEYRING_FILE", keyring)
1744 monkeypatch.setattr(desktop_hooks, "XPRA_SOURCE_FILE", source)
1745 monkeypatch.setattr(desktop_hooks, "_download", lambda url: b"xpra-key")
1746 monkeypatch.setattr(desktop_hooks, "_read_os_release", lambda: {"ID": "kali", "VERSION_CODENAME": "kali-rolling"})
1747 monkeypatch.setattr(desktop_hooks, "_dpkg_architecture", lambda: "amd64")
1748 monkeypatch.setattr(desktop_hooks, "_package_installed", lambda package: installed_state.get(package, False))
1749
1750 def fake_run(command, **kwargs):
1751 calls.append(command)
1752 if command[:2] == ["apt-cache", "policy"]:
1753 return types.SimpleNamespace(returncode=0, stdout="", stderr="")
1754 if command[:2] == ["apt-get", "install"]:
1755 installed_state["xpra"] = True
1756 return types.SimpleNamespace(returncode=0, stdout="", stderr="")
1757
1758 monkeypatch.setattr(desktop_hooks.subprocess, "run", fake_run)
1759 installed = []
1760 errors = []
1761
1762 desktop_hooks._ensure_runtime_dependencies(installed, errors)
1763
1764 assert errors == []
1765 assert installed == ["xpra"]
1766 assert keyring.read_bytes() == b"xpra-key"
1767 assert "URIs: https://xpra.org\n" in source.read_text(encoding="utf-8")
1768 assert "Suites: trixie" in source.read_text(encoding="utf-8")
1769 assert calls.count(["apt-get", "update"]) == 2
1770 assert calls[-1][:4] == ["apt-get", "install", "-y", "--no-install-recommends"]
1771
1772
1773 def test_cleanup_hook_uses_trixie_xpra_components_for_kali_arm64(tmp_path, monkeypatch):
1774 calls = []
1775 installed_state = {"xpra-server": False, "xpra-x11": False, "xpra-html5": False, "ca-certificates": True}
1776 keyring = tmp_path / "keyrings" / "xpra.asc"
1777 source = tmp_path / "sources.list.d" / "xpra.sources"
1778
1779 monkeypatch.setattr(desktop_hooks.os, "geteuid", lambda: 0)
1780 monkeypatch.setattr(
1781 desktop_hooks.shutil,
1782 "which",
1783 lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query", "apt-cache"} else "",
1784 )
1785 monkeypatch.setattr(desktop_hooks, "RUNTIME_PACKAGES", ("xpra-server", "xpra-x11", "xpra-html5"))
1786 monkeypatch.setattr(desktop_hooks, "XPRA_KEYRING_FILE", keyring)
1787 monkeypatch.setattr(desktop_hooks, "XPRA_SOURCE_FILE", source)
1788 monkeypatch.setattr(desktop_hooks, "_download", lambda url: b"xpra-key")
1789 monkeypatch.setattr(desktop_hooks, "_read_os_release", lambda: {"ID": "kali", "VERSION_CODENAME": "kali-rolling"})
1790 monkeypatch.setattr(desktop_hooks, "_dpkg_architecture", lambda: "arm64")
1791 monkeypatch.setattr(desktop_hooks, "_package_installed", lambda package: installed_state.get(package, False))
1792
1793 def fake_run(command, **kwargs):
1794 calls.append(command)
1795 if command[:2] == ["apt-cache", "policy"]:
1796 return types.SimpleNamespace(returncode=0, stdout="", stderr="")
1797 if command[:2] == ["apt-get", "install"]:
1798 for package in command[4:]:
1799 installed_state[package] = True
1800 return types.SimpleNamespace(returncode=0, stdout="", stderr="")
1801
1802 monkeypatch.setattr(desktop_hooks.subprocess, "run", fake_run)
1803 installed = []
1804 errors = []
1805
1806 desktop_hooks._ensure_runtime_dependencies(installed, errors)
1807
1808 assert errors == []
1809 assert installed == ["xpra-server", "xpra-x11", "xpra-html5"]
1810 source_text = source.read_text(encoding="utf-8")
1811 assert "URIs: https://xpra.org\n" in source_text
1812 assert "Suites: trixie" in source_text
1813 assert "xpra" not in calls[-1]
1814 assert calls[-1][-3:] == [
1815 f"xpra-server={desktop_hooks.XPRA_VERSION}",
1816 f"xpra-x11={desktop_hooks.XPRA_VERSION}",
1817 "xpra-html5",
1818 ]
1819
1820
1821 def test_cleanup_hook_installs_matching_xpra_client_stack(monkeypatch):
1822 calls = []
1823 installed_state = {
1824 "xpra-client": False,
1825 "xpra-client-gtk3": False,
1826 }
1827
1828 monkeypatch.setattr(desktop_hooks.os, "geteuid", lambda: 0)
1829 monkeypatch.setattr(
1830 desktop_hooks.shutil,
1831 "which",
1832 lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query", "apt-cache"} else "",
1833 )
1834 monkeypatch.setattr(
1835 desktop_hooks,
1836 "RUNTIME_PACKAGES",
1837 ("xpra-client", "xpra-client-gtk3"),
1838 )
1839 monkeypatch.setattr(desktop_hooks, "_package_installed", lambda package: installed_state.get(package, False))
1840 monkeypatch.setattr(desktop_hooks, "_package_version", lambda package: "6.5.2-r0-1")
1841
1842 def fake_run(command, **kwargs):
1843 calls.append(command)
1844 if command[:2] == ["apt-cache", "policy"]:
1845 return types.SimpleNamespace(returncode=0, stdout="Candidate: 6.5.3-r0-1\n", stderr="")
1846 if command[:2] == ["apt-get", "install"]:
1847 installed_state["xpra-client"] = True
1848 installed_state["xpra-client-gtk3"] = True
1849 return types.SimpleNamespace(returncode=0, stdout="", stderr="")
1850
1851 monkeypatch.setattr(desktop_hooks.subprocess, "run", fake_run)
1852 installed = []
1853 errors = []
1854
1855 desktop_hooks._ensure_runtime_dependencies(installed, errors)
1856
1857 assert installed == ["xpra-client", "xpra-client-gtk3"]
1858 assert errors == []
1859 assert calls[-1][-2:] == ["xpra-client=6.5.2-r0-1", "xpra-client-gtk3=6.5.2-r0-1"]
1860
1861
1862 def test_cleanup_hook_repairs_kali_gtk_from_rolling_source(monkeypatch):
1863 calls = []
1864 source_text = []
1865 installed_state = {desktop_hooks.GTK_RUNTIME_PACKAGE: False}
1866
1867 monkeypatch.setattr(desktop_hooks.os, "geteuid", lambda: 0)
1868 monkeypatch.setattr(
1869 desktop_hooks.shutil,
1870 "which",
1871 lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query"} else "",
1872 )
1873 monkeypatch.setattr(desktop_hooks, "RUNTIME_PACKAGES", (desktop_hooks.GTK_RUNTIME_PACKAGE,))
1874 monkeypatch.setattr(desktop_hooks, "_read_os_release", lambda: {"ID": "kali"})
1875 monkeypatch.setattr(desktop_hooks, "_package_installed", lambda package: installed_state.get(package, False))
1876
1877 def fake_run(command, **kwargs):
1878 calls.append(command)
1879 source_option = next(
1880 (item for item in command if item.startswith("Dir::Etc::sourcelist=")),
1881 "",
1882 )
1883 if source_option:
1884 source_text.append(Path(source_option.split("=", 1)[1]).read_text(encoding="utf-8"))
1885 if "install" in command:
1886 installed_state[desktop_hooks.GTK_RUNTIME_PACKAGE] = True
1887 return types.SimpleNamespace(returncode=0, stdout="", stderr="")
1888
1889 monkeypatch.setattr(desktop_hooks.subprocess, "run", fake_run)
1890 installed = []
1891 errors = []
1892
1893 desktop_hooks._ensure_runtime_dependencies(installed, errors)
1894
1895 assert installed == [desktop_hooks.GTK_RUNTIME_PACKAGE]
1896 assert errors == []
1897 assert source_text == [desktop_hooks.KALI_ROLLING_SOURCE, desktop_hooks.KALI_ROLLING_SOURCE]
1898 assert calls[0][-1] == "update"
1899 assert calls[1][-2:] == ["--no-install-recommends", desktop_hooks.GTK_RUNTIME_PACKAGE]
1900
1901
1902 def test_cleanup_hook_reports_required_xpra_codec_conflict(monkeypatch):
1903 codec_error = (
1904 "E: Unable to satisfy dependencies. Reached two conflicting assignments:\n"
1905 " 1. xpra-codecs:arm64=6.4.3-r0-1 is selected for install\n"
1906 " 2. xpra-codecs:arm64 Depends libvpx9 (>= 1.12.0)\n"
1907 " but none of the choices are installable: [no choices]"
1908 )
1909
1910 monkeypatch.setattr(desktop_hooks.os, "geteuid", lambda: 0)
1911 monkeypatch.setattr(
1912 desktop_hooks.shutil,
1913 "which",
1914 lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query", "apt-cache"} else "",
1915 )
1916 monkeypatch.setattr(desktop_hooks, "RUNTIME_PACKAGES", ("xpra-server",))
1917 monkeypatch.setattr(desktop_hooks, "_package_installed", lambda package: False)
1918
1919 def fake_run(command, **kwargs):
1920 if command[:2] == ["apt-cache", "policy"]:
1921 return types.SimpleNamespace(returncode=0, stdout="Candidate: 6.4.3-r0-1\n", stderr="")
1922 if command[:2] == ["apt-get", "install"]:
1923 return types.SimpleNamespace(returncode=100, stdout="", stderr=codec_error)
1924 return types.SimpleNamespace(returncode=0, stdout="", stderr="")
1925
1926 monkeypatch.setattr(desktop_hooks.subprocess, "run", fake_run)
1927 installed = []
1928 errors = []
1929
1930 desktop_hooks._ensure_runtime_dependencies(installed, errors)
1931
1932 assert installed == []
1933 assert errors == [codec_error]
1934
1935
1936 def test_self_update_launch_invokes_office_cleanup(monkeypatch, tmp_path):
1937 manager = load_self_update_manager()
1938 calls = []
1939
1940 class Logger:
1941 def log(self, message=""):
1942 return None
1943
1944 class Process:
1945 pass
1946
1947 monkeypatch.setattr(manager, "run_office_cleanup_hook", lambda repo_dir, logger: calls.append(repo_dir))
1948 monkeypatch.setattr(manager, "run_command", lambda *args, **kwargs: None)
1949 monkeypatch.setattr(manager.subprocess, "Popen", lambda *args, **kwargs: Process())
1950
1951 repo = tmp_path / "repo"
1952 repo.mkdir()
1953 process = manager.launch_ui_process(repo, Logger())
1954
1955 assert isinstance(process, Process)
1956 assert calls == [repo]
1957
1958
1959 def load_self_update_manager():
1960 manager_path = PROJECT_ROOT / "docker" / "run" / "fs" / "exe" / "self_update_manager.py"
1961 spec = importlib.util.spec_from_file_location("test_self_update_manager_office", manager_path)
1962 assert spec is not None and spec.loader is not None
1963 module = importlib.util.module_from_spec(spec)
1964 spec.loader.exec_module(module)
1965 return module