mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
* feat(authz): add plugin resource targets, provenance, action and management enforcement
PR1 of the extension-tool-authorization series
(docs/plans/2026-09-24-extension-tool-authorization-spec.md): plugin
authorization plumbing, no new author-facing declaration.
- `authz/plugin_targets.py` is the single encoder/validator for composite
targets (`namespace/part`), with one validating composer behind three
kind-checked constructors; invalid input raises instead of degrading.
- `authz/plugin_authz.py` is the harness decision layer (`plugin_action`,
`plugin_management`, plus the batch filters the page slice will consume):
disabled is a no-op, an explicit deny raises, and provider errors follow
`fail_closed` like the sandbox gate.
- `POST /api/plugins/{ns}/actions/{name}` decides `plugin_action:invoke`
after the action resolves and before the body is streamed.
- `deerflow_extension_api.auth` publishes `require_plugin_management` /
`arequire_plugin_management` with two `app.state` resolver keys; the
Gateway installs both. Contract version 0.2.3 -> 0.2.4.
- `tools/tool_provenance.py` records host-written plugin provenance for
display (assembly descriptor, Layer-2 context later); no decision reads a
label.
- Plugin providers use a `(config signature, loop key)` cache so a
loop-affine provider is never handed to another loop or to a sync caller.
- RBAC gains `plugin_actions` / `plugin_management` aliases;
`config_version` 47 -> 48 across the three mirrors.
Tests: new plugin target/provenance/action/management suites plus a
blocking-IO anchor whose offloads were reverted to prove red->green.
* fix(authz): decide plugin resources from the request's config snapshot
Review round 1 on PR #5842 (willem-bd).
P1 — the decision layer no longer reloads the configuration. The six
`authz/plugin_authz.py` functions take a required `app_config` snapshot (the
same request-scoped config the Gateway used to resolve the provider), and
`resolve_/aresolve_plugin_authorization` return it as a third element, so
`enabled`/`fail_closed` can never come from a second read that drifted from
the one that produced the provider. A snapshot the caller could not read at
all is *unavailable*, not disabled: it fails closed as
`authz.config_unavailable`. The Gateway's `_plugin_app_config` now separates
an absent `config.yaml` (no gate, today's behavior) from a config that exists
but cannot be read or validated (deny) instead of swallowing both through
`safe_app_config`.
P2 — `_validated_decision` requires `type(decision.allow) is bool` on both
the sync `authorize` and async `aauthorize` paths, so a provider returning
`AuthzDecision(allow="false")` is a malformed verdict that follows
`fail_closed` instead of being read as an allow.
Both fixes are covered by regression tests that were verified red against the
pre-fix behavior (P1: 200 vs 403 and resolver True vs False; P2: 12 cases
across fail_closed/fail_open and sync/async), plus tests pinning that an
absent config and `authorization.enabled: false` keep today's behavior.
* fix(authz): treat a request-time config absence as unavailable
Review round 2 on PR #5842 (willem-bd).
P1 — `_plugin_app_config[_async]` no longer maps a missing `config.yaml` to
`None` ("no gate"). A Gateway cannot have started without a readable config
(`app/gateway/app.py` reads it through this same accessor in `lifespan`), so
absence *during a request* means the configuration this process was running on
became unavailable — a hot reload, an atomic replace, a ConfigMap remount. It is
unavailable, not disabled: the error propagates,
`resolve_/aresolve_plugin_authorization` raise
`_PluginAuthorizationUnavailable(fail_closed=True)`, the action dispatcher
returns 403 and the management resolvers answer `False`. The dead
`(None, None, None)` allow path is gone, the resolvers return a non-optional
`AppConfig` as their third element, and callers key "authorization disabled" on
`provider is None` alone. This matches the rule the decision layer already
documented (`authz.config_unavailable`) and the public docs.
P2 — `_deny_reason_code` is total: a `reasons` that is not iterable degrades the
code to `authz.denied` instead of raising `TypeError` outside the guarded
provider path, which turned a denial into an unhandled error. It is deliberately
not folded into `_validated_decision`: `allow` was a real `bool`, so the verdict
is a valid denial, and treating it as malformed would flip it to an allow under
`fail_closed: false`.
Both fixes are covered by regression tests verified red against the pre-fix
behavior (P1: resolver True vs False, async `PermissionError` not raised, route
200 vs 403; P2: `TypeError: 'NoneType' object is not iterable` at
`plugin_authz.py:113`). The action-route tests in
`tests/test_plugin_contributions.py` now install a readable
`authorization.enabled: false` config instead of relying on a config-less
process, and `docs/full-stack-plugins.md` plus the PR1 decision log record the
new rule.
* fix(authz): apply the running policy when the plugin config is unreadable
Follow-up to f03d2ea9 (review round 2 on PR #5842, willem-bd).
The first attempt read the P1 finding as "absence is never disabled" and failed
every config read closed unconditionally. That is too broad for this repo: a
config-less host is deliberately treated as having no policy to apply
(`_get_route_authorization_config` falls back to a disabled config,
`sandbox_authz.safe_app_config` documents the same no-op for "CI runners and
direct-call tests"), and the rule turned the config-less e2e plugin host
(`extension_test_fixtures.bookmark_plugin_gateway`) into a 403 — the three
`bookmark-plugin.spec.ts` cases lost the save action. Reproduced locally:
`POST /api/plugins/community.bookmarks/actions/save` against that fixture host
returned 403 before, 200 after this change.
The rule is now "has this process loaded a configuration?", not "was the last
read successful?":
- No loaded config (`peek_loaded_app_config()`, new read-only accessor on
`deerflow.config.app_config`) and no `config.yaml`: no policy to apply, so the
gate stays a no-op — unchanged for CI runners, direct-call tests and hosts
that mount only the plugins router.
- A loaded config whose file disappears or cannot be read: *unavailable*, never
*disabled*. The resolvers raise `_PluginAuthorizationUnavailable` carrying the
failure flag of the configuration this process runs on (authorization
disabled there -> no gate; enabled -> that policy's `fail_closed`, `True` by
default), so an enabled Gateway answers 403/False in the window instead of
letting protected plugin actions and management routes through.
- A config file that exists but cannot be read or validated right now: fail
closed when nothing is loaded (round 1's rule), otherwise the loaded policy
decides.
`peek_loaded_app_config()` is preferred over a Gateway-side startup snapshot: it
reflects the last successfully loaded config, so a hot-reloaded policy is what
applies during the window, and there is no lifecycle hook to keep in sync.
Evidence: the three no-config/lost-config branches in
`tests/test_plugin_management_guard.py` and
`tests/test_plugin_action_authorization.py`, round 1's unreadable-config tests
unchanged, `test_peek_loaded_app_config_survives_a_missing_file` in
`tests/test_app_config_reload.py`, and the fixture-host probe above. Docs
(`docs/full-stack-plugins.md`, `reference.mdx` en/zh) and the PR1 decision log
state the final rule; the log records the superseded variant.
* fix(authz): keep a denial whose reasons raise while being read
* fix(authz): intersect plugin batch filters with the candidates
The plugin_page/plugin_management batch helpers returned the provider's
`filter_resources` answer verbatim, so a custom provider could hand the
projections a target that was never asked about ("*", another namespace,
a phantom page). Every other filter call site re-projects from its own
candidate list; do the same here, state the intersection on the provider
contract, and pin it with a regression test that fails on the old
pass-through.
* docs(plugins): map a management denial to 403 in the route example
The contributed-route example called `arequire_plugin_management` and let
the documented `PermissionError` escape. The Gateway installs no
`PermissionError` handler, so a route copied from the example answered 500
on a normal policy denial instead of 403. Both example routes now translate
the denial, and the surrounding bullets say why the translation is the
route's job.
* docs(extensions): point the 0.2.4 contract row at the PR that ships it
Every other row in the version-history table links the pull request that
shipped the version; 0.2.4 linked the authorization tracking issue instead.
Both the English and Chinese tables now link this PR.
360 lines
12 KiB
Python
360 lines
12 KiB
Python
"""Tests for the extension contract surface.
|
|
|
|
The contracts carry two compatibility promises that are easy to break by
|
|
accident and impossible to catch at runtime later: every Protocol method has a
|
|
default implementation, and every optional dataclass field has a default. Both
|
|
are asserted here.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import dataclasses
|
|
import importlib.resources
|
|
import inspect
|
|
|
|
import pytest
|
|
from deerflow_extension_api import (
|
|
API_VERSION,
|
|
AgentBuildContext,
|
|
AgentScope,
|
|
ExtensionData,
|
|
ExtensionInstall,
|
|
ExtensionRegistry,
|
|
ExtensionRuntimeDeps,
|
|
ExtensionService,
|
|
HostPolicySnapshot,
|
|
InvalidRunEvidenceCursor,
|
|
MiddlewareContributor,
|
|
MiddlewarePlacement,
|
|
Placement,
|
|
RunEventPage,
|
|
RunEventView,
|
|
RunEvidenceReader,
|
|
RunPage,
|
|
RunStatusView,
|
|
SystemModelCallObserver,
|
|
SystemModelRequest,
|
|
SystemModelResult,
|
|
SystemOperationKind,
|
|
TaskInfo,
|
|
TaskLifecycleContributor,
|
|
TaskOutcome,
|
|
extension,
|
|
)
|
|
from deerflow_extension_api.runtime_bridge import (
|
|
EXTENSION_TASK_STORE_KEY,
|
|
task_store_from_runtime,
|
|
)
|
|
|
|
|
|
def test_placement_members_cover_both_axes():
|
|
assert Placement.MODEL_LOGICAL.value == "model_logical"
|
|
assert Placement.MODEL_PHYSICAL.value == "model_physical"
|
|
assert Placement.TOOL_VISIBLE.value == "tool_visible"
|
|
assert Placement.TOOL_RAW.value == "tool_raw"
|
|
assert Placement.STANDARD.value == "standard"
|
|
|
|
|
|
def test_agent_scope_both_is_union():
|
|
assert AgentScope.BOTH == AgentScope.LEAD | AgentScope.SUBAGENT
|
|
assert AgentScope.LEAD in AgentScope.BOTH
|
|
|
|
|
|
def test_middleware_placement_defaults():
|
|
p = MiddlewarePlacement(middleware=object(), placement=Placement.STANDARD)
|
|
assert p.scope is AgentScope.BOTH
|
|
assert p.order == 0
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"cls",
|
|
[
|
|
HostPolicySnapshot,
|
|
ExtensionRuntimeDeps,
|
|
AgentBuildContext,
|
|
TaskInfo,
|
|
SystemModelRequest,
|
|
SystemModelResult,
|
|
MiddlewarePlacement,
|
|
RunEventPage,
|
|
RunEventView,
|
|
RunPage,
|
|
RunStatusView,
|
|
],
|
|
)
|
|
def test_every_dataclass_is_frozen(cls):
|
|
assert dataclasses.is_dataclass(cls)
|
|
assert cls.__dataclass_params__.frozen, f"{cls.__name__} must be frozen"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"cls",
|
|
[HostPolicySnapshot, ExtensionRuntimeDeps, TaskInfo, SystemModelRequest, SystemModelResult],
|
|
)
|
|
def test_additive_dataclasses_are_constructible_with_required_fields_only(cls):
|
|
"""Fields added later must carry defaults, or old extensions break on upgrade.
|
|
|
|
HostPolicySnapshot and the two system-call snapshots are host-constructed
|
|
and fully optional. TaskInfo has a required identity core and optional
|
|
remainder. AgentBuildContext gets its own dedicated test below because its
|
|
scope is legitimately required.
|
|
"""
|
|
if cls is TaskInfo:
|
|
info = cls(task_id="t", run_id="r", thread_id="th", kind="lead")
|
|
assert info.parent_task_id is None
|
|
assert info.agent_name is None
|
|
assert info.resumed is False
|
|
else:
|
|
assert cls() is not None
|
|
|
|
|
|
def test_agent_build_context_optional_fields_keep_their_defaults():
|
|
"""AgentBuildContext has one required field (scope); the rest must default.
|
|
|
|
Unlike the fully-optional dataclasses above, scope is legitimately
|
|
required, so this is not folded into the parametrized test above — it
|
|
would misrepresent the required/optional split this suite is meant to
|
|
document.
|
|
"""
|
|
ctx = AgentBuildContext(scope=AgentScope.LEAD)
|
|
assert ctx.agent_name is None
|
|
assert ctx.model_name is None
|
|
assert isinstance(ctx.policy, HostPolicySnapshot)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"protocol",
|
|
[
|
|
ExtensionRegistry,
|
|
ExtensionService,
|
|
MiddlewareContributor,
|
|
RunEvidenceReader,
|
|
TaskLifecycleContributor,
|
|
SystemModelCallObserver,
|
|
],
|
|
)
|
|
def test_every_protocol_method_has_a_default_implementation(protocol):
|
|
"""Adding a method to a Protocol is only additive when it has a default.
|
|
|
|
Without this, shipping a new contract method breaks every already-released
|
|
extension that does not implement it.
|
|
"""
|
|
checked = 0
|
|
for name, member in vars(protocol).items():
|
|
if name.startswith("_") or not inspect.isfunction(member):
|
|
continue
|
|
checked += 1
|
|
body = inspect.getsource(member).split("\n", 1)[1]
|
|
if protocol is RunEvidenceReader:
|
|
assert "raise NotImplementedError" in body
|
|
else:
|
|
assert "return" in body, f"{protocol.__name__}.{name} has no default implementation. Adding a contract method is only additive when it returns a default; otherwise every already-released extension breaks on upgrade."
|
|
assert checked > 0, f"{protocol.__name__} declared no methods to check"
|
|
|
|
|
|
def test_contributor_defaults_return_empty():
|
|
class _Bare:
|
|
pass
|
|
|
|
bare = _Bare()
|
|
assert MiddlewareContributor.contribute_middlewares(bare, ExtensionData("app"), AgentBuildContext(scope=AgentScope.LEAD)) == ()
|
|
|
|
|
|
def test_task_lifecycle_contract_is_public_and_defaults_to_noop():
|
|
class _Bare:
|
|
pass
|
|
|
|
app_store = ExtensionData("app")
|
|
task_store = ExtensionData("task-1")
|
|
info = TaskInfo(
|
|
task_id="task-1",
|
|
run_id="run-1",
|
|
thread_id="thread-1",
|
|
kind="lead",
|
|
)
|
|
|
|
assert TaskOutcome.COMPLETED.value == "completed"
|
|
assert asyncio.run(TaskLifecycleContributor.on_task_start(_Bare(), app_store, task_store, info)) is None
|
|
assert asyncio.run(TaskLifecycleContributor.on_task_stop(_Bare(), app_store, task_store, info, TaskOutcome.COMPLETED)) is None
|
|
|
|
|
|
def test_system_model_observer_contract_reports_success_and_failure_shapes():
|
|
class _Bare:
|
|
pass
|
|
|
|
app_store = ExtensionData("app")
|
|
task_store = ExtensionData("task-1")
|
|
request = SystemModelRequest(messages=("prompt",), model_name="system-model")
|
|
success = SystemModelResult(response="answer", duration_ms=1.5)
|
|
failure = SystemModelResult(error=RuntimeError("provider failed"), duration_ms=2.0)
|
|
|
|
assert SystemOperationKind.GOAL.value == "goal"
|
|
assert asyncio.run(SystemModelCallObserver.on_system_model_call(_Bare(), app_store, task_store, SystemOperationKind.GOAL, request, success)) is None
|
|
assert asyncio.run(SystemModelCallObserver.on_system_model_call(_Bare(), app_store, task_store, SystemOperationKind.GOAL, request, failure)) is None
|
|
|
|
|
|
def test_system_model_request_normalizes_messages_into_an_immutable_sequence():
|
|
"""``messages`` is a snapshot of a message sequence, never a per-character view.
|
|
|
|
Title and summarization pass a single prompt string, so a bare ``str`` must not
|
|
reach observers as a ``Sequence`` whose items are characters. A live ``list`` from
|
|
a call site must also be copied: the snapshot is documented as read-only, and the
|
|
caller keeps mutating its own list after the observation is dispatched.
|
|
"""
|
|
assert SystemModelRequest(messages="one prompt").messages == ("one prompt",)
|
|
|
|
live: list[str] = ["first"]
|
|
request = SystemModelRequest(messages=live)
|
|
live.append("second")
|
|
assert request.messages == ("first",)
|
|
|
|
assert SystemModelRequest().messages == ()
|
|
assert SystemModelRequest(messages=("already", "a", "tuple")).messages == ("already", "a", "tuple")
|
|
|
|
|
|
def test_gateway_contribution_points_are_part_of_the_public_surface():
|
|
import deerflow_extension_api
|
|
|
|
for name in (
|
|
"ExtensionRuntimeDeps",
|
|
"ExtensionService",
|
|
"InvalidRunEvidenceCursor",
|
|
"RunEvidenceReader",
|
|
"RunEventPage",
|
|
"RunPage",
|
|
):
|
|
assert name in deerflow_extension_api.__all__
|
|
assert hasattr(deerflow_extension_api, name)
|
|
assert callable(ExtensionRegistry.service)
|
|
assert callable(ExtensionRegistry.routers)
|
|
assert not hasattr(deerflow_extension_api, "RouterContributor")
|
|
|
|
|
|
def test_run_evidence_pages_are_immutable_and_empty_by_default():
|
|
assert issubclass(InvalidRunEvidenceCursor, ValueError)
|
|
assert RunPage().items == ()
|
|
assert RunPage().next_cursor is None
|
|
assert RunPage().has_more is False
|
|
assert RunEventPage().items == ()
|
|
assert RunEventPage().next_after_seq is None
|
|
|
|
|
|
def test_bare_run_evidence_reader_fails_explicitly_instead_of_looking_caught_up():
|
|
class _Bare:
|
|
pass
|
|
|
|
async def invoke():
|
|
with pytest.raises(NotImplementedError):
|
|
await RunEvidenceReader.list_changed_runs(_Bare(), cursor=None, limit=10)
|
|
|
|
asyncio.run(invoke())
|
|
|
|
|
|
def test_task_store_from_runtime_reads_the_host_key():
|
|
class _Runtime:
|
|
def __init__(self, context):
|
|
self.context = context
|
|
|
|
store = ExtensionData("task-1")
|
|
assert task_store_from_runtime(_Runtime({EXTENSION_TASK_STORE_KEY: store})) is store
|
|
|
|
|
|
def test_task_store_from_runtime_returns_none_on_missing_or_wrong_shape():
|
|
class _Runtime:
|
|
def __init__(self, context):
|
|
self.context = context
|
|
|
|
assert task_store_from_runtime(None) is None
|
|
assert task_store_from_runtime(_Runtime({})) is None
|
|
assert task_store_from_runtime(_Runtime("not-a-mapping")) is None
|
|
assert task_store_from_runtime(_Runtime({EXTENSION_TASK_STORE_KEY: "wrong type"})) is None
|
|
|
|
|
|
def test_extension_decorator_stamps_api_requirement():
|
|
@extension(api="0.1", name="demo")
|
|
def install(registry, config):
|
|
return None
|
|
|
|
assert install.__deerflow_api__ == "0.1"
|
|
assert install.__deerflow_name__ == "demo"
|
|
|
|
|
|
def test_task_outcome_members():
|
|
assert {outcome.value for outcome in TaskOutcome} == {"completed", "aborted", "failed"}
|
|
|
|
|
|
def test_system_operation_kind_members():
|
|
assert {kind.value for kind in SystemOperationKind} == {"goal", "memory", "title", "summarization"}
|
|
|
|
|
|
def test_registry_and_install_alias_are_part_of_the_public_surface():
|
|
"""Independent extensions annotate install(registry, config) against the
|
|
contract package alone — importing the host's concrete registry would pin
|
|
them to the harness release cadence and advertise host-only machinery."""
|
|
import typing
|
|
|
|
import deerflow_extension_api
|
|
|
|
assert "ExtensionRegistry" in deerflow_extension_api.__all__
|
|
assert "ExtensionInstall" in deerflow_extension_api.__all__
|
|
parameters, return_type = typing.get_args(ExtensionInstall)
|
|
assert parameters[0] is ExtensionRegistry, "install()'s first argument must be the public registry contract"
|
|
|
|
|
|
def test_distribution_marks_the_contract_package_as_typed():
|
|
marker = importlib.resources.files("deerflow_extension_api").joinpath("py.typed")
|
|
assert marker.is_file()
|
|
|
|
|
|
def test_contract_package_keeps_runtime_dependencies_empty():
|
|
import tomllib
|
|
from pathlib import Path
|
|
|
|
pyproject = Path(__file__).parent.parent / "packages" / "extension-api" / "pyproject.toml"
|
|
|
|
assert tomllib.loads(pyproject.read_text())["project"]["dependencies"] == []
|
|
|
|
|
|
def test_harness_pins_the_contract_package_exactly():
|
|
"""The version contract (extension-system design): the host pins the
|
|
contract package exactly, extensions use ranges. A range here would let an
|
|
older harness resolve a newer 1.x contract package — API_VERSION would
|
|
then come from the upgraded package and newer extensions would look
|
|
supported against a host whose registry/placements/hook pipeline still
|
|
implements the older contract. The pin makes pip reject that skew at
|
|
install time."""
|
|
import tomllib
|
|
from importlib.metadata import version
|
|
from pathlib import Path
|
|
|
|
from packaging.requirements import Requirement
|
|
|
|
pyproject = Path(__file__).parent.parent / "packages" / "harness" / "pyproject.toml"
|
|
dependencies = tomllib.loads(pyproject.read_text())["project"]["dependencies"]
|
|
requirement = next(Requirement(dep) for dep in dependencies if Requirement(dep).name == "deerflow-extension-api")
|
|
|
|
expected = f"=={version('deerflow-extension-api')}"
|
|
assert str(requirement.specifier) == expected, f"the host must pin deerflow-extension-api exactly ({expected}); a range lets pip resolve a contract newer than the host implements"
|
|
|
|
|
|
def test_runtime_api_version_matches_the_installed_contract_package():
|
|
"""Every additive contract slice bumps both gates together."""
|
|
from importlib.metadata import version
|
|
|
|
assert API_VERSION == "0.2.4"
|
|
assert API_VERSION == version("deerflow-extension-api")
|
|
|
|
|
|
def test_extension_service_contract_is_public_and_defaults_to_noop():
|
|
class _Bare:
|
|
pass
|
|
|
|
deps = ExtensionRuntimeDeps()
|
|
|
|
assert deps.app_store is None
|
|
assert deps.session_factory is None
|
|
assert deps.run_evidence_reader is None
|
|
assert asyncio.run(ExtensionService.start(_Bare(), deps)) is None
|
|
assert asyncio.run(ExtensionService.stop(_Bare())) is None
|