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.
432 lines
16 KiB
Python
432 lines
16 KiB
Python
"""Runtime task-store wiring required by contributed middleware."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from types import ModuleType, SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
from deerflow_extension_api import EXTENSION_TASK_STORE_KEY, ExtensionData
|
|
from langchain_core.messages import AIMessage
|
|
from langgraph.checkpoint.memory import InMemorySaver
|
|
|
|
from deerflow.extensions import (
|
|
EXTENSION_SNAPSHOT_CONTEXT_KEY,
|
|
get_agent_build_extensions,
|
|
reset_loaded_extensions,
|
|
resolve_run_extensions,
|
|
set_loaded_extensions,
|
|
)
|
|
from deerflow.extensions.registry import ExtensionRegistry
|
|
from deerflow.runtime.runs.manager import RunManager
|
|
from deerflow.runtime.runs.worker import RunContext, _build_runtime_context, run_agent
|
|
|
|
|
|
def test_build_runtime_context_installs_the_extension_store():
|
|
store = ExtensionData("task-1")
|
|
|
|
context = _build_runtime_context("thread-1", "run-1", None, None, store)
|
|
|
|
assert context[EXTENSION_TASK_STORE_KEY] is store
|
|
|
|
|
|
def test_build_runtime_context_omits_the_key_without_a_store():
|
|
context = _build_runtime_context("thread-1", "run-1", None, None, None)
|
|
|
|
assert EXTENSION_TASK_STORE_KEY not in context
|
|
|
|
|
|
def test_build_runtime_context_installs_the_run_extension_snapshot():
|
|
loaded = ExtensionRegistry().build()
|
|
|
|
context = _build_runtime_context("thread-1", "run-1", None, None, None, loaded)
|
|
|
|
assert context[EXTENSION_SNAPSHOT_CONTEXT_KEY] is loaded
|
|
|
|
|
|
def test_build_runtime_context_omits_the_snapshot_key_without_extensions():
|
|
context = _build_runtime_context("thread-1", "run-1", None, None, None, None)
|
|
|
|
assert EXTENSION_SNAPSHOT_CONTEXT_KEY not in context
|
|
|
|
|
|
def test_build_runtime_context_never_keeps_a_caller_supplied_snapshot():
|
|
"""``runtime.context`` is caller-mergeable, so the run's own snapshot must
|
|
win and a caller value must never survive when the run has none."""
|
|
loaded = ExtensionRegistry().build()
|
|
forged = ExtensionRegistry().build()
|
|
|
|
overridden = _build_runtime_context("thread-1", "run-1", {EXTENSION_SNAPSHOT_CONTEXT_KEY: forged}, None, None, loaded)
|
|
dropped = _build_runtime_context("thread-1", "run-1", {EXTENSION_SNAPSHOT_CONTEXT_KEY: forged}, None, None, None)
|
|
|
|
assert overridden[EXTENSION_SNAPSHOT_CONTEXT_KEY] is loaded
|
|
assert EXTENSION_SNAPSHOT_CONTEXT_KEY not in dropped
|
|
|
|
|
|
def test_resolve_run_extensions_rejects_a_foreign_context_value():
|
|
loaded = ExtensionRegistry().build()
|
|
|
|
assert resolve_run_extensions({EXTENSION_SNAPSHOT_CONTEXT_KEY: loaded}) is loaded
|
|
assert resolve_run_extensions({EXTENSION_SNAPSHOT_CONTEXT_KEY: "not-a-snapshot"}) is None
|
|
assert resolve_run_extensions({}) is None
|
|
assert resolve_run_extensions(None) is None
|
|
|
|
|
|
def test_gateway_run_context_captures_the_app_extension_snapshot(monkeypatch):
|
|
from app.gateway import deps
|
|
|
|
loaded = ExtensionRegistry().build()
|
|
request = SimpleNamespace(
|
|
app=SimpleNamespace(
|
|
state=SimpleNamespace(
|
|
extensions=loaded,
|
|
run_events_config=None,
|
|
checkpoint_channel_mode="full",
|
|
checkpoint_snapshot_frequency=None,
|
|
)
|
|
)
|
|
)
|
|
monkeypatch.setattr(deps, "get_checkpointer", lambda request: None)
|
|
monkeypatch.setattr(deps, "get_store", lambda request: None)
|
|
monkeypatch.setattr(deps, "get_run_event_store", lambda request: None)
|
|
monkeypatch.setattr(deps, "get_thread_store", lambda request: None)
|
|
monkeypatch.setattr(deps, "get_config", lambda: SimpleNamespace())
|
|
|
|
context = deps.get_run_context(request)
|
|
|
|
assert context.extensions is loaded
|
|
|
|
|
|
@pytest.fixture
|
|
def _isolated_extensions():
|
|
reset_loaded_extensions()
|
|
yield
|
|
reset_loaded_extensions()
|
|
|
|
|
|
class _MiddlewareContributor:
|
|
def contribute_middlewares(self, app_store, ctx):
|
|
return ()
|
|
|
|
|
|
class _TaskStoreReadingAgent:
|
|
def __init__(self) -> None:
|
|
self.task_store = None
|
|
self.extensions = None
|
|
|
|
async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
|
|
runtime = (config or {})["configurable"]["__pregel_runtime"]
|
|
self.task_store = runtime.context.get(EXTENSION_TASK_STORE_KEY)
|
|
self.extensions = resolve_run_extensions(runtime.context)
|
|
yield {"messages": []}
|
|
|
|
|
|
def _bridge():
|
|
return SimpleNamespace(publish=AsyncMock(), publish_end=AsyncMock(), cleanup=AsyncMock())
|
|
|
|
|
|
_MOCKED_SUBAGENT_MODULES = (
|
|
"deerflow.agents",
|
|
"deerflow.agents.thread_state",
|
|
"deerflow.agents.middlewares",
|
|
"deerflow.agents.middlewares.thread_data_middleware",
|
|
"deerflow.sandbox",
|
|
"deerflow.sandbox.middleware",
|
|
"deerflow.sandbox.security",
|
|
"deerflow.models",
|
|
"deerflow.skills.storage",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def _subagent_env():
|
|
"""Import the real executor behind tests/conftest.py's cycle-breaking mock."""
|
|
# Load the real leaf before replacing its parent package with a cycle-breaking
|
|
# mock; otherwise isolated execution depends on earlier test collection.
|
|
import importlib
|
|
|
|
importlib.import_module("deerflow.agents.middlewares.audit_context")
|
|
importlib.import_module("deerflow.authz.principal")
|
|
original_modules = {name: sys.modules.get(name) for name in _MOCKED_SUBAGENT_MODULES}
|
|
original_executor = sys.modules.get("deerflow.subagents.executor")
|
|
missing = object()
|
|
subagents_pkg = sys.modules.get("deerflow.subagents")
|
|
original_executor_attr = getattr(subagents_pkg, "executor", missing) if subagents_pkg is not None else missing
|
|
|
|
sys.modules.pop("deerflow.subagents.executor", None)
|
|
if subagents_pkg is not None and hasattr(subagents_pkg, "executor"):
|
|
delattr(subagents_pkg, "executor")
|
|
|
|
try:
|
|
for name in _MOCKED_SUBAGENT_MODULES:
|
|
sys.modules[name] = MagicMock()
|
|
storage_module = ModuleType("deerflow.skills.storage")
|
|
storage_module.get_or_new_skill_storage = lambda **kwargs: SimpleNamespace(load_skills=lambda *, enabled_only: [])
|
|
storage_module.get_or_new_user_skill_storage = lambda user_id, **kwargs: SimpleNamespace(load_skills=lambda *, enabled_only: [])
|
|
sys.modules["deerflow.skills.storage"] = storage_module
|
|
|
|
from deerflow.subagents.config import SubagentConfig
|
|
from deerflow.subagents.executor import SubagentExecutor, SubagentResult, SubagentStatus
|
|
|
|
executor_module = sys.modules["deerflow.subagents.executor"]
|
|
executor_module.get_app_config = lambda: SimpleNamespace(
|
|
tool_search=SimpleNamespace(enabled=False),
|
|
authorization=SimpleNamespace(enabled=False),
|
|
)
|
|
yield SimpleNamespace(
|
|
SubagentConfig=SubagentConfig,
|
|
SubagentExecutor=SubagentExecutor,
|
|
SubagentResult=SubagentResult,
|
|
SubagentStatus=SubagentStatus,
|
|
)
|
|
finally:
|
|
for name, original in original_modules.items():
|
|
if original is None:
|
|
sys.modules.pop(name, None)
|
|
else:
|
|
sys.modules[name] = original
|
|
if original_executor is None:
|
|
sys.modules.pop("deerflow.subagents.executor", None)
|
|
else:
|
|
sys.modules["deerflow.subagents.executor"] = original_executor
|
|
subagents_pkg = sys.modules.get("deerflow.subagents")
|
|
if subagents_pkg is not None:
|
|
if original_executor_attr is missing:
|
|
if hasattr(subagents_pkg, "executor"):
|
|
delattr(subagents_pkg, "executor")
|
|
else:
|
|
setattr(subagents_pkg, "executor", original_executor_attr)
|
|
|
|
|
|
class _CapturingSubagent:
|
|
def __init__(self, seen: dict) -> None:
|
|
self._seen = seen
|
|
|
|
async def astream(self, *args, **kwargs):
|
|
self._seen["context"] = kwargs.get("context")
|
|
yield {"messages": [AIMessage(content="done")]}
|
|
|
|
|
|
def _create_agent_stub(seen: dict):
|
|
"""Async stand-in for SubagentExecutor._create_agent (the real one is async)."""
|
|
|
|
async def _create(self, tools, **kwargs):
|
|
return _CapturingSubagent(seen)
|
|
|
|
return _create
|
|
|
|
|
|
async def _run_subagent(monkeypatch, env, *, seen: dict, result_holder=None):
|
|
async def _initial_state(self, task):
|
|
return ({}, [], None)
|
|
|
|
monkeypatch.setattr(env.SubagentExecutor, "_build_initial_state", _initial_state)
|
|
monkeypatch.setattr(env.SubagentExecutor, "_create_agent", _create_agent_stub(seen))
|
|
config = env.SubagentConfig(name="researcher", description="d", system_prompt="p", tools=[])
|
|
executor = env.SubagentExecutor(config=config, tools=[], thread_id="thread-1", run_id=None)
|
|
return await executor._aexecute("do the thing", result_holder=result_holder)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_lead_middleware_receives_a_run_scoped_store(_isolated_extensions):
|
|
registry = ExtensionRegistry()
|
|
with registry.attributed_to("demo:install"):
|
|
registry.middlewares(_MiddlewareContributor())
|
|
set_loaded_extensions(registry.build())
|
|
|
|
run_manager = RunManager()
|
|
record = await run_manager.create("thread-ext-middleware")
|
|
agent = _TaskStoreReadingAgent()
|
|
|
|
await run_agent(
|
|
_bridge(),
|
|
run_manager,
|
|
record,
|
|
ctx=RunContext(checkpointer=InMemorySaver()),
|
|
agent_factory=lambda *, config: agent,
|
|
graph_input={},
|
|
config={},
|
|
)
|
|
|
|
assert agent.task_store is not None
|
|
assert agent.task_store.scope_id == record.run_id
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_lead_agent_factory_receives_the_same_extension_snapshot_as_the_store(_isolated_extensions):
|
|
registry = ExtensionRegistry()
|
|
with registry.attributed_to("demo:install"):
|
|
registry.middlewares(_MiddlewareContributor())
|
|
loaded = registry.build()
|
|
set_loaded_extensions(ExtensionRegistry().build())
|
|
run_manager = RunManager()
|
|
record = await run_manager.create("thread-ext-snapshot")
|
|
agent = _TaskStoreReadingAgent()
|
|
seen = {}
|
|
|
|
def agent_factory(*, config):
|
|
seen["extensions"] = get_agent_build_extensions()
|
|
return agent
|
|
|
|
await run_agent(
|
|
_bridge(),
|
|
run_manager,
|
|
record,
|
|
ctx=RunContext(checkpointer=None, extensions=loaded),
|
|
agent_factory=agent_factory,
|
|
graph_input={},
|
|
config={},
|
|
)
|
|
|
|
assert seen["extensions"] is loaded
|
|
assert agent.task_store is not None
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_lead_run_publishes_its_snapshot_for_delegated_work(_isolated_extensions):
|
|
"""Delegation happens during graph execution, long after the graph-build
|
|
contextvar binding has exited, so the run's snapshot has to travel through
|
|
runtime context to stay reachable from ``task_tool``."""
|
|
registry = ExtensionRegistry()
|
|
with registry.attributed_to("demo:install"):
|
|
registry.middlewares(_MiddlewareContributor())
|
|
loaded = registry.build()
|
|
set_loaded_extensions(ExtensionRegistry().build())
|
|
run_manager = RunManager()
|
|
record = await run_manager.create("thread-ext-delegation")
|
|
agent = _TaskStoreReadingAgent()
|
|
|
|
await run_agent(
|
|
_bridge(),
|
|
run_manager,
|
|
record,
|
|
ctx=RunContext(checkpointer=None, extensions=loaded),
|
|
agent_factory=lambda *, config: agent,
|
|
graph_input={},
|
|
config={},
|
|
)
|
|
|
|
assert agent.extensions is loaded
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_subagent_middleware_without_parent_run_receives_its_task_store(monkeypatch, _isolated_extensions, _subagent_env):
|
|
registry = ExtensionRegistry()
|
|
with registry.attributed_to("demo:install"):
|
|
registry.middlewares(_MiddlewareContributor())
|
|
set_loaded_extensions(registry.build())
|
|
seen: dict = {}
|
|
|
|
result = await _run_subagent(monkeypatch, _subagent_env, seen=seen)
|
|
|
|
store = (seen.get("context") or {}).get(EXTENSION_TASK_STORE_KEY)
|
|
assert isinstance(store, ExtensionData)
|
|
assert store.scope_id == result.task_id
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_async_subagent_task_store_preserves_external_correlation_scope(monkeypatch, _isolated_extensions, _subagent_env):
|
|
registry = ExtensionRegistry()
|
|
with registry.attributed_to("demo:install"):
|
|
registry.middlewares(_MiddlewareContributor())
|
|
set_loaded_extensions(registry.build())
|
|
seen: dict = {}
|
|
result_holder = _subagent_env.SubagentResult(
|
|
task_id="server-execution-id",
|
|
external_task_id="provider-tool-call-id",
|
|
trace_id="trace-1",
|
|
status=_subagent_env.SubagentStatus.RUNNING,
|
|
)
|
|
|
|
result = await _run_subagent(
|
|
monkeypatch,
|
|
_subagent_env,
|
|
seen=seen,
|
|
result_holder=result_holder,
|
|
)
|
|
|
|
store = (seen.get("context") or {}).get(EXTENSION_TASK_STORE_KEY)
|
|
assert isinstance(store, ExtensionData)
|
|
assert result.task_id == "server-execution-id"
|
|
assert store.scope_id == "provider-tool-call-id"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_subagent_builder_receives_the_same_extension_snapshot_as_the_store(monkeypatch, _isolated_extensions, _subagent_env):
|
|
registry = ExtensionRegistry()
|
|
with registry.attributed_to("demo:install"):
|
|
registry.middlewares(_MiddlewareContributor())
|
|
loaded = registry.build()
|
|
set_loaded_extensions(loaded)
|
|
seen: dict = {}
|
|
|
|
async def _initial_state(self, task):
|
|
set_loaded_extensions(ExtensionRegistry().build())
|
|
return ({}, [], None)
|
|
|
|
async def _create_agent(self, tools, *, deferred_setup=None, extensions=None):
|
|
seen["extensions"] = extensions
|
|
return _CapturingSubagent(seen)
|
|
|
|
monkeypatch.setattr(_subagent_env.SubagentExecutor, "_build_initial_state", _initial_state)
|
|
monkeypatch.setattr(_subagent_env.SubagentExecutor, "_create_agent", _create_agent)
|
|
config = _subagent_env.SubagentConfig(name="researcher", description="d", system_prompt="p", tools=[])
|
|
executor = _subagent_env.SubagentExecutor(config=config, tools=[], thread_id="thread-1", run_id=None)
|
|
|
|
result = await executor._aexecute("do the thing")
|
|
|
|
assert seen["extensions"] is loaded
|
|
store = (seen.get("context") or {}).get(EXTENSION_TASK_STORE_KEY)
|
|
assert isinstance(store, ExtensionData)
|
|
assert store.scope_id == result.task_id
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_subagent_prefers_the_parent_run_snapshot_over_the_singleton(monkeypatch, _isolated_extensions, _subagent_env):
|
|
"""A singleton replacement between the lead run's start and the subagent's
|
|
execution must not mix two extension generations within one run."""
|
|
registry = ExtensionRegistry()
|
|
with registry.attributed_to("demo:install"):
|
|
registry.middlewares(_MiddlewareContributor())
|
|
run_snapshot = registry.build()
|
|
replaced_singleton = ExtensionRegistry().build()
|
|
set_loaded_extensions(replaced_singleton)
|
|
seen: dict = {}
|
|
|
|
async def _initial_state(self, task):
|
|
return ({}, [], None)
|
|
|
|
async def _create_agent(self, tools, *, deferred_setup=None, extensions=None):
|
|
seen["extensions"] = extensions
|
|
return _CapturingSubagent(seen)
|
|
|
|
monkeypatch.setattr(_subagent_env.SubagentExecutor, "_build_initial_state", _initial_state)
|
|
monkeypatch.setattr(_subagent_env.SubagentExecutor, "_create_agent", _create_agent)
|
|
config = _subagent_env.SubagentConfig(name="researcher", description="d", system_prompt="p", tools=[])
|
|
executor = _subagent_env.SubagentExecutor(
|
|
config=config,
|
|
tools=[],
|
|
thread_id="thread-1",
|
|
run_id=None,
|
|
extensions=run_snapshot,
|
|
)
|
|
|
|
result = await executor._aexecute("do the thing")
|
|
|
|
assert seen["extensions"] is run_snapshot
|
|
# The store follows the same snapshot: the replaced singleton contributes
|
|
# no middleware and would have allocated nothing.
|
|
store = (seen.get("context") or {}).get(EXTENSION_TASK_STORE_KEY)
|
|
assert isinstance(store, ExtensionData)
|
|
assert store.scope_id == result.task_id
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_subagent_without_task_store_contributors_does_not_inject_one(monkeypatch, _isolated_extensions, _subagent_env):
|
|
seen: dict = {}
|
|
|
|
await _run_subagent(monkeypatch, _subagent_env, seen=seen)
|
|
|
|
assert EXTENSION_TASK_STORE_KEY not in (seen.get("context") or {})
|