From 4fe64ac91664cf415bf7703f4ba4af84f947ebfd Mon Sep 17 00:00:00 2001 From: Xuehao Xu Date: Wed, 23 Sep 2026 15:25:45 +0800 Subject: [PATCH] feat(extensions): add request-scoped run evidence access (#5727) * feat(extensions): add request-scoped run evidence reader * fix(extensions): reject padded evidence reader identities --- README.md | 12 ++ backend/app/gateway/app.py | 19 ++- backend/app/gateway/deps.py | 6 +- .../deerflow_extension_api/__init__.py | 6 + .../deerflow_extension_api/run_evidence.py | 33 ++++++ .../harness/deerflow/extensions/AGENTS.md | 11 +- .../deerflow/extensions/run_evidence.py | 15 +++ .../tests/test_extension_request_evidence.py | 111 ++++++++++++++++++ .../tests/test_extension_route_principal.py | 2 + backend/tests/test_run_evidence_reader.py | 30 ++++- 10 files changed, 239 insertions(+), 6 deletions(-) create mode 100644 backend/tests/test_extension_request_evidence.py diff --git a/README.md b/README.md index 1cd3011e9..9c03f7c5b 100644 --- a/README.md +++ b/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 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 diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py index e7c2e4a5a..3eb29c34d 100644 --- a/backend/app/gateway/app.py +++ b/backend/app/gateway/app.py @@ -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) diff --git a/backend/app/gateway/deps.py b/backend/app/gateway/deps.py index d9e359444..9815841ee 100644 --- a/backend/app/gateway/deps.py +++ b/backend/app/gateway/deps.py @@ -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 diff --git a/backend/packages/extension-api/deerflow_extension_api/__init__.py b/backend/packages/extension-api/deerflow_extension_api/__init__.py index 1a40ea69f..34a85f514 100644 --- a/backend/packages/extension-api/deerflow_extension_api/__init__.py +++ b/backend/packages/extension-api/deerflow_extension_api/__init__.py @@ -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", diff --git a/backend/packages/extension-api/deerflow_extension_api/run_evidence.py b/backend/packages/extension-api/deerflow_extension_api/run_evidence.py index 6881a1215..41acb807f 100644 --- a/backend/packages/extension-api/deerflow_extension_api/run_evidence.py +++ b/backend/packages/extension-api/deerflow_extension_api/run_evidence.py @@ -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 diff --git a/backend/packages/harness/deerflow/extensions/AGENTS.md b/backend/packages/harness/deerflow/extensions/AGENTS.md index bd5423df1..56981a218 100644 --- a/backend/packages/harness/deerflow/extensions/AGENTS.md +++ b/backend/packages/harness/deerflow/extensions/AGENTS.md @@ -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 diff --git a/backend/packages/harness/deerflow/extensions/run_evidence.py b/backend/packages/harness/deerflow/extensions/run_evidence.py index b9209f34f..deef8d380 100644 --- a/backend/packages/harness/deerflow/extensions/run_evidence.py +++ b/backend/packages/harness/deerflow/extensions/run_evidence.py @@ -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) diff --git a/backend/tests/test_extension_request_evidence.py b/backend/tests/test_extension_request_evidence.py new file mode 100644 index 000000000..fc0a8a2a0 --- /dev/null +++ b/backend/tests/test_extension_request_evidence.py @@ -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 diff --git a/backend/tests/test_extension_route_principal.py b/backend/tests/test_extension_route_principal.py index 1c7f71314..f3c413ea2 100644 --- a/backend/tests/test_extension_route_principal.py +++ b/backend/tests/test_extension_route_principal.py @@ -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): diff --git a/backend/tests/test_run_evidence_reader.py b/backend/tests/test_run_evidence_reader.py index 5619b29dc..fc6b6bbdc 100644 --- a/backend/tests/test_run_evidence_reader.py +++ b/backend/tests/test_run_evidence_reader.py @@ -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()