mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
fix(gateway): scope per-run reads to the run's data identity (#6282)
* fix(gateway): scope per-run message and event reads to the run's data identity
start_run stamps run-event rows with the raw trusted owner id, but
GET /runs/{rid}/messages and /runs/{rid}/events called the event store with
user_id=AUTO, which resolves to the internal caller's make_safe_user_id form.
On the database event store, owners whose id contains characters outside
[A-Za-z0-9_-] (IM channel ids such as "feishu:owner-777") therefore got an
empty list. The regenerate source-run scan had the same mismatch and fell
back or returned 409.
Pass _run_scope_user_id() explicitly, as the thread message routes already
do (#5448), and add user_id to RunEventStore.list_messages_by_run() so every
backend accepts it like list_messages()/list_events(). Browser and API
sessions keep their per-user filter.
* fix(gateway): scope workspace-changes and archive reads to the run's data identity
The same raw-owner mismatch reached two more run-scoped routes. On the SQL
stores, GET /runs/{rid}/workspace-changes reported no changes because its
event lookup used user_id=AUTO, and GET /runs/{rid}/artifacts/archive
answered 404 because both its run-row lookup and its run.delivery event
lookup did.
Pass _run_scope_user_id() to both, and let get_workspace_changes_response()
forward an explicit user_id to the event store.
* docs(changelog): reference #6282 in the run-scope read fix entries
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
56aa71c583
commit
c540399a5e
12
CHANGELOG.md
12
CHANGELOG.md
@ -466,6 +466,17 @@ This release closes that milestone with **301 merged pull requests**.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **gateway:** Per-run reads now return the rows of IM-channel owners.
|
||||
`start_run` stamps run rows and run events with the raw trusted owner id (for
|
||||
example `feishu:owner-777`), but several run-scoped routes filtered by the
|
||||
internal caller's normalized id, so on the SQL stores any owner id containing
|
||||
characters outside `[A-Za-z0-9_-]` matched nothing:
|
||||
`GET /api/threads/{id}/runs/{rid}/messages` and `/events` returned an empty
|
||||
list, `/workspace-changes` reported no changes, `/artifacts/archive` answered
|
||||
404, and the regenerate source-run lookup fell back or failed with 409. These
|
||||
reads now use the same data identity as the thread message routes (#5448),
|
||||
and every event store accepts `user_id` on `list_messages_by_run()`. Browser
|
||||
and API sessions keep their per-user filter. ([#6282])
|
||||
- **runtime:** A multi-worker run that finished successfully is no longer
|
||||
reclaimed as an orphan `error` while its worker is still finalizing. With an
|
||||
event store, the worker records the terminal status in memory first and
|
||||
@ -7722,3 +7733,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
|
||||
[#6238]: https://github.com/bytedance/deer-flow/pull/6238
|
||||
[#6255]: https://github.com/bytedance/deer-flow/pull/6255
|
||||
[#6263]: https://github.com/bytedance/deer-flow/pull/6263
|
||||
[#6282]: https://github.com/bytedance/deer-flow/pull/6282
|
||||
|
||||
@ -423,6 +423,15 @@
|
||||
|
||||
### 修复
|
||||
|
||||
- **网关:** 单次运行的读取现在能返回 IM 渠道所有者的数据。`start_run` 用原始
|
||||
受信所有者 ID(例如 `feishu:owner-777`)标记运行行与运行事件,但多个运行级路由
|
||||
按内部调用方规范化后的 ID 过滤,因此在 SQL 存储上,只要所有者 ID 含有
|
||||
`[A-Za-z0-9_-]` 以外的字符就匹配不到任何数据:
|
||||
`GET /api/threads/{id}/runs/{rid}/messages` 与 `/events` 返回空列表,
|
||||
`/workspace-changes` 报告没有变更,`/artifacts/archive` 返回 404,重新生成的
|
||||
源运行查找退回兜底路径或返回 409。这些读取现在使用与线程消息路由(#5448)相同
|
||||
的数据身份,所有事件存储的 `list_messages_by_run()` 都接受 `user_id`。浏览器与
|
||||
API 会话仍保留按用户过滤。([#6282])
|
||||
- **运行时:** 多 worker 部署中已成功结束的运行,不会再在其 worker 仍在收尾时被
|
||||
当作孤儿运行回收为 `error`。配置了事件存储时,worker 先在内存中记录终态,直到
|
||||
journal 刷新、交付回执、工作区扫描与时长 checkpoint 写入完成后才写入运行存储。
|
||||
@ -6445,3 +6454,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
|
||||
[#6238]: https://github.com/bytedance/deer-flow/pull/6238
|
||||
[#6255]: https://github.com/bytedance/deer-flow/pull/6255
|
||||
[#6263]: https://github.com/bytedance/deer-flow/pull/6263
|
||||
[#6282]: https://github.com/bytedance/deer-flow/pull/6282
|
||||
|
||||
@ -604,8 +604,9 @@ async def _find_target_run_id(
|
||||
source_human: Any,
|
||||
request: Request,
|
||||
) -> str:
|
||||
user_id = await _run_scope_user_id(request, thread_id)
|
||||
event_store = get_run_event_store(request)
|
||||
rows = await event_store.list_messages(thread_id, limit=REGENERATE_HISTORY_SCAN_LIMIT)
|
||||
rows = await event_store.list_messages(thread_id, limit=REGENERATE_HISTORY_SCAN_LIMIT, user_id=user_id)
|
||||
for row in reversed(rows):
|
||||
if row.get("event_type") not in {"ai_message", "llm.ai.response"}:
|
||||
continue
|
||||
@ -619,7 +620,6 @@ async def _find_target_run_id(
|
||||
return source_run_id
|
||||
|
||||
run_mgr = get_run_manager(request)
|
||||
user_id = await _run_scope_user_id(request, thread_id)
|
||||
records = await run_mgr.list_by_thread(thread_id, user_id=user_id, limit=10)
|
||||
fallback_record = next(
|
||||
(record for record in records if record.status == RunStatus.success and _run_last_ai_matches_message(record, target_message)),
|
||||
@ -1159,10 +1159,12 @@ async def _require_run_visible_to_scope(run_id: str, thread_id: str, request: Re
|
||||
"""Gate run-scoped sub-resource reads and writes (events, messages, join,
|
||||
stream, cancel, artifact archive).
|
||||
|
||||
These routes query or mutate by ``(thread_id, run_id)`` without a
|
||||
per-user filter of their own. For trusted internal callers on threads
|
||||
without established ownership, that let an internal caller acting for
|
||||
owner A read or cancel owner B's run by id (#5448 review P1 follow-up).
|
||||
These routes query or mutate by ``(thread_id, run_id)``; events and
|
||||
messages add only the ``_run_scope_user_id`` data filter, which matches
|
||||
row stamps rather than gating the run itself. For trusted internal
|
||||
callers on threads without established ownership, a missing gate let an
|
||||
internal caller acting for owner A read or cancel owner B's run by id
|
||||
(#5448 review P1 follow-up).
|
||||
The run's own stamp must therefore match the acting owner's raw value (or
|
||||
the legacy ``"default"`` stamp); every other caller and every
|
||||
established-ownership thread keeps its existing semantics.
|
||||
@ -1638,6 +1640,7 @@ async def list_run_messages(
|
||||
Response: { data: [...], has_more: bool }
|
||||
"""
|
||||
await _require_run_visible_to_scope(run_id, thread_id, request)
|
||||
user_id = await _run_scope_user_id(request, thread_id)
|
||||
event_store = get_run_event_store(request)
|
||||
rows = await event_store.list_messages_by_run(
|
||||
thread_id,
|
||||
@ -1645,6 +1648,7 @@ async def list_run_messages(
|
||||
limit=limit + 1,
|
||||
before_seq=before_seq,
|
||||
after_seq=after_seq,
|
||||
user_id=user_id,
|
||||
)
|
||||
data, has_more = trim_run_message_page(rows, limit=limit, after_seq=after_seq)
|
||||
|
||||
@ -1718,7 +1722,8 @@ def _presented_files_from_delivery(events: list[dict]) -> list[str]:
|
||||
|
||||
|
||||
async def _archive_presented_paths(thread_id: ThreadId, run_id: str, request: Request) -> list[str]:
|
||||
run = await get_run_store(request).get(run_id)
|
||||
user_id = await _run_scope_user_id(request, thread_id)
|
||||
run = await get_run_store(request).get(run_id, user_id=user_id)
|
||||
if run is None or run.get("thread_id") != thread_id or run.get("operation_kind", "run") != "run":
|
||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
||||
if run.get("status") in {RunStatus.pending.value, RunStatus.running.value}:
|
||||
@ -1729,6 +1734,7 @@ async def _archive_presented_paths(thread_id: ThreadId, run_id: str, request: Re
|
||||
run_id,
|
||||
event_types=["run.delivery"],
|
||||
limit=2,
|
||||
user_id=user_id,
|
||||
)
|
||||
return _presented_files_from_delivery(events)
|
||||
|
||||
@ -1825,6 +1831,7 @@ async def list_run_events(
|
||||
task's persisted steps without the run-wide ``limit`` truncating the tail (#3779).
|
||||
"""
|
||||
await _require_run_visible_to_scope(run_id, thread_id, request)
|
||||
user_id = await _run_scope_user_id(request, thread_id)
|
||||
event_store = get_run_event_store(request)
|
||||
types = event_types.split(",") if event_types else None
|
||||
events = await event_store.list_events(
|
||||
@ -1834,6 +1841,7 @@ async def list_run_events(
|
||||
task_id=task_id,
|
||||
limit=limit,
|
||||
after_seq=after_seq,
|
||||
user_id=user_id,
|
||||
)
|
||||
return [
|
||||
{
|
||||
@ -1857,6 +1865,7 @@ async def get_run_workspace_changes(
|
||||
) -> dict:
|
||||
"""Return workspace/output file changes recorded for one run."""
|
||||
await _require_run_visible_to_scope(run_id, thread_id, request)
|
||||
user_id = await _run_scope_user_id(request, thread_id)
|
||||
event_store = get_run_event_store(request)
|
||||
return await get_workspace_changes_response(
|
||||
event_store,
|
||||
@ -1864,6 +1873,7 @@ async def get_run_workspace_changes(
|
||||
run_id,
|
||||
include_files=include_files,
|
||||
include_diff=include_diff,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -132,6 +132,13 @@ writer enter the supposedly stable snapshot. The default and JSONL paths share t
|
||||
`normalize_message_ids()` and `match_ai_message_run_id()` helpers from
|
||||
`events/store/base.py`. Database owner filtering is inherited on every page.
|
||||
|
||||
**Run-event read identity**: `list_messages`, `list_events` and
|
||||
`list_messages_by_run` accept `user_id` on every backend (DB filters;
|
||||
memory/JSONL accept it for parity). `start_run` stamps rows with the raw trusted
|
||||
owner, but `AUTO` resolves to the internal user's `make_safe_user_id` form, so
|
||||
Gateway thread/run reads (including run-row lookups) must pass
|
||||
`_run_scope_user_id()` explicitly.
|
||||
|
||||
**Event-store mutation fence** (`runtime/events/store/`): every thread mutation —
|
||||
`put`, `put_batch`, `put_if_absent`, `delete_by_thread`, `delete_by_run` — shares
|
||||
one serialization domain: the per-thread `asyncio` lock, plus (on PostgreSQL) the
|
||||
|
||||
@ -218,6 +218,7 @@ class RunEventStore(abc.ABC):
|
||||
limit: int = 50,
|
||||
before_seq: int | None = None,
|
||||
after_seq: int | None = None,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
) -> list[dict]:
|
||||
"""Return displayable messages (category=message) for a specific run, ordered by seq ascending.
|
||||
|
||||
@ -228,6 +229,8 @@ class RunEventStore(abc.ABC):
|
||||
|
||||
When both cursors are supplied, return the first ``limit`` records in
|
||||
the exclusive window ``after_seq < seq < before_seq`` (ascending).
|
||||
``user_id`` follows the same explicit-caller semantics as
|
||||
:meth:`list_messages`.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
|
||||
@ -399,7 +399,7 @@ class JsonlRunEventStore(RunEventStore):
|
||||
events = [e for e in events if e.get("seq", 0) > after_seq]
|
||||
return events[:limit]
|
||||
|
||||
async def list_messages_by_run(self, thread_id, run_id, *, limit=50, before_seq=None, after_seq=None):
|
||||
async def list_messages_by_run(self, thread_id, run_id, *, limit=50, before_seq=None, after_seq=None, user_id: str | None | _AutoSentinel = AUTO):
|
||||
events = await asyncio.to_thread(self._read_run_events, thread_id, run_id)
|
||||
filtered = [e for e in events if e.get("category") == "message"]
|
||||
if before_seq is not None:
|
||||
|
||||
@ -164,7 +164,7 @@ class MemoryRunEventStore(RunEventStore):
|
||||
run_events = [e for e in run_events if e.get("seq", 0) > after_seq]
|
||||
return run_events[:limit]
|
||||
|
||||
async def list_messages_by_run(self, thread_id, run_id, *, limit=50, before_seq=None, after_seq=None):
|
||||
async def list_messages_by_run(self, thread_id, run_id, *, limit=50, before_seq=None, after_seq=None, user_id: str | None | _AutoSentinel = AUTO):
|
||||
# Per-run, messages-only, seq-sorted: the seq window is a contiguous
|
||||
# slice located with bisect (O(log m_run)) over only this run's
|
||||
# messages, instead of re-scanning the whole thread's event log.
|
||||
|
||||
@ -2,6 +2,8 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from deerflow.runtime.user_context import AUTO, _AutoSentinel
|
||||
|
||||
from .types import WORKSPACE_CHANGES_EVENT_TYPE, WORKSPACE_CHANGES_METADATA_KEY
|
||||
|
||||
EMPTY_SUMMARY = {
|
||||
@ -22,12 +24,14 @@ async def get_workspace_changes_response(
|
||||
*,
|
||||
include_files: bool = True,
|
||||
include_diff: bool = True,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
) -> dict[str, Any]:
|
||||
events = await event_store.list_events(
|
||||
thread_id,
|
||||
run_id,
|
||||
event_types=[WORKSPACE_CHANGES_EVENT_TYPE],
|
||||
limit=10,
|
||||
user_id=user_id,
|
||||
)
|
||||
if not events:
|
||||
return _empty_response()
|
||||
|
||||
@ -393,6 +393,41 @@ class TestListMessagesByRun:
|
||||
assert messages[0]["run_id"] == "r1"
|
||||
assert messages[0]["category"] == "message"
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("backend", ["memory", "jsonl", "db"])
|
||||
async def test_explicit_user_id_follows_list_messages_semantics(self, tmp_path, backend):
|
||||
"""Run-scoped Gateway reads pass an explicit data identity on every backend.
|
||||
|
||||
Only the SQL store isolates by user; the others accept and ignore it,
|
||||
as they do for ``list_messages`` and ``list_events``.
|
||||
"""
|
||||
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
|
||||
from deerflow.runtime.events.store.db import DbRunEventStore
|
||||
from deerflow.runtime.events.store.jsonl import JsonlRunEventStore
|
||||
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||
|
||||
if backend == "db":
|
||||
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'test.db'}", sqlite_dir=str(tmp_path))
|
||||
s = DbRunEventStore(get_session_factory())
|
||||
elif backend == "jsonl":
|
||||
s = JsonlRunEventStore(base_dir=tmp_path / "jsonl")
|
||||
else:
|
||||
s = MemoryRunEventStore()
|
||||
try:
|
||||
token = set_current_user(type("Owner", (), {"id": "feishu:owner"})())
|
||||
try:
|
||||
await s.put(thread_id="t1", run_id="r1", event_type="ai_message", category="message")
|
||||
finally:
|
||||
reset_current_user(token)
|
||||
|
||||
assert len(await s.list_messages_by_run("t1", "r1", user_id="feishu:owner")) == 1
|
||||
assert len(await s.list_messages_by_run("t1", "r1", user_id=None)) == 1
|
||||
other = await s.list_messages_by_run("t1", "r1", user_id="someone-else")
|
||||
assert len(other) == (0 if backend == "db" else 1)
|
||||
finally:
|
||||
if backend == "db":
|
||||
await close_engine()
|
||||
|
||||
|
||||
# -- count_messages --
|
||||
|
||||
|
||||
@ -35,8 +35,8 @@ async def test_list_run_events_forwards_task_id_and_after_seq():
|
||||
calls: dict = {}
|
||||
|
||||
class FakeStore:
|
||||
async def list_events(self, thread_id, run_id, *, event_types=None, task_id=None, limit=500, after_seq=None):
|
||||
calls.update(thread_id=thread_id, run_id=run_id, event_types=event_types, task_id=task_id, limit=limit, after_seq=after_seq)
|
||||
async def list_events(self, thread_id, run_id, *, event_types=None, task_id=None, limit=500, after_seq=None, user_id=None):
|
||||
calls.update(thread_id=thread_id, run_id=run_id, event_types=event_types, task_id=task_id, limit=limit, after_seq=after_seq, user_id=user_id)
|
||||
return [{"seq": 1, "event_type": "subagent.step"}]
|
||||
|
||||
class FakeState:
|
||||
@ -48,6 +48,7 @@ async def test_list_run_events_forwards_task_id_and_after_seq():
|
||||
class FakeRequest:
|
||||
app = FakeApp()
|
||||
_deerflow_test_bypass_auth = True
|
||||
cookies: dict = {}
|
||||
|
||||
result = await list_run_events(
|
||||
thread_id="t1",
|
||||
@ -63,6 +64,7 @@ async def test_list_run_events_forwards_task_id_and_after_seq():
|
||||
assert calls["task_id"] == "task-A"
|
||||
assert calls["after_seq"] == 7
|
||||
assert calls["event_types"] == ["subagent.start", "subagent.step", "subagent.end"]
|
||||
assert calls["user_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@ -80,7 +82,7 @@ async def test_list_run_events_redacts_historical_run_start_metadata():
|
||||
}
|
||||
|
||||
class FakeStore:
|
||||
async def list_events(self, thread_id, run_id, *, event_types=None, task_id=None, limit=500, after_seq=None):
|
||||
async def list_events(self, thread_id, run_id, *, event_types=None, task_id=None, limit=500, after_seq=None, user_id=None):
|
||||
return [stored_row]
|
||||
|
||||
class FakeState:
|
||||
@ -92,6 +94,7 @@ async def test_list_run_events_redacts_historical_run_start_metadata():
|
||||
class FakeRequest:
|
||||
app = FakeApp()
|
||||
_deerflow_test_bypass_auth = True
|
||||
cookies: dict = {}
|
||||
|
||||
events = await list_run_events(
|
||||
thread_id="legacy-thread",
|
||||
@ -175,6 +178,7 @@ async def test_effective_memory_flows_from_injection_to_the_existing_debug_api()
|
||||
class FakeRequest_:
|
||||
app = FakeApp()
|
||||
_deerflow_test_bypass_auth = True
|
||||
cookies: dict = {}
|
||||
|
||||
events = await list_run_events(
|
||||
thread_id="t1",
|
||||
|
||||
@ -181,7 +181,7 @@ class FakeEventStore:
|
||||
def __init__(self, rows):
|
||||
self.rows = rows
|
||||
|
||||
async def list_messages(self, thread_id, *, limit=50, before_seq=None, after_seq=None):
|
||||
async def list_messages(self, thread_id, *, limit=50, before_seq=None, after_seq=None, user_id=None):
|
||||
return self.rows[-limit:]
|
||||
|
||||
|
||||
|
||||
@ -164,6 +164,7 @@ def test_after_seq_forwarded_to_event_store():
|
||||
limit=51, # default limit(50) + 1
|
||||
before_seq=None,
|
||||
after_seq=5,
|
||||
user_id=None,
|
||||
)
|
||||
|
||||
|
||||
@ -181,6 +182,7 @@ def test_before_seq_forwarded_to_event_store():
|
||||
limit=51,
|
||||
before_seq=10,
|
||||
after_seq=None,
|
||||
user_id=None,
|
||||
)
|
||||
|
||||
|
||||
@ -198,6 +200,7 @@ def test_custom_limit_forwarded_to_event_store():
|
||||
limit=11, # 10 + 1
|
||||
before_seq=None,
|
||||
after_seq=None,
|
||||
user_id=None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -86,6 +86,7 @@ def test_read_endpoints_accept_positive_limits_and_hit_store():
|
||||
limit=2,
|
||||
before_seq=None,
|
||||
after_seq=None,
|
||||
user_id=None,
|
||||
)
|
||||
app.state.run_event_store.list_events.assert_awaited_once_with(
|
||||
"thread-1",
|
||||
@ -94,6 +95,7 @@ def test_read_endpoints_accept_positive_limits_and_hit_store():
|
||||
task_id=None,
|
||||
limit=1,
|
||||
after_seq=None,
|
||||
user_id=None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -16,6 +16,7 @@ import asyncio
|
||||
from types import SimpleNamespace
|
||||
from uuid import UUID
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from fastapi.testclient import TestClient
|
||||
@ -27,10 +28,15 @@ from app.gateway.auth_disabled import AUTH_SOURCE_INTERNAL, AUTH_SOURCE_SESSION
|
||||
from app.gateway.authz import AuthContext, Permissions
|
||||
from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME, get_internal_user
|
||||
from app.gateway.routers import thread_runs
|
||||
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
|
||||
from deerflow.persistence.run.sql import RunRepository
|
||||
from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore
|
||||
from deerflow.runtime.events.store.db import DbRunEventStore
|
||||
from deerflow.runtime.events.store.memory import MemoryRunEventStore
|
||||
from deerflow.runtime.runs.manager import RunManager
|
||||
from deerflow.runtime.runs.store.memory import MemoryRunStore
|
||||
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||
from deerflow.workspace_changes.types import WORKSPACE_CHANGES_EVENT_TYPE
|
||||
|
||||
THREAD_ID = "thread-scope"
|
||||
BROWSER_USER_ID = UUID("00000000-0000-0000-0000-00000000000a")
|
||||
@ -49,7 +55,11 @@ _STUB_PERMISSIONS: list[str] = [
|
||||
|
||||
|
||||
class _ScopeAuthMiddleware(BaseHTTPMiddleware):
|
||||
"""Stamp the same state trio production ``AuthMiddleware`` stamps."""
|
||||
"""Stamp the state trio and user ContextVar production ``AuthMiddleware`` sets.
|
||||
|
||||
The ContextVar is what ``user_id=AUTO`` repository reads resolve against,
|
||||
so SQL-backed stores see the same identity they would in the Gateway.
|
||||
"""
|
||||
|
||||
def __init__(self, app, *, user, auth_source: str) -> None:
|
||||
super().__init__(app)
|
||||
@ -60,7 +70,11 @@ class _ScopeAuthMiddleware(BaseHTTPMiddleware):
|
||||
request.state.user = self._user
|
||||
request.state.auth_source = self._auth_source
|
||||
request.state.auth = AuthContext(user=self._user, permissions=list(_STUB_PERMISSIONS))
|
||||
return await call_next(request)
|
||||
token = set_current_user(self._user)
|
||||
try:
|
||||
return await call_next(request)
|
||||
finally:
|
||||
reset_current_user(token)
|
||||
|
||||
|
||||
class _PermissiveThreadStore:
|
||||
@ -778,3 +792,188 @@ def test_token_usage_unfiltered_on_established_ownership_for_internal_callers()
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["total_tokens"] == 166 # both stamps fold when ownership is established
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SQL-backed run-event reads
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# ``MemoryRunEventStore`` ignores ``user_id``, so the identity mismatch only
|
||||
# surfaces on the SQL store: ``start_run`` stamps run-event rows with the raw
|
||||
# trusted owner, while an ``AUTO`` read filters by the internal user's
|
||||
# ``make_safe_user_id``-normalized id and matches nothing.
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db_event_store(tmp_path):
|
||||
async def _init() -> DbRunEventStore:
|
||||
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'run_events.db'}", sqlite_dir=str(tmp_path))
|
||||
return DbRunEventStore(get_session_factory())
|
||||
|
||||
yield anyio.run(_init)
|
||||
anyio.run(close_engine)
|
||||
|
||||
|
||||
def _as_owner(owner: str, write) -> None:
|
||||
"""Run ``write`` under ``owner``'s user context, as ``start_run`` does."""
|
||||
|
||||
async def _run() -> None:
|
||||
token = set_current_user(SimpleNamespace(id=owner))
|
||||
try:
|
||||
await write()
|
||||
finally:
|
||||
reset_current_user(token)
|
||||
|
||||
anyio.run(_run)
|
||||
|
||||
|
||||
def _seed_owner_stamped_message(event_store: DbRunEventStore, run_id: str, message_id: str, *, owner: str) -> None:
|
||||
_as_owner(
|
||||
owner,
|
||||
lambda: event_store.put(
|
||||
thread_id=THREAD_ID,
|
||||
run_id=run_id,
|
||||
event_type="llm.ai.response",
|
||||
category="message",
|
||||
content={"type": "ai", "id": message_id, "content": message_id, "additional_kwargs": {}},
|
||||
metadata={},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ownership", ["established", "missing_meta"])
|
||||
def test_internal_caller_reads_raw_owner_stamped_run_events(db_event_store: DbRunEventStore, ownership: str) -> None:
|
||||
"""Run messages/events resolve the same data identity as thread messages."""
|
||||
thread_store = _PermissiveThreadStore() if ownership == "established" else MemoryThreadMetaStore(InMemoryStore())
|
||||
run_store = _RecordingRunStore()
|
||||
_seed_run(run_store, RUN_OWNER, user_id=OWNER_RAW)
|
||||
_seed_owner_stamped_message(db_event_store, RUN_OWNER, "msg-owner", owner=OWNER_RAW)
|
||||
|
||||
client = _make_app(
|
||||
user=_internal_user(OWNER_RAW),
|
||||
auth_source=AUTH_SOURCE_INTERNAL,
|
||||
run_store=run_store,
|
||||
event_store=db_event_store,
|
||||
thread_store=thread_store,
|
||||
)
|
||||
headers = {INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW}
|
||||
base = f"/api/threads/{THREAD_ID}/runs/{RUN_OWNER}"
|
||||
|
||||
with client:
|
||||
messages = client.get(base + "/messages", headers=headers)
|
||||
events = client.get(base + "/events", headers=headers)
|
||||
|
||||
assert messages.status_code == 200
|
||||
assert [row["content"]["id"] for row in messages.json()["data"]] == ["msg-owner"]
|
||||
assert events.status_code == 200
|
||||
assert [event["content"]["id"] for event in events.json()] == ["msg-owner"]
|
||||
|
||||
|
||||
def test_browser_session_run_events_keep_per_user_filter(db_event_store: DbRunEventStore) -> None:
|
||||
"""Browser sessions still read only rows stamped with their own id."""
|
||||
run_store = _RecordingRunStore()
|
||||
_seed_run(run_store, RUN_OWNER, user_id=OWNER_RAW)
|
||||
_seed_owner_stamped_message(db_event_store, RUN_OWNER, "msg-owner", owner=OWNER_RAW)
|
||||
|
||||
client = _make_app(
|
||||
user=_browser_user(),
|
||||
auth_source=AUTH_SOURCE_SESSION,
|
||||
run_store=run_store,
|
||||
event_store=db_event_store,
|
||||
)
|
||||
base = f"/api/threads/{THREAD_ID}/runs/{RUN_OWNER}"
|
||||
|
||||
with client:
|
||||
messages = client.get(base + "/messages")
|
||||
events = client.get(base + "/events")
|
||||
|
||||
assert messages.status_code == 200
|
||||
assert messages.json()["data"] == []
|
||||
assert events.status_code == 200
|
||||
assert events.json() == []
|
||||
|
||||
|
||||
def test_regenerate_target_lookup_reads_raw_owner_stamped_events(db_event_store: DbRunEventStore) -> None:
|
||||
"""The regenerate source-run scan finds the AI message by its raw stamp.
|
||||
|
||||
The run store is empty, so only the event-store scan can resolve the run.
|
||||
"""
|
||||
_seed_owner_stamped_message(db_event_store, RUN_OWNER, "msg-owner", owner=OWNER_RAW)
|
||||
user = _internal_user(OWNER_RAW)
|
||||
request = _helper_request(
|
||||
user=user,
|
||||
auth_source=AUTH_SOURCE_INTERNAL,
|
||||
run_store=_RecordingRunStore(),
|
||||
event_store=db_event_store,
|
||||
owner_header=OWNER_RAW,
|
||||
)
|
||||
request.app.state.thread_store = _PermissiveThreadStore()
|
||||
|
||||
async def _lookup() -> str:
|
||||
token = set_current_user(user)
|
||||
try:
|
||||
return await thread_runs._find_target_run_id(
|
||||
THREAD_ID,
|
||||
"msg-owner",
|
||||
{"content": "msg-owner"},
|
||||
{"additional_kwargs": {}},
|
||||
request,
|
||||
)
|
||||
finally:
|
||||
reset_current_user(token)
|
||||
|
||||
assert anyio.run(_lookup) == RUN_OWNER
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ownership", ["established", "null_owner"])
|
||||
def test_internal_caller_reads_raw_owner_stamped_run_outputs(db_event_store: DbRunEventStore, ownership: str) -> None:
|
||||
"""Workspace changes and the archive manifest resolve the raw owner stamp.
|
||||
|
||||
Both read SQL rows stamped by ``start_run``: the archive manifest also
|
||||
looks the run row up itself, so it needs the SQL run store here.
|
||||
"""
|
||||
if ownership == "established":
|
||||
thread_store = _PermissiveThreadStore()
|
||||
else:
|
||||
thread_store = MemoryThreadMetaStore(InMemoryStore())
|
||||
asyncio.run(thread_store.create(THREAD_ID, assistant_id=None, user_id=None))
|
||||
run_store = RunRepository(get_session_factory())
|
||||
|
||||
async def _seed() -> None:
|
||||
await run_store.put(RUN_OWNER, thread_id=THREAD_ID, status="success")
|
||||
await db_event_store.put(
|
||||
thread_id=THREAD_ID,
|
||||
run_id=RUN_OWNER,
|
||||
event_type="run.delivery",
|
||||
category="outputs",
|
||||
content={"presented": 1, "by_tool": {"present_files": ["/mnt/user-data/outputs/a.txt"]}},
|
||||
)
|
||||
await db_event_store.put(
|
||||
thread_id=THREAD_ID,
|
||||
run_id=RUN_OWNER,
|
||||
event_type=WORKSPACE_CHANGES_EVENT_TYPE,
|
||||
category="outputs",
|
||||
content={"summary": {"created": 1}, "files": []},
|
||||
)
|
||||
|
||||
_as_owner(OWNER_RAW, _seed)
|
||||
|
||||
client = _make_app(
|
||||
user=_internal_user(OWNER_RAW),
|
||||
auth_source=AUTH_SOURCE_INTERNAL,
|
||||
run_store=run_store,
|
||||
event_store=db_event_store,
|
||||
thread_store=thread_store,
|
||||
)
|
||||
headers = {INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW}
|
||||
base = f"/api/threads/{THREAD_ID}/runs/{RUN_OWNER}"
|
||||
|
||||
with client:
|
||||
changes = client.get(base + "/workspace-changes", headers=headers)
|
||||
manifest = client.get(base + "/artifacts/archive", headers=headers)
|
||||
|
||||
assert changes.status_code == 200
|
||||
assert changes.json()["available"] is True
|
||||
assert changes.json()["summary"]["created"] == 1
|
||||
assert manifest.status_code == 200, manifest.text
|
||||
assert manifest.json()["file_count"] == 1
|
||||
|
||||
@ -703,7 +703,7 @@ async def test_workspace_changes_route_forwards_include_files_flag():
|
||||
calls: dict = {}
|
||||
|
||||
class FakeStore:
|
||||
async def list_events(self, thread_id, run_id, *, event_types=None, task_id=None, limit=500, after_seq=None):
|
||||
async def list_events(self, thread_id, run_id, *, event_types=None, task_id=None, limit=500, after_seq=None, user_id=None):
|
||||
calls.update(thread_id=thread_id, run_id=run_id, event_types=event_types)
|
||||
return [
|
||||
{
|
||||
@ -734,6 +734,7 @@ async def test_workspace_changes_route_forwards_include_files_flag():
|
||||
class FakeRequest:
|
||||
app = FakeApp()
|
||||
_deerflow_test_bypass_auth = True
|
||||
cookies: dict = {}
|
||||
|
||||
response = await get_run_workspace_changes(
|
||||
thread_id="thread-1",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user