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"
}
]
},