deer-flow/backend/tests/test_batch_extension_snapshot.py
Zeren Wang d3e8e78f3f
feat(authz): middleware-declared tool path Layer 1 now covers tools declared by middlewares (#6105)
* feat(authz): extension tool authorization PR2 — middleware-declared tool path

Layer 1 now covers tools declared by middlewares, which LangChain merges
into the bound tool set after the host's explicit-list filter:

- new agents/middlewares/tool_declarations.py: record the ordinary pass's
  verdict, collect declarations from the built stack, decide only the
  delta seeded by the first pass (a name denied for this build stays
  denied), narrow the stack build-locally on independent state-preserving
  copies (direct __dict__ write, refused up front for tools data
  descriptors, never mutating caller-owned instances), and verify the
  exact bound stack after the final normalization copy.
- IsolatedMiddleware ships a state-preserving __copy__ so narrowing
  survives full/delta normalization and copies of copies.
- TodoMiddleware degrades when write_todos is denied: no system-prompt
  injection, no completion/context-loss reminders, no jump_to.
- Layer 2 forwards host-resolved tool provenance into AuthzRequest.context
  and binds the infrastructure exemption to the concrete host-created
  tool object instead of a name set (tool=None goes to the provider).
- Wired into all four assembly paths (lead x2, native subagent with the
  decision offloaded via asyncio.to_thread, DeerFlowClient).

Tests: new test_middleware_declared_tools.py (35) and
test_layer2_tool_provenance.py (7); blocking-IO anchor pinning the
subagent offload (teeth verified red->green); placement-guarantee and
enforcement suites extended; executor test doubles converted for the
async _create_agent. Behavior change called out in CHANGELOG: with
authorization enabled and a tools policy that excludes write_todos,
plan-mode builds no longer bind it (built-in RBAC default unchanged).

* fix(authz): close residual gaps from review — narrow non-collectable declarations, dedupe candidates, warn on skipped pass

- apply_declared_tool_view now removes non-BaseTool declarations from the
  bound view when authorization is enabled (they would otherwise bind via
  LangChain's create_tool auto-conversion with no provider decision);
  verify_declared_tool_view fails loudly if one survives normalization
- decide_declared_tools dedupes never-submitted names so the provider sees
  each candidate once
- subagent executor logs a warning when the provider is set but the seeded
  Layer-1 state is missing, instead of skipping the declaration pass silently
- docs: middleware-declared tools must be BaseTool instances with usable names

* fix(authz): fail loudly on non-list/tuple middleware tools declarations

_iter_declared warned and returned () for a 'tools' attribute that is not
a list/tuple, but LangChain's factory iterates the attribute as-is — so a
set/generator/dict_values declaration bound with no Layer-1 decision.
Raise DeclaredToolViewError instead, matching the module's fail-loud
standard for every other un-narrowable shape. Disabled path remains a
strict no-op. Docs updated to match.
2026-10-01 18:26:58 +08:00

119 lines
6.1 KiB
Python

"""Pin durable worker plugin tools and execution to one app generation."""
from copy import deepcopy
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from deerflow_extension_api.plugins import ModelTool, PluginContribution
from test_extension_task_store_runtime import _CapturingSubagent
from test_extension_task_store_runtime import _subagent_env as _subagent_env
from deerflow.config.subagent_batches_config import SubagentBatchesConfig
from deerflow.config.subagent_runtime_config import SubagentRuntimeConfig
from deerflow.extensions import get_loaded_extensions, set_loaded_extensions
from deerflow.extensions.plugin_tools import plugin_tool_name
from deerflow.extensions.registry import ExtensionRegistry
from deerflow.subagents import batch_service as service_module
from deerflow.tools import tools as assembly
def _generation(name):
async def identify(payload, context):
return name
registry = ExtensionRegistry()
with registry.attributed_to("probe"):
registry.plugin(PluginContribution(namespace=f"probe.{name}", title=name, enabled=True, tools=(ModelTool("identify", "Identify generation", {"type": "object", "properties": {}, "additionalProperties": False}, identify),)))
return registry.build()
@pytest.mark.asyncio
@pytest.mark.parametrize("replacement,explicit_snapshot,late_replace,empty", [(False, False, False, False), (True, False, False, False), (True, True, False, False), (False, False, True, False), (True, True, True, True)])
async def test_batch_keeps_its_construction_snapshot(monkeypatch, _subagent_env, replacement, explicit_snapshot, late_replace, empty):
original = get_loaded_extensions()
a, b = _generation("alpha"), _generation("beta")
if empty:
a = ExtensionRegistry().build()
monkeypatch.setattr("deerflow.extensions._loaded", a)
if explicit_snapshot:
set_loaded_extensions(b)
config = SimpleNamespace(tools=[], models=[], acp_agents={}, get_model_config=lambda name: None, authorization=SimpleNamespace(enabled=False), tool_search=SimpleNamespace(enabled=False))
seen = {}
captured = {}
repo = SimpleNamespace(renew_item_lease=AsyncMock(return_value={"valid": True}), finalize_item=AsyncMock(return_value=True))
service = service_module.SubagentBatchService(repository=repo, config=SubagentBatchesConfig(), runtime_config=SubagentRuntimeConfig(), app_config=config, **({"extensions": a} if explicit_snapshot else {}))
item = {
"id": "item-1",
"item_key": "key-1",
"prompt": "identify",
"batch": {
"id": "batch-1",
"thread_id": "thread-1",
"user_id": "user-1",
"run_id": None,
"execution_spec": {"subagent_config": {"name": "researcher", "description": "d", "system_prompt": "p"}, "parent_model": "model-a", "mcp_plugins": [], "tool_groups": ["extensions"]},
},
}
real_assembly = assembly.get_available_tools
monkeypatch.setattr(assembly, "BUILTIN_TOOLS", [])
monkeypatch.setattr(assembly, "is_host_bash_allowed", lambda config: False)
monkeypatch.setattr(assembly, "is_mcp_task_runtime_available", lambda: False)
monkeypatch.setattr("deerflow.mcp.cache.get_cached_mcp_tools", lambda: [])
monkeypatch.setattr(service_module, "resolve_subagent_model_name", lambda *args, **kwargs: "model-a")
def assemble(**kwargs):
kwargs["include_mcp"] = False
tools = real_assembly(**kwargs)
captured["tool_names"] = [t.name for t in tools]
return tools
monkeypatch.setattr("deerflow.tools.get_available_tools", assemble)
class CaptureExecutor:
def __init__(self, **kwargs):
captured["executor_kwargs"] = kwargs
def execute_async(self, prompt, task_id=None):
return "exec-1"
monkeypatch.setattr(service_module, "SubagentExecutor", CaptureExecutor)
monkeypatch.setattr(service_module, "SubagentStatus", _subagent_env.SubagentStatus)
monkeypatch.setattr(service_module, "get_background_task_result", lambda _: SimpleNamespace(status=service_module.SubagentStatus.COMPLETED, result="done", error=None, stop_reason=None, token_usage_records=None))
monkeypatch.setattr(service_module, "cleanup_background_task", lambda _: None)
async def initial(self, task):
return ({}, self.tools, None)
async def create(self, tools, *, deferred_setup=None, extensions=None):
seen["extensions"] = extensions
return _CapturingSubagent(seen)
monkeypatch.setattr(_subagent_env.SubagentExecutor, "_build_initial_state", initial)
monkeypatch.setattr(_subagent_env.SubagentExecutor, "_create_agent", create)
try:
set_loaded_extensions(b if replacement else a)
await service._execute_item(item)
assert repo.finalize_item.await_args.kwargs["succeeded"]
if late_replace:
set_loaded_extensions(b)
executor = _subagent_env.SubagentExecutor(**captured["executor_kwargs"])
result = await executor._aexecute("identify")
assert result.status == _subagent_env.SubagentStatus.COMPLETED, result.error
assert captured["tool_names"] == ([] if empty else [plugin_tool_name("probe.alpha", "identify")])
assert seen["extensions"] is a
# Replay the same durable record under a new worker after a restart.
# The record carries no Python snapshot; the new worker owns generation B.
original_spec = deepcopy(item["batch"]["execution_spec"])
restarted = service_module.SubagentBatchService(repository=repo, config=SubagentBatchesConfig(), runtime_config=SubagentRuntimeConfig(), app_config=config, extensions=b)
set_loaded_extensions(a)
await restarted._execute_item(item)
executor = _subagent_env.SubagentExecutor(**captured["executor_kwargs"])
result = await executor._aexecute("identify")
assert result.status == _subagent_env.SubagentStatus.COMPLETED, result.error
assert seen["extensions"] is b
assert captured["tool_names"] == [plugin_tool_name("probe.beta", "identify")]
assert item["batch"]["execution_spec"] == original_spec
finally:
set_loaded_extensions(original)