diff --git a/libreoffice/agent-harness/cli_anything/libreoffice/tests/test_core.py b/libreoffice/agent-harness/cli_anything/libreoffice/tests/test_core.py index a5e48088c..0496b778d 100644 --- a/libreoffice/agent-harness/cli_anything/libreoffice/tests/test_core.py +++ b/libreoffice/agent-harness/cli_anything/libreoffice/tests/test_core.py @@ -9,6 +9,7 @@ import sys import tempfile import shutil import zipfile +from pathlib import Path import pytest sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) @@ -819,3 +820,158 @@ class TestSession: assert len(sess.get_project()["content"]) == 1 sess.undo() assert len(sess.get_project()["content"]) == 0 + + +# ── LibreOffice backend (subprocess-mocked) ──────────────────────── + +from unittest.mock import patch, MagicMock +import subprocess as _subprocess +from cli_anything.libreoffice.utils import lo_backend + + +def _make_completed(returncode=0, stderr="", stdout=""): + """Build a subprocess.CompletedProcess for monkeypatched runs.""" + return _subprocess.CompletedProcess( + args=[], returncode=returncode, stdout=stdout, stderr=stderr, + ) + + +class TestBackend: + def test_conservative_flags_present(self, monkeypatch, tmp_path): + """Direct headless invocation includes the full conservative flag set.""" + monkeypatch.setattr(lo_backend, "find_libreoffice", + lambda: "/Applications/LibreOffice.app/Contents/MacOS/soffice") + monkeypatch.setattr(lo_backend.sys, "platform", "linux") + + input_file = tmp_path / "doc.odt" + input_file.write_bytes(b"fake") + out_dir = tmp_path / "out" + out_dir.mkdir() + + captured = {} + + def fake_run(cmd, **kwargs): + captured["cmd"] = cmd + # Simulate the output file being produced + base = os.path.splitext(os.path.basename(cmd[-1]))[0] + out_path = os.path.join(kwargs.get("env", {}).get("OUTDIR", str(out_dir)), + f"{base}.pdf") + # The real cmd has --outdir as positional args; pluck from cmd + outdir_idx = cmd.index("--outdir") + 1 + out_path = os.path.join(cmd[outdir_idx], f"{base}.pdf") + Path(out_path).write_bytes(b"%PDF-") + return _make_completed(returncode=0) + + monkeypatch.setattr(lo_backend.subprocess, "run", fake_run) + + result = lo_backend.convert(str(input_file), "pdf", output_dir=str(out_dir)) + assert result == str((out_dir / "doc.pdf").resolve()) + cmd = captured["cmd"] + for flag in ("--headless", "--nologo", "--nodefault", + "--norestore", "--nolockcheck", "--nofirststartwizard"): + assert flag in cmd, f"missing flag {flag} in cmd: {cmd}" + + def test_macos_app_bundle_resolution(self): + with patch.object(lo_backend.sys, "platform", "darwin"): + bundle = lo_backend._macos_app_bundle( + "/Applications/LibreOffice.app/Contents/MacOS/soffice") + assert bundle == "/Applications/LibreOffice.app" + + def test_macos_app_bundle_returns_none_on_linux(self): + with patch.object(lo_backend.sys, "platform", "linux"): + assert lo_backend._macos_app_bundle( + "/Applications/LibreOffice.app/Contents/MacOS/soffice") is None + + def test_looks_like_macos_abort_signal(self): + assert lo_backend._looks_like_macos_abort(_make_completed(returncode=-6)) + assert not lo_backend._looks_like_macos_abort(_make_completed(returncode=0)) + + def test_looks_like_macos_abort_stderr_text(self): + assert lo_backend._looks_like_macos_abort( + _make_completed(returncode=1, stderr="something Trace/BPT trap: 5")) + assert lo_backend._looks_like_macos_abort( + _make_completed(returncode=1, stderr="Abort trap: 6")) + assert not lo_backend._looks_like_macos_abort( + _make_completed(returncode=1, stderr="some other failure")) + + def test_macos_fallback_invoked_on_abort(self, monkeypatch, tmp_path): + """On macOS, when direct soffice aborts, LaunchServices is retried.""" + monkeypatch.setattr(lo_backend, "find_libreoffice", + lambda: "/Applications/LibreOffice.app/Contents/MacOS/soffice") + monkeypatch.setattr(lo_backend.sys, "platform", "darwin") + + input_file = tmp_path / "doc.odt" + input_file.write_bytes(b"fake") + out_dir = tmp_path / "out" + out_dir.mkdir() + + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + if len(calls) == 1: + # Direct soffice invocation: simulate SIGABRT + return _make_completed(returncode=-6, stderr="Trace/BPT trap: 5") + # LaunchServices retry: write the output and return success + outdir_idx = cmd.index("--outdir") + 1 + out_path = os.path.join(cmd[outdir_idx], "doc.pdf") + Path(out_path).write_bytes(b"%PDF-") + return _make_completed(returncode=0) + + monkeypatch.setattr(lo_backend.subprocess, "run", fake_run) + + result = lo_backend.convert(str(input_file), "pdf", output_dir=str(out_dir)) + assert result == str((out_dir / "doc.pdf").resolve()) + assert len(calls) == 2 + # Second call must be the LaunchServices `open -W -n -a --args` + assert calls[1][0] == "open" + assert "-W" in calls[1] and "-n" in calls[1] + assert "/Applications/LibreOffice.app" in calls[1] + assert "--args" in calls[1] + + def test_no_fallback_on_linux(self, monkeypatch, tmp_path): + """Linux/Windows: direct soffice failure is surfaced without `open`.""" + monkeypatch.setattr(lo_backend, "find_libreoffice", lambda: "/usr/bin/soffice") + monkeypatch.setattr(lo_backend.sys, "platform", "linux") + + input_file = tmp_path / "doc.odt" + input_file.write_bytes(b"fake") + out_dir = tmp_path / "out" + out_dir.mkdir() + + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + return _make_completed(returncode=1, stderr="some failure") + + monkeypatch.setattr(lo_backend.subprocess, "run", fake_run) + + with pytest.raises(RuntimeError, match="exit 1"): + lo_backend.convert(str(input_file), "pdf", output_dir=str(out_dir)) + assert len(calls) == 1 + assert calls[0][0] == "/usr/bin/soffice" + + def test_macos_double_failure_raises_helpful_error(self, monkeypatch, tmp_path): + """When both direct and LaunchServices paths fail on macOS, the error + mentions the upstream bug + manual workarounds.""" + monkeypatch.setattr(lo_backend, "find_libreoffice", + lambda: "/Applications/LibreOffice.app/Contents/MacOS/soffice") + monkeypatch.setattr(lo_backend.sys, "platform", "darwin") + + input_file = tmp_path / "doc.odt" + input_file.write_bytes(b"fake") + out_dir = tmp_path / "out" + out_dir.mkdir() + + # Both invocations report failure and produce no output file + monkeypatch.setattr(lo_backend.subprocess, "run", + lambda cmd, **kw: _make_completed(returncode=-6, + stderr="Trace/BPT trap: 5")) + + with pytest.raises(RuntimeError) as exc: + lo_backend.convert(str(input_file), "pdf", output_dir=str(out_dir)) + msg = str(exc.value) + assert "bugs.documentfoundation.org/show_bug.cgi?id=169711" in msg + assert "open -W -n -a" in msg + assert "soffice" in msg diff --git a/libreoffice/agent-harness/cli_anything/libreoffice/utils/lo_backend.py b/libreoffice/agent-harness/cli_anything/libreoffice/utils/lo_backend.py index bba2770b8..1ed698faf 100644 --- a/libreoffice/agent-harness/cli_anything/libreoffice/utils/lo_backend.py +++ b/libreoffice/agent-harness/cli_anything/libreoffice/utils/lo_backend.py @@ -13,11 +13,35 @@ Requires: libreoffice (system package) import os import shutil import subprocess +import sys import tempfile from pathlib import Path from typing import Optional +# Conservative flags used for every headless invocation. Each one suppresses an +# interactive UI surface that headless conversions should never need: +# --headless no GUI +# --nologo no startup splash +# --nodefault do not open any default doc/start center +# --norestore do not try to restore a crashed session +# --nolockcheck ignore stale .~lock files left by a prior Office run +# --nofirststartwizard skip first-run setup +# +# These are particularly important on macOS, where stale lock files from prior +# Office runs and the start center can both trigger SIGABRT ("Trace/BPT trap") +# during headless conversion — see upstream bug +# https://bugs.documentfoundation.org/show_bug.cgi?id=169711. +_HEADLESS_FLAGS = ( + "--headless", + "--nologo", + "--nodefault", + "--norestore", + "--nolockcheck", + "--nofirststartwizard", +) + + def find_libreoffice() -> str: """Find the LibreOffice executable. @@ -32,7 +56,6 @@ def find_libreoffice() -> str: return path # 2) Check common installation paths (Windows) - import sys if sys.platform == "win32" or os.name == "nt" or "MSYS" in os.environ.get("MSYSTEM", "") or "msys" in sys.platform or os.path.exists("C:/"): win_candidates = [ os.path.join(os.environ.get("PROGRAMFILES", r"C:\Program Files"), @@ -78,6 +101,93 @@ def get_version() -> str: return f"LibreOffice (path: {lo})" +def _macos_app_bundle(soffice_path: str) -> Optional[str]: + """Return the .app bundle path for a macOS soffice binary, or None. + + Detects layouts like ``/Applications/LibreOffice.app/Contents/MacOS/soffice`` + and returns ``/Applications/LibreOffice.app``. Used to drive the + ``open -W -n -a `` fallback when a direct headless invocation + aborts during conversion. + """ + if sys.platform != "darwin": + return None + p = Path(soffice_path).resolve() + for ancestor in p.parents: + if ancestor.suffix == ".app": + return str(ancestor) + return None + + +def _looks_like_macos_abort(result: subprocess.CompletedProcess) -> bool: + """Detect the macOS SIGABRT pattern reported in issue #221. + + On macOS, soffice's headless conversion sometimes aborts with a negative + return code (signal) or a "Trace/BPT trap" / "Abort trap" stderr message + before producing any output. Treat any of those as a candidate for the + LaunchServices retry path. + """ + if result.returncode < 0: + return True + stderr = (result.stderr or "").lower() + return "trace/bpt trap" in stderr or "abort trap" in stderr + + +def _convert_via_launchservices( + app_bundle: str, + output_format: str, + output_dir: str, + input_path: str, + profile_uri: str, + timeout: int, +) -> subprocess.CompletedProcess: + """Re-attempt a conversion through ``open -W -n -a --args …``. + + LaunchServices spawns the .app bundle through the standard macOS app + activation pathway, which avoids some of the headless-abort scenarios that + direct ``soffice`` invocation can hit. ``open`` does not proxy the child's + stdout/stderr, so callers must verify success by checking for the expected + output file. + """ + cmd = [ + "open", "-W", "-n", "-a", app_bundle, "--args", + *_HEADLESS_FLAGS, + f"-env:UserInstallation={profile_uri}", + "--convert-to", output_format, + "--outdir", output_dir, + input_path, + ] + return subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout, + ) + + +def _macos_conversion_error( + cmd: list[str], + result: subprocess.CompletedProcess, + input_path: str, + output_dir: str, +) -> RuntimeError: + """Build a clear, actionable error for the macOS headless-abort case.""" + return RuntimeError( + "LibreOffice headless conversion failed on macOS even after the " + "LaunchServices fallback. This commonly tracks upstream LibreOffice " + "bug https://bugs.documentfoundation.org/show_bug.cgi?id=169711, " + "where headless conversion can SIGABRT in some macOS environments.\n" + f" Last command: {' '.join(cmd)}\n" + f" Return code: {result.returncode}\n" + f" stderr: {(result.stderr or '').strip()}\n" + "Manual workarounds to try:\n" + f" 1) Re-run with an isolated profile yourself:\n" + f" /Applications/LibreOffice.app/Contents/MacOS/soffice \\\n" + f" -env:UserInstallation=file:///tmp/lo-profile-$$ \\\n" + f" --headless --nologo --nodefault --norestore --nolockcheck \\\n" + f" --convert-to --outdir {output_dir} {input_path}\n" + f" 2) Force the LaunchServices path manually:\n" + f" open -W -n -a 'LibreOffice' --args --headless --convert-to " + f"--outdir {output_dir} {input_path}" + ) + + def convert( input_path: str, output_format: str, @@ -129,9 +239,7 @@ def convert( cmd = [ lo, - "--headless", - "--nologo", - "--nofirststartwizard", + *_HEADLESS_FLAGS, f"-env:UserInstallation={profile_uri}", "--convert-to", output_format, "--outdir", output_dir, @@ -145,6 +253,29 @@ def convert( env=env, ) + base = os.path.splitext(os.path.basename(input_path))[0] + output_path = os.path.join(output_dir, f"{base}.{output_format}") + + # macOS: if the direct invocation aborted or produced no output, + # retry through LaunchServices once. See issue #221. + if (sys.platform == "darwin" + and (result.returncode != 0 + or _looks_like_macos_abort(result) + or not os.path.exists(output_path))): + app_bundle = _macos_app_bundle(lo) + if app_bundle is not None: + fallback = _convert_via_launchservices( + app_bundle, output_format, output_dir, + input_path, profile_uri, timeout, + ) + if os.path.exists(output_path): + return os.path.abspath(output_path) + # LaunchServices also failed — surface a macOS-specific error + # with manual workarounds before the generic path takes over. + raise _macos_conversion_error( + cmd, fallback, input_path, output_dir, + ) + if result.returncode != 0: raise RuntimeError( f"LibreOffice conversion failed (exit {result.returncode}):\n" @@ -152,10 +283,6 @@ def convert( f" stderr: {result.stderr.strip()}" ) - # Determine the output filename - base = os.path.splitext(os.path.basename(input_path))[0] - output_path = os.path.join(output_dir, f"{base}.{output_format}") - if not os.path.exists(output_path): raise RuntimeError( f"LibreOffice conversion produced no output file.\n" diff --git a/libreoffice/agent-harness/setup.py b/libreoffice/agent-harness/setup.py index 1f629c582..ee7503db0 100644 --- a/libreoffice/agent-harness/setup.py +++ b/libreoffice/agent-harness/setup.py @@ -13,7 +13,7 @@ with open("cli_anything/libreoffice/README.md", "r", encoding="utf-8") as fh: setup( name="cli-anything-libreoffice", - version="1.0.0", + version="1.0.1", author="cli-anything contributors", author_email="", description="CLI harness for LibreOffice - import, create, edit, and export ODF/Office documents via LibreOffice headless", diff --git a/registry.json b/registry.json index 6488bb30a..683b17d1d 100644 --- a/registry.json +++ b/registry.json @@ -293,7 +293,7 @@ { "name": "libreoffice", "display_name": "LibreOffice", - "version": "1.0.0", + "version": "1.0.1", "description": "Create and manipulate ODF documents, export to PDF/DOCX/XLSX/PPTX via headless mode", "requires": "libreoffice", "homepage": "https://www.libreoffice.org", @@ -306,6 +306,10 @@ { "name": "CLI-Anything-Team", "url": "https://github.com/HKUDS/CLI-Anything" + }, + { + "name": "Mubashirrrr", + "url": "https://github.com/Mubashirrrr" } ] },