mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-15 17:18:38 +00:00
* fix(runs): reject cancel actions on GET stream joins
stream_existing_run is registered for both GET and POST, and its
?action=interrupt|rollback branch cancels the run. The CSRF middleware
exempts GET, so a session-authenticated browser could be forced
cross-site (img/script/top-level navigation) into
GET /api/threads/{id}/runs/{run_id}/stream?action=interrupt|rollback —
a state-changing GET that bypasses the CSRF protection guarding the
POST variant. Introduced with the dual registration in #1403.
The handler's docstring already documents cancel-then-stream as
POST-only (the LangGraph SDK's joinStream/useStream stop button uses
POST); enforce it: GET with an action answers 405, action-less GET
joins and POST cancel-then-stream are unchanged.
Regression drives the real router: GET+action is 405 with the run left
running, plain GET join still streams, POST+action still cancels.
* fix(runs): scope the 405 detail to the action requirement
"GET is a read-only stream join" overstates the current main: on a
locally-owned run with the default on_disconnect=cancel, a GET join's
disconnect can still trigger cancellation. That observer-disconnect
vector is closed by #5041; the detail here should only claim what this
guard enforces.
* fix(runs): harden GET stream action rejection
* fix(runs): align stream schema with method contract
* test(runs): pin GET stream action 405 through the production stack
Review follow-up (defence-in-depth): the GET-action suite drove bare
FastAPI() apps, so nothing pinned that a session-authenticated
cross-site GET reaches the route gate at all once CSRF exempts the
safe method. test_pat_auth.py already assembles the production
middleware order (AuthMiddleware inner, CSRFMiddleware outer), so its
mirror app now registers the real _reject_get_stream_action
dependency on a GET join route.
The new case pins the end-to-end premise: an authenticated GET
?action=interrupt is answered 405 + Allow: POST by the production
route dependency, while the same unauthenticated GET dies at
AuthMiddleware's 401 before any route logic runs.
Validation: focused suites (test_pat_auth, test_stream_get_action,
test_csrf_middleware) — 62 passed; ruff check + format clean; the new
case errors on the pre-fix baseline (guard absent), confirming the
pin.
115 lines
4.0 KiB
Python
115 lines
4.0 KiB
Python
"""Observer joins must not apply the creator's cancel-on-disconnect policy.
|
|
|
|
Review round 5 (PR #5041): every consumer of ``sse_consumer`` used to apply
|
|
the record's ``on_disconnect=cancel`` policy in its ``finally`` block, so a
|
|
read-only stream observer could cancel a locally-owned running run just by
|
|
closing the SSE connection. The fix separates creator streams
|
|
(``apply_on_disconnect=True``, the default) from join/observer streams
|
|
(``False``). These tests drive a real generator close — the same machinery
|
|
Starlette runs when a client drops the connection — against the production
|
|
consumer.
|
|
"""
|
|
|
|
import asyncio
|
|
import inspect
|
|
from types import SimpleNamespace
|
|
|
|
from app.gateway.services import sse_consumer
|
|
from deerflow.runtime import DisconnectMode, RunRecord, RunStatus
|
|
|
|
|
|
def _running_record() -> RunRecord:
|
|
return RunRecord(
|
|
run_id="run-1",
|
|
thread_id="t1",
|
|
assistant_id=None,
|
|
status=RunStatus.running,
|
|
on_disconnect=DisconnectMode.cancel,
|
|
)
|
|
|
|
|
|
class _StubBridge:
|
|
"""Yields one event, then parks until the consumer closes the generator."""
|
|
|
|
def subscribe(self, run_id, last_event_id=None):
|
|
async def _gen():
|
|
yield SimpleNamespace(event="message", data="{}", id="1")
|
|
await asyncio.Event().wait()
|
|
|
|
return _gen()
|
|
|
|
|
|
class _CancelRecorder:
|
|
"""Stands in for the RunManager: records cancel calls, mutates nothing."""
|
|
|
|
def __init__(self):
|
|
self.cancelled: list[str] = []
|
|
|
|
async def cancel(self, run_id, action="interrupt"):
|
|
self.cancelled.append(run_id)
|
|
|
|
|
|
class _StubRequest:
|
|
"""Minimal request: headers for Last-Event-ID, never-disconnected client
|
|
(the disconnect under test happens between events, via generator close)."""
|
|
|
|
def __init__(self):
|
|
self.headers = {}
|
|
|
|
async def is_disconnected(self) -> bool:
|
|
return False
|
|
|
|
|
|
def _request() -> _StubRequest:
|
|
return _StubRequest()
|
|
|
|
|
|
async def _drive_disconnect(consumer) -> None:
|
|
"""Start the generator (it yields one frame), then close it — a real
|
|
disconnect of the response stream, running the ``finally`` block."""
|
|
await consumer.__anext__()
|
|
await consumer.aclose()
|
|
|
|
|
|
def test_creator_stream_disconnect_applies_cancel_policy():
|
|
"""The stream returned by the creating endpoint keeps the creator's
|
|
cancel-on-disconnect semantics."""
|
|
|
|
async def scenario():
|
|
recorder = _CancelRecorder()
|
|
consumer = sse_consumer(_StubBridge(), _running_record(), _request(), recorder)
|
|
await _drive_disconnect(consumer)
|
|
return recorder.cancelled
|
|
|
|
assert asyncio.run(scenario()) == ["run-1"]
|
|
|
|
|
|
def test_observer_join_disconnect_does_not_cancel():
|
|
"""A join/observer stream closing must not cancel the run — including for
|
|
a read-only credential that never held runs:cancel."""
|
|
|
|
async def scenario():
|
|
recorder = _CancelRecorder()
|
|
consumer = sse_consumer(_StubBridge(), _running_record(), _request(), recorder, apply_on_disconnect=False)
|
|
await _drive_disconnect(consumer)
|
|
return recorder.cancelled
|
|
|
|
assert asyncio.run(scenario()) == []
|
|
|
|
|
|
def test_join_routes_wire_sse_consumer_as_observers():
|
|
"""Both join surfaces must be wired as observers, and the creator's
|
|
create-and-stream endpoints must keep the creator policy (default)."""
|
|
from app.gateway.routers import runs as runs_router
|
|
from app.gateway.routers import thread_runs
|
|
|
|
thread_runs_source = inspect.getsource(thread_runs)
|
|
# join_run + the shared existing-run stream implementation
|
|
assert thread_runs_source.count("sse_consumer(bridge, record, request, run_mgr, apply_on_disconnect=False)") == 2
|
|
# stream_run — the creator's create-and-stream endpoint
|
|
assert thread_runs_source.count("sse_consumer(bridge, record, request, run_mgr),") == 1
|
|
|
|
runs_source = inspect.getsource(runs_router)
|
|
# stateless create-and-stream — also a creator stream
|
|
assert "sse_consumer(bridge, record, request, run_mgr, apply_on_disconnect=False)" not in runs_source
|