Files
turnstone/tests/test_open_preview_tool.py
T
Patrick Buckley 7a06f5e8bc refactor(session): make ModelLane the provider boundary (#979) (#989)
* refactor(session): make ModelLane the provider boundary (#979)

## Summary

This closes the model-lane ownership gap left by #832: `ChatSession` no longer stores raw provider/client handles. `ResolvedModelBinding` now carries the provider, client, model, capabilities, registry generation, and backend-auth configuration as one coherent snapshot.

- Atomically rebind existing sessions after model-registry changes while pinning each in-flight send, fallback, judge, output guard, task agent, title, compaction, perception, and voice operation to its initiating principal and binding.
- Fence UI publication, canonical trajectory folds, durable writes, streams, retries, child scopes, and judge work by generation. Stop can hand off to a successor without accepting late state; cancelled tools retain typed effect receipts, and concurrent approval batches resolve by exact cycle or call.
- Make create, fork, open, close, and delete race-safe with hidden `creating` reservations, incarnation-aware state tails, and an ACL-rechecked transaction that clones checkpoint-bounded history, configuration, project/persona state, and attachment references.
- Extend REST/OpenAPI and Python/TypeScript SDK contracts for create/fork inputs, routed-create metadata, live-workstream probes, targeted approvals, and structured cancellation results.
- Update architecture, storage, authentication, judge, channel, console, API, and SDK documentation, including regenerated architecture diagrams and OpenAPI artifacts.

## Validation

- SQLite suite: 11,188 passed, 9 skipped, 10 deselected
- PostgreSQL suite: 11,195 passed, 2 skipped, 10 deselected
- Live backend: 3 passed
- SSE recovery: 6 passed; browser recovery harness passed all scenarios
- Ruff: clean; 595 files correctly formatted
- mypy: 243 source files clean
- TypeScript: typecheck/build and 35 tests passed
- OpenAPI artifacts fresh; all 14 changed diagrams reproduce byte-for-byte
- `git diff --check` and Git LFS integrity clean

Closes #979.

* fix(deps): update nanoid for GHSA-2v37-7h3g-55p8

Refresh the transitive lock entry admitted by PostCSS so the TypeScript security gate no longer resolves the vulnerable custom-generator implementation.

Validation:
- npm ci
- npm audit --audit-level=moderate: 0 vulnerabilities
- TypeScript typecheck and build
- TypeScript tests: 35 passed

* fix(test): assert canonical model registry URLs

Replace prefix checks with exact canonical base URL assertions so the tests do not model incomplete URL validation.

Validation: tests/test_model_registry.py (185 passed); Ruff check/format; mypy.
2026-08-08 16:13:35 -07:00

821 lines
31 KiB
Python

"""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_url_pdf_over_10mb_previews_to_kind_cap(self, monkeypatch):
# Review finding (PR #800): a flat 10 MB URL pre-check rejected PDFs
# the 32 MiB pdf kind cap allows — the fetch ceiling must track the
# widest kind cap and leave the per-kind caps as the authority.
from turnstone.core.preview import PREVIEW_SIZE_CAPS
s = _make_session()
body = b"%PDF-1.7\n" + b"a" * (12 * 1024 * 1024)
seen = {}
def _capture(url, **kw):
seen.update(kw)
return _fake_response(url, body, "application/pdf")
monkeypatch.setattr("turnstone.core.session.fetch_with_ssrf_guard", _capture)
item = s._prepare_open_preview("c1", {"target": "https://acme.com/report.pdf"})
_, msg = s._exec_open_preview(item)
assert not msg.startswith("Error:")
descriptor, _ = s._tool_previews["c1"]
assert descriptor["kind"] == "pdf"
assert descriptor["size"] == len(body)
assert seen["max_bytes"] == max(PREVIEW_SIZE_CAPS.values())
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_preview_blob_id_salted_out_of_upload_namespace(self, tmp_path):
import hashlib
s = _make_session()
p = tmp_path / "chart.png"
p.write_bytes(PNG_1x1)
item = s._prepare_open_preview("c1", {"target": str(p)})
s._exec_open_preview(item)
_, att = s._tool_previews["c1"]
# Uploads are keyed bare sha256(body) and save_attachment freezes
# `kind` at first insert — an unsalted preview of identical bytes
# would collide with (or pre-empt) a real upload's row.
assert att.attachment_id != hashlib.sha256(PNG_1x1).hexdigest()
assert att.attachment_id == hashlib.sha256(b"preview:" + PNG_1x1).hexdigest()
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_legacy_charset_table_stored_as_utf8(self, monkeypatch):
# A latin-1 CSV attachment previews as a table, and the executor
# transcodes it to UTF-8 at store time so "café" round-trips instead of
# erroring "not previewable".
s = _make_session(ws_id="ws-1")
latin1_csv = "name,city\nRené,Montréal\n".encode("iso-8859-1")
monkeypatch.setattr(
"turnstone.core.memory.get_attachment",
lambda aid: {
"content": latin1_csv,
"mime_type": "text/csv; charset=iso-8859-1",
"filename": "people.csv",
},
)
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, att = s._tool_previews["c1"]
assert descriptor["kind"] == "table"
assert descriptor["content_type"].startswith("text/csv")
# Stored bytes are valid UTF-8 with the accented characters preserved.
assert att.content.decode("utf-8") == "name,city\nRené,Montréal\n"
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:
"""client.stream() double: a context manager yielding chunked body bytes."""
def __init__(self, status, headers=None, body=b""):
self.status_code = status
self.headers = headers or {}
self._chunks = body if isinstance(body, list) else [body]
def __enter__(self):
return self
def __exit__(self, *a):
return False
def iter_bytes(self):
yield from self._chunks
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 stream(self, method, 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, {}, body=b"landed"),
},
)
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, {}),
},
)
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
# The realized response carries the FINAL hop's URL — open_preview's
# descriptor source and stored <base href> both key off it.
assert str(resp.url) == "https://a.example/moved"
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)
def test_body_over_budget_aborts(self, monkeypatch):
import pytest
from turnstone.core.web import fetch_with_ssrf_guard
self._wire(
monkeypatch,
{"https://a.example/": _FakeHop(200, {}, body=[b"aaaa", b"bbbb", b"cccc"])},
)
monkeypatch.setattr("turnstone.core.web.check_ssrf", lambda url: None)
with pytest.raises(ValueError, match="fetch limit"):
fetch_with_ssrf_guard("https://a.example/", timeout=5, max_bytes=10)
def test_redirect_hop_body_never_read(self, monkeypatch):
from turnstone.core.web import fetch_with_ssrf_guard
class _BodyBomb(_FakeHop):
def iter_bytes(self):
raise AssertionError("redirect hop body must not be read")
self._wire(
monkeypatch,
{
"https://a.example/": _BodyBomb(302, {"location": "https://b.example/x"}),
"https://b.example/x": _FakeHop(200, {}, body=b"ok"),
},
)
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 resp.content == b"ok"
def test_stale_framing_headers_dropped(self, monkeypatch):
from turnstone.core.web import fetch_with_ssrf_guard
self._wire(
monkeypatch,
{
"https://a.example/": _FakeHop(
200,
{
"content-encoding": "gzip",
"content-length": "999",
"content-type": "text/html; charset=utf-8",
},
body=b"<html>hi</html>",
)
},
)
monkeypatch.setattr("turnstone.core.web.check_ssrf", lambda url: None)
resp = fetch_with_ssrf_guard("https://a.example/", timeout=5)
# iter_bytes() hands the guard content-DECODED bytes — a surviving
# content-encoding would make .text try to gunzip plain text, and the
# upstream content-length no longer describes the body carried.
assert "content-encoding" not in resp.headers
assert resp.headers.get("content-length") != "999"
assert resp.headers.get("content-type") == "text/html; charset=utf-8"
assert resp.text == "<html>hi</html>"
# ---------------------------------------------------------------------------
# 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", ws_id=None: persisted.update(
{
"row": row_id,
"ids": [a.attachment_id for a in atts],
"origin": origin,
"ws_id": ws_id,
}
),
)
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", "ws_id": "ws-1"}
# 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
# ---------------------------------------------------------------------------
# tools.allow_private_network — the self-hoster opt-in (admin Settings → Tools)
# ---------------------------------------------------------------------------
class TestAllowPrivateNetwork:
def test_screen_public_url_passes(self):
from turnstone.core.session import _screen_tool_url
err, private = _screen_tool_url("https://example.com/x", False)
assert err is None and private is False
def test_screen_private_blocked_with_discoverable_hint(self):
from turnstone.core.session import _screen_tool_url
err, private = _screen_tool_url("http://10.0.0.7/grafana", False)
assert err is not None and private is False
# The refusal teaches the knob (mirrors the oidc opt-in hint pattern).
assert "tools.allow_private_network" in err
assert "Settings" in err
def test_screen_private_allowed_when_opted_in(self):
from turnstone.core.session import _screen_tool_url
err, private = _screen_tool_url("http://10.0.0.7/grafana", True)
assert err is None and private is True
def test_screen_invalid_url_never_hints(self):
from turnstone.core.session import _screen_tool_url
err, private = _screen_tool_url("http://", True)
assert err is not None and private is False
assert "allow_private_network" not in err
def test_bare_session_defaults_strict(self):
# No ConfigStore (CLI / eval surface) → no admin opted in → strict.
s = _make_session()
assert s._allow_private_network() is False
def test_prepare_web_fetch_private_opted_in(self, monkeypatch):
s = _make_session()
monkeypatch.setattr(ChatSession, "_allow_private_network", lambda self: True)
item = s._prepare_web_fetch(
"c1", {"url": "http://192.168.1.50:3000/d/home", "question": "what is shown?"}
)
assert "error" not in item
assert item["needs_approval"] is True # the human gate stays
assert "(private network)" in item["header"]
assert item["allow_private_origin"] is True
def test_prepare_open_preview_private_opted_in(self, monkeypatch):
s = _make_session()
monkeypatch.setattr(ChatSession, "_allow_private_network", lambda self: True)
item = s._prepare_open_preview("c1", {"target": "http://192.168.1.50:3000/d/home"})
assert "error" not in item
assert item["needs_approval"] is True
assert "(private network)" in item["header"]
assert item["allow_private_origin"] is True
def test_prepare_private_still_blocked_by_default(self, monkeypatch):
s = _make_session()
monkeypatch.setattr(ChatSession, "_allow_private_network", lambda self: False)
for prepare, args in (
(s._prepare_web_fetch, {"url": "http://10.0.0.7/x", "question": "q"}),
(s._prepare_open_preview, {"target": "http://10.0.0.7/x"}),
):
item = prepare("c1", args)
assert "error" in item
assert "tools.allow_private_network" in item["error"]
def test_executor_passes_private_origin_to_guard(self, monkeypatch):
s = _make_session()
monkeypatch.setattr(ChatSession, "_allow_private_network", lambda self: True)
seen = {}
def _capture(url, **kw):
seen.update(kw, url=url)
return _fake_response(url, b"<html><head></head><body>x</body></html>", "text/html")
monkeypatch.setattr("turnstone.core.session.fetch_with_ssrf_guard", _capture)
item = s._prepare_open_preview("c1", {"target": "http://10.0.0.7/status"})
s._exec_open_preview(item)
assert seen["allow_private_origin"] is True
def test_guard_skips_hop_screen_for_private_origin(self, monkeypatch):
from turnstone.core.web import fetch_with_ssrf_guard
_FakeClient.calls = []
_FakeClient.table = {
"http://10.0.0.7/a": _FakeHop(302, {"location": "http://10.0.0.8/b"}),
"http://10.0.0.8/b": _FakeHop(200, {}),
}
monkeypatch.setattr("turnstone.core.web.httpx.Client", _FakeClient)
def _explode(url):
raise AssertionError("hop screening must be skipped for a private origin")
monkeypatch.setattr("turnstone.core.web.check_ssrf", _explode)
resp = fetch_with_ssrf_guard("http://10.0.0.7/a", timeout=5, allow_private_origin=True)
assert resp.status_code == 200
assert _FakeClient.calls == ["http://10.0.0.7/a", "http://10.0.0.8/b"]
def test_registry_entry_shape(self):
from turnstone.core.settings_registry import SETTINGS
d = SETTINGS["tools.allow_private_network"]
assert d.type == "bool"
assert d.default is False
assert d.section == "tools"
assert d.help # the admin form renders this — it must explain the caveat