From e010124008a297fa6e087b329bf7847514fe7988 Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Tue, 7 Jul 2026 01:07:43 -0700 Subject: [PATCH] feat(preview): rich preview pane + open_preview tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool results only ever rendered as plain text in the transcript. This adds the model-driven rich-preview lane every comparable surface has, in turnstone's developer-tool idiom: a preview pane that opens BESIDE the conversation, keyboard-operable, sandboxed, never replacing the transcript that spawned it. Backend - New built-in open_preview(target, kind?, title?): resolves an http(s) URL, a file path, or attachment: to bytes; classifies into web/pdf/image/table/text/markdown (magic bytes > MIME hint > extension > UTF-8 fallback, legacy-charset pages transcoded); caps size per kind; persists content-addressed with kind="preview" — refcounted and GC'd with the workstream, skipped by trajectory reconstruction so preview bytes can never materialize onto the wire. URL targets gate like web_fetch (network egress); paths/attachments run unprompted like read_file. - New core.web.fetch_with_ssrf_guard: manual redirect walk that SSRF-screens every hop BEFORE requesting it (follow_redirects=True checked nothing between hops); adopted by both open_preview and web_fetch. URL userinfo is stripped before the descriptor or the stored bytes see it; is injected doctype-safely so relative assets resolve without quirks mode. - The preview descriptor rides the tool turn's meta side channel with ONE shape on every boundary: the live tool_result SSE event, the conversations.meta column, and the /history projection. Cancelled batches commit an already-announced preview (blob + meta) instead of stranding the open pane on a permanent 404. - New GET {ws}/attachments/{id}/preview (read scope, same ownership gate as /content) serves the STORED type with per-MIME hardening: bare CSP sandbox for text/html (renderable, scriptless, opaque origin), no CSP for application/pdf (Chromium's viewer refuses sandboxed contexts), full default-src 'none' otherwise; filenames fold to latin-1-safe ASCII. The console /node proxy now forwards CSP/nosniff/disposition/cache-control instead of dropping them. - History loads exclude preview blobs from the bulk content fetch at the query (they were read and discarded on every load). Frontend - New "preview" pane type registered in the shared shell (server + console): openPaneBeside placement, per-kind renderers — fully sandboxed iframe for pages, browser PDF viewer, sortable tables (CSV/TSV/JSON, ragged-file safe, 5k-row cap), rendered markdown, text — plus back/forward history with arrow keys, reload persistence via pane meta, and backoff auto-retry (0.9s..7.2s) bridging the gap between the live descriptor and the batch fold that commits its blob. - Tool results carrying a descriptor render a credential-redacted preview chip (the reopen + replay affordance); live results auto-open the pane only while the originating pane holds focus. Docs: docs/tools.md + prompts/tools.md. Tests: policy unit tests, tool prepare/exec (mocked fetch), serving route + proxy header pass-through, storage exclusion on both backends, cancel-path commit, JS static guards; a headless-Chrome harness drives the real module graph (32 DOM assertions). --- docs/tools.md | 34 ++ tests/test_cancel.py | 11 +- tests/test_console.py | 54 ++ tests/test_coordinator_tools.py | 9 +- tests/test_open_preview_tool.py | 565 +++++++++++++++++++++ tests/test_preview.py | 234 +++++++++ tests/test_preview_js.py | 129 +++++ tests/test_server_attachments_endpoints.py | 103 ++++ tests/test_session_routes.py | 2 +- tests/test_shell_js.py | 2 + tests/test_storage_attachments.py | 26 + tests/test_storage_sqlite.py | 4 +- tests/test_tools_schema.py | 5 +- turnstone/cli.py | 7 +- turnstone/console/server.py | 20 + turnstone/console/static/index.html | 1 + turnstone/core/history_decoration.py | 6 + turnstone/core/preview.py | 301 +++++++++++ turnstone/core/session.py | 334 +++++++++++- turnstone/core/session_routes.py | 33 ++ turnstone/core/session_ui_base.py | 7 + turnstone/core/storage/_postgresql.py | 20 +- turnstone/core/storage/_protocol.py | 7 +- turnstone/core/storage/_sqlite.py | 20 +- turnstone/core/storage/_utils.py | 22 +- turnstone/core/trajectory.py | 8 + turnstone/core/web.py | 44 +- turnstone/eval/core.py | 1 + turnstone/prompts/tools.md | 8 + turnstone/server.py | 3 +- turnstone/shared_static/conversation.js | 40 ++ turnstone/shared_static/interactive.js | 40 +- turnstone/shared_static/preview.css | 249 +++++++++ turnstone/shared_static/preview.js | 556 ++++++++++++++++++++ turnstone/shared_static/shell.js | 36 ++ turnstone/tools/open_preview.json | 24 + turnstone/ui/static/index.html | 1 + 37 files changed, 2907 insertions(+), 59 deletions(-) create mode 100644 tests/test_open_preview_tool.py create mode 100644 tests/test_preview.py create mode 100644 tests/test_preview_js.py create mode 100644 turnstone/core/preview.py create mode 100644 turnstone/shared_static/preview.css create mode 100644 turnstone/shared_static/preview.js create mode 100644 turnstone/tools/open_preview.json diff --git a/docs/tools.md b/docs/tools.md index ebbf885d..d2149470 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -125,6 +125,9 @@ Each item's `execute` callable is invoked: - `web_fetch` -- fetches a URL (SSRF-protected, but makes network requests) - `web_search` -- web search via self-hosted SearxNG (makes network requests) - `task_agent` -- spawns an autonomous sub-agent +- `open_preview` -- **URL targets only** (network access, gated like `web_fetch`); + file-path and `attachment:` targets are local reads and run unprompted like + `read_file` Note: The JSON schema metadata key `auto_approve` controls membership in `TASK_AUTO_TOOLS` (used for task agent sub-sessions). The actual runtime @@ -157,6 +160,7 @@ Every tool defines a `primary_key`. The mapping is: | `search` | `query` | | `web_fetch` | `url` | | `web_search` | `query` | +| `open_preview` | `target` | | `task_agent` | `prompt` | | `memory` | `name` | | `recall` | `query` | @@ -345,6 +349,35 @@ It reports the score scale, whether the endpoint cleanly separates relevant from --- +### open_preview + +Show the user rich content in a preview pane beside the conversation. + +| Parameter | Type | Required | Description | +|-----------|--------|----------|-------------| +| `target` | string | yes | An http(s) URL, a file path, or `attachment:` for a file attached to the conversation. | +| `kind` | string | no | Rendering override: `web`, `pdf`, `image`, `table`, `text`, or `markdown`. Detected from the content when omitted. | +| `title` | string | no | Pane header title. Defaults to the page title, filename, or URL. | + +- **What it does**: Resolves the target to bytes (URLs fetch through the same + SSRF-guarded path as `web_fetch`, re-checked after redirects), classifies the + content, stores it content-addressed against the workstream, and opens the + frontend preview pane beside the conversation: web pages render in a fully + sandboxed iframe (no scripts, opaque origin), PDFs in the browser viewer, + images inline, CSV/TSV/JSON as a sortable table, text/markdown rendered. The + model receives only a one-line confirmation — to reason about content, use + `web_fetch` / `read_file` instead. Preview content is size-capped per kind + (pages 4 MB, PDFs 32 MB, images 4 MB, tables 2 MB, text 512 KB) and GC'd + with the workstream. +- **Auto-approve**: URL targets require confirmation (network access); file + paths and `attachment:` targets run unprompted (local reads). +- **Agent availability**: interactive sessions only (not `task_agent`, not + coordinators). +- **Surfaces**: the pane renders in the web UI (standalone and console). The + CLI prints the confirmation line only — there is no terminal pane. + +--- + ## Agent The tool name uses the `_agent` suffix — bare `task` collides with @@ -545,6 +578,7 @@ pre-configure skills at workstream creation. | `search` | File Ops | Yes | Yes | `query` | | `web_fetch` | Info | No | Yes | `url` | | `web_search` | Info | No | Yes | `query` | +| `open_preview`| Info | URL: no; path/attachment: yes | No | `target` | | `task_agent` | Agent | No | No | `prompt` | | `memory` | Memory | Yes | No | `name` | | `recall` | Memory | Yes | No | `query` | diff --git a/tests/test_cancel.py b/tests/test_cancel.py index 2893789c..d891bcfd 100644 --- a/tests/test_cancel.py +++ b/tests/test_cancel.py @@ -13,7 +13,7 @@ from turnstone.core.session import ( ChatSession, GenerationCancelled, _CancelRef, - _effect_status_meta, + _tool_turn_meta, ) from turnstone.core.trajectory import ( EffectStatus, @@ -1183,8 +1183,13 @@ class TestEffectStatusPersistence: effect-record appendix — the ledger persists for audit).""" def test_effect_status_meta_envelope(self): - assert _effect_status_meta(None) is None - assert json.loads(_effect_status_meta(EffectStatus.UNKNOWN)) == {"effect_status": "unknown"} + assert _tool_turn_meta(None) is None + assert json.loads(_tool_turn_meta(EffectStatus.UNKNOWN)) == {"effect_status": "unknown"} + assert json.loads(_tool_turn_meta(None, {"kind": "web"})) == {"preview": {"kind": "web"}} + assert json.loads(_tool_turn_meta(EffectStatus.UNKNOWN, {"kind": "web"})) == { + "effect_status": "unknown", + "preview": {"kind": "web"}, + } def test_reconstruct_routes_tool_effect_status(self): from turnstone.core.storage._utils import reconstruct_turns diff --git a/tests/test_console.py b/tests/test_console.py index 20030e5e..65b06eca 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -2690,3 +2690,57 @@ class TestCollectorMCPAggregation: assert overview["mcp_servers"] == 3 assert overview["mcp_resources"] == 10 assert overview["mcp_prompts"] == 7 + + +class TestProxyGetHeaderPassThrough: + """The generic /node/{id} GET proxy must carry the node's hardening + headers through — dropping Content-Security-Policy would serve previewed + attacker HTML from the CONSOLE origin with no CSP sandbox (review + finding, preview-pane branch).""" + + def test_security_headers_forwarded(self, monkeypatch): + import asyncio + from types import SimpleNamespace + from unittest.mock import MagicMock + + import httpx + + from turnstone.console import server as csrv + + upstream = httpx.Response( + 200, + content=b"page", + headers={ + "content-type": "text/html; charset=utf-8", + "content-security-policy": "sandbox", + "x-content-type-options": "nosniff", + "content-disposition": 'inline; filename="p"', + "cache-control": "private, no-store", + "server": "upstream-internal", # hop metadata: must NOT pass + }, + request=httpx.Request("GET", "http://n:1/x"), + ) + + async def _mock_get(*a, **kw): + return upstream + + proxy_client = MagicMock(spec=httpx.AsyncClient) + proxy_client.get = MagicMock(side_effect=_mock_get) + request = SimpleNamespace( + app=SimpleNamespace(state=SimpleNamespace(proxy_client=proxy_client)), + url=SimpleNamespace(query=""), + ) + monkeypatch.setattr(csrv, "_proxy_auth_headers", lambda r: {}) + + resp = asyncio.run(csrv._proxy_get(request, "http://n:1", "v1/api/x")) + + assert resp.status_code == 200 + assert resp.headers["content-security-policy"] == "sandbox" + assert resp.headers["x-content-type-options"] == "nosniff" + assert resp.headers["content-disposition"] == 'inline; filename="p"' + assert resp.headers["cache-control"] == "private, no-store" + assert resp.headers["content-type"].startswith("text/html") + assert ( + "server" not in {k.lower() for k in resp.headers} + or resp.headers.get("server") != "upstream-internal" + ) diff --git a/tests/test_coordinator_tools.py b/tests/test_coordinator_tools.py index 80c4068b..6426c826 100644 --- a/tests/test_coordinator_tools.py +++ b/tests/test_coordinator_tools.py @@ -35,7 +35,14 @@ class _StubUI: def on_error(self, msg: str) -> None: self.errors.append(msg) - def on_tool_result(self, call_id: str, name: str, output: str, is_error: bool = False) -> None: + def on_tool_result( + self, + call_id: str, + name: str, + output: str, + is_error: bool = False, + preview: dict[str, Any] | None = None, + ) -> None: self.tool_results.append((call_id, name, output, is_error)) # Other SessionUI methods — only stubs, not exercised here. diff --git a/tests/test_open_preview_tool.py b/tests/test_open_preview_tool.py new file mode 100644 index 00000000..e77a7711 --- /dev/null +++ b/tests/test_open_preview_tool.py @@ -0,0 +1,565 @@ +"""End-to-end coverage for the ``open_preview`` tool wiring. + +Spans the seams the preview descriptor rides: preparer validation + +approval posture, executor target resolution (mocked ``httpx`` for URLs, +tmp files for paths, monkeypatched storage for attachments), the +``_tool_previews`` side channel + live SSE event, the ``Turn.meta`` +round-trip, the ``/history`` projection, the storage reconstruct routing, +and the auth scope of the serving route. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock + +from turnstone.core.session import ChatSession +from turnstone.core.trajectory import Role, turn_from_dict, turn_to_dict + +PNG_1x1 = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01" + b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\xfc\xcf" + b"\xc0\xc0\xc0\x00\x00\x00\x05\x00\x01\xa5\xf6E@\x00\x00\x00\x00IEND\xaeB`\x82" +) + + +class _RecordingUI: + """SessionUI double that records tool_result calls (kwargs included).""" + + def __init__(self): + self.tool_results = [] + + def __getattr__(self, name): + # Every other SessionUI hook is an inert no-op. + def _noop(*args, **kwargs): + return None + + return _noop + + def on_tool_result(self, call_id, name, output, **kwargs): + self.tool_results.append((call_id, name, output, kwargs)) + + +def _make_session(**kwargs): + defaults = dict( + client=MagicMock(), + model="test-model", + ui=_RecordingUI(), + instructions=None, + temperature=0.5, + max_tokens=4096, + tool_timeout=5, + ) + defaults.update(kwargs) + return ChatSession(**defaults) + + +def _fake_response(url, body, content_type): + import httpx + + resp = SimpleNamespace() + # A real httpx.URL so the executor's userinfo-strip path runs unmocked. + resp.url = httpx.URL(url) + resp.content = body + resp.text = body.decode("utf-8", errors="replace") + resp.headers = {"content-type": content_type} + resp.raise_for_status = lambda: None + return resp + + +# --------------------------------------------------------------------------- +# Preparer +# --------------------------------------------------------------------------- + + +class TestPrepareOpenPreview: + def test_missing_target_errors(self): + s = _make_session() + item = s._prepare_open_preview("c1", {}) + assert item["error"].startswith("Error: missing target") + + def test_invalid_kind_errors(self): + s = _make_session() + item = s._prepare_open_preview("c1", {"target": "a.txt", "kind": "hologram"}) + assert "kind must be one of" in item["error"] + + def test_url_target_needs_approval(self): + s = _make_session() + item = s._prepare_open_preview("c1", {"target": "https://example.com/x"}) + assert item["needs_approval"] is True + assert item["target_kind"] == "url" + assert item["approval_label"] == "open_preview" + assert "error" not in item + + def test_private_url_blocked_pre_approval(self): + s = _make_session() + item = s._prepare_open_preview("c1", {"target": "http://169.254.169.254/meta"}) + assert "error" in item + assert item["needs_approval"] is False + + def test_path_target_runs_unprompted(self): + s = _make_session() + item = s._prepare_open_preview("c1", {"target": "~/notes.md"}) + assert item["needs_approval"] is False + assert item["target_kind"] == "path" + assert not item["path"].startswith("~") + + def test_attachment_target(self): + s = _make_session() + item = s._prepare_open_preview("c1", {"target": "attachment:abc123"}) + assert item["needs_approval"] is False + assert item["target_kind"] == "attachment" + assert item["attachment_id"] == "abc123" + empty = s._prepare_open_preview("c1", {"target": "attachment:"}) + assert "error" in empty + + +# --------------------------------------------------------------------------- +# Executor +# --------------------------------------------------------------------------- + + +class TestExecOpenPreview: + def test_url_html_builds_web_descriptor(self, monkeypatch): + s = _make_session() + body = b"Acme Pricingx" + monkeypatch.setattr( + "turnstone.core.session.fetch_with_ssrf_guard", + lambda url, **kw: _fake_response(url, body, "text/html; charset=utf-8"), + ) + item = s._prepare_open_preview("c1", {"target": "https://acme.com/pricing"}) + call_id, msg = s._exec_open_preview(item) + assert call_id == "c1" + assert "Acme Pricing" in msg + descriptor, att = s._tool_previews["c1"] + assert descriptor["kind"] == "web" + assert descriptor["title"] == "Acme Pricing" + assert descriptor["source"] == "https://acme.com/pricing" + assert descriptor["content_type"].startswith("text/html") + assert att.kind == "preview" + # The stored bytes gained a base for relative-asset resolution. + assert b'' in att.content + # The live event carried the descriptor. + results = s.ui.tool_results + assert results and results[-1][3].get("preview") == descriptor + + def test_url_userinfo_stripped_from_descriptor(self, monkeypatch): + s = _make_session() + body = b"x" + monkeypatch.setattr( + "turnstone.core.session.fetch_with_ssrf_guard", + lambda url, **kw: _fake_response(url, body, "text/html"), + ) + item = s._prepare_open_preview("c1", {"target": "https://user:sekret@acme.com/page"}) + s._exec_open_preview(item) + descriptor, att = s._tool_previews["c1"] + assert "sekret" not in descriptor["source"] + assert "sekret" not in descriptor["title"] + assert b"sekret" not in att.content # the injected + + def test_redirect_into_private_space_blocked(self, monkeypatch): + s = _make_session() + + # The guarded fetch raises BEFORE requesting a private hop — the + # executor's ValueError lane turns that into a tool error. + def _blocked(url, **kw): + raise ValueError("Blocked: URL resolves to private/internal address (169.254.169.254)") + + monkeypatch.setattr("turnstone.core.session.fetch_with_ssrf_guard", _blocked) + item = s._prepare_open_preview("c1", {"target": "https://innocent.example/"}) + _, msg = s._exec_open_preview(item) + assert msg.startswith("Error: fetch failed: Blocked") + assert "c1" not in s._tool_previews + + def test_oversized_web_content_errors(self, monkeypatch): + s = _make_session() + big = b"" + b"x" * (4 * 1024 * 1024 + 16) + b"" + monkeypatch.setattr( + "turnstone.core.session.fetch_with_ssrf_guard", + lambda url, **kw: _fake_response(url, big, "text/html"), + ) + item = s._prepare_open_preview("c1", {"target": "https://example.com/big"}) + _, msg = s._exec_open_preview(item) + assert msg.startswith("Error:") + assert "too large" in msg + + def test_path_image(self, tmp_path): + s = _make_session() + p = tmp_path / "chart.png" + p.write_bytes(PNG_1x1) + item = s._prepare_open_preview("c1", {"target": str(p)}) + _, msg = s._exec_open_preview(item) + assert not msg.startswith("Error:") + descriptor, att = s._tool_previews["c1"] + assert descriptor["kind"] == "image" + assert descriptor["content_type"] == "image/png" + assert descriptor["title"] == "chart.png" + assert att.content == PNG_1x1 + + def test_path_csv_is_table(self, tmp_path): + s = _make_session() + p = tmp_path / "results.csv" + p.write_text("name,score\na,1\nb,2\n") + item = s._prepare_open_preview("c1", {"target": str(p)}) + s._exec_open_preview(item) + descriptor, _ = s._tool_previews["c1"] + assert descriptor["kind"] == "table" + assert descriptor["content_type"].startswith("text/csv") + + def test_path_missing_errors(self): + s = _make_session() + item = s._prepare_open_preview("c1", {"target": "/nonexistent/nowhere.txt"}) + _, msg = s._exec_open_preview(item) + assert msg.startswith("Error: file not found") + + def test_path_binary_unpreviewable(self, tmp_path): + s = _make_session() + p = tmp_path / "blob.bin" + p.write_bytes(b"\x00\x01\x02\x03" * 64) + item = s._prepare_open_preview("c1", {"target": str(p)}) + _, msg = s._exec_open_preview(item) + assert "not previewable" in msg + + def test_attachment_target_requires_ws_reference(self, monkeypatch): + s = _make_session(ws_id="ws-1") + monkeypatch.setattr( + "turnstone.core.memory.get_attachment", + lambda aid: {"content": b"# doc", "mime_type": "text/markdown", "filename": "d.md"}, + ) + monkeypatch.setattr( + "turnstone.core.memory.attachment_referenced_in_ws", + lambda aid, ws: False, + ) + item = s._prepare_open_preview("c1", {"target": "attachment:deadbeef"}) + _, msg = s._exec_open_preview(item) + assert msg.startswith("Error: attachment not found") + + def test_attachment_target_happy_path(self, monkeypatch): + s = _make_session(ws_id="ws-1") + monkeypatch.setattr( + "turnstone.core.memory.get_attachment", + lambda aid: {"content": b"# doc", "mime_type": "text/markdown", "filename": "d.md"}, + ) + monkeypatch.setattr( + "turnstone.core.memory.attachment_referenced_in_ws", + lambda aid, ws: True, + ) + item = s._prepare_open_preview("c1", {"target": "attachment:deadbeef"}) + _, msg = s._exec_open_preview(item) + assert not msg.startswith("Error:") + descriptor, _ = s._tool_previews["c1"] + assert descriptor["kind"] == "markdown" + assert descriptor["title"] == "d.md" + + def test_title_override_wins(self, tmp_path): + s = _make_session() + p = tmp_path / "x.csv" + p.write_text("a,b\n") + item = s._prepare_open_preview("c1", {"target": str(p), "title": "Q3 numbers"}) + s._exec_open_preview(item) + descriptor, _ = s._tool_previews["c1"] + assert descriptor["title"] == "Q3 numbers" + + +# --------------------------------------------------------------------------- +# Trajectory / history / storage seams +# --------------------------------------------------------------------------- + + +class TestDescriptorSeams: + DESCRIPTOR = { + "kind": "web", + "title": "T", + "source": "https://a.io", + "attachment_id": "abc", + "content_type": "text/html; charset=utf-8", + "size": 7, + } + + def test_turn_roundtrip(self): + turn = turn_from_dict( + { + "role": "tool", + "tool_call_id": "c1", + "content": "Preview shown", + "_preview": self.DESCRIPTOR, + } + ) + assert turn.meta.extra["preview"] == self.DESCRIPTOR + out = turn_to_dict(turn) + assert out["_preview"] == self.DESCRIPTOR + + def test_history_projection_carries_preview(self): + from turnstone.core.history_decoration import project_history_messages + + msgs = [ + { + "role": "assistant", + "content": "", + "tool_calls": [ + {"id": "c1", "function": {"name": "open_preview", "arguments": "{}"}} + ], + }, + { + "role": "tool", + "tool_call_id": "c1", + "content": "Preview shown to the user: T (web, 7 bytes)", + "_preview": self.DESCRIPTOR, + }, + ] + history = project_history_messages(msgs) + tool_entries = [h for h in history if h.get("role") == "tool"] + assert tool_entries and tool_entries[0]["preview"] == self.DESCRIPTOR + + def test_reconstruct_routes_tool_preview_meta(self): + import json + + from turnstone.core.storage._utils import reconstruct_turns + + # Row layout per reconstruct_turns' unpack: (row_id, role, content, + # tool_name, tool_call_id, provider_data, tool_calls_json, source, + # event_id, is_error, meta). + row = ( + 1, + "tool", + "ok", + "open_preview", + "c1", + None, + None, + None, + 7, + 0, + json.dumps({"effect_status": "unknown", "preview": self.DESCRIPTOR}), + ) + turns = reconstruct_turns([row], "ws-1", attachments_by_msg={}) + assert turns[0].role is Role.TOOL + assert turns[0].meta.extra["preview"] == self.DESCRIPTOR + assert turns[0].meta.extra["effect_status"] == "unknown" + + def test_reconstruct_skips_preview_blob_refs(self): + """A preview blob on a tool row's ref-list must NOT become a content + block — it is meta-addressed frontend content, and a content block + would be materialized onto the wire on reload.""" + from turnstone.core.storage._utils import reconstruct_turns + + row = ( + 1, + "tool", + "ok", + "open_preview", + "c1", + None, + None, + None, + None, + 0, + None, + ) + atts = { + 1: [ + { + "attachment_id": "abc", + "kind": "preview", + "filename": "preview-web", + "mime_type": "text/html; charset=utf-8", + "size_bytes": 7, + }, + { + "attachment_id": "img1", + "kind": "image", + "filename": "shot.png", + "mime_type": "image/png", + "size_bytes": 9, + }, + ] + } + turns = reconstruct_turns([row], "ws-1", attachments_by_msg=atts) + kinds = [b.kind for b in turns[0].content if b.__class__.__name__ == "AttachmentRef"] + # The vision lane still reconstructs; the preview blob does not. + assert kinds == ["image"] + + def test_preview_route_scope_is_read(self): + from turnstone.core.auth import required_scope + + assert required_scope("GET", "/v1/api/workstreams/ws1/attachments/abc/preview") == "read" + assert ( + required_scope("GET", "/node/n1/v1/api/workstreams/ws1/attachments/abc/preview") + == "read" + ) + + +# --------------------------------------------------------------------------- +# fetch_with_ssrf_guard — per-hop redirect screening (core/web.py) +# --------------------------------------------------------------------------- + + +class _FakeHop: + def __init__(self, status, headers=None, url=""): + self.status_code = status + self.headers = headers or {} + self.url = url + + +class _FakeClient: + """httpx.Client double: serves a scripted {url: response} table.""" + + calls: list[str] = [] + table: dict[str, _FakeHop] = {} + + def __init__(self, **kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def get(self, url): + _FakeClient.calls.append(url) + return _FakeClient.table[url] + + +class TestFetchWithSsrfGuard: + def _wire(self, monkeypatch, table): + _FakeClient.calls = [] + _FakeClient.table = table + monkeypatch.setattr("turnstone.core.web.httpx.Client", _FakeClient) + + def test_follows_public_redirect_chain(self, monkeypatch): + from turnstone.core.web import fetch_with_ssrf_guard + + self._wire( + monkeypatch, + { + "https://a.example/": _FakeHop(302, {"location": "https://b.example/x"}), + "https://b.example/x": _FakeHop(200, {}, url="https://b.example/x"), + }, + ) + monkeypatch.setattr("turnstone.core.web.check_ssrf", lambda url: None) + resp = fetch_with_ssrf_guard("https://a.example/", timeout=5) + assert resp.status_code == 200 + assert _FakeClient.calls == ["https://a.example/", "https://b.example/x"] + + def test_private_hop_blocked_before_request(self, monkeypatch): + import pytest + + from turnstone.core.web import fetch_with_ssrf_guard + + self._wire( + monkeypatch, + { + "https://a.example/": _FakeHop(302, {"location": "http://169.254.169.254/latest"}), + }, + ) + blocked = {"http://169.254.169.254/latest": "Blocked: private"} + monkeypatch.setattr("turnstone.core.web.check_ssrf", lambda url: blocked.get(url)) + with pytest.raises(ValueError, match="Blocked: private"): + fetch_with_ssrf_guard("https://a.example/", timeout=5) + # The load-bearing assertion: the private hop was NEVER requested. + assert _FakeClient.calls == ["https://a.example/"] + + def test_relative_location_resolves_against_current(self, monkeypatch): + from turnstone.core.web import fetch_with_ssrf_guard + + self._wire( + monkeypatch, + { + "https://a.example/start": _FakeHop(301, {"location": "/moved"}), + "https://a.example/moved": _FakeHop(200, {}, url="https://a.example/moved"), + }, + ) + monkeypatch.setattr("turnstone.core.web.check_ssrf", lambda url: None) + resp = fetch_with_ssrf_guard("https://a.example/start", timeout=5) + assert resp.status_code == 200 + + def test_redirect_loop_capped(self, monkeypatch): + import pytest + + from turnstone.core.web import fetch_with_ssrf_guard + + self._wire( + monkeypatch, + {"https://a.example/": _FakeHop(302, {"location": "https://a.example/"})}, + ) + monkeypatch.setattr("turnstone.core.web.check_ssrf", lambda url: None) + with pytest.raises(ValueError, match="redirects"): + fetch_with_ssrf_guard("https://a.example/", timeout=5) + + +# --------------------------------------------------------------------------- +# Cancelled-batch synthesis — a staged preview whose descriptor already +# reached the frontend must commit, not vanish (session.py review fix) +# --------------------------------------------------------------------------- + + +class TestCancelledBatchPreservesPreview: + def test_synthesize_commits_staged_preview(self, monkeypatch): + import json as _json + + from turnstone.core.attachments import Attachment + from turnstone.core.trajectory import Turn + + s = _make_session(ws_id="ws-1") + descriptor = { + "kind": "web", + "title": "T", + "source": "https://a.io", + "attachment_id": "abc", + "content_type": "text/html; charset=utf-8", + "size": 7, + } + att = Attachment( + attachment_id="abc", + filename="preview-web", + mime_type="text/html; charset=utf-8", + kind="preview", + content=b"

x

", + ) + s._tool_previews["c1"] = (descriptor, att) + # Assistant turn with one UNANSWERED call — the cancel shape. + s.messages.append( + turn_from_dict( + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "c1", + "type": "function", + "function": {"name": "open_preview", "arguments": "{}"}, + } + ], + } + ) + ) + s._msg_tokens.append(1) + + saved = {} + monkeypatch.setattr( + "turnstone.core.session.save_message", + lambda ws, role, content, name, **kw: ( + saved.update({"meta": kw.get("meta"), "row": 42}) or 42 + ), + ) + persisted = {} + monkeypatch.setattr( + ChatSession, + "_persist_attachment_refs", + lambda self, row_id, atts, origin="upload": persisted.update( + {"row": row_id, "ids": [a.attachment_id for a in atts], "origin": origin} + ), + ) + + s._synthesize_cancelled_results("Cancelled by user.") + + # Side channel drained; descriptor + blob committed with the turn. + assert "c1" not in s._tool_previews + meta = _json.loads(saved["meta"]) + assert meta["preview"] == descriptor + assert meta["effect_status"] == "unknown" + assert persisted == {"row": 42, "ids": ["abc"], "origin": "tool"} + # The in-memory synthesized turn carries the descriptor too. + tool_turns = [t for t in s.messages if isinstance(t, Turn) and t.role is Role.TOOL] + assert tool_turns and tool_turns[-1].meta.extra.get("preview") == descriptor diff --git a/tests/test_preview.py b/tests/test_preview.py new file mode 100644 index 00000000..bd3eae02 --- /dev/null +++ b/tests/test_preview.py @@ -0,0 +1,234 @@ +"""Unit tests for the preview-content policy module (``turnstone/core/preview.py``). + +Pure-function coverage: kind resolution precedence (magic bytes → MIME hint → +extension → UTF-8 fallback), the explicit ``kind`` override lanes, base-href +injection, title extraction, and the per-MIME serving headers the route +attaches. The tool executor and the HTTP route are covered separately +(``test_open_preview_tool.py`` / ``test_server_attachments_endpoints.py``). +""" + +from __future__ import annotations + +from turnstone.core.attachments import IMAGE_SIZE_CAP, PDF_SIZE_CAP, TEXT_DOC_SIZE_CAP +from turnstone.core.preview import ( + PREVIEW_BLOB_KIND, + PREVIEW_KINDS, + PREVIEW_SERVE_MIMES, + PREVIEW_SIZE_CAPS, + build_preview_descriptor, + inject_base_href, + page_title, + preview_response_headers, + resolve_preview_kind, +) + +PNG_1x1 = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01" + b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\xfc\xcf" + b"\xc0\xc0\xc0\x00\x00\x00\x05\x00\x01\xa5\xf6E@\x00\x00\x00\x00IEND\xaeB`\x82" +) +PDF_MIN = b"%PDF-1.4 fake body" +HTML_DOC = b"Acme Pricinghi" + + +class TestResolvePreviewKind: + def test_magic_bytes_win_over_everything(self): + # A PNG claiming to be CSV by both MIME and extension is an image. + assert resolve_preview_kind("text/csv", "data.csv", PNG_1x1) == ("image", "image/png") + assert resolve_preview_kind("text/plain", "doc.txt", PDF_MIN) == ( + "pdf", + "application/pdf", + ) + + def test_mime_hint_html(self): + kind, mime = resolve_preview_kind("text/html; charset=iso-8859-1", "page", HTML_DOC) + assert kind == "web" + assert mime == "text/html; charset=utf-8" + + def test_mime_hint_families(self): + assert resolve_preview_kind("text/csv", "x", b"a,b\n1,2")[0] == "table" + assert resolve_preview_kind("application/json", "x", b"[]") == ( + "table", + "application/json", + ) + assert resolve_preview_kind("text/markdown", "x", b"# hi")[0] == "markdown" + assert resolve_preview_kind("text/x-log", "x", b"line")[0] == "text" + + def test_extension_fallback_when_no_mime(self): + assert resolve_preview_kind("", "report.html", HTML_DOC)[0] == "web" + assert resolve_preview_kind("", "data.tsv", b"a\tb")[0] == "table" + assert resolve_preview_kind("", "notes.md", b"# t")[0] == "markdown" + # URL tails strip query/fragment before the extension check. + assert resolve_preview_kind("", "https://x.io/a.csv?dl=1#f", b"a,b")[0] == "table" + + def test_utf8_text_fallback(self): + assert resolve_preview_kind("", "LICENSE", b"MIT License") == ( + "text", + "text/plain; charset=utf-8", + ) + + def test_binary_is_not_previewable(self): + assert resolve_preview_kind("", "blob.bin", b"\x00\x01\x02\x03" * 8) is None + # Text-DECLARED binary is misdeclared, not previewable text. + assert resolve_preview_kind("text/plain", "x", b"\x00\xff" * 8) is None + assert resolve_preview_kind("application/octet-stream", "x", b"\x00" * 32) is None + + def test_override_validates_bytes(self): + # image override on non-image bytes fails rather than mislabeling. + assert resolve_preview_kind("", "x", b"not an image", "image") is None + assert resolve_preview_kind("", "x", PNG_1x1, "image") == ("image", "image/png") + assert resolve_preview_kind("", "x", b"not a pdf", "pdf") is None + # Text-family override on binary bytes fails. + assert resolve_preview_kind("", "x", b"\x00\x01", "text") is None + + def test_override_forces_view(self): + # kind='text' on an HTML doc = view source. + assert resolve_preview_kind("text/html", "p.html", HTML_DOC, "text")[0] == "text" + # kind='table' keeps the real payload type for the client parser. + assert resolve_preview_kind("application/json", "d", b"[1]", "table") == ( + "table", + "application/json", + ) + assert resolve_preview_kind("", "d.tsv", b"a\tb", "table") == ( + "table", + "text/tab-separated-values; charset=utf-8", + ) + assert resolve_preview_kind("", "d.txt", b"a,b", "table") == ( + "table", + "text/csv; charset=utf-8", + ) + + def test_unknown_override_rejected(self): + assert resolve_preview_kind("text/plain", "x", b"hi", "hologram") is None + + +class TestHtmlHelpers: + def test_base_href_inserted_after_head(self): + out = inject_base_href("", "https://a.io/p/q") + assert out.startswith('') + + def test_base_href_prepended_without_head(self): + out = inject_base_href("

bare

", "https://a.io/") + assert out.startswith('') + + def test_existing_base_untouched(self): + doc = '' + assert inject_base_href(doc, "https://other/") == doc + + def test_base_href_attribute_escaped(self): + out = inject_base_href("", 'https://a.io/">') + assert "