diff --git a/runtime/tools/public/attachment_local_url_tool.py b/runtime/tools/public/attachment_local_url_tool.py new file mode 100644 index 00000000..b2de9fa9 --- /dev/null +++ b/runtime/tools/public/attachment_local_url_tool.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import re +from typing import Any +from pathlib import Path + +from oclaw.platform.files.attachment_assets import AttachmentAssetStore +from oclaw.runtime.tools.base import ToolSpec + +_ATTACHMENT_ID_RE = re.compile(r"^[a-f0-9]{64}$") + + +def attachment_local_url_tool() -> ToolSpec: + def _handler(args: dict[str, Any]) -> dict[str, Any]: + attachment_id = str(args.get("attachment_id") or "").strip().lower() + if not attachment_id: + return {"ok": False, "error": "attachment_id_required"} + if not _ATTACHMENT_ID_RE.fullmatch(attachment_id): + return {"ok": False, "error": "attachment_id_invalid"} + + store = AttachmentAssetStore() + meta = store.get_meta(attachment_id) + local_path = store.get_local_path(attachment_id) + local_path_text = str(local_path) if local_path else "" + file_url = Path(local_path).resolve().as_uri() if local_path else "" + + return { + "ok": True, + "attachment_id": attachment_id, + "exists": bool(meta or local_path), + # For direct rendering in desktop/electron contexts, prefer file URL first. + "preferred_url": file_url or local_path_text, + "mime": str(getattr(meta, "mime", "") or ""), + "name": str(getattr(meta, "name", "") or ""), + "bytes": int(getattr(meta, "bytes", 0) or 0), + "local_path": local_path_text, + "file_url": file_url, + } + + return ToolSpec( + name="attachment_local_url", + description="Resolve attachment_id to local download URL under this oclaw gateway.", + parameters={ + "type": "object", + "properties": { + "attachment_id": {"type": "string", "description": "Stored attachment id."}, + }, + "required": ["attachment_id"], + "additionalProperties": False, + }, + handler=_handler, + read_only=True, + tags=frozenset({"attachment", "url", "read"}), + risk_level="low", + timeout_s=2.0, + ) + + +__all__ = ["attachment_local_url_tool"] diff --git a/tests/test_attachment_local_url_public_tool.py b/tests/test_attachment_local_url_public_tool.py new file mode 100644 index 00000000..512d1235 --- /dev/null +++ b/tests/test_attachment_local_url_public_tool.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from pathlib import Path + +from oclaw.runtime.tools.public.attachment_local_url_tool import attachment_local_url_tool + + +class _FakeMeta: + mime = "image/png" + name = "cat.png" + bytes = 123 + + +class _FakeStore: + def get_meta(self, attachment_id: str) -> _FakeMeta | None: + if attachment_id.startswith("a"): + return _FakeMeta() + return None + + def get_local_path(self, attachment_id: str) -> Path | None: + if attachment_id.startswith("a"): + return Path("D:/project/chatgpt/oclaw/data/attachments/aa/bb/cat.png") + return None + + +def test_attachment_local_url_rejects_invalid_id() -> None: + spec = attachment_local_url_tool() + out = spec.handler({"attachment_id": "bad-id"}) + assert out.get("ok") is False + assert out.get("error") == "attachment_id_invalid" + + +def test_attachment_local_url_builds_absolute_url(monkeypatch) -> None: + monkeypatch.setattr("oclaw.runtime.tools.public.attachment_local_url_tool.AttachmentAssetStore", _FakeStore) + spec = attachment_local_url_tool() + aid = "a" * 64 + out = spec.handler({"attachment_id": aid}) + assert out.get("ok") is True + assert out.get("exists") is True + assert str(out.get("local_path") or "").endswith("cat.png") + assert str(out.get("file_url") or "").startswith("file:///") + assert out.get("preferred_url") == out.get("file_url") + assert out.get("mime") == "image/png" + + +def test_attachment_local_url_is_visible_in_public_registry() -> None: + from oclaw.runtime.tools.catalog import default_registry + + names = [t.name for t in default_registry(expert="network_ops+memory", specialist="ops").list()] + assert "attachment_local_url" in names