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:
Xuehao Xu 2026-09-23 15:25:45 +08:00 committed by GitHub
parent c57d57ec63
commit 4fe64ac916
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 239 additions and 6 deletions

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -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)

View 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

View File

@ -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):

View File

@ -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()