diff --git a/CHANGELOG.md b/CHANGELOG.md index eb63f5fa4..2cfaf3b60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1492,6 +1492,16 @@ This section accumulates work toward the **2.1.0** milestone environment — inheriting the host ssh-agent socket lets sandboxed code sign and authenticate with every key the agent holds — unless a skill explicitly declares it via required-secrets. ([#5145]) +- **artifacts:** Serve XML artifacts as download attachments like HTML and + SVG. `GET /api/threads/{id}/artifacts/{path}` rendered `.xml`, `.xsl`, and + `.rdf` files — and `+xml` types such as `.rss` wherever the host MIME + database maps them — inline in the application origin, so an XML document + with an XHTML-namespaced `' + ACTIVE_ARTIFACT_CASES = [ ("poc.html", ""), ("page.xhtml", 'hello'), ("image.svg", ''), + ("report.xml", XHTML_SCRIPT_XML), + ("transform.xsl", XHTML_SCRIPT_XML), + ("graph.rdf", XHTML_SCRIPT_XML), ] @@ -587,6 +594,79 @@ def test_get_artifact_forces_download_for_active_content_in_skill_archive(tmp_pa assert bytes(response.body) == content.encode("utf-8") +@pytest.mark.parametrize("in_skill_archive", [False, True]) +def test_get_artifact_forces_download_for_any_xml_subtype(tmp_path, monkeypatch, in_skill_archive: bool) -> None: + # Whether .rss guesses to application/rss+xml depends on the host's + # mime.types file, so pin the guess to exercise the +xml rule on both paths. + content = 'alert("xss")' + monkeypatch.setattr(artifacts_router.mimetypes, "guess_type", lambda *_args, **_kwargs: ("application/rss+xml", None)) + if in_skill_archive: + artifact_path = tmp_path / "sample.skill" + with zipfile.ZipFile(artifact_path, "w") as zip_ref: + zip_ref.writestr("feed.rss", content) + path = "mnt/user-data/outputs/sample.skill/feed.rss" + else: + artifact_path = tmp_path / "feed.rss" + artifact_path.write_text(content, encoding="utf-8") + path = "mnt/user-data/outputs/feed.rss" + monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path, user_id=None: artifact_path) + + response = asyncio.run(call_unwrapped(artifacts_router.get_artifact, "thread-1", path, _make_request())) + + assert response.headers.get("content-disposition", "").startswith("attachment;") + + +@pytest.mark.parametrize( + "mime_type", + [ + "text/html", + "application/xhtml+xml", + "image/svg+xml", + "text/xml", + "application/xml", + "text/xsl", + "application/rss+xml", + "application/atom+xml", + "application/xslt+xml", + "TEXT/XML", + ], +) +def test_is_active_content_mime_type_covers_html_and_xml_documents(mime_type: str) -> None: + # Whether .rss or .atom guess to a +xml type depends on the host's + # mime.types file, so the classification is pinned on MIME types directly. + assert artifacts_router._is_active_content_mime_type(mime_type) + + +@pytest.mark.parametrize( + "mime_type", + [None, "text/plain", "text/markdown", "text/csv", "application/json", "application/pdf", "image/png", "application/xml-dtd"], +) +def test_is_active_content_mime_type_keeps_passive_types_inline(mime_type: str | None) -> None: + assert not artifacts_router._is_active_content_mime_type(mime_type) + + +def test_get_artifact_xml_download_supports_bounded_range_requests(tmp_path, monkeypatch) -> None: + # The artifacts panel previews .xml as code through a Range fetch, so + # forcing the attachment disposition must keep the bounded preview. + payload = ('' + "0123456789" * 50_000 + "").encode() + artifact_path = tmp_path / "large.xml" + artifact_path.write_bytes(payload) + monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path, user_id=None: artifact_path) + + app = make_authed_test_app() + app.include_router(artifacts_router.router) + with TestClient(app) as client: + preview = client.get( + "/api/threads/thread-1/artifacts/mnt/user-data/outputs/large.xml", + headers={"Range": "bytes=0-1048575"}, + ) + + assert preview.status_code == 206 + assert preview.content == payload[:1_048_576] + assert preview.headers["content-range"] == f"bytes 0-1048575/{len(payload)}" + assert preview.headers["content-disposition"].startswith("attachment;") + + def test_get_artifact_download_false_does_not_force_attachment(tmp_path, monkeypatch) -> None: artifact_path = tmp_path / "note.txt" artifact_path.write_text("hello", encoding="utf-8") diff --git a/frontend/src/core/artifacts/viewer.ts b/frontend/src/core/artifacts/viewer.ts index 9121abaaf..6fdd54842 100644 --- a/frontend/src/core/artifacts/viewer.ts +++ b/frontend/src/core/artifacts/viewer.ts @@ -34,8 +34,9 @@ export function resolveStoredArtifactLanguage(filepath: string) { * Markdown and tabular files go to the in-app viewer route, which renders it with the same * components as the panel instead of handing the browser a `text/markdown` * response it can only show as raw source. Everything else keeps the raw - * Gateway URL — notably HTML/SVG, which the Gateway deliberately serves as a - * download so active content never executes in the application origin. + * Gateway URL — notably HTML, SVG, and other XML documents, which the Gateway + * deliberately serves as a download so active content never executes in the + * application origin. */ export function resolveArtifactOpenURL({ filepath,