mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-25 14:06:18 +00:00
feat(extensions): add request-scoped run evidence access (#5727)
* feat(extensions): add request-scoped run evidence reader * fix(extensions): reject padded evidence reader identities
This commit is contained in:
parent
c57d57ec63
commit
4fe64ac916
12
README.md
12
README.md
@ -1369,6 +1369,18 @@ custom lifespans, Mounts, and WebSocket routes are not accepted; lifetime resour
|
|||||||
`ExtensionService`, and WebSocket contributions require a future host-owned
|
`ExtensionService`, and WebSocket contributions require a future host-owned
|
||||||
authentication/Origin wrapper. Lifecycle and system-model callbacks use the Gateway's
|
authentication/Origin wrapper. Lifecycle and system-model callbacks use the Gateway's
|
||||||
canonical notification loop, including subagents on isolated loops.
|
canonical notification loop, including subagents on isolated loops.
|
||||||
|
|
||||||
|
User-facing extension routes can call
|
||||||
|
`deerflow_extension_api.require_run_evidence_reader(request)` (extension API 0.2.2+).
|
||||||
|
The Gateway requires authenticated `runs:read` permission and fixes the reader's scope
|
||||||
|
to that user, including administrators and internal callers. Caller-supplied IDs cannot
|
||||||
|
change that scope. Invisible and missing runs return the same result; changed-run cursors
|
||||||
|
cannot be reused directly across users. The optional `resolve_run_evidence_reader(request)`
|
||||||
|
returns `None` when the host does not support the capability; the required helper raises
|
||||||
|
`NotImplementedError` instead. Denied access raises `PermissionError`. Routes should map
|
||||||
|
these exceptions to HTTP 503 and 403 respectively; resolver failures never fall back to
|
||||||
|
the global service reader.
|
||||||
|
|
||||||
Plugin order is deterministic, per-plugin configuration is passed to `install()`, and
|
Plugin order is deterministic, per-plugin configuration is passed to `install()`, and
|
||||||
`required: true` makes load failure abort startup; otherwise failures are reported and
|
`required: true` makes load failure abort startup; otherwise failures are reported and
|
||||||
skipped. `enabled: false` skips resolution and import. The manager preserves the extension's
|
skipped. `enabled: false` skips resolution and import. The manager preserves the extension's
|
||||||
|
|||||||
@ -3,7 +3,11 @@ import logging
|
|||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from contextlib import asynccontextmanager, suppress
|
from contextlib import asynccontextmanager, suppress
|
||||||
|
|
||||||
from deerflow_extension_api import EXTENSION_PRINCIPAL_RESOLVER_KEY, ExtensionPrincipal
|
from deerflow_extension_api import (
|
||||||
|
EXTENSION_PRINCIPAL_RESOLVER_KEY,
|
||||||
|
RUN_EVIDENCE_READER_RESOLVER_KEY,
|
||||||
|
ExtensionPrincipal,
|
||||||
|
)
|
||||||
from fastapi import FastAPI, Request, Response
|
from fastapi import FastAPI, Request, Response
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
@ -840,6 +844,19 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for
|
|||||||
|
|
||||||
setattr(app.state, EXTENSION_PRINCIPAL_RESOLVER_KEY, _resolve_extension_principal)
|
setattr(app.state, EXTENSION_PRINCIPAL_RESOLVER_KEY, _resolve_extension_principal)
|
||||||
|
|
||||||
|
def _resolve_extension_run_evidence_reader(request):
|
||||||
|
"""Bind evidence access to the principal stamped by AuthMiddleware."""
|
||||||
|
principal = _resolve_extension_principal(request)
|
||||||
|
auth = getattr(request.state, "auth", None)
|
||||||
|
if principal is None or not principal.user_id or auth is None or not auth.has_permission("runs", "read"):
|
||||||
|
raise PermissionError("run evidence requires an authenticated user with runs:read")
|
||||||
|
factory = getattr(app.state, "run_evidence_reader_factory", None)
|
||||||
|
if factory is None:
|
||||||
|
return None
|
||||||
|
return factory.for_principal(principal)
|
||||||
|
|
||||||
|
setattr(app.state, RUN_EVIDENCE_READER_RESOLVER_KEY, _resolve_extension_run_evidence_reader)
|
||||||
|
|
||||||
# CSRF: Double Submit Cookie pattern for state-changing requests
|
# CSRF: Double Submit Cookie pattern for state-changing requests
|
||||||
app.add_middleware(CSRFMiddleware)
|
app.add_middleware(CSRFMiddleware)
|
||||||
|
|
||||||
|
|||||||
@ -499,7 +499,7 @@ async def langgraph_runtime(app: FastAPI, startup_config: AppConfig) -> AsyncGen
|
|||||||
app.state.run_events_config = run_events_config
|
app.state.run_events_config = run_events_config
|
||||||
app.state.run_event_store = make_run_event_store(run_events_config)
|
app.state.run_event_store = make_run_event_store(run_events_config)
|
||||||
|
|
||||||
from deerflow.extensions.run_evidence import StoreRunEvidenceReader
|
from deerflow.extensions.run_evidence import StoreRunEvidenceReader, StoreRunEvidenceReaderFactory
|
||||||
|
|
||||||
# Gateway-lifetime services are trusted operator extensions without a
|
# Gateway-lifetime services are trusted operator extensions without a
|
||||||
# request principal. None deliberately binds this app-scoped reader to
|
# request principal. None deliberately binds this app-scoped reader to
|
||||||
@ -509,6 +509,10 @@ async def langgraph_runtime(app: FastAPI, startup_config: AppConfig) -> AsyncGen
|
|||||||
app.state.run_event_store,
|
app.state.run_event_store,
|
||||||
user_id=None,
|
user_id=None,
|
||||||
)
|
)
|
||||||
|
app.state.run_evidence_reader_factory = StoreRunEvidenceReaderFactory(
|
||||||
|
app.state.run_store,
|
||||||
|
app.state.run_event_store,
|
||||||
|
)
|
||||||
|
|
||||||
# Services are app-scoped. Capture this app's immutable extension set
|
# Services are app-scoped. Capture this app's immutable extension set
|
||||||
# once and close over the same object for teardown; the process-wide
|
# once and close over the same object for teardown; the process-wide
|
||||||
|
|||||||
@ -63,12 +63,15 @@ from deerflow_extension_api.release import (
|
|||||||
collect_release_policies,
|
collect_release_policies,
|
||||||
)
|
)
|
||||||
from deerflow_extension_api.run_evidence import (
|
from deerflow_extension_api.run_evidence import (
|
||||||
|
RUN_EVIDENCE_READER_RESOLVER_KEY,
|
||||||
InvalidRunEvidenceCursor,
|
InvalidRunEvidenceCursor,
|
||||||
RunEventPage,
|
RunEventPage,
|
||||||
RunEventView,
|
RunEventView,
|
||||||
RunEvidenceReader,
|
RunEvidenceReader,
|
||||||
RunPage,
|
RunPage,
|
||||||
RunStatusView,
|
RunStatusView,
|
||||||
|
require_run_evidence_reader,
|
||||||
|
resolve_run_evidence_reader,
|
||||||
)
|
)
|
||||||
from deerflow_extension_api.runtime_bridge import (
|
from deerflow_extension_api.runtime_bridge import (
|
||||||
EXTENSION_TASK_STORE_KEY,
|
EXTENSION_TASK_STORE_KEY,
|
||||||
@ -118,10 +121,13 @@ __all__ = [
|
|||||||
"Placement",
|
"Placement",
|
||||||
"ReleasePolicyProvider",
|
"ReleasePolicyProvider",
|
||||||
"RunEvidenceReader",
|
"RunEvidenceReader",
|
||||||
|
"RUN_EVIDENCE_READER_RESOLVER_KEY",
|
||||||
"RunEventPage",
|
"RunEventPage",
|
||||||
"RunEventView",
|
"RunEventView",
|
||||||
"RunPage",
|
"RunPage",
|
||||||
"RunStatusView",
|
"RunStatusView",
|
||||||
|
"require_run_evidence_reader",
|
||||||
|
"resolve_run_evidence_reader",
|
||||||
"SystemModelCallObserver",
|
"SystemModelCallObserver",
|
||||||
"SystemModelRequest",
|
"SystemModelRequest",
|
||||||
"SystemModelResult",
|
"SystemModelResult",
|
||||||
|
|||||||
@ -5,6 +5,8 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Protocol
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
RUN_EVIDENCE_READER_RESOLVER_KEY = "deerflow_extension_run_evidence_reader_resolver"
|
||||||
|
|
||||||
|
|
||||||
class InvalidRunEvidenceCursor(ValueError):
|
class InvalidRunEvidenceCursor(ValueError):
|
||||||
"""The cursor is malformed, unsupported, or belongs to another scope."""
|
"""The cursor is malformed, unsupported, or belongs to another scope."""
|
||||||
@ -91,3 +93,34 @@ class RunEvidenceReader(Protocol):
|
|||||||
async def get_run_status(self, *, thread_id: str, run_id: str) -> RunStatusView | None:
|
async def get_run_status(self, *, thread_id: str, run_id: str) -> RunStatusView | None:
|
||||||
"""Return authoritative status, or ``None`` when not visible."""
|
"""Return authoritative status, or ``None`` when not visible."""
|
||||||
raise NotImplementedError("the host does not provide run-status reading")
|
raise NotImplementedError("the host does not provide run-status reading")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_run_evidence_reader(request: object) -> RunEvidenceReader | None:
|
||||||
|
"""Resolve a reader bound to the authenticated request, if supported.
|
||||||
|
|
||||||
|
The resolver receives the request rather than a caller-supplied user ID or
|
||||||
|
principal, so the host remains responsible for authentication and scope
|
||||||
|
binding. Extensions should use this for user-facing routes; the global
|
||||||
|
reader injected into ``ExtensionRuntimeDeps`` is for trusted services.
|
||||||
|
Unsupported hosts return ``None``; denied authentication/authorization
|
||||||
|
raises ``PermissionError``. Unexpected resolver errors propagate.
|
||||||
|
"""
|
||||||
|
app = getattr(request, "app", None)
|
||||||
|
state = getattr(app, "state", None)
|
||||||
|
resolver = getattr(state, RUN_EVIDENCE_READER_RESOLVER_KEY, None)
|
||||||
|
if not callable(resolver):
|
||||||
|
return None
|
||||||
|
return resolver(request)
|
||||||
|
|
||||||
|
|
||||||
|
def require_run_evidence_reader(request: object) -> RunEvidenceReader:
|
||||||
|
"""Return a reader; unsupported hosts raise ``NotImplementedError``.
|
||||||
|
|
||||||
|
Authentication/authorization denial raises ``PermissionError``. Extensions
|
||||||
|
may translate these to HTTP 503 and 403 respectively. Resolver failures
|
||||||
|
propagate, never falling back to the global reader.
|
||||||
|
"""
|
||||||
|
reader = resolve_run_evidence_reader(request)
|
||||||
|
if reader is None:
|
||||||
|
raise NotImplementedError("request-scoped run evidence is unavailable")
|
||||||
|
return reader
|
||||||
|
|||||||
@ -294,8 +294,15 @@ change its visibility. Content and redacted metadata are deep-copied snapshots:
|
|||||||
fields are frozen, but nested containers remain locally mutable without touching host
|
fields are frozen, but nested containers remain locally mutable without touching host
|
||||||
storage. The production Gateway injects one
|
storage. The production Gateway injects one
|
||||||
app-scoped reader with `user_id=None`, deliberately granting trusted operator extensions
|
app-scoped reader with `user_id=None`, deliberately granting trusted operator extensions
|
||||||
global cross-user visibility because services have no request principal. A host embedding
|
global cross-user visibility because services have no request principal. User-facing contributed
|
||||||
the harness may instead bind a reader to one user. This is not a sandbox boundary: services
|
routes must use `resolve_run_evidence_reader(request)` or `require_run_evidence_reader(request)`;
|
||||||
|
the Gateway binds that reader to the authenticated principal rather than a caller-supplied user ID.
|
||||||
|
The factory rejects empty or whitespace-padded IDs instead of normalizing authorization identities.
|
||||||
|
The resolver requires the request's effective `runs:read` permission and never widens admin
|
||||||
|
or internal callers to global visibility. Unsupported hosts resolve to `None` (the required
|
||||||
|
helper raises `NotImplementedError`); denied access raises `PermissionError`. Extensions map
|
||||||
|
these to 503/403 at their HTTP boundary. The public API remains framework-independent.
|
||||||
|
A host embedding the harness may instead bind a reader to one user. This is not a sandbox boundary: services
|
||||||
already retain `session_factory` and execute with Gateway privileges. Empty pages mean
|
already retain `session_factory` and execute with Gateway privileges. Empty pages mean
|
||||||
caught up or not visible, never unsupported -- absence is represented
|
caught up or not visible, never unsupported -- absence is represented
|
||||||
by `ExtensionRuntimeDeps.run_evidence_reader is None`, and protocol defaults raise
|
by `ExtensionRuntimeDeps.run_evidence_reader is None`, and protocol defaults raise
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import json
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from deerflow_extension_api import (
|
from deerflow_extension_api import (
|
||||||
|
ExtensionPrincipal,
|
||||||
InvalidRunEvidenceCursor,
|
InvalidRunEvidenceCursor,
|
||||||
RunEventPage,
|
RunEventPage,
|
||||||
RunEventView,
|
RunEventView,
|
||||||
@ -151,3 +152,17 @@ class StoreRunEvidenceReader:
|
|||||||
next_after_seq=next_after_seq,
|
next_after_seq=next_after_seq,
|
||||||
has_more=len(events) > limit,
|
has_more=len(events) > limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StoreRunEvidenceReaderFactory:
|
||||||
|
"""Create readers whose scope is fixed from a host-authenticated principal."""
|
||||||
|
|
||||||
|
def __init__(self, run_store: Any, event_store: Any) -> None:
|
||||||
|
self._run_store = run_store
|
||||||
|
self._event_store = event_store
|
||||||
|
|
||||||
|
def for_principal(self, principal: ExtensionPrincipal) -> StoreRunEvidenceReader:
|
||||||
|
# Reject malformed IDs instead of normalizing an authorization identity.
|
||||||
|
if not isinstance(principal, ExtensionPrincipal) or not isinstance(principal.user_id, str) or not principal.user_id or principal.user_id != principal.user_id.strip():
|
||||||
|
raise ValueError("a host-authenticated extension principal is required")
|
||||||
|
return StoreRunEvidenceReader(self._run_store, self._event_store, user_id=principal.user_id)
|
||||||
|
|||||||
111
backend/tests/test_extension_request_evidence.py
Normal file
111
backend/tests/test_extension_request_evidence.py
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
"""Request evidence access through real contributed routes and host auth."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import asdict
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from deerflow_extension_api import InvalidRunEvidenceCursor, require_run_evidence_reader, resolve_run_evidence_reader
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
|
||||||
|
from deerflow.extensions.run_evidence import StoreRunEvidenceReader, StoreRunEvidenceReaderFactory
|
||||||
|
from deerflow.runtime.events.store.memory import MemoryRunEventStore
|
||||||
|
from deerflow.runtime.runs.store.memory import MemoryRunStore
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def evidence_app(monkeypatch):
|
||||||
|
import app.gateway.app as app_module
|
||||||
|
import deerflow.extensions as extensions
|
||||||
|
from deerflow.config.app_config import AppConfig
|
||||||
|
from deerflow.config.sandbox_config import SandboxConfig
|
||||||
|
from deerflow.extensions.registry import ExtensionRegistry
|
||||||
|
|
||||||
|
monkeypatch.setattr(app_module, "get_app_config", lambda: AppConfig(sandbox=SandboxConfig(use="test")))
|
||||||
|
monkeypatch.setattr("app.gateway.auth_middleware.is_auth_disabled", lambda: False)
|
||||||
|
|
||||||
|
async def authenticate(request):
|
||||||
|
return SimpleNamespace(id=request.cookies["access_token"], system_role="admin")
|
||||||
|
|
||||||
|
async def permissions(user, **kwargs):
|
||||||
|
return [] if user.id == "denied" else ["runs:read"]
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.gateway.deps.get_current_user_from_request", authenticate)
|
||||||
|
monkeypatch.setattr("app.gateway.auth_middleware.resolve_route_permissions", permissions)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/api/evidence-test")
|
||||||
|
async def evidence(request: Request, thread_id: str = "thread-a", run_id: str = "run-a", cursor: str | None = None):
|
||||||
|
try:
|
||||||
|
reader = require_run_evidence_reader(request)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
status = await reader.get_run_status(thread_id=thread_id, run_id=run_id)
|
||||||
|
events = await reader.list_run_events(thread_id=thread_id, run_id=run_id, after_seq=None, limit=10)
|
||||||
|
changed = await reader.list_changed_runs(cursor=cursor, limit=10)
|
||||||
|
return {"status": asdict(status) if status else None, "events": asdict(events), "changed": asdict(changed)}
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise HTTPException(403, str(exc)) from exc
|
||||||
|
except NotImplementedError as exc:
|
||||||
|
raise HTTPException(503, str(exc)) from exc
|
||||||
|
except InvalidRunEvidenceCursor as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
|
||||||
|
registry = ExtensionRegistry()
|
||||||
|
with registry.attributed_to("evidence:install"):
|
||||||
|
registry.routers((router,))
|
||||||
|
monkeypatch.setattr(extensions, "load_extensions", lambda plugins: (registry.build(), []))
|
||||||
|
app = app_module.create_app()
|
||||||
|
app.state.run_store = MemoryRunStore()
|
||||||
|
app.state.run_event_store = MemoryRunEventStore()
|
||||||
|
app.state.run_evidence_reader_factory = StoreRunEvidenceReaderFactory(app.state.run_store, app.state.run_event_store)
|
||||||
|
yield app
|
||||||
|
extensions.reset_loaded_extensions()
|
||||||
|
extensions.reset_runtime_diagnostics()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("state", [SimpleNamespace(), SimpleNamespace(user=SimpleNamespace(id="a", system_role="admin"))])
|
||||||
|
def test_resolver_rejects_missing_auth_context(evidence_app, state):
|
||||||
|
request = SimpleNamespace(app=evidence_app, state=state)
|
||||||
|
with pytest.raises(PermissionError):
|
||||||
|
require_run_evidence_reader(request)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolver_failure_does_not_return_a_global_reader():
|
||||||
|
def fail(request):
|
||||||
|
raise RuntimeError("resolver failed")
|
||||||
|
|
||||||
|
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(deerflow_extension_run_evidence_reader_resolver=fail)))
|
||||||
|
with pytest.raises(RuntimeError, match="resolver failed"):
|
||||||
|
resolve_run_evidence_reader(request)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_route_scope_cursor_and_concurrent_requests(evidence_app):
|
||||||
|
app = evidence_app
|
||||||
|
for owner in ("a", "b"):
|
||||||
|
await app.state.run_store.put(f"run-{owner}", thread_id=f"thread-{owner}", user_id=owner)
|
||||||
|
await app.state.run_event_store.put(thread_id=f"thread-{owner}", run_id=f"run-{owner}", event_type="test", category="message", content=owner)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
|
||||||
|
async def read(owner, **params):
|
||||||
|
return await client.get("/api/evidence-test", headers={"cookie": f"access_token={owner}"}, params=params)
|
||||||
|
|
||||||
|
a, b = await asyncio.gather(read("a", user_id="b"), read("b"))
|
||||||
|
assert a.status_code == b.status_code == 200
|
||||||
|
assert a.json()["status"]["run_id"] == "run-a"
|
||||||
|
assert a.json()["events"]["items"][0]["content"] == "a"
|
||||||
|
assert [item["run_id"] for item in b.json()["changed"]["items"]] == ["run-b"]
|
||||||
|
assert b.json()["status"] is None
|
||||||
|
assert b.json()["events"]["items"] == []
|
||||||
|
missing = await read("b", run_id="missing")
|
||||||
|
assert missing.json() == b.json()
|
||||||
|
assert (await read("b", cursor=a.json()["changed"]["next_cursor"])).status_code == 400
|
||||||
|
assert (await read("denied")).status_code == 403
|
||||||
|
assert (await client.get("/api/evidence-test")).status_code == 401
|
||||||
|
del app.state.run_evidence_reader_factory
|
||||||
|
assert (await read("a")).status_code == 503
|
||||||
|
|
||||||
|
global_reader = StoreRunEvidenceReader(app.state.run_store, app.state.run_event_store)
|
||||||
|
assert len((await global_reader.list_changed_runs(cursor=None, limit=10)).items) == 2
|
||||||
@ -10,6 +10,7 @@ from types import SimpleNamespace
|
|||||||
import pytest
|
import pytest
|
||||||
from deerflow_extension_api import (
|
from deerflow_extension_api import (
|
||||||
EXTENSION_PRINCIPAL_RESOLVER_KEY,
|
EXTENSION_PRINCIPAL_RESOLVER_KEY,
|
||||||
|
RUN_EVIDENCE_READER_RESOLVER_KEY,
|
||||||
ExtensionPrincipal,
|
ExtensionPrincipal,
|
||||||
require_admin,
|
require_admin,
|
||||||
resolve_principal,
|
resolve_principal,
|
||||||
@ -87,6 +88,7 @@ def test_host_installs_a_resolver_on_app_state(_stub_app_config):
|
|||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
assert callable(getattr(app.state, EXTENSION_PRINCIPAL_RESOLVER_KEY, None))
|
assert callable(getattr(app.state, EXTENSION_PRINCIPAL_RESOLVER_KEY, None))
|
||||||
|
assert callable(getattr(app.state, RUN_EVIDENCE_READER_RESOLVER_KEY, None))
|
||||||
|
|
||||||
|
|
||||||
def test_the_installed_resolver_projects_system_role_into_roles(_stub_app_config):
|
def test_the_installed_resolver_projects_system_role_into_roles(_stub_app_config):
|
||||||
|
|||||||
@ -4,9 +4,9 @@ import asyncio
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from deerflow_extension_api import InvalidRunEvidenceCursor
|
from deerflow_extension_api import ExtensionPrincipal, InvalidRunEvidenceCursor, require_run_evidence_reader, resolve_run_evidence_reader
|
||||||
|
|
||||||
from deerflow.extensions.run_evidence import StoreRunEvidenceReader
|
from deerflow.extensions.run_evidence import StoreRunEvidenceReader, StoreRunEvidenceReaderFactory
|
||||||
from deerflow.runtime.events.store.memory import MemoryRunEventStore
|
from deerflow.runtime.events.store.memory import MemoryRunEventStore
|
||||||
from deerflow.runtime.runs.store.memory import MemoryRunStore
|
from deerflow.runtime.runs.store.memory import MemoryRunStore
|
||||||
|
|
||||||
@ -190,6 +190,32 @@ async def test_reader_hides_runs_outside_scope_and_rejects_cursor_from_another_s
|
|||||||
assert (await other.list_run_events(thread_id="thread-a", run_id="run-a", after_seq=None, limit=10)).items == ()
|
assert (await other.list_run_events(thread_id="thread-a", run_id="run-a", after_seq=None, limit=10)).items == ()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"principal",
|
||||||
|
[None, SimpleNamespace(user_id="user-1"), *[ExtensionPrincipal(user_id=value) for value in (None, 1, "", " \t", " user-1", "user-1 ", "\tuser-1\n")]],
|
||||||
|
)
|
||||||
|
def test_reader_factory_rejects_invalid_principals(principal):
|
||||||
|
factory = StoreRunEvidenceReaderFactory(MemoryRunStore(), MemoryRunEventStore())
|
||||||
|
with pytest.raises(ValueError, match="principal"):
|
||||||
|
factory.for_principal(principal)
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_resolver_binds_reader_to_principal_and_fails_when_unavailable():
|
||||||
|
runs = MemoryRunStore()
|
||||||
|
events = MemoryRunEventStore()
|
||||||
|
factory = StoreRunEvidenceReaderFactory(runs, events)
|
||||||
|
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(deerflow_extension_run_evidence_reader_resolver=lambda _request: factory.for_principal(ExtensionPrincipal(user_id="user-1")))))
|
||||||
|
|
||||||
|
reader = resolve_run_evidence_reader(request)
|
||||||
|
assert isinstance(reader, StoreRunEvidenceReader)
|
||||||
|
assert reader._user_id == "user-1"
|
||||||
|
|
||||||
|
unsupported = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace()))
|
||||||
|
assert resolve_run_evidence_reader(unsupported) is None
|
||||||
|
with pytest.raises(NotImplementedError, match="unavailable"):
|
||||||
|
require_run_evidence_reader(unsupported)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_memory_progress_updates_do_not_advance_changed_run_cursor():
|
async def test_memory_progress_updates_do_not_advance_changed_run_cursor():
|
||||||
runs = MemoryRunStore()
|
runs = MemoryRunStore()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user