mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-15 00:19:14 +00:00
* feat: add scoped conversation reads to gateway runs * refactor: share conversation tool path and reuse parsed text --------- Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
217 lines
11 KiB
Python
217 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):
|
|
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 [],
|
|
}
|
|
)
|
|
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()
|