feat(preview): rich preview pane + open_preview tool

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:<id> 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; <base href> 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).
This commit is contained in:
Patrick Buckley
2026-07-07 01:07:43 -07:00
parent 4350248d8f
commit e010124008
37 changed files with 2907 additions and 59 deletions
+34
View File
@@ -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:<id>` 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` |
+8 -3
View File
@@ -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
+54
View File
@@ -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"<html>page</html>",
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"
)
+8 -1
View File
@@ -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.
+565
View File
@@ -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"<html><head><title>Acme Pricing</title></head><body>x</body></html>"
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'<base href="https://acme.com/pricing">' 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"<html><head></head><body>x</body></html>"
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 <base href>
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"<html>" + b"x" * (4 * 1024 * 1024 + 16) + b"</html>"
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"<p>x</p>",
)
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
+234
View File
@@ -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"<html><head><title>Acme Pricing</title></head><body>hi</body></html>"
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("<html><head><meta x></head></html>", "https://a.io/p/q")
assert out.startswith('<html><head><base href="https://a.io/p/q">')
def test_base_href_prepended_without_head(self):
out = inject_base_href("<p>bare</p>", "https://a.io/")
assert out.startswith('<base href="https://a.io/">')
def test_existing_base_untouched(self):
doc = '<head><base href="https://original/"></head>'
assert inject_base_href(doc, "https://other/") == doc
def test_base_href_attribute_escaped(self):
out = inject_base_href("<head></head>", 'https://a.io/"><script>x</script>')
assert "<script>" not in out
assert "&quot;&gt;&lt;script&gt;" in out
def test_page_title_extraction(self):
assert page_title(HTML_DOC.decode()) == "Acme Pricing"
assert page_title("<title>a &amp; b\n c</title>") == "a & b c"
assert page_title("<p>no title</p>") is None
assert page_title("<title></title>") is None
class TestServingPolicy:
def test_html_gets_bare_sandbox_csp(self):
h = preview_response_headers("text/html", "page.html")
assert h["Content-Security-Policy"] == "sandbox"
assert h["X-Content-Type-Options"] == "nosniff"
assert h["Cache-Control"] == "private, no-store"
assert h["Content-Disposition"].startswith("inline;")
def test_pdf_gets_no_csp(self):
h = preview_response_headers("application/pdf", "doc.pdf")
assert "Content-Security-Policy" not in h
assert h["X-Content-Type-Options"] == "nosniff"
def test_other_kinds_keep_full_csp(self):
for mime in ("image/png", "text/csv", "text/plain"):
h = preview_response_headers(mime, "f")
assert h["Content-Security-Policy"] == "default-src 'none'; sandbox"
def test_filename_header_injection_stripped(self):
h = preview_response_headers("text/plain", 'a"\r\nX-Evil: 1')
assert "\r" not in h["Content-Disposition"]
assert "\n" not in h["Content-Disposition"]
assert '"' not in h["Content-Disposition"].split("filename=")[1].strip('"')
def test_serve_allowlist_covers_every_stored_kind(self):
for mime in (
"text/html",
"application/pdf",
"image/png",
"image/webp",
"text/csv",
"text/tab-separated-values",
"application/json",
"text/markdown",
"text/plain",
):
assert mime in PREVIEW_SERVE_MIMES
def test_caps_reuse_attachment_constants(self):
assert PREVIEW_SIZE_CAPS["image"] == IMAGE_SIZE_CAP
assert PREVIEW_SIZE_CAPS["pdf"] == PDF_SIZE_CAP
assert PREVIEW_SIZE_CAPS["text"] == TEXT_DOC_SIZE_CAP
assert set(PREVIEW_SIZE_CAPS) == set(PREVIEW_KINDS)
def test_blob_kind_is_outside_model_vocabulary(self):
assert PREVIEW_BLOB_KIND not in ("image", "text", "pdf", "audio")
def test_descriptor_shape(self):
d = build_preview_descriptor(
kind="web",
title="T",
source="https://a.io",
attachment_id="abc",
content_type="text/html; charset=utf-8",
size=7,
)
assert d == {
"kind": "web",
"title": "T",
"source": "https://a.io",
"attachment_id": "abc",
"content_type": "text/html; charset=utf-8",
"size": 7,
}
class TestReviewHardening:
"""Pins for the review-round fixes (2026-07-07)."""
def test_filename_folds_to_latin1_safe_ascii(self):
# Starlette encodes header values latin-1; em dashes / CJK titles
# must fold, not 500 the serving route.
h = preview_response_headers("text/html", "Docs — v1.7 日本語.html")
h["Content-Disposition"].encode("latin-1") # must not raise
h2 = preview_response_headers("text/plain", "——")
h2["Content-Disposition"].encode("latin-1")
assert (
'filename="preview"' in h2["Content-Disposition"]
or "filename=" in h2["Content-Disposition"]
)
def test_base_href_never_precedes_doctype(self):
doc = "<!DOCTYPE html><body>no head</body>"
out = inject_base_href(doc, "https://a.io/")
assert out.startswith("<!DOCTYPE html>")
assert '<base href="https://a.io/">' in out
# <html> without <head> also keeps document order.
doc2 = "<!doctype html><html lang=en><body>x</body></html>"
out2 = inject_base_href(doc2, "https://a.io/")
assert out2.startswith("<!doctype html><html lang=en>")
assert out2.index("<base") > out2.index("<html")
def test_legacy_charset_web_pages_stay_previewable(self):
# windows-1252 / iso-8859-1 bytes are not UTF-8; web kind must not
# reject them (the executor transcodes at store time).
latin1_html = "<html><body>café</body></html>".encode("latin-1")
assert resolve_preview_kind("text/html; charset=iso-8859-1", "p", latin1_html) == (
"web",
"text/html; charset=utf-8",
)
# Extension lane and explicit override agree.
assert resolve_preview_kind("", "page.html", latin1_html)[0] == "web"
assert resolve_preview_kind("", "page.bin", latin1_html, "web")[0] == "web"
# Non-web text kinds stay strict.
assert resolve_preview_kind("text/csv", "d.csv", latin1_html) is None
+129
View File
@@ -0,0 +1,129 @@
"""Static guards for the preview pane frontend (shared_static/preview.js and
its wiring through conversation.js / interactive.js / shell.js).
Same posture as ``test_shell_js.py``: Python-side string-presence assertions
that catch the silent one-line regression (a renamed export, a dropped
sandbox attribute, a de-registered pane type). Parse + sink + var guards for
``preview.js`` itself live in ``test_shell_js.py``'s bundle sweeps.
"""
from __future__ import annotations
from pathlib import Path
_ROOT = Path(__file__).resolve().parent.parent
_SHARED = _ROOT / "turnstone/shared_static"
_PREVIEW_JS = _SHARED / "preview.js"
_CONVERSATION_JS = _SHARED / "conversation.js"
_INTERACTIVE_JS = _SHARED / "interactive.js"
_SHELL_JS = _SHARED / "shell.js"
_PREVIEW_CSS = _SHARED / "preview.css"
_UI_INDEX = _ROOT / "turnstone/ui/static/index.html"
_CONSOLE_INDEX = _ROOT / "turnstone/console/static/index.html"
def _read(p: Path) -> str:
return p.read_text(encoding="utf-8")
class TestPreviewPaneModule:
def test_factory_exported(self) -> None:
assert "export function createPreviewPane" in _read(_PREVIEW_JS)
def test_web_iframe_is_fully_sandboxed(self) -> None:
"""The web renderer must keep the empty-sandbox attribute — every
capability (scripts, same-origin, forms, popups) stays off. Dropping
or loosening it turns fetched pages into live documents."""
body = _read(_PREVIEW_JS)
assert 'frame.setAttribute("sandbox", "")' in body
assert 'frame.setAttribute("referrerpolicy", "no-referrer")' in body
def test_pdf_iframe_is_not_sandboxed(self) -> None:
"""Deliberate asymmetry: Chromium's PDF viewer refuses to paint in a
sandboxed context. The renderer comment carries the rationale; this
pins that renderPdf never gained a sandbox attribute by copy-paste."""
body = _read(_PREVIEW_JS)
pdf_fn = body.split("const renderPdf")[1].split("const renderImage")[0]
assert "sandbox" not in pdf_fn or "No sandbox attribute" in pdf_fn
def test_content_loads_through_authfetch_preflight(self) -> None:
"""src-loaded kinds preflight with authFetch HEAD (surfaces the
persist race + auth failures as a typed error card, and rides the
401-refresh retry that a bare iframe/img src can't)."""
body = _read(_PREVIEW_JS)
assert 'authFetch(url, { method: "HEAD" })' in body
def test_markdown_uses_the_sanctioned_html_lane(self) -> None:
body = _read(_PREVIEW_JS)
assert "setSafeHtml(doc, renderMarkdown(text))" in body
def test_history_is_bounded(self) -> None:
assert "HISTORY_CAP" in _read(_PREVIEW_JS)
def test_table_renderer_caps_rows(self) -> None:
assert "TABLE_ROW_CAP" in _read(_PREVIEW_JS)
def test_url_builder_encodes_path_parts(self) -> None:
body = _read(_PREVIEW_JS)
assert "encodeURIComponent(ws)" in body
assert 'encodeURIComponent(descriptor.attachment_id || "")' in body
class TestTranscriptChip:
def test_chip_builder_exported(self) -> None:
assert "export function buildPreviewChip" in _read(_CONVERSATION_JS)
def test_live_path_gates_auto_open_on_focus(self) -> None:
"""A backgrounded session must not commandeer the split — the live
path auto-opens only while the originating pane is focused; the chip
is the deliberate reopen everywhere else."""
body = _read(_INTERACTIVE_JS)
assert "if (this._host.isFocused(this)) this._host.onPreview(preview);" in body
def test_replay_path_renders_chip_without_auto_open(self) -> None:
body = _read(_INTERACTIVE_JS)
# The replay branch builds the chip…
assert "buildPreviewChip(msg.preview" in body
# …and the auto-open call appears exactly once (the live path).
assert body.count("this._host.onPreview(preview)") == 1
def test_tool_result_event_passes_preview(self) -> None:
assert "evt.preview," in _read(_INTERACTIVE_JS)
def test_host_bridge_carries_transport_ctx(self) -> None:
"""The preview pane fetches blobs from the ORIGINATING workstream
through the same node proxy the bridge must pass both base and
wsId, not just the descriptor."""
body = _read(_INTERACTIVE_JS)
assert "window.TS_SHELL.openPreview(descriptor, { base: base, wsId: wsId })" in body
class TestShellWiring:
def test_pane_type_registered(self) -> None:
body = _read(_SHELL_JS)
assert 'pm.registerType("preview"' in body
assert "createPreviewPane" in body
def test_opens_beside_the_conversation(self) -> None:
"""openPaneBeside is the load-bearing gesture — the preview coexists
with the conversation that spawned it instead of replacing it."""
body = _read(_SHELL_JS)
assert 'pm.openPaneBeside("preview")' in body
def test_seam_exported_on_ts_shell(self) -> None:
assert "openPreview," in _read(_SHELL_JS)
class TestStylesheets:
def test_both_surfaces_link_preview_css(self) -> None:
for page in (_UI_INDEX, _CONSOLE_INDEX):
assert "/shared/preview.css" in _read(page), page.name
def test_stylesheet_uses_ds_tokens_not_legacy_vars(self) -> None:
"""conv-* card rule: DS tokens only — chat.css legacy vars
(--green/--red/--fg) must not creep into the new sheet."""
body = _read(_PREVIEW_CSS)
assert "var(--ink-" in body
assert "var(--hair)" in body
for legacy in ("var(--green)", "var(--red)", "var(--fg)"):
assert legacy not in body
+103
View File
@@ -1032,3 +1032,106 @@ class TestTextToSpeech:
body = resp.json()
assert body["error"] == "Speech synthesis backend failed"
assert "internal-host" not in body["error"]
# ---------------------------------------------------------------------------
# GET /preview — the renderable serving route (preview pane)
# ---------------------------------------------------------------------------
def _seed_committed(ws_id: str, kind: str, mime: str, body: bytes, filename: str) -> str:
"""Commit a blob the way the open_preview fold does: content-addressed
save + a tool row whose ref-list names it (the serving ownership gate)."""
import hashlib
from turnstone.core.memory import save_attachment, save_message, set_message_attachments
aid = hashlib.sha256(body).hexdigest()
save_attachment(aid, filename, mime, len(body), kind, body, "tool")
row_id = save_message(ws_id, "tool", "Preview shown", "open_preview", tool_call_id="c1")
assert row_id is not None
set_message_attachments(ws_id, row_id, [aid])
return aid
class TestGetPreview:
def test_html_served_renderable_with_bare_sandbox_csp(self, app_client):
client, _ = app_client
body = b'<html><head><base href="https://acme.com/"></head><body>x</body></html>'
aid = _seed_committed("ws-A", "preview", "text/html; charset=utf-8", body, "preview-web")
resp = client.get(
f"/v1/api/workstreams/ws-A/attachments/{aid}/preview",
headers=_auth("userA"),
)
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/html")
assert resp.content == body
# Renderable but locked down: bare sandbox (no default-src 'none' —
# the page's own subresources must load), nosniff, inline, no-store.
assert resp.headers.get("content-security-policy") == "sandbox"
assert resp.headers.get("x-content-type-options") == "nosniff"
assert resp.headers.get("content-disposition", "").startswith("inline;")
assert resp.headers.get("cache-control") == "private, no-store"
def test_pdf_served_without_csp(self, app_client):
client, _ = app_client
aid = _seed_committed("ws-A", "preview", "application/pdf", b"%PDF-1.4 x", "d.pdf")
resp = client.get(
f"/v1/api/workstreams/ws-A/attachments/{aid}/preview",
headers=_auth("userA"),
)
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("application/pdf")
# Chromium's viewer refuses sandboxed contexts — the route omits CSP.
assert "content-security-policy" not in resp.headers
def test_image_keeps_full_csp(self, app_client):
client, _ = app_client
aid = _seed_committed("ws-A", "preview", "image/png", PNG_1x1, "chart.png")
resp = client.get(
f"/v1/api/workstreams/ws-A/attachments/{aid}/preview",
headers=_auth("userA"),
)
assert resp.status_code == 200
assert "default-src 'none'" in resp.headers.get("content-security-policy", "")
def test_non_renderable_mime_415(self, app_client):
client, _ = app_client
aid = _seed_committed("ws-A", "audio", "audio/wav", WAV_12, "a.wav")
resp = client.get(
f"/v1/api/workstreams/ws-A/attachments/{aid}/preview",
headers=_auth("userA"),
)
assert resp.status_code == 415
def test_uploaded_attachment_also_previews(self, app_client):
# An UPLOADED image (committed via the normal user lane) renders
# through /preview too — the pane serves attachment: targets.
client, _ = app_client
aid = _seed_committed("ws-A", "image", "image/png", PNG_1x1, "up.png")
resp = client.get(
f"/v1/api/workstreams/ws-A/attachments/{aid}/preview",
headers=_auth("userA"),
)
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("image/png")
def test_unreferenced_id_404(self, app_client):
client, _ = app_client
aid = _seed_committed("ws-A", "preview", "text/html", b"<p>x</p>", "p")
resp = client.get(
f"/v1/api/workstreams/ws-B/attachments/{aid}/preview",
headers=_auth("userB"),
)
assert resp.status_code == 404
def test_head_preflight_supported(self, app_client):
# The pane preflights src-loaded kinds with HEAD (the persist race);
# Starlette derives HEAD from the GET route.
client, _ = app_client
aid = _seed_committed("ws-A", "preview", "text/html", b"<p>x</p>", "p")
resp = client.head(
f"/v1/api/workstreams/ws-A/attachments/{aid}/preview",
headers=_auth("userA"),
)
assert resp.status_code == 200
+1 -1
View File
@@ -37,7 +37,7 @@ async def _stub(_request: Request) -> JSONResponse:
def _attach() -> AttachmentHandlers:
return AttachmentHandlers(
upload=_stub, list=_stub, get_content=_stub, thumbnail=_stub, delete=_stub
upload=_stub, list=_stub, get_content=_stub, thumbnail=_stub, preview=_stub, delete=_stub
)
+2
View File
@@ -49,6 +49,7 @@ _ESM_BUNDLES = [
_SHARED / "composer_queue.js",
_SHARED / "interactive.js",
_SHARED / "conversation.js",
_SHARED / "preview.js",
_SHARED / "redact_credentials.js",
]
@@ -69,6 +70,7 @@ _ESM_NO_VAR_BUNDLES = [
_SHARED / "auth.js",
_SHARED / "interactive.js",
_SHARED / "conversation.js",
_SHARED / "preview.js",
_SHARED / "redact_credentials.js",
]
+26
View File
@@ -404,3 +404,29 @@ class TestParametrizedKind:
assert len(rows) == 1
assert rows[0]["content"] == payload
assert rows[0]["kind"] == kind
class TestGetAttachmentsExcludeKinds:
def test_exclude_kinds_filters_at_the_query(self, backend):
"""Preview-pane blobs ride ref-lists only for GC + the serving gate;
the reconstruct loader excludes them so a history load never pulls
their multi-MB content just to discard it."""
backend.register_workstream("ws-ex")
blob = _hash(b"<html>big page</html>")
img = _hash(PNG_1x1)
backend.save_attachment(
blob,
"preview-web",
"text/html; charset=utf-8",
21,
"preview",
b"<html>big page</html>",
"tool",
)
backend.save_attachment(
img, "shot.png", "image/png", len(PNG_1x1), "image", PNG_1x1, "tool"
)
rows = backend.get_attachments([blob, img], exclude_kinds=("preview",))
assert [r["attachment_id"] for r in rows] == [img]
# Default stays unfiltered — the serving route still resolves previews.
assert {r["attachment_id"] for r in backend.get_attachments([blob, img])} == {blob, img}
+2 -2
View File
@@ -298,9 +298,9 @@ class TestLoadMessagesLimit:
captured: list[list[str]] = []
orig = backend.get_attachments
def _spy(ids):
def _spy(ids, exclude_kinds=()):
captured.append(sorted(ids))
return orig(ids)
return orig(ids, exclude_kinds=exclude_kinds)
# Tail-N=5 fetches only the 5 newest rows (all plain) — the
# attachment row is excluded, so NO blob fetch is issued.
+3 -2
View File
@@ -60,8 +60,8 @@ class TestToolsMetadata:
"""Validate the metadata extracted from JSON files."""
def test_tool_count(self):
# 16 interactive tools + 12 coordinator-only tools.
assert len(TOOLS) == 28
# 17 interactive tools + 12 coordinator-only tools.
assert len(TOOLS) == 29
def test_task_agent_tools_count(self):
assert len(TASK_AGENT_TOOLS) == 11
@@ -126,6 +126,7 @@ class TestToolsMetadata:
"edit_file": "old_string",
"web_fetch": "url",
"web_search": "query",
"open_preview": "target",
"task_agent": "prompt",
"memory": "name",
"recall": "query",
+6 -1
View File
@@ -277,7 +277,11 @@ class TerminalUI(SessionUI):
output: str,
*,
is_error: bool = False,
preview: dict[str, Any] | None = None,
) -> None:
# ``preview`` renders nowhere in a terminal -- the result line already
# names what was shown, and the header carries the target for the
# operator to open themselves.
if is_error:
with self._print_lock:
sys.stderr.write(f"{RED}\u2717 {name}: {output}{RESET}\n")
@@ -479,9 +483,10 @@ class WorkstreamTerminalUI(TerminalUI):
output: str,
*,
is_error: bool = False,
preview: dict[str, Any] | None = None,
) -> None:
if self.is_foreground:
super().on_tool_result(call_id, name, output, is_error=is_error)
super().on_tool_result(call_id, name, output, is_error=is_error, preview=preview)
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None:
if self.is_foreground:
+20
View File
@@ -3154,6 +3154,25 @@ async def proxy_non_api(request: Request) -> Response:
return await _proxy_get(request, server_url, path)
# Upstream response headers the generic proxy must carry through. These are
# the node's hardening + disposition headers: dropping Content-Security-Policy
# would serve previewed attacker HTML from the CONSOLE origin with no CSP
# sandbox — opened top-level, its scripts would run with the operator's
# console cookies, where the same bytes on the node origin are inert. The
# rendezvous attachment proxy (route_attachment_proxy) already preserves
# these; the /node/{id} lane must match.
_PROXY_PASS_HEADERS = (
"content-security-policy",
"x-content-type-options",
"content-disposition",
"cache-control",
)
def _proxy_pass_headers(resp: httpx.Response) -> dict[str, str]:
return {h: resp.headers[h] for h in _PROXY_PASS_HEADERS if h in resp.headers}
async def _proxy_get(request: Request, server_url: str, path: str) -> Response:
"""Forward a GET request to the target server."""
client: httpx.AsyncClient = request.app.state.proxy_client
@@ -3166,6 +3185,7 @@ async def _proxy_get(request: Request, server_url: str, path: str) -> Response:
content=resp.content,
status_code=resp.status_code,
media_type=resp.headers.get("content-type", "application/json"),
headers=_proxy_pass_headers(resp),
)
except httpx.HTTPError as exc:
log.debug("Proxy GET error for %s: %s", target, exc)
+1
View File
@@ -25,6 +25,7 @@
<link rel="stylesheet" href="/static/coordinator/coordinator.css" />
<link rel="stylesheet" href="/static/coordinator/coord-chrome.css" />
<link rel="stylesheet" href="/shared/interactive.css" />
<link rel="stylesheet" href="/shared/preview.css" />
<link rel="stylesheet" href="/shared/hatch.css" />
</head>
<body>
+6
View File
@@ -630,6 +630,12 @@ def project_history_messages(
result_call_id = msg.get("tool_call_id")
if result_call_id:
entry["tool_call_id"] = str(result_call_id)
# Preview-pane descriptor → top-level ``preview``, mirroring the
# live ``tool_result`` SSE event's field so replay renders the
# same reopen chip the live path did.
preview = msg.get("_preview")
if isinstance(preview, dict) and preview:
entry["preview"] = preview
if isinstance(content, list):
# Renderers require a string (``replayHistory`` calls
# ``stripAnsi(content).trim()``; coord joins text parts), so
+301
View File
@@ -0,0 +1,301 @@
"""Preview-content policy for the ``open_preview`` tool.
The preview pane renders tool-selected content a fetched web page, a PDF, an
image, a data table, a text/markdown document in a dedicated frontend pane
beside the conversation. This module owns the pure policy so the session
executor and the serving route share one definition: the content-kind
vocabulary, how bytes + hints resolve to a kind, the per-kind size caps, the
serving MIME allowlist + response headers, and the small HTML mutations
(base-href injection, title extraction) applied to fetched pages at store
time.
Preview blobs are persisted content-addressed with attachment kind
``PREVIEW_BLOB_KIND``. That kind is deliberately outside the model-visible
attachment vocabulary (image / text / pdf / audio): trajectory reconstruction
skips it, so a preview blob can never be lifted into a turn's content and
materialized onto the wire the tool turn's ``meta.extra["preview"]``
descriptor is the only carrier, and it is frontend-facing only.
"""
from __future__ import annotations
import html
import re
from typing import Any
from turnstone.core.attachments import (
ALLOWED_IMAGE_MIMES,
IMAGE_SIZE_CAP,
PDF_SIZE_CAP,
TEXT_DOC_SIZE_CAP,
sniff_image_mime,
sniff_pdf_mime,
)
# Rendered-content kinds the pane knows how to display. ``web`` is a fetched
# HTML document (sandboxed iframe); ``table`` is CSV/TSV/JSON parsed and
# rendered client-side; the rest map 1:1 onto native browser rendering.
PREVIEW_KINDS: frozenset[str] = frozenset({"web", "pdf", "image", "table", "text", "markdown"})
# Storage ``kind`` for preview blobs — see the module docstring for why this
# is not one of the model-visible attachment kinds.
PREVIEW_BLOB_KIND = "preview"
# Per-kind byte caps on the STORED preview content. image/pdf/text reuse the
# attachment-subsystem caps so a previewable file and an uploadable file agree
# on "too big". Fetched pages get their own cap (real-world pages fit well
# under it; over-cap pages error rather than truncate — a mid-tag cut renders
# garbage). Tables get headroom over plain text: a few-MB CSV is a normal
# artifact of "bash produced data", and the client-side renderer row-caps.
PREVIEW_SIZE_CAPS: dict[str, int] = {
"web": 4 * 1024 * 1024,
"pdf": PDF_SIZE_CAP,
"image": IMAGE_SIZE_CAP,
"table": 2 * 1024 * 1024,
"text": TEXT_DOC_SIZE_CAP,
"markdown": TEXT_DOC_SIZE_CAP,
}
# MIME types the preview route will serve with a renderable Content-Type.
# Everything stored by ``open_preview`` lands in this set; the route still
# allowlists defensively so a non-preview blob addressed by id serves nothing
# renderable. Parameterized types (``text/html; charset=utf-8``) match on the
# bare type.
PREVIEW_SERVE_MIMES: frozenset[str] = frozenset(
{
"text/html",
"application/pdf",
"text/plain",
"text/csv",
"text/tab-separated-values",
"application/json",
"text/markdown",
}
| set(ALLOWED_IMAGE_MIMES)
)
# Extension → (kind, stored mime). Consulted after magic bytes and the
# transport MIME hint; keys are lowercase with the dot.
_EXT_KINDS: dict[str, tuple[str, str]] = {
".html": ("web", "text/html; charset=utf-8"),
".htm": ("web", "text/html; charset=utf-8"),
".pdf": ("pdf", "application/pdf"),
".csv": ("table", "text/csv; charset=utf-8"),
".tsv": ("table", "text/tab-separated-values; charset=utf-8"),
".json": ("table", "application/json"),
".md": ("markdown", "text/markdown; charset=utf-8"),
".markdown": ("markdown", "text/markdown; charset=utf-8"),
}
# Stored mime per kind when the kind is chosen first (explicit ``kind`` arg or
# a MIME-hint match): the inverse of ``_EXT_KINDS`` plus the text fallback.
_KIND_MIMES: dict[str, str] = {
"web": "text/html; charset=utf-8",
"pdf": "application/pdf",
"table": "text/csv; charset=utf-8",
"text": "text/plain; charset=utf-8",
"markdown": "text/markdown; charset=utf-8",
}
def _is_utf8_text(data: bytes) -> bool:
"""True when *data* decodes as UTF-8 and carries no NUL (binary tell)."""
if b"\x00" in data:
return False
try:
data.decode("utf-8")
except UnicodeDecodeError:
return False
return True
def _kind_from_mime(mime: str) -> tuple[str, str] | None:
"""Map a transport MIME hint to ``(kind, stored_mime)``, or ``None``."""
bare = mime.split(";", 1)[0].strip().lower()
if not bare:
return None
if "html" in bare:
return "web", _KIND_MIMES["web"]
if bare == "application/pdf":
return "pdf", _KIND_MIMES["pdf"]
if bare in ALLOWED_IMAGE_MIMES:
return "image", bare
if bare == "text/csv":
return "table", "text/csv; charset=utf-8"
if bare == "text/tab-separated-values":
return "table", "text/tab-separated-values; charset=utf-8"
if bare in ("application/json", "text/json"):
return "table", "application/json"
if bare == "text/markdown":
return "markdown", _KIND_MIMES["markdown"]
if bare.startswith("text/"):
return "text", _KIND_MIMES["text"]
return None
def resolve_preview_kind(
mime_hint: str,
name_hint: str,
body: bytes,
kind_override: str | None = None,
) -> tuple[str, str] | None:
"""Resolve ``(kind, stored_mime)`` for *body*, or ``None`` if unpreviewable.
Precedence: explicit *kind_override* (the model's ``kind`` argument) →
magic bytes (image / pdf never extension-trusted, mirroring the upload
classifier) transport MIME hint filename/URL extension UTF-8 text
fallback. A binary body that matches nothing is not previewable.
"""
if kind_override:
if kind_override not in PREVIEW_KINDS:
return None
if kind_override == "image":
sniffed_image = sniff_image_mime(body)
return ("image", sniffed_image) if sniffed_image else None
if kind_override == "pdf":
return ("pdf", "application/pdf") if sniff_pdf_mime(body) else None
# Text-family overrides (table / text / markdown) require text; web
# is transcoded at store time (see the mime lane below) so a legacy
# charset page can still be forced to render.
if kind_override != "web" and not _is_utf8_text(body):
return None
if kind_override == "table":
# Preserve a JSON payload's real type so the client parser branches.
bare = mime_hint.split(";", 1)[0].strip().lower()
ext = _name_ext(name_hint)
if bare in ("application/json", "text/json") or ext == ".json":
return "table", "application/json"
if bare == "text/tab-separated-values" or ext == ".tsv":
return "table", "text/tab-separated-values; charset=utf-8"
return "table", _KIND_MIMES["table"]
return kind_override, _KIND_MIMES[kind_override]
sniffed = sniff_image_mime(body)
if sniffed:
return "image", sniffed
if sniff_pdf_mime(body):
return "pdf", "application/pdf"
from_mime = _kind_from_mime(mime_hint)
if from_mime:
# Text-declared bytes that aren't text are misdeclared — reject rather
# than serve binary under a text MIME. ``web`` is exempt: legacy
# charsets (windows-1252 / Shift-JIS pages) are not UTF-8 on the raw
# bytes, and the executor transcodes web content to UTF-8 at store
# time (charset-aware for fetches, replacement-decoded otherwise).
if from_mime[0] in ("table", "text", "markdown") and not _is_utf8_text(body):
return None
return from_mime
ext_match = _EXT_KINDS.get(_name_ext(name_hint))
if ext_match:
if ext_match[0] != "web" and not _is_utf8_text(body):
return None
return ext_match
if _is_utf8_text(body):
return "text", _KIND_MIMES["text"]
return None
def _name_ext(name: str) -> str:
"""Lowercase extension of a path / URL tail (query and fragment stripped)."""
tail = name.rsplit("/", 1)[-1].split("?", 1)[0].split("#", 1)[0]
dot = tail.rfind(".")
return tail[dot:].lower() if dot >= 0 else ""
# ``<base>`` / ``<head>`` / ``<html>`` / doctype openers in the first slice of
# the document — enough for any real page; scanning megabytes for a head that
# must appear early is wasted work.
_HEAD_SCAN_LIMIT = 65536
_BASE_TAG_RE = re.compile(r"<base[\s>/]", re.IGNORECASE)
_HEAD_OPEN_RE = re.compile(r"<head(?:\s[^>]*)?>", re.IGNORECASE)
_HTML_OPEN_RE = re.compile(r"<html(?:\s[^>]*)?>", re.IGNORECASE)
_DOCTYPE_RE = re.compile(r"<!doctype[^>]*>", re.IGNORECASE)
def inject_base_href(html_text: str, base_url: str) -> str:
"""Give a fetched page a ``<base href>`` so relative assets resolve.
The stored bytes are what the fetch saw; without a base, every relative
``src``/``href`` inside the sandboxed iframe would resolve against the
turnstone origin and 404. A page that declares its own ``<base>`` is left
alone. Insertion goes right after the ``<head>`` opener when present,
else after ``<html>`` / the doctype the parser hoists the tag into the
implied head from there. Never ahead of the doctype: markup before
``<!doctype`` voids it and drops the whole preview into quirks mode.
"""
head_slice = html_text[:_HEAD_SCAN_LIMIT]
if _BASE_TAG_RE.search(head_slice):
return html_text
tag = f'<base href="{html.escape(base_url, quote=True)}">'
m = _HEAD_OPEN_RE.search(head_slice) or _HTML_OPEN_RE.search(head_slice)
if not m:
m = _DOCTYPE_RE.search(head_slice)
if m:
return html_text[: m.end()] + tag + html_text[m.end() :]
return tag + html_text
_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
def page_title(html_text: str) -> str | None:
"""The document's ``<title>`` text (unescaped, whitespace-collapsed), or None."""
m = _TITLE_RE.search(html_text[:_HEAD_SCAN_LIMIT])
if not m:
return None
title = " ".join(html.unescape(m.group(1)).split())
return title[:200] or None
def build_preview_descriptor(
*,
kind: str,
title: str,
source: str,
attachment_id: str,
content_type: str,
size: int,
) -> dict[str, Any]:
"""The structured descriptor that rides the tool turn's meta to the frontend.
One shape on every boundary the live ``tool_result`` SSE event, the
persisted ``conversations.meta`` column, and the ``/history`` projection
so the pane renders identically live and on replay.
"""
return {
"kind": kind,
"title": title,
"source": source,
"attachment_id": attachment_id,
"content_type": content_type,
"size": size,
}
def preview_response_headers(bare_mime: str, filename: str) -> dict[str, str]:
"""Response headers for the preview serving route, per rendered MIME.
``text/html`` gets ``Content-Security-Policy: sandbox`` the document
renders (its subresources load) but scripts never run and its origin is
opaque, so it can't touch the app origin's cookies or DOM; the embedding
iframe carries the ``sandbox`` attribute too. ``application/pdf`` gets no
CSP: Chromium's PDF viewer refuses to paint inside a sandboxed context,
and the response is inert media rendered by browser chrome, not an active
document. Everything else keeps the attachment endpoints' full
``default-src 'none'; sandbox`` posture.
"""
# Header values must be latin-1 encodable (Starlette raises on anything
# else), and page-title-derived filenames routinely carry em dashes / CJK
# — fold to ASCII rather than 500 the route.
safe_name = filename.replace('"', "").replace("\r", "").replace("\n", "")
safe_name = safe_name.encode("ascii", errors="replace").decode("ascii") or "preview"
headers = {
"X-Content-Type-Options": "nosniff",
"Content-Disposition": f'inline; filename="{safe_name}"',
"Cache-Control": "private, no-store",
}
if bare_mime == "text/html":
headers["Content-Security-Policy"] = "sandbox"
elif bare_mime != "application/pdf":
headers["Content-Security-Policy"] = "default-src 'none'; sandbox"
return headers
+309 -25
View File
@@ -124,6 +124,15 @@ from turnstone.core.personas import (
snapshot_from_config,
snapshot_from_persona,
)
from turnstone.core.preview import (
PREVIEW_BLOB_KIND,
PREVIEW_KINDS,
PREVIEW_SIZE_CAPS,
build_preview_descriptor,
inject_base_href,
page_title,
resolve_preview_kind,
)
from turnstone.core.providers import create_provider
from turnstone.core.ratelimit import TokenBucket
from turnstone.core.safety import is_command_blocked, sanitize_command
@@ -166,7 +175,7 @@ from turnstone.core.trajectory import (
turns_from_dicts,
)
from turnstone.core.watch import WATCH_REMINDER_OPTIONAL_KEYS
from turnstone.core.web import check_ssrf, strip_html
from turnstone.core.web import check_ssrf, fetch_with_ssrf_guard, strip_html
from turnstone.core.workstream import WorkstreamKind
from turnstone.prompts import (
INTERACTIVE_CONSENT_CLIENT_TYPES,
@@ -1056,6 +1065,7 @@ class SessionUI(Protocol):
output: str,
*,
is_error: bool = False,
preview: dict[str, Any] | None = None,
) -> None: ...
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None: ...
def on_status(self, usage: dict[str, Any], context_window: int, effort: str) -> None: ...
@@ -1164,12 +1174,19 @@ def _notify_auth_headers() -> dict[str, str]:
return header
def _effect_status_meta(status: EffectStatus | None) -> str | None:
"""Serialize a tool effect status to the ``conversations.meta`` JSON
envelope. Role-exclusive with ``source_meta`` (which rides SYSTEM turns),
so a tool row's meta column holds only ``{"effect_status": ...}``; the
decode + role routing lives in ``reconstruct_turns``. ``None`` no meta."""
return json.dumps({"effect_status": status.value}) if status is not None else None
def _tool_turn_meta(
status: EffectStatus | None, preview: dict[str, Any] | None = None
) -> str | None:
"""Serialize a tool turn's typed side-channels to the ``conversations.meta``
JSON envelope: the effect disposition and/or the preview-pane descriptor.
Role-exclusive with ``source_meta`` (which rides SYSTEM turns); the decode +
role routing lives in ``reconstruct_turns``. No channels no meta."""
envelope: dict[str, Any] = {}
if status is not None:
envelope["effect_status"] = status.value
if preview:
envelope["preview"] = preview
return json.dumps(envelope) if envelope else None
# ---------------------------------------------------------------------------
@@ -1487,6 +1504,11 @@ class ChatSession:
# (only for non-ordinary outcomes — e.g. UNKNOWN on a timeout/cancel)
# and popped at the fold; same lifecycle as ``_tool_error_flags``.
self._tool_status: dict[str, EffectStatus] = {}
# Preview-pane side channel: call_id → (descriptor, blob Attachment),
# set by ``_exec_open_preview`` and popped at the fold, where the
# descriptor lands on the tool turn's meta and the blob persists
# content-addressed against the turn; same lifecycle as the two above.
self._tool_previews: dict[str, tuple[dict[str, Any], Attachment]] = {}
# Cooperative cancellation: set from outside to stop generation
self._cancel_event = threading.Event()
self._cancel_ref: _CancelRef = _CancelRef(self) # provider appends SDK stream here
@@ -2964,18 +2986,21 @@ class ChatSession:
*,
is_error: bool = False,
status: EffectStatus | None = None,
preview: dict[str, Any] | None = None,
) -> None:
"""Notify the UI and record error flag for message persistence.
``status`` is the typed effect disposition (HYPOTHESIS.md effect-record
appendix), set only for non-ordinary outcomes UNKNOWN on a timeout or
mid-flight cancel and folded onto the persisted tool turn. ``None``
leaves the turn unclassified (the ordinary case)."""
leaves the turn unclassified (the ordinary case). ``preview`` is the
preview-pane descriptor riding the live event so the pane opens without
waiting for the fold."""
if is_error:
self._tool_error_flags[call_id] = True
if status is not None:
self._tool_status[call_id] = status
self.ui.on_tool_result(call_id, name, output, is_error=is_error)
self.ui.on_tool_result(call_id, name, output, is_error=is_error, preview=preview)
def _ui_event_id(self) -> int | None:
"""Current per-ws SSE ring-buffer high-water mark for stamping
@@ -5996,6 +6021,12 @@ class ChatSession:
tool_status = self._tool_status.pop(tc_id, None)
if tool_status is not None:
tool_msg["_effect_status"] = tool_status.value
# Preview descriptor + blob (``_exec_open_preview``): the
# descriptor rides the turn's meta side channel to the
# frontend; the blob persists content-addressed below.
tool_preview = self._tool_previews.pop(tc_id, None)
if tool_preview is not None:
tool_msg["_preview"] = tool_preview[0]
self.messages.append(turn_from_dict(tool_msg))
# Token estimation — image content uses a fixed heuristic
@@ -6037,12 +6068,15 @@ class ChatSession:
tool_call_id=tc_id,
event_id=self._ui_event_id(),
is_error=tool_is_error,
meta=_effect_status_meta(tool_status),
meta=_tool_turn_meta(
tool_status, tool_preview[0] if tool_preview else None
),
)
if tool_image_atts and tool_message_id:
self._persist_attachment_refs(
tool_message_id, tool_image_atts, origin="tool"
)
tool_atts = list(tool_image_atts)
if tool_preview is not None:
tool_atts.append(tool_preview[1])
if tool_atts and tool_message_id:
self._persist_attachment_refs(tool_message_id, tool_atts, origin="tool")
# Accumulate this result's operator context (guard
# findings per-result; queued interjections + metacog
@@ -6223,11 +6257,23 @@ class ChatSession:
tc_id = tc.id
func_name = tc.name
if tc_id and tc_id not in answered_ids:
self.messages.append(
Turn.tool(tc_id, detail, is_error=True, effect_status=EffectStatus.UNKNOWN)
# A staged preview means _exec_open_preview COMPLETED and its
# descriptor already reached the frontend (live SSE at exec
# time) — the pane is open on it. Discarding the blob here
# would 404 that pane forever and drop the reopen chip from
# replay, so commit blob + descriptor with the synthesized
# turn even though the BATCH outcome is unknown. Popping
# regardless also keeps a never-shown blob from pinning its
# bytes in memory for the session's life.
preview_entry = self._tool_previews.pop(tc_id, None)
cancelled_turn = Turn.tool(
tc_id, detail, is_error=True, effect_status=EffectStatus.UNKNOWN
)
if preview_entry is not None:
cancelled_turn.meta.extra["preview"] = preview_entry[0]
self.messages.append(cancelled_turn)
self._msg_tokens.append(1)
save_message(
cancelled_row_id = save_message(
self._ws_id,
"tool",
detail,
@@ -6235,8 +6281,15 @@ class ChatSession:
tool_call_id=tc_id,
event_id=self._ui_event_id(),
is_error=True,
meta=_effect_status_meta(EffectStatus.UNKNOWN),
meta=_tool_turn_meta(
EffectStatus.UNKNOWN,
preview_entry[0] if preview_entry else None,
),
)
if preview_entry is not None and cancelled_row_id:
self._persist_attachment_refs(
cancelled_row_id, [preview_entry[1]], origin="tool"
)
# Emit synthetic tool_result so live SSE listeners can
# complete the in-DOM tool batch — without this the
# coord ``--running`` indicator (added by SSE
@@ -9063,6 +9116,7 @@ class ChatSession:
"edit_file": self._prepare_edit_file,
"web_fetch": self._prepare_web_fetch,
"web_search": self._prepare_web_search,
"open_preview": self._prepare_open_preview,
"tool_search": self._prepare_tool_search,
"task_agent": self._prepare_task,
"memory": self._prepare_memory,
@@ -9684,6 +9738,95 @@ class ChatSession:
"question": question,
}
def _prepare_open_preview(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
"""Prepare a preview-pane open for approval / execution.
The target decides the approval posture: an http(s) URL is network
egress and gates like ``web_fetch``; a file path or an
``attachment:<id>`` reference is a local read and runs unprompted like
``read_file``. SSRF screening happens here (pre-approval) so a
blocked target never even reaches the approval card, and again on the
post-redirect URL at execution.
"""
target = str(args.get("target") or "").strip()
kind = args.get("kind")
title = args.get("title")
if not target:
return {
"call_id": call_id,
"func_name": "open_preview",
"header": "✗ open_preview: missing target",
"preview": "",
"needs_approval": False,
"error": "Error: missing target",
}
if kind is not None and kind not in PREVIEW_KINDS:
return {
"call_id": call_id,
"func_name": "open_preview",
"header": "✗ open_preview: invalid kind",
"preview": "",
"needs_approval": False,
"error": (f"Error: kind must be one of {sorted(PREVIEW_KINDS)} (got {kind!r})"),
}
item: dict[str, Any] = {
"call_id": call_id,
"func_name": "open_preview",
"header": f"⚙ open_preview: {target[:80]}",
"preview": "",
"execute": self._exec_open_preview,
"kind": kind,
"title": str(title).strip() if title else None,
}
if target.startswith(("http://", "https://")):
ssrf_err = check_ssrf(target)
if ssrf_err:
return {
"call_id": call_id,
"func_name": "open_preview",
"header": "✗ open_preview: blocked (private network)",
"preview": f" {target}",
"needs_approval": False,
"error": f"Error: {ssrf_err}",
}
item.update(
{
"preview": f" {target}",
"needs_approval": True,
"approval_label": "open_preview",
"target_kind": "url",
"url": target,
}
)
return item
if target.startswith("attachment:"):
attachment_id = target[len("attachment:") :].strip()
if not attachment_id:
return {
"call_id": call_id,
"func_name": "open_preview",
"header": "✗ open_preview: empty attachment id",
"preview": "",
"needs_approval": False,
"error": "Error: attachment:<id> requires an id",
}
item.update(
{
"needs_approval": False,
"target_kind": "attachment",
"attachment_id": attachment_id,
}
)
return item
item.update(
{
"needs_approval": False,
"target_kind": "path",
"path": os.path.expanduser(target),
}
)
return item
def _prepare_web_search(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
"""Prepare a web search via the configured backend for approval."""
query = (args.get("query") or "").strip()
@@ -15838,14 +15981,11 @@ class ChatSession:
call_id, url = item["call_id"], item["url"]
question = item.get("question", "Summarize the key content of this page.")
# Phase 1: fetch the URL
# Phase 1: fetch the URL. The guarded fetch SSRF-screens every
# redirect hop before requesting it (the prepare-time check covers
# only the URL the model named, not where it 302s).
try:
resp = httpx.get(
url,
headers={"User-Agent": "turnstone/1.0"},
timeout=self.tool_timeout,
follow_redirects=True,
)
resp = fetch_with_ssrf_guard(url, timeout=self.tool_timeout)
resp.raise_for_status()
ct = resp.headers.get("content-type", "")
text = resp.text
@@ -15930,6 +16070,150 @@ class ChatSession:
return call_id, answer
def _exec_open_preview(self, item: dict[str, Any]) -> tuple[str, str]:
"""Resolve the preview target to bytes and hand the pane its descriptor.
Content is resolved server-side (URL fetch through the web_fetch
guards, local read, or committed-attachment lookup), classified by
``resolve_preview_kind``, size-capped, and stashed on the
``_tool_previews`` side channel: the fold persists the bytes
content-addressed against the tool turn and folds the descriptor onto
its meta. The model gets a one-line confirmation the content itself
is for the user's pane, not the wire.
"""
self._check_cancelled()
call_id = item["call_id"]
target_kind = item["target_kind"]
kind_override = item.get("kind")
title_override = item.get("title")
def _fail(msg: str) -> tuple[str, str]:
self._report_tool_result(call_id, "open_preview", msg, is_error=True)
return call_id, msg
body: bytes
if target_kind == "url":
url = item["url"]
try:
# Every redirect hop is SSRF-screened BEFORE its request goes
# out — the pre-approval screen covers only the URL the model
# named, not where it 302s.
resp = fetch_with_ssrf_guard(url, timeout=self.tool_timeout)
resp.raise_for_status()
except httpx.HTTPStatusError as e:
return _fail(f"Error: fetch failed: HTTP {e.response.status_code}")
except (httpx.RequestError, ValueError) as e:
return _fail(f"Error: fetch failed: {e}")
final_url = str(resp.url)
# The final URL feeds the descriptor (displayed, persisted) and
# the stored <base href> — strip any userinfo so embedded
# credentials never reach the transcript or the stored bytes.
if resp.url.username or resp.url.password:
final_url = str(resp.url.copy_with(username=None, password=None))
body = resp.content
if len(body) > 10 * 1024 * 1024:
return _fail(
f"Error: response too large to preview ({len(body):,} bytes; cap 10 MB)"
)
mime_hint = resp.headers.get("content-type", "")
name_hint = final_url
source = final_url
elif target_kind == "attachment":
from turnstone.core.memory import attachment_referenced_in_ws, get_attachment
attachment_id = item["attachment_id"]
row = get_attachment(attachment_id)
if not row or not attachment_referenced_in_ws(attachment_id, self._ws_id):
# Mirror the serving gate: unreferenced ids read as absent so
# existence in other workstreams doesn't leak.
return _fail(f"Error: attachment not found: {attachment_id}")
body = row.get("content") or b""
mime_hint = str(row.get("mime_type") or "")
name_hint = str(row.get("filename") or "")
source = name_hint or f"attachment:{attachment_id}"
else:
path = item["path"]
resolved = os.path.realpath(path)
if not os.path.isfile(resolved):
return _fail(f"Error: file not found: {path}")
try:
size = os.path.getsize(resolved)
if size > max(PREVIEW_SIZE_CAPS.values()):
return _fail(
f"Error: file too large to preview ({size:,} bytes; "
f"cap {max(PREVIEW_SIZE_CAPS.values()):,})"
)
with open(resolved, "rb") as f:
body = f.read()
except OSError as e:
return _fail(f"Error: could not read file: {e}")
mime_hint = ""
name_hint = path
source = path
if not body:
return _fail("Error: nothing to preview (empty content)")
resolved_kind = resolve_preview_kind(mime_hint, name_hint, body, kind_override)
if resolved_kind is None:
hint = f" (content-type {mime_hint.split(';')[0]})" if mime_hint else ""
return _fail(
f"Error: content is not previewable{hint} — supported kinds: "
f"{', '.join(sorted(PREVIEW_KINDS))}"
)
kind, stored_mime = resolved_kind
cap = PREVIEW_SIZE_CAPS[kind]
if len(body) > cap:
return _fail(
f"Error: {kind} content too large to preview ({len(body):,} bytes; cap {cap:,})"
)
title = title_override
if kind == "web":
# Store what the fetch saw, made renderable: decode on the
# transport charset, give relative assets a base to resolve
# against, and re-encode UTF-8 (matching the stored mime).
if target_kind == "url":
text = resp.text
text = inject_base_href(text, final_url)
else:
text = body.decode("utf-8", errors="replace")
if not title:
title = page_title(text)
body = text.encode("utf-8")
if len(body) > cap:
return _fail(
f"Error: web content too large to preview ({len(body):,} bytes; cap {cap:,})"
)
if not title:
tail = name_hint.rsplit("/", 1)[-1].split("?", 1)[0]
title = tail or source
title = title[:200]
blob_id = hashlib.sha256(body).hexdigest()
descriptor = build_preview_descriptor(
kind=kind,
title=title,
source=source,
attachment_id=blob_id,
content_type=stored_mime,
size=len(body),
)
filename = title if "." in title else f"preview-{kind}"
self._tool_previews[call_id] = (
descriptor,
Attachment(
attachment_id=blob_id,
filename=filename[:120],
mime_type=stored_mime,
kind=PREVIEW_BLOB_KIND,
content=body,
),
)
msg = f"Preview shown to the user: {title} ({kind}, {len(body):,} bytes)"
self._report_tool_result(call_id, "open_preview", msg, preview=descriptor)
return call_id, msg
def _exec_web_search(self, item: dict[str, Any]) -> tuple[str, str]:
"""Search the web via the configured backend (SearxNG or MCP)."""
self._check_cancelled()
+33
View File
@@ -486,6 +486,7 @@ class AttachmentHandlers:
list: Handler # GET {prefix}/{ws_id}/attachments
get_content: Handler # GET {prefix}/{ws_id}/attachments/{attachment_id}/content
thumbnail: Handler # GET {prefix}/{ws_id}/attachments/{attachment_id}/thumbnail
preview: Handler # GET {prefix}/{ws_id}/attachments/{attachment_id}/preview
delete: Handler # DELETE {prefix}/{ws_id}/attachments/{attachment_id}
@@ -638,6 +639,13 @@ def register_session_routes(
methods=["GET"],
)
)
routes.append(
Route(
f"{p}/{{ws_id}}/attachments/{{attachment_id}}/preview",
a.preview,
methods=["GET"],
)
)
routes.append(
Route(
f"{p}/{{ws_id}}/attachments/{{attachment_id}}",
@@ -4364,6 +4372,30 @@ def make_attachment_handlers(cfg: SessionEndpointConfig) -> AttachmentHandlers:
}
return _Response(body, media_type=response_mime, headers=headers)
async def get_preview(request: Request) -> Response:
from starlette.responses import Response as _Response
from turnstone.core.preview import PREVIEW_SERVE_MIMES, preview_response_headers
resolved = await _resolve_served_blob(request)
if not isinstance(resolved, tuple):
return resolved
body, _kind, stored_mime, filename = resolved
# Serve the STORED type so the browser renders it (html document, pdf
# viewer, image) — the opposite posture from ``get_content``'s
# force-text/plain, made safe by the per-mime CSP sandbox headers
# (``preview_response_headers``) plus the pane's iframe sandbox.
# Non-renderable types 415 rather than fall back to octet-stream: this
# route exists to render, ``/content`` exists to download.
bare_mime = stored_mime.split(";", 1)[0].strip().lower()
if bare_mime not in PREVIEW_SERVE_MIMES:
return JSONResponse({"error": "attachment is not previewable"}, status_code=415)
return _Response(
body,
media_type=stored_mime,
headers=preview_response_headers(bare_mime, filename),
)
async def get_thumbnail(request: Request) -> Response:
import asyncio
@@ -4415,6 +4447,7 @@ def make_attachment_handlers(cfg: SessionEndpointConfig) -> AttachmentHandlers:
list=list_pending,
get_content=get_content,
thumbnail=get_thumbnail,
preview=get_preview,
delete=delete_,
)
+7
View File
@@ -2785,6 +2785,7 @@ class SessionUIBase:
output: str,
*,
is_error: bool = False,
preview: dict[str, Any] | None = None,
) -> None:
"""Track per-ws tool-call counts + clear activity + enqueue.
@@ -2792,6 +2793,10 @@ class SessionUIBase:
``WebUI`` calls :func:`_metrics.record_tool_call` on top of the
shared writes); call ``super().on_tool_result(...)`` to keep
the per-ws counters consistent.
``preview`` is the preview-pane descriptor (``open_preview``) it
rides the live event verbatim, mirrored by the ``/history``
projection's ``preview`` field so live and replay render identically.
"""
with self._ws_lock:
self._ws_tool_calls[name] = self._ws_tool_calls.get(name, 0) + 1
@@ -2807,6 +2812,8 @@ class SessionUIBase:
}
if is_error:
event["is_error"] = True
if preview:
event["preview"] = preview
self._enqueue(event)
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None:
+13 -7
View File
@@ -513,7 +513,10 @@ class PostgreSQLBackend:
all_ids.update(ids)
if not all_ids:
return {}
blobs = self.get_attachments(list(all_ids))
# Preview-pane blobs (kind='preview', see core.preview.PREVIEW_BLOB_KIND)
# ride ref-lists only for GC + the serving gate; reconstruction skips
# them, so don't pull their multi-MB content off disk on every load.
blobs = self.get_attachments(list(all_ids), exclude_kinds=("preview",))
rows_by_id = {str(b["attachment_id"]): b for b in blobs}
return _build_attachments_by_msg(attachment_refs, rows_by_id)
@@ -1136,15 +1139,18 @@ class PostgreSQLBackend:
)
conn.commit()
def get_attachments(self, attachment_ids: list[str]) -> list[dict[str, Any]]:
def get_attachments(
self, attachment_ids: list[str], exclude_kinds: tuple[str, ...] = ()
) -> list[dict[str, Any]]:
if not attachment_ids:
return []
with self._conn() as conn:
rows = conn.execute(
sa.select(workstream_attachments).where(
workstream_attachments.c.attachment_id.in_(attachment_ids)
)
).fetchall()
stmt = sa.select(workstream_attachments).where(
workstream_attachments.c.attachment_id.in_(attachment_ids)
)
if exclude_kinds:
stmt = stmt.where(workstream_attachments.c.kind.notin_(exclude_kinds))
rows = conn.execute(stmt).fetchall()
return [dict(r._mapping) for r in rows]
def get_attachment(self, attachment_id: str) -> dict[str, Any] | None:
+6 -1
View File
@@ -347,10 +347,15 @@ class StorageBackend(Protocol):
"""
...
def get_attachments(self, attachment_ids: list[str]) -> list[dict[str, Any]]:
def get_attachments(
self, attachment_ids: list[str], exclude_kinds: tuple[str, ...] = ()
) -> list[dict[str, Any]]:
"""Bulk fetch attachments by id, including their ``content`` bytes.
Unknown ids are silently skipped. Order is unspecified.
``exclude_kinds`` filters at the QUERY so callers that will discard a
kind anyway (trajectory reconstruction skips ``preview`` blobs) don't
pull multi-megabyte content off disk just to drop it.
"""
...
+13 -7
View File
@@ -576,7 +576,10 @@ class SQLiteBackend:
all_ids.update(ids)
if not all_ids:
return {}
blobs = self.get_attachments(list(all_ids))
# Preview-pane blobs (kind='preview', see core.preview.PREVIEW_BLOB_KIND)
# ride ref-lists only for GC + the serving gate; reconstruction skips
# them, so don't pull their multi-MB content off disk on every load.
blobs = self.get_attachments(list(all_ids), exclude_kinds=("preview",))
rows_by_id = {str(b["attachment_id"]): b for b in blobs}
return _build_attachments_by_msg(attachment_refs, rows_by_id)
@@ -1293,15 +1296,18 @@ class SQLiteBackend:
)
conn.commit()
def get_attachments(self, attachment_ids: list[str]) -> list[dict[str, Any]]:
def get_attachments(
self, attachment_ids: list[str], exclude_kinds: tuple[str, ...] = ()
) -> list[dict[str, Any]]:
if not attachment_ids:
return []
with self._conn() as conn:
rows = conn.execute(
sa.select(workstream_attachments).where(
workstream_attachments.c.attachment_id.in_(attachment_ids)
)
).fetchall()
stmt = sa.select(workstream_attachments).where(
workstream_attachments.c.attachment_id.in_(attachment_ids)
)
if exclude_kinds:
stmt = stmt.where(workstream_attachments.c.kind.notin_(exclude_kinds))
rows = conn.execute(stmt).fetchall()
return [dict(r._mapping) for r in rows]
def get_attachment(self, attachment_id: str) -> dict[str, Any] | None:
+16 -6
View File
@@ -426,6 +426,12 @@ def _reconstruct_attachment_refs(
# placeholder type ({type:pdf} / {type:audio}) consistent with the live
# injection path, which already emits those.
kind_str = str(att.get("kind") or "")
if kind_str == "preview":
# Preview-pane blobs ride the ref-list ONLY for refcount GC and
# the serving-route ownership gate — they are frontend content,
# addressed by the tool turn's meta descriptor, and must never
# become a content block a wire materialization could inline.
continue
ref_kind = kind_str if kind_str in ("image", "pdf", "audio") else "document"
refs.append(
AttachmentRef(
@@ -1087,12 +1093,16 @@ def reconstruct_turns(
raw_meta = _source_meta_from_json(row[10]) if len(row) > 10 else None
if raw_meta is not None:
# The ``meta`` column is role-exclusive: a TOOL row carries the
# typed ``{"effect_status": ...}`` envelope; a SYSTEM row carries
# operator-context ``source_meta``. Route so a tool's disposition
# doesn't land under source_meta (and vice versa). Legacy SYSTEM
# rows (bare source_meta dict, no effect_status key) fall through.
if role == "tool" and "effect_status" in raw_meta:
meta.extra["effect_status"] = raw_meta["effect_status"]
# typed ``{"effect_status": ..., "preview": ...}`` envelope (each
# key optional); a SYSTEM row carries operator-context
# ``source_meta``. Route so a tool's disposition doesn't land
# under source_meta (and vice versa). Legacy SYSTEM rows (bare
# source_meta dict, no tool keys) fall through.
if role == "tool" and ("effect_status" in raw_meta or "preview" in raw_meta):
if "effect_status" in raw_meta:
meta.extra["effect_status"] = raw_meta["effect_status"]
if "preview" in raw_meta:
meta.extra["preview"] = raw_meta["preview"]
elif role == "user" and "sender" in raw_meta:
# Per-message sender identity (shared-workstream attribution).
# A USER row's meta blob carries only ``{"sender": ...}`` — route
+8
View File
@@ -305,6 +305,11 @@ def turn_from_dict(msg: dict[str, Any]) -> Turn:
es = msg.get("_effect_status")
if es:
meta.extra["effect_status"] = es
# Preview-pane descriptor (``open_preview`` tool results) — frontend-facing
# only; never folded into wire content.
pv = msg.get("_preview")
if pv:
meta.extra["preview"] = pv
# Per-message sender identity for shared workstreams (who actually sent this
# user turn). Wire-invisible side channel — folded into the model-visible
# content at :meth:`ChatSession._prepare_wire_messages` only when the
@@ -359,6 +364,9 @@ def turn_to_dict(turn: Turn) -> dict[str, Any]:
es = turn.meta.extra.get("effect_status")
if es:
msg["_effect_status"] = es
pv = turn.meta.extra.get("preview")
if pv:
msg["_preview"] = pv
sndr = turn.meta.extra.get("sender")
if sndr:
msg["_sender"] = sndr
+43 -1
View File
@@ -1,4 +1,4 @@
"""Web utilities — HTML stripping and SSRF protection."""
"""Web utilities — HTML stripping, SSRF protection, and the guarded fetch."""
import ipaddress
import re
@@ -6,6 +6,8 @@ import socket
from html import unescape as _html_unescape
from urllib.parse import urlparse
import httpx
_RE_INVISIBLE = re.compile(
r"<(script|style|template|noscript)\b[^>]*>.*?</\1\s*>",
re.DOTALL | re.IGNORECASE,
@@ -120,3 +122,43 @@ def check_ssrf(url: str) -> str | None:
except (socket.gaierror, OSError):
pass # DNS failure — let the actual fetch handle it
return None
def fetch_with_ssrf_guard(
url: str,
*,
timeout: float,
user_agent: str = "turnstone/1.0",
max_redirects: int = 5,
) -> httpx.Response:
"""GET *url* following redirects manually, SSRF-screening EVERY hop.
``httpx.get(follow_redirects=True)`` checks nothing between hops a
public URL that 302s into private address space (cloud metadata, an
internal admin endpoint) would be fetched before any post-hoc check runs,
executing the private-network request even if the response is later
discarded. Here each hop's URL is screened BEFORE its request is issued.
Raises ``ValueError`` for a blocked hop or a redirect chain past
*max_redirects* (callers already route ``ValueError`` to their
fetch-failed lane), and lets ``httpx`` transport errors propagate
unchanged. ``resp.raise_for_status()`` stays the caller's call.
"""
current = url
with httpx.Client(
headers={"User-Agent": user_agent},
timeout=timeout,
follow_redirects=False,
) as client:
for _hop in range(max_redirects + 1):
ssrf_err = check_ssrf(current)
if ssrf_err:
raise ValueError(ssrf_err)
resp = client.get(current)
if resp.status_code in (301, 302, 303, 307, 308):
location = resp.headers.get("location")
if location:
current = str(httpx.URL(current).join(location))
continue
return resp
raise ValueError(f"Blocked: more than {max_redirects} redirects")
+1
View File
@@ -112,6 +112,7 @@ class NullUI:
output: str,
*,
is_error: bool = False,
preview: dict[str, Any] | None = None,
) -> None:
pass
+8
View File
@@ -26,6 +26,14 @@ Run a command, git, or tests → bash:
Retrieve a URL → web_fetch:
web_fetch(url='https://example.com')
Show the user content visually (rendered page, PDF, image, data table) → open_preview:
open_preview(target='https://example.com/pricing')
open_preview(target='chart.png')
open_preview(target='results.csv')
User asks to READ/SEE something → open_preview; you need to reason about it yourself → web_fetch / read_file.
Render then show → bash then open_preview:
bash(command='python plot.py') → open_preview(target='plot.png')
Search the web for information → web_search:
web_search(query='current population of Tokyo')
+2 -1
View File
@@ -335,10 +335,11 @@ class WebUI(SessionUIBase):
output: str,
*,
is_error: bool = False,
preview: dict[str, Any] | None = None,
) -> None:
"""Layer node-only Prometheus metrics on top of the shared body."""
_metrics.record_tool_call(name)
super().on_tool_result(call_id, name, output, is_error=is_error)
super().on_tool_result(call_id, name, output, is_error=is_error, preview=preview)
def on_status(self, usage: dict[str, Any], context_window: int, effort: str) -> None:
"""Layer node-only Prometheus metrics on top of the shared body.
+40
View File
@@ -643,6 +643,46 @@ export function buildConvResult(output, opts) {
return block;
}
// Preview chip (.conv-preview-chip) — the transcript affordance for a tool
// result that carries a preview-pane descriptor. Live results auto-open the
// pane; this chip is how the operator RE-opens one (after closing the pane,
// or from a replayed transcript, where nothing auto-opens). `descriptor` is
// the structured preview object off the tool_result event / history entry;
// `onOpen(descriptor)` is the pane-supplied opener (routed through the host
// seam so this builder stays shell-agnostic). textContent only.
export function buildPreviewChip(descriptor, onOpen) {
const d = descriptor || {};
const chip = document.createElement("button");
chip.type = "button";
chip.className = "conv-preview-chip";
// The label can be a raw URL (no <title> found) — a target with embedded
// basic-auth must not print credentials into the transcript.
const label = redactCredentials(d.title || d.source || "preview");
chip.setAttribute("aria-label", "Open preview: " + label);
chip.title = d.source
? "Open preview — " + redactCredentials(d.source)
: "Open preview";
const glyph = document.createElement("span");
glyph.className = "conv-preview-glyph";
glyph.setAttribute("aria-hidden", "true");
glyph.textContent = "▤";
chip.appendChild(glyph);
const text = document.createElement("span");
text.className = "conv-preview-title";
text.textContent = label;
chip.appendChild(text);
if (d.kind) {
const kind = document.createElement("span");
kind.className = "conv-preview-kind";
kind.textContent = d.kind;
chip.appendChild(kind);
}
if (typeof onOpen === "function") {
chip.addEventListener("click", () => onOpen(d));
}
return chip;
}
// Expandable body for a task_agent row's nested sub-steps (the "agent card").
// A task agent runs its own sub-tools; this gives that row a collapsible body
// the pane renders the live step stream into, so the steps nest UNDER the
+39 -1
View File
@@ -32,6 +32,7 @@ import {
buildConvActions,
buildConvStatus,
buildAgentCardBody,
buildPreviewChip,
batchKicker,
indexLabel,
} from "./conversation.js";
@@ -166,6 +167,10 @@ const INTERACTIVE_DEFAULT_HOST = {
// An MCP server needs (re-)consent — the standalone shell drives its
// settings-gear badge; a bare/console pane surfaces it inline only.
onConsentDetected() {},
// A tool result carried a preview-pane descriptor — the L-shell host opens
// the preview pane beside this one; a bare pane keeps the transcript chip
// as the only affordance.
onPreview() {},
};
class Pane {
@@ -1640,6 +1645,7 @@ class Pane {
evt.name,
evt.output,
evt.is_error,
evt.preview,
);
break;
@@ -2662,6 +2668,16 @@ class Pane {
insertChained(renderCollapsibleOutput(stripped, isToolError));
}
}
// Replayed preview descriptor: chip only — a reload must never
// auto-open panes for every historical preview (the live path's
// focused auto-open already happened when it was current). Error
// turns keep their chip: a cancelled BATCH synthesizes an error
// result for an open_preview whose content committed fine.
if (msg.preview && !isDenied) {
insertChained(
buildPreviewChip(msg.preview, (d) => this._host.onPreview(d)),
);
}
if (
isToolError &&
!lastToolBlock.classList.contains("conv-batch--denied")
@@ -3265,7 +3281,7 @@ class Pane {
return card.wrap;
}
appendToolOutput(callId, name, output, isError) {
appendToolOutput(callId, name, output, isError, preview) {
// Capture pin before the streamEl removal + result insertion change
// scrollHeight — see announceToolBlock. The result block is the other
// tall one-shot append in the tool flow (up to 10 lines before collapse).
@@ -3382,6 +3398,15 @@ class Pane {
}
target.after(out);
// Preview descriptor (open_preview): chip in the transcript always; the
// pane auto-opens only while THIS pane is the user's focus — a
// backgrounded session must not commandeer the split, and the chip
// remains the deliberate reopen for that case (and for replay).
if (preview && !isError) {
const chip = buildPreviewChip(preview, (d) => this._host.onPreview(d));
out.after(chip);
if (this._host.isFocused(this)) this._host.onPreview(preview);
}
this.scrollToBottom(stick);
}
@@ -4412,6 +4437,19 @@ function createInteractivePane(root, wsId, opts) {
window.TS_APP.onConsentDetected(server);
}
},
// Preview descriptors open the shell's preview pane beside this one.
// Bridged through the TS_SHELL seam (mountShell defines it) with THIS
// pane's transport context attached, so the preview pane fetches blob
// content from the same workstream through the same node proxy the
// session streams from.
onPreview(descriptor) {
if (
window.TS_SHELL &&
typeof window.TS_SHELL.openPreview === "function"
) {
window.TS_SHELL.openPreview(descriptor, { base: base, wsId: wsId });
}
},
};
const pane = new Pane(wsId, {
+249
View File
@@ -0,0 +1,249 @@
/* ==========================================================================
Preview pane (shared_static/preview.js) header bar + per-kind content.
DS tokens only (--ink-*, --panel*, --hair, --accent); no chat.css legacy
vars. The pane fills its PaneManager cell; only .preview-content scrolls.
========================================================================== */
.preview-root {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
/* ----- header bar ----- */
.preview-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-bottom: 1px solid var(--hair);
background: var(--panel);
flex: none;
}
.preview-nav {
flex: none;
width: 24px;
height: 22px;
padding: 0;
font-size: 10px;
line-height: 1;
color: var(--ink-2);
background: var(--panel-2);
border: 1px solid var(--hair);
border-radius: 3px;
cursor: pointer;
}
.preview-nav:hover:not(:disabled) {
color: var(--ink-1);
border-color: var(--ink-4);
}
.preview-nav:disabled {
opacity: 0.35;
cursor: default;
}
.preview-kindchip {
flex: none;
padding: 1px 6px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--ink-2);
background: color-mix(in srgb, var(--accent) 18%, transparent);
border: 1px solid var(--hair);
border-radius: 3px;
}
.preview-kindchip:empty {
display: none;
}
.preview-titletext {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--ink-1);
}
.preview-ext {
flex: none;
font-size: 11px;
color: var(--ink-2);
text-decoration: none;
border-bottom: 1px dotted var(--ink-4);
}
.preview-ext:hover {
color: var(--ink-1);
}
/* ----- content host ----- */
.preview-content {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
background: var(--panel-2);
}
/* web + pdf fill the cell */
.preview-frame {
display: block;
width: 100%;
height: 100%;
border: 0;
background: #fff; /* pages assume a light canvas until their CSS paints */
}
.preview-imgwrap {
display: flex;
align-items: flex-start;
justify-content: center;
padding: 12px;
}
.preview-img {
max-width: 100%;
height: auto;
}
.preview-pre {
margin: 0;
padding: 10px 12px;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.5;
color: var(--ink-2);
white-space: pre-wrap;
word-break: break-word;
}
.preview-markdown {
padding: 12px 16px;
font-size: 13px;
color: var(--ink-1);
}
/* ----- table kind ----- */
.preview-tablewrap {
overflow: auto;
padding: 8px;
}
.preview-table {
border-collapse: collapse;
font-family: var(--font-mono);
font-size: 11px;
color: var(--ink-2);
}
.preview-table th,
.preview-table td {
padding: 3px 8px;
border: 1px solid var(--hair);
text-align: left;
white-space: nowrap;
max-width: 360px;
overflow: hidden;
text-overflow: ellipsis;
}
.preview-table thead th {
position: sticky;
top: 0;
background: var(--panel);
padding: 0;
}
.preview-th {
display: block;
width: 100%;
padding: 4px 8px;
font: inherit;
font-weight: 600;
color: var(--ink-1);
text-align: left;
background: none;
border: 0;
cursor: pointer;
}
.preview-th:hover {
background: color-mix(in srgb, var(--accent) 15%, transparent);
}
.preview-table th[aria-sort] .preview-th::after {
content: " ▲";
font-size: 9px;
}
.preview-table th[aria-sort="descending"] .preview-th::after {
content: " ▼";
}
/* ----- states ----- */
.preview-empty,
.preview-loading,
.preview-error {
padding: 24px 16px;
font-size: 12px;
color: var(--ink-3);
text-align: center;
}
.preview-error-msg {
margin-bottom: 10px;
}
.preview-retry {
padding: 4px 14px;
font-size: 11px;
color: var(--ink-1);
background: var(--panel);
border: 1px solid var(--hair);
border-radius: 3px;
cursor: pointer;
}
.preview-retry:hover {
border-color: var(--ink-4);
}
.preview-note {
padding: 6px 12px;
font-size: 10px;
color: var(--ink-3);
border-top: 1px solid var(--hair);
}
/* ----- transcript chip (conversation.js buildPreviewChip) ----- */
.conv-preview-chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
margin-top: 6px;
padding: 3px 10px;
font-size: 11px;
color: var(--ink-2);
background: color-mix(in srgb, var(--accent) 15%, transparent);
border: 1px solid var(--hair);
border-radius: 3px;
cursor: pointer;
}
.conv-preview-chip:hover {
color: var(--ink-1);
border-color: var(--ink-4);
}
.conv-preview-glyph {
flex: none;
color: var(--ink-3);
}
.conv-preview-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.conv-preview-kind {
flex: none;
padding: 0 5px;
font-size: 9px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--ink-3);
border: 1px solid var(--hair);
border-radius: 3px;
}
+556
View File
@@ -0,0 +1,556 @@
// preview.js — the preview pane: rich rendering of tool-selected content
// (a fetched web page, PDF, image, data table, text/markdown document) in a
// dedicated pane beside the conversation.
//
// One singleton pane per shell (PaneManager type "preview"). Content arrives
// as a DESCRIPTOR — the structured object the open_preview tool folds onto
// its tool turn ({kind, title, source, attachment_id, content_type, size}) —
// plus a transport context {base, wsId} from the ORIGINATING conversation
// pane, so blob fetches route through the same node proxy that session
// streams from. Bytes are never inlined into events; everything loads from
// the per-workstream preview route on cookie auth (the house pattern:
// plain same-origin src URLs, no blob:/createObjectURL).
//
// Developer-tool posture: keyboard-operable (←/→ walk the session's preview
// history), sandboxed (web content renders in a fully sandboxed iframe on top
// of the route's own CSP), and coexistent — it opens BESIDE the conversation
// via openPaneBeside, never replacing it.
//
// House style: ES module, programmatic DOM (createElement / textContent /
// append), NO innerHTML — the one HTML sink is setSafeHtml over
// renderMarkdown, the sanctioned renderer.js lane. All queries root-scoped.
import { ShellPane } from "./pane.js";
import { authFetch } from "./auth.js";
import { redactCredentials } from "./redact_credentials.js";
import { renderMarkdown } from "./renderer.js";
import { setSafeHtml } from "./utils.js";
// How many viewed descriptors the ←/→ history keeps. Session-scoped and
// in-memory; only the CURRENT one is persisted for reload (pane.meta).
const HISTORY_CAP = 20;
// Table renderer row cap — past this the DOM cost buys no decision value;
// the notice row reports what was withheld.
const TABLE_ROW_CAP = 5000;
// Automatic reloads for the persist race: the live descriptor beats the
// tool-turn fold that commits its blob whenever a parallel SIBLING tool is
// still running — the fold waits on the whole batch, so the gap is that
// sibling's remaining runtime, not milliseconds. Backoff doubles from
// RETRY_BASE_MS across MAX_AUTO_RETRIES attempts (0.9s, 1.8s, 3.6s, 7.2s —
// ~13.5s covered) before the manual Retry card takes over.
const RETRY_BASE_MS = 900;
const MAX_AUTO_RETRIES = 4;
function make(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text != null) node.textContent = text;
return node;
}
// The per-workstream serving route for a descriptor, through the originating
// pane's transport base ("" local, "/node/{id}" console-proxied).
function previewContentUrl(ctx, descriptor) {
const base = (ctx && ctx.base) || "";
const ws = (ctx && ctx.wsId) || "";
return (
base +
"/v1/api/workstreams/" +
encodeURIComponent(ws) +
"/attachments/" +
encodeURIComponent(descriptor.attachment_id || "") +
"/preview"
);
}
// ---------------------------------------------------------------------------
// Delimited-text parsing (table kind). Minimal RFC-4180 state machine:
// quoted fields, "" escapes, \r\n and \n rows. Returns rows of strings.
// ---------------------------------------------------------------------------
function parseDelimited(text, delim) {
const rows = [];
let row = [];
let field = "";
let quoted = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (quoted) {
if (ch === '"') {
if (text[i + 1] === '"') {
field += '"';
i++;
} else {
quoted = false;
}
} else {
field += ch;
}
} else if (ch === '"' && field === "") {
quoted = true;
} else if (ch === delim) {
row.push(field);
field = "";
} else if (ch === "\n" || ch === "\r") {
if (ch === "\r" && text[i + 1] === "\n") i++;
row.push(field);
field = "";
rows.push(row);
row = [];
} else {
field += ch;
}
}
if (field !== "" || row.length) {
row.push(field);
rows.push(row);
}
// Drop a pure-empty trailing row (text ending in a newline).
if (rows.length && rows[rows.length - 1].every((f) => f === "")) rows.pop();
return rows;
}
// JSON payloads → (header, rows). Array of objects: columns = key union in
// first-seen order. Array of scalars/arrays: index-labelled columns. A bare
// object: two-column key/value listing.
function tableFromJson(parsed) {
if (Array.isArray(parsed)) {
if (
parsed.length &&
parsed.every((r) => r && typeof r === "object" && !Array.isArray(r))
) {
const cols = [];
for (const r of parsed) {
for (const k of Object.keys(r)) if (!cols.includes(k)) cols.push(k);
}
const rows = parsed.map((r) =>
cols.map((c) => {
const v = r[c];
if (v == null) return "";
return typeof v === "object" ? JSON.stringify(v) : String(v);
}),
);
return { header: cols, rows };
}
const rows = parsed.map((r) =>
Array.isArray(r)
? r.map((v) =>
v == null
? ""
: typeof v === "object"
? JSON.stringify(v)
: String(v),
)
: [
typeof r === "object" && r != null
? JSON.stringify(r)
: String(r ?? ""),
],
);
const width = rows.reduce((w, r) => Math.max(w, r.length), 0);
const header = [];
for (let i = 0; i < width; i++) header.push(String(i + 1));
return { header, rows };
}
if (parsed && typeof parsed === "object") {
return {
header: ["key", "value"],
rows: Object.entries(parsed).map(([k, v]) => [
k,
typeof v === "object" && v != null
? JSON.stringify(v)
: String(v ?? ""),
]),
};
}
return { header: ["value"], rows: [[String(parsed)]] };
}
// Numeric-aware comparator for column sorts: numbers order numerically,
// everything else falls back to locale string compare.
function compareCells(a, b) {
const na = parseFloat(a);
const nb = parseFloat(b);
const bothNumeric =
!Number.isNaN(na) &&
!Number.isNaN(nb) &&
a.trim() !== "" &&
b.trim() !== "";
if (bothNumeric && na !== nb) return na < nb ? -1 : 1;
if (bothNumeric) return 0;
return a.localeCompare(b);
}
// ---------------------------------------------------------------------------
// createPreviewPane — the PaneManager factory body.
// hostApi: { persistMeta(meta), setTitle(text) } — the two pm-owned verbs
// the pane needs, injected by the shell so the pane owns no manager ref.
// extra: the rehydrate hint ({descriptor, ctx}) persisted via pane.meta.
// ---------------------------------------------------------------------------
export function createPreviewPane(extra, hostApi) {
const api = hostApi || {};
const pane = new ShellPane({
type: "preview",
title: "Preview",
glyph: "▤",
});
// Viewed-descriptor history (session-scoped): entries are {descriptor, ctx}.
pane._stack = [];
pane._idx = -1;
pane._loadToken = 0;
const shortTitle = (d) => {
const t = redactCredentials(d.title || d.source || "preview");
return t.length > 40 ? t.slice(0, 39) + "…" : t;
};
const setNavState = () => {
pane._backBtn.disabled = pane._idx <= 0;
pane._fwdBtn.disabled = pane._idx >= pane._stack.length - 1;
};
const renderEmpty = () => {
pane._contentEl.replaceChildren(
make(
"div",
"preview-empty",
"Nothing previewed yet — ask the assistant to show a page, file, image, or data table.",
),
);
};
const renderError = (message, entry) => {
const wrap = make("div", "preview-error");
wrap.append(make("div", "preview-error-msg", message));
const retry = make("button", "preview-retry", "Retry");
retry.type = "button";
// A manual retry is a single deliberate attempt — no silent backoff run.
retry.addEventListener("click", () => renderEntry(entry, MAX_AUTO_RETRIES));
wrap.append(retry);
pane._contentEl.replaceChildren(wrap);
};
const renderNote = (text) => make("div", "preview-note", text);
// ----- per-kind renderers -------------------------------------------------
const renderWeb = (url) => {
const frame = make("iframe", "preview-frame");
// Full lockdown: no scripts, no same-origin, no forms/popups. The route
// additionally serves the document under its own CSP sandbox — neither
// layer alone is load-bearing.
frame.setAttribute("sandbox", "");
frame.setAttribute("referrerpolicy", "no-referrer");
frame.title = "Web page preview";
frame.src = url;
pane._contentEl.replaceChildren(frame);
};
const renderPdf = (url, d) => {
const frame = make("iframe", "preview-frame");
// No sandbox attribute: Chromium's built-in PDF viewer refuses to paint
// in a sandboxed context, and the response is inert media (the serving
// route omits CSP for application/pdf for the same reason).
frame.title = (d.title || "Document") + " (PDF preview)";
frame.src = url;
pane._contentEl.replaceChildren(frame);
};
const renderImage = (url, d) => {
const holder = make("div", "preview-imgwrap");
const img = make("img", "preview-img");
img.alt = d.title || "Image preview";
img.decoding = "async";
img.src = url;
holder.append(img);
pane._contentEl.replaceChildren(holder);
};
const renderText = (text) => {
const pre = make("pre", "preview-pre");
pre.textContent = text;
pane._contentEl.replaceChildren(pre);
};
const renderMarkdownDoc = (text) => {
const doc = make("div", "preview-markdown");
// The one sanctioned HTML lane: renderer.js output through setSafeHtml.
setSafeHtml(doc, renderMarkdown(text));
pane._contentEl.replaceChildren(doc);
};
const renderTable = (text, d) => {
let header;
let rows;
const bare = String(d.content_type || "")
.split(";")[0]
.trim()
.toLowerCase();
if (bare === "application/json") {
let parsed;
try {
parsed = JSON.parse(text);
} catch (e) {
renderText(text); // not actually JSON — degrade to plain text
return;
}
({ header, rows } = tableFromJson(parsed));
} else {
const delim = bare === "text/tab-separated-values" ? "\t" : ",";
const all = parseDelimited(text, delim);
if (!all.length) {
renderText(text);
return;
}
header = all[0];
rows = all.slice(1);
// Ragged files: a short first row must not silently hide trailing
// columns of later rows — pad the header out to the widest row so
// every parsed cell renders (and sorts).
const width = rows.reduce((w, r) => Math.max(w, r.length), header.length);
while (header.length < width) header.push(String(header.length + 1));
}
const wrap = make("div", "preview-tablewrap");
const table = make("table", "preview-table");
const thead = document.createElement("thead");
const headRow = document.createElement("tr");
const state = { col: -1, dir: 1, rows };
const tbody = document.createElement("tbody");
const renderBody = () => {
tbody.replaceChildren();
const shown = state.rows.slice(0, TABLE_ROW_CAP);
for (const r of shown) {
const tr = document.createElement("tr");
for (let c = 0; c < header.length; c++) {
const td = document.createElement("td");
td.textContent = r[c] != null ? r[c] : "";
tr.append(td);
}
tbody.append(tr);
}
};
header.forEach((h, ci) => {
const th = document.createElement("th");
th.scope = "col";
const btn = make("button", "preview-th", h);
btn.type = "button";
btn.title = "Sort by " + h;
btn.addEventListener("click", () => {
state.dir = state.col === ci ? -state.dir : 1;
state.col = ci;
state.rows = state.rows
.slice()
.sort((a, b) => state.dir * compareCells(a[ci] || "", b[ci] || ""));
for (const other of headRow.querySelectorAll("th")) {
other.removeAttribute("aria-sort");
}
th.setAttribute(
"aria-sort",
state.dir > 0 ? "ascending" : "descending",
);
renderBody();
});
th.append(btn);
headRow.append(th);
});
thead.append(headRow);
table.append(thead, tbody);
renderBody();
wrap.append(table);
pane._contentEl.replaceChildren(wrap);
if (rows.length > TABLE_ROW_CAP) {
pane._contentEl.append(
renderNote(
"Showing " +
TABLE_ROW_CAP.toLocaleString() +
" of " +
rows.length.toLocaleString() +
" rows",
),
);
}
};
// ----- load + dispatch ----------------------------------------------------
// attempt 0..MAX_AUTO_RETRIES-1 = silent backoff retries for the persist
// race; past that (including the manual Retry button, which passes
// MAX_AUTO_RETRIES) failures land on the error card immediately.
const renderEntry = (entry, attempt) => {
const token = ++pane._loadToken;
const d = entry.descriptor;
const url = previewContentUrl(entry.ctx, d);
const failed = (why) => {
if (token !== pane._loadToken) return;
if (attempt < MAX_AUTO_RETRIES) {
setTimeout(
() => {
if (token === pane._loadToken) renderEntry(entry, attempt + 1);
},
RETRY_BASE_MS * Math.pow(2, attempt),
);
return;
}
renderError(why, entry);
};
pane._kindEl.textContent = d.kind || "";
// Display strings can be raw URLs — never print embedded credentials.
// The ext link below keeps the RAW url (it must actually navigate).
pane._titleEl.textContent = redactCredentials(d.title || d.source || "");
pane._titleEl.title = redactCredentials(d.source || "");
const isWeb = d.kind === "web" && /^https?:\/\//.test(d.source || "");
pane._extLink.hidden = !isWeb;
if (isWeb) pane._extLink.href = d.source;
api.setTitle && api.setTitle("Preview · " + shortTitle(d));
pane._contentEl.replaceChildren(make("div", "preview-loading", "Loading…"));
if (d.kind === "web" || d.kind === "pdf" || d.kind === "image") {
// src-loaded kinds: preflight with authFetch so the persist race and
// auth failures surface as a typed error card, not a broken frame.
authFetch(url, { method: "HEAD" })
.then((r) => {
if (token !== pane._loadToken) return;
if (!r.ok) {
failed(
r.status === 404
? "This preview's content isn't available yet."
: "Could not load the preview (" + r.status + ").",
);
return;
}
if (d.kind === "web") renderWeb(url);
else if (d.kind === "pdf") renderPdf(url, d);
else renderImage(url, d);
})
.catch(() => failed("Could not load the preview."));
return;
}
// text-family kinds render client-side from fetched text.
authFetch(url)
.then((r) => {
if (token !== pane._loadToken) return null;
if (!r.ok) {
failed(
r.status === 404
? "This preview's content isn't available yet."
: "Could not load the preview (" + r.status + ").",
);
return null;
}
return r.text();
})
.then((text) => {
if (text == null || token !== pane._loadToken) return;
if (d.kind === "table") renderTable(text, d);
else if (d.kind === "markdown") renderMarkdownDoc(text);
else renderText(text);
})
.catch(() => failed("Could not load the preview."));
};
const showAt = (idx) => {
if (idx < 0 || idx >= pane._stack.length) return;
pane._idx = idx;
setNavState();
const entry = pane._stack[idx];
renderEntry(entry, 0);
// Persist ONLY the current view — reload restores it via the factory's
// rehydrate hint (the meta shape is exactly the entry: serializable).
api.persistMeta &&
api.persistMeta({ descriptor: entry.descriptor, ctx: entry.ctx });
};
/** Public API: show a descriptor (new gesture or chip re-open). */
pane.showPreview = (descriptor, ctx) => {
if (!descriptor || !descriptor.attachment_id) return;
const top = pane._stack[pane._idx];
if (
top &&
top.descriptor.attachment_id === descriptor.attachment_id &&
top.descriptor.kind === descriptor.kind
) {
// Same content re-requested — re-render in place (retry semantics),
// don't grow the history with duplicates.
showAt(pane._idx);
return;
}
// A new view truncates any forward history (browser-history semantics).
pane._stack = pane._stack.slice(0, pane._idx + 1);
pane._stack.push({ descriptor, ctx: ctx || null });
if (pane._stack.length > HISTORY_CAP) pane._stack.shift();
showAt(pane._stack.length - 1);
};
pane.onMount = function () {
const root = make("div", "preview-root");
const bar = make("div", "preview-bar");
const back = make("button", "preview-nav", "◀");
back.type = "button";
back.title = "Previous preview (←)";
back.setAttribute("aria-label", "Previous preview");
back.addEventListener("click", () => showAt(pane._idx - 1));
const fwd = make("button", "preview-nav", "▶");
fwd.type = "button";
fwd.title = "Next preview (→)";
fwd.setAttribute("aria-label", "Next preview");
fwd.addEventListener("click", () => showAt(pane._idx + 1));
const kind = make("span", "preview-kindchip", "");
const title = make("span", "preview-titletext", "");
const ext = make("a", "preview-ext", "Open in browser ↗");
ext.target = "_blank";
ext.rel = "noopener noreferrer";
ext.hidden = true;
bar.append(back, fwd, kind, title, ext);
const content = make("div", "preview-content");
pane._backBtn = back;
pane._fwdBtn = fwd;
pane._kindEl = kind;
pane._titleEl = title;
pane._extLink = ext;
pane._contentEl = content;
root.append(bar, content);
this.bodyEl.append(root);
// ←/→ walk the preview history while the pane has focus. The pane hosts
// no text inputs; the guard keeps future in-pane fields (and the header
// link) from losing their native arrow behaviour.
this.el.addEventListener("keydown", (e) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
const t = e.target;
if (
t &&
(t.tagName === "INPUT" ||
t.tagName === "TEXTAREA" ||
t.isContentEditable)
)
return;
e.preventDefault();
showAt(pane._idx + (e.key === "ArrowLeft" ? -1 : 1));
});
setNavState();
// Rehydrate: a reload restores the LAST viewed preview from pane.meta.
if (extra && extra.descriptor) {
pane.showPreview(extra.descriptor, extra.ctx || null);
} else {
renderEmpty();
}
};
pane.onClose = function () {
pane._loadToken++; // orphan any in-flight loads
pane._stack = [];
pane._idx = -1;
};
return pane;
}
+36
View File
@@ -33,6 +33,7 @@ import { authFetch } from "./auth.js";
// standalone turnstone-server has no /static/coordinator/* and a static import
// would 404 and abort the whole shell module.
import { createInteractivePane } from "./interactive.js";
import { createPreviewPane } from "./preview.js";
function make(tag, className, text) {
const node = document.createElement(tag);
@@ -1003,6 +1004,40 @@ async function mountShell() {
}
}
// Preview pane: rich rendering of tool-selected content (the open_preview
// tool) — a singleton that opens BESIDE the conversation that produced it.
// Surface-agnostic: the descriptor arrives on a conversational pane's
// Tier-2 stream and reaches here through the TS_SHELL.openPreview seam.
// `extra` is the rehydrate hint (last-viewed descriptor + transport ctx)
// the pane keeps current via setPaneMeta, so a reload restores the view.
pm.registerType("preview", (id, extra) => {
const pane = createPreviewPane(extra, {
persistMeta: (meta) => pm.setPaneMeta("preview", meta),
setTitle: (text) => pm.setTabTitle("preview", text),
});
pane.tabMenu = () => [
{
label: "Close pane",
accel: "close-pane",
key: paneAccelBadge("close-pane"),
action: () => pm.close(pane.id),
},
];
return pane;
});
// Create-or-focus the preview pane BESIDE the focused cell (the
// conversation stays visible; a denied split degrades to a tab swap
// inside openPaneBeside), then hand it the descriptor. `ctx` is the
// originating pane's transport context ({base, wsId}) — blob fetches ride
// the same node proxy the session streams from.
const openPreview = (descriptor, ctx) => {
if (!descriptor) return;
const pane = pm.openPaneBeside("preview");
if (pane && typeof pane.showPreview === "function") {
pane.showPreview(descriptor, ctx || null);
}
};
// Tier-1 lifecycle → pane signal. The console's ws_closed handler calls
// this so an open pane on a CLOSED session closes outright — tab gone, a
// split cell collapses onto its sibling. This is the coordinator-closes-
@@ -1023,6 +1058,7 @@ async function mountShell() {
notifySessionClosed,
setRowBadge,
inEditable,
openPreview,
};
// Login fan-out: app.js owns the single window.onLoginSuccess (the Tier-1
+24
View File
@@ -0,0 +1,24 @@
{
"name": "open_preview",
"description": "Show the user rich content in a preview pane beside the conversation: a rendered web page, a PDF, an image, a data table (CSV/TSV/JSON), or a text/markdown document. Use this when the user asks to SEE something — a page, document, chart image, or data file — rather than asking a question about its contents. The result only confirms what was shown; to reason about content yourself, use web_fetch or read_file. Typical flows: 'show me the pricing page' → open_preview(target='https://…'); after bash renders chart.png → open_preview(target='chart.png'); 'let me see that PDF' → open_preview(target='attachment:<id>').",
"parameters": {
"type": "object",
"properties": {
"target": {
"type": "string",
"description": "What to preview: an http(s) URL, a file path, or attachment:<id> for a file attached to this conversation."
},
"kind": {
"type": "string",
"enum": ["web", "pdf", "image", "table", "text", "markdown"],
"description": "Optional rendering override. Usually omitted — the kind is detected from the content. Set it to force a view, e.g. kind='table' for a .txt containing CSV, or kind='text' to show a page's raw HTML."
},
"title": {
"type": "string",
"description": "Optional title for the preview pane header. Defaults to the page title, filename, or URL."
}
},
"required": ["target"]
},
"primary_key": "target"
}
+1
View File
@@ -22,6 +22,7 @@
<link rel="stylesheet" href="/static/style.css" />
<link rel="stylesheet" href="/shared/shell.css" />
<link rel="stylesheet" href="/shared/interactive.css" />
<link rel="stylesheet" href="/shared/preview.css" />
<link rel="stylesheet" href="/shared/hatch.css" />
<link rel="stylesheet" href="/shared/katex-0.17.0/katex.min.css" />
</head>