mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
7a06f5e8bc
* 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.
821 lines
31 KiB
Python
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
|