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
authentication/Origin wrapper. Lifecycle and system-model callbacks use the Gateway's
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
`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

View File

@ -3,7 +3,11 @@ import logging
from collections.abc import AsyncGenerator
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.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)
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
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_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
# 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,
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
# 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,
)
from deerflow_extension_api.run_evidence import (
RUN_EVIDENCE_READER_RESOLVER_KEY,
InvalidRunEvidenceCursor,
RunEventPage,
RunEventView,
RunEvidenceReader,
RunPage,
RunStatusView,
require_run_evidence_reader,
resolve_run_evidence_reader,
)
from deerflow_extension_api.runtime_bridge import (
EXTENSION_TASK_STORE_KEY,
@ -118,10 +121,13 @@ __all__ = [
"Placement",
"ReleasePolicyProvider",
"RunEvidenceReader",
"RUN_EVIDENCE_READER_RESOLVER_KEY",
"RunEventPage",
"RunEventView",
"RunPage",
"RunStatusView",
"require_run_evidence_reader",
"resolve_run_evidence_reader",
"SystemModelCallObserver",
"SystemModelRequest",
"SystemModelResult",

View File

@ -5,6 +5,8 @@ from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Protocol
RUN_EVIDENCE_READER_RESOLVER_KEY = "deerflow_extension_run_evidence_reader_resolver"
class InvalidRunEvidenceCursor(ValueError):
"""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:
"""Return authoritative status, or ``None`` when not visible."""
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
storage. The production Gateway injects one
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
the harness may instead bind a reader to one user. This is not a sandbox boundary: services
global cross-user visibility because services have no request principal. User-facing contributed
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
caught up or not visible, never unsupported -- absence is represented
by `ExtensionRuntimeDeps.run_evidence_reader is None`, and protocol defaults raise

View File

@ -9,6 +9,7 @@ import json
from typing import Any
from deerflow_extension_api import (
ExtensionPrincipal,
InvalidRunEvidenceCursor,
RunEventPage,
RunEventView,
@ -151,3 +152,17 @@ class StoreRunEvidenceReader:
next_after_seq=next_after_seq,
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
from deerflow_extension_api import (
EXTENSION_PRINCIPAL_RESOLVER_KEY,
RUN_EVIDENCE_READER_RESOLVER_KEY,
ExtensionPrincipal,
require_admin,
resolve_principal,
@ -87,6 +88,7 @@ def test_host_installs_a_resolver_on_app_state(_stub_app_config):
app = create_app()
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):

View File

@ -4,9 +4,9 @@ import asyncio
from types import SimpleNamespace
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.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 == ()
@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
async def test_memory_progress_updates_do_not_advance_changed_run_cursor():
runs = MemoryRunStore()