mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* 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.
119 lines
6.1 KiB
Python
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)
|