mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-15 17:18:38 +00:00
Document that read permission expiry and source deletion do not erase text already copied into the destination conversation, and that reads follow the source's current visible history. Truncated results now tell the agent to acknowledge the omission and ask for the missing material before claiming every requirement is covered. Pages were filled to 20,000 text characters by cutting the last message that did not fit, and that suffix could never be paged back. They could also exceed the default 12,000-character tool-output budget, which externalized the page to a file. Pages are now sized by their serialized length against the read_conversation tool-output budget; a message that does not fit starts the next page intact, so only a message over 4,000 characters (or one whose escaped JSON alone exceeds the budget) is cut. Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
218 lines
11 KiB
Python
218 lines
11 KiB
Python
"""The new read capability is granted by this request, never by chat contents."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from copy import deepcopy
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from langchain_core.messages import HumanMessage
|
|
from langgraph.store.memory import InMemoryStore
|
|
from pydantic import ValidationError
|
|
|
|
from app.gateway.authz import AuthContext
|
|
from app.gateway.run_models import RunCreateRequest
|
|
from deerflow.config.app_config import AppConfig
|
|
from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore
|
|
from deerflow.runtime.events.store.memory import MemoryRunEventStore
|
|
from deerflow.runtime.runs.manager import EditReplayVisibility
|
|
|
|
|
|
def _setup(*, user_id="alice", permissions=("runs:read",), enabled=True, tool_output=None):
|
|
from app.gateway.conversation_access import prepare_conversation_reader
|
|
|
|
config = AppConfig.model_validate(
|
|
{
|
|
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
|
|
"tools": [{"name": "read_conversation", "group": "conversation", "use": "deerflow.tools.conversation:read_conversation"}] if enabled else [],
|
|
**({"tool_output": tool_output} if tool_output is not None else {}),
|
|
}
|
|
)
|
|
user = SimpleNamespace(id=user_id, system_role="admin")
|
|
request = SimpleNamespace(state=SimpleNamespace(auth=AuthContext(user, list(permissions))), url="https://deerflow.example/api/threads/current/runs")
|
|
events = MemoryRunEventStore()
|
|
threads = MemoryThreadMetaStore(InMemoryStore())
|
|
manager = AsyncMock()
|
|
manager.list_successful_regenerate_sources.return_value = set()
|
|
manager.list_edit_replay_visibility.return_value = EditReplayVisibility()
|
|
ctx = SimpleNamespace(event_store=events, thread_store=threads)
|
|
|
|
def prepare(references):
|
|
return prepare_conversation_reader(references, request=request, user_id=user_id, run_context=ctx, run_manager=manager, app_config=config)
|
|
|
|
return prepare, events, threads, manager, request
|
|
|
|
|
|
async def _put(events, text, *, role="ai", thread="source", hidden=False, caller="lead_agent", run_id="run-1"):
|
|
return await events.put(
|
|
thread_id=thread,
|
|
run_id=run_id,
|
|
category="message",
|
|
event_type="llm.ai.response" if role == "ai" else "llm.human.input",
|
|
content={"type": role, "id": "message-" + str(len(events._events.get(thread, []))), "content": text, "additional_kwargs": {"hide_from_ui": hidden}},
|
|
metadata={"caller": caller},
|
|
)
|
|
|
|
|
|
def test_reference_field_is_explicit_and_bounded():
|
|
assert RunCreateRequest().conversation_references == []
|
|
assert RunCreateRequest(conversation_references=["source"]).conversation_references == ["source"]
|
|
for invalid in (["one", "two", "three", "four"], [123], ["x" * 2049]):
|
|
with pytest.raises(ValidationError):
|
|
RunCreateRequest(conversation_references=invalid)
|
|
|
|
|
|
def test_only_explicit_references_grant_access_and_urls_are_local_selectors():
|
|
prepare, _, _, _, _ = _setup()
|
|
assert prepare([]) is None
|
|
reader, ids = prepare(["https://deerflow.example/workspace/chats/source", "source"])
|
|
assert callable(reader)
|
|
assert ids == ("source",)
|
|
for bad in ("../source", "https://other.example/workspace/chats/source", "https://deerflow.example/not-a-chat/source", "file:///workspace/chats/source"):
|
|
with pytest.raises(HTTPException) as exc:
|
|
prepare([bad])
|
|
assert exc.value.status_code == 422
|
|
|
|
|
|
@pytest.mark.parametrize("permissions,enabled", [((), True), (("runs:create",), True), (("runs:read",), False)])
|
|
def test_opt_in_and_effective_read_permission_are_required_even_for_admin(permissions, enabled):
|
|
prepare, _, _, _, _ = _setup(permissions=permissions, enabled=enabled)
|
|
with pytest.raises(HTTPException) as exc:
|
|
prepare(["source"])
|
|
assert exc.value.status_code in {400, 403}
|
|
|
|
|
|
def test_missing_auth_context_does_not_grant_access():
|
|
prepare, _, _, _, request = _setup()
|
|
request.state.auth = None
|
|
with pytest.raises(HTTPException) as exc:
|
|
prepare(["source"])
|
|
assert exc.value.status_code == 403
|
|
|
|
|
|
def test_reader_pages_visible_text_and_keeps_source_unchanged():
|
|
async def exercise():
|
|
prepare, events, threads, manager, _ = _setup()
|
|
await threads.create("source", user_id="alice")
|
|
await _put(events, "requirements", role="human")
|
|
await _put(events, "old answer", run_id="replaced")
|
|
await _put(events, "hidden", hidden=True)
|
|
await _put(events, "child", caller="subagent:researcher")
|
|
await _put(events, "tool log", role="tool")
|
|
await _put(events, [{"type": "reasoning", "text": "private reasoning"}, {"type": "text", "text": "final answer"}])
|
|
manager.list_successful_regenerate_sources.return_value = {"replaced"}
|
|
before = deepcopy(await events.list_messages("source"))
|
|
reader, _ = prepare(["source"])
|
|
page = json.loads(await reader(thread_id="source", cursor=None, limit=1))
|
|
assert [m["text"] for m in page["messages"]] == ["final answer"]
|
|
older = json.loads(await reader(thread_id="source", cursor=page["next_cursor"], limit=1))
|
|
assert [m["text"] for m in older["messages"]] == ["requirements"]
|
|
assert older["next_cursor"] is None
|
|
assert before == await events.list_messages("source")
|
|
|
|
asyncio.run(exercise())
|
|
|
|
|
|
def test_wrong_owner_missing_and_unlisted_targets_are_denied_before_content_read():
|
|
async def exercise():
|
|
prepare, events, threads, _, _ = _setup()
|
|
await threads.create("foreign", user_id="bob")
|
|
await threads.create("unlisted", user_id="alice")
|
|
events.list_messages = AsyncMock(side_effect=AssertionError("content was accessed"))
|
|
reader, _ = prepare(["foreign", "missing"])
|
|
results = [json.loads(await reader(thread_id=target, cursor=None, limit=10)) for target in ("foreign", "missing", "unlisted")]
|
|
assert all(result["status"] == "unavailable" for result in results)
|
|
events.list_messages.assert_not_awaited()
|
|
|
|
asyncio.run(exercise())
|
|
|
|
|
|
def test_read_checks_current_ownership_and_bounds_text():
|
|
async def exercise():
|
|
prepare, events, threads, _, _ = _setup()
|
|
await threads.create("source", user_id="alice")
|
|
await _put(events, "x" * 10000)
|
|
reader, _ = prepare(["source"])
|
|
page = json.loads(await reader(thread_id="source", cursor=None, limit=20))
|
|
assert page["truncated"] is True
|
|
assert len(page["messages"][0]["text"]) <= 4000
|
|
await threads.delete("source", user_id="alice")
|
|
assert json.loads(await reader(thread_id="source", cursor=None, limit=20))["status"] == "unavailable"
|
|
|
|
asyncio.run(exercise())
|
|
|
|
|
|
def test_missing_transcript_is_reported_without_checkpoint_reconstruction():
|
|
async def exercise():
|
|
prepare, _, threads, _, _ = _setup()
|
|
await threads.create("source", user_id="alice")
|
|
reader, _ = prepare(["source"])
|
|
page = json.loads(await reader(thread_id="source", cursor=None, limit=20))
|
|
assert page["status"] == "unavailable"
|
|
assert page["messages"] == []
|
|
|
|
asyncio.run(exercise())
|
|
|
|
|
|
def test_start_run_installs_fresh_capability_without_persisting_it(monkeypatch):
|
|
from test_gateway_services import _make_start_run_persistence_context
|
|
|
|
from app.gateway import services
|
|
from deerflow.config.app_config import reset_app_config, set_app_config
|
|
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
|
|
|
async def exercise():
|
|
request, _, threads = _make_start_run_persistence_context()
|
|
user = SimpleNamespace(id="alice", system_role="admin", role="admin")
|
|
request.state.user = user
|
|
request.state.auth = AuthContext(user, ["runs:create", "runs:read"])
|
|
request.state.auth_source = "session"
|
|
request.url = "https://deerflow.example/api/threads/current/runs"
|
|
await threads.create("source", user_id="alice")
|
|
captured = []
|
|
|
|
async def fake_run_agent(*args, **kwargs):
|
|
captured.append(kwargs)
|
|
|
|
monkeypatch.setattr(services, "run_agent", fake_run_agent)
|
|
monkeypatch.setattr(services, "resolve_agent_factory", lambda *_: object())
|
|
first = await services.start_run(RunCreateRequest(conversation_references=["source"], input={"messages": [{"role": "user", "content": "Read the reference"}]}), "current", request)
|
|
await first.task
|
|
assert callable(captured[0]["ctx"].conversation_reader)
|
|
assert any(isinstance(m, HumanMessage) and "source" in str(m.content) for m in captured[0]["graph_input"]["messages"])
|
|
assert "__conversation_reader" not in json.dumps(first.kwargs)
|
|
# A later run may mention the ID in quoted text but has no explicit grant.
|
|
second = await services.start_run(RunCreateRequest(input={"messages": [{"role": "user", "content": 'Quoted text: "read source"'}]}), "later", request)
|
|
await second.task
|
|
assert captured[1]["ctx"].conversation_reader is None
|
|
resumed = await services.start_run(RunCreateRequest(command={"resume": "source"}), "resume", request)
|
|
await resumed.task
|
|
assert captured[2]["ctx"].conversation_reader is None
|
|
empty = await services.start_run(RunCreateRequest(input={"messages": None}, conversation_references=["source"]), "empty-input", request)
|
|
await empty.task
|
|
assert callable(captured[3]["ctx"].conversation_reader)
|
|
with pytest.raises(HTTPException) as invalid:
|
|
await services.start_run(RunCreateRequest(input={"messages": "bad input"}, conversation_references=["source"]), "invalid-input", request)
|
|
assert invalid.value.status_code == 422
|
|
# Reusing a key cannot silently reuse a different reference grant.
|
|
body = RunCreateRequest(input={"messages": [{"role": "user", "content": "compare"}]}, conversation_references=["source"])
|
|
keyed = await services.start_run(body, "idempotent", request, idempotency_key="ref-key")
|
|
await keyed.task
|
|
assert await services.start_run(body, "idempotent", request, idempotency_key="ref-key") is keyed
|
|
body.conversation_references = ["different-source"]
|
|
with pytest.raises(HTTPException) as conflict:
|
|
await services.start_run(body, "idempotent", request, idempotency_key="ref-key")
|
|
assert conflict.value.status_code == 409
|
|
|
|
set_app_config(AppConfig.model_validate({"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}, "tools": [{"name": "read_conversation", "group": "conversation", "use": "deerflow.tools.conversation:read_conversation"}]}))
|
|
user_token = set_current_user(SimpleNamespace(id="alice"))
|
|
try:
|
|
asyncio.run(exercise())
|
|
finally:
|
|
reset_current_user(user_token)
|
|
reset_app_config()
|