feat(maf): add secure roundtable workflow runtime

This commit is contained in:
董宏凯 committed 2026-08-01 17:51:53 +08:00
1 parent e690475e6e
commit bd4131b4e2
52 files changed
+10315 -96

No files matched your search

@@ -0,0 +1,33 @@
"""Shared helpers for MAF roundtable unit tests."""
from __future__ import annotations
from typing import Any
from app.maf_roundtable.service import MafRoundtableService
from deerflow.config import get_app_config
from deerflow.config.maf_roundtable_config import MafRoundtableConfig
class FakeAgentStore:
"""Treat every agent id as visible to any user (tests only)."""
async def get_visible(self, agent_id: str, user_id: str) -> dict[str, Any]:
return {"id": agent_id, "user_id": user_id}
def make_maf_service(
store,
*,
enabled: bool = True,
admin_diagnostic_access: bool = True,
config: MafRoundtableConfig | None = None,
) -> MafRoundtableService:
return MafRoundtableService(
store,
enabled=enabled,
admin_diagnostic_access=admin_diagnostic_access,
config=config,
agent_store=FakeAgentStore(),
app_config=get_app_config(),
)
@@ -0,0 +1,438 @@
"""Tests for the MAF DeerFlow Agent Adapter (Phase 2 rework).
Tests: event normalization, context building, sanitization, cancellation,
audit event reliability, seat run persistence, and user isolation.
Uses in-memory SQLite + mocks for the DeerFlow runtime (no real model calls).
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.maf_roundtable.context_builder import build_manager_synthesis_input, build_seat_input
from app.maf_roundtable.deerflow_agent_adapter import (
AuditEventError,
DeerFlowAgentAdapter,
SeatResponse,
)
from app.maf_roundtable.event_normalizer import (
make_run_completed,
make_turn_completed,
make_turn_started,
normalize_stream_event,
)
from app.maf_roundtable.runtime_client import DeerFlowRuntimeClient, ExecutionPrincipal
from app.maf_roundtable.sanitize import sanitize, sanitize_payload
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
from deerflow.persistence.maf_roundtable.model import Base
@pytest_asyncio.fixture
async def store():
engine = create_async_engine("sqlite+aiosqlite://", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
s = make_maf_roundtable_store(sf)
assert s is not None
yield s
await engine.dispose()
def _svc(store, enabled: bool = True):
from maf_test_helpers import make_maf_service
return make_maf_service(store, enabled=enabled)
def _mock_client() -> DeerFlowRuntimeClient:
app = MagicMock()
app.state.checkpointer = AsyncMock()
app.state.thread_store = AsyncMock()
app.state.stream_bridge = None
app.state.run_manager = AsyncMock()
return DeerFlowRuntimeClient(app)
# ── event_normalizer tests ────────────────────────────────────────────────────
class TestEventNormalizer:
def test_metadata_event(self):
events = normalize_stream_event("metadata", {"run_id": "r1", "thread_id": "t1"}, agent_id="a1", turn_index=0)
assert len(events) == 1
assert events[0]["event_type"] == "run.started"
def test_error_event(self):
events = normalize_stream_event("error", {"message": "boom", "name": "TimeoutError"}, agent_id="a1", turn_index=1)
assert len(events) == 1
assert events[0]["event_type"] == "run.failed"
def test_messages_ai_with_tool_calls(self):
data = ["msg-1", {"type": "ai", "content": "", "tool_calls": [{"name": "search", "id": "tc1", "args": {"q": "test"}}]}]
events = normalize_stream_event("messages", data, agent_id="a1", turn_index=1)
assert any(e["event_type"] == "tool.requested" for e in events)
def test_messages_tool_result(self):
data = ["msg-1", {"type": "tool", "name": "search", "tool_call_id": "tc1", "content": "results...", "status": "completed"}]
events = normalize_stream_event("messages", data, agent_id="a1", turn_index=1)
assert events[0]["event_type"] == "tool.completed"
def test_no_skill_guessing_from_text(self):
data = {"agent_node": {"messages": [{"type": "ai", "content": "I will use the skill to analyze this"}]}}
events = normalize_stream_event("updates", data, agent_id="a1", turn_index=1)
assert not any("skill" in e["event_type"] for e in events)
def test_source_event_id_for_dedup(self):
data = ["msg-1", {"type": "tool", "name": "search", "tool_call_id": "tc1", "content": "ok", "status": "completed"}]
events = normalize_stream_event("messages", data, agent_id="a1", turn_index=1)
assert events[0].get("source_event_id") == "tool:tc1"
def test_unknown_event_ignored(self):
events = normalize_stream_event("checkpoints", {"foo": "bar"}, agent_id="a1", turn_index=0)
assert events == []
# ── sanitization tests ────────────────────────────────────────────────────────
class TestSanitization:
def test_sensitive_keys_redacted(self):
data = {"token": "abc123", "name": "safe", "nested": {"password": "secret", "ok": "yes"}}
result = sanitize(data)
assert result["token"] == "***REDACTED***"
assert result["name"] == "safe"
assert result["nested"]["password"] == "***REDACTED***"
assert result["nested"]["ok"] == "yes"
def test_bearer_token_redacted(self):
data = {"header": "Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig"}
result = sanitize(data)
assert result["header"] == "***REDACTED***"
def test_api_key_pattern_redacted(self):
data = {"key": "sk-abcdefghijklmnopqrstuvwxyz123456"}
result = sanitize(data)
assert result["key"] == "***REDACTED***"
def test_normal_values_untouched(self):
data = {"message": "hello world", "count": 42, "items": ["a", "b"]}
result = sanitize(data)
assert result == data
def test_sanitize_payload_none(self):
assert sanitize_payload(None) is None
def test_event_payload_sanitized(self):
events = normalize_stream_event(
"messages",
["m1", {"type": "ai", "content": "", "tool_calls": [{"name": "api_call", "id": "tc1", "args": {"api_key": "secret123", "url": "http://x"}}]}],
agent_id="a1",
turn_index=1,
)
tool_ev = next(e for e in events if e["event_type"] == "tool.requested")
assert tool_ev["payload"]["args"]["api_key"] == "***REDACTED***"
assert tool_ev["payload"]["args"]["url"] == "http://x"
# ── context_builder tests ─────────────────────────────────────────────────────
class TestContextBuilder:
def test_strict_truncation(self):
result = build_seat_input(
task_text="x" * 20000,
stage_instruction="",
role_description="",
prior_turns=[],
max_context_chars=100,
)
assert len(result) <= 100
def test_no_overflow_after_truncation(self):
result = build_seat_input(
task_text="task",
stage_instruction="stage",
role_description="role",
prior_turns=[{"agent_id": "a", "turn_index": 1, "summary": "y" * 5000}],
fact_ledger={"confirmed_facts": ["f" * 5000]},
max_context_chars=200,
)
assert len(result) <= 200
def test_fact_ledger_sanitized(self):
ledger = {"confirmed_facts": ["server uses token=abc123"]}
result = build_seat_input(
task_text="check",
stage_instruction="",
role_description="",
prior_turns=[],
fact_ledger=ledger,
)
assert "abc123" not in result or "token" not in result.lower()
def test_manager_synthesis(self):
result = build_manager_synthesis_input(
task_text="evaluate",
seat_results=[{"agent_id": "risk", "content": "high risk"}],
)
assert "risk" in result
assert "建议动作" in result
# ── ExecutionPrincipal tests ──────────────────────────────────────────────────
class TestExecutionPrincipal:
def test_requires_user_id(self):
with pytest.raises(ValueError):
ExecutionPrincipal(user_id="", session_id="s1", run_id="r1")
def test_valid_principal(self):
p = ExecutionPrincipal(user_id="u1", session_id="s1", run_id="r1")
assert p.user_id == "u1"
def test_satisfies_current_user_protocol(self):
from app.maf_roundtable.runtime_client import _PrincipalUser
from deerflow.runtime.user_context import CurrentUser
p = ExecutionPrincipal(user_id="test-user", session_id="s", run_id="r")
user = _PrincipalUser(p)
assert isinstance(user, CurrentUser)
assert user.id == "test-user"
# ── adapter unit tests ────────────────────────────────────────────────────────
class TestDeerFlowAgentAdapter:
@pytest.mark.asyncio
async def test_ensure_threads_creates_and_binds(self, store):
svc = _svc(store)
session = await svc.create_session(
user_id="u1",
body={"task_text": "test", "participants": [{"agent_id": "a1"}, {"agent_id": "a2"}]},
)
run = await svc.create_run(session_id=session["id"], user_id="u1")
client = _mock_client()
client._app.state.checkpointer.aput = AsyncMock()
client._app.state.thread_store.create = AsyncMock()
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
thread_map = await adapter.ensure_threads(session["id"], run["id"], session["participants"])
assert len(thread_map) == 2
assert thread_map["a1"] != thread_map["a2"]
updated = await svc.get_session(session_id=session["id"], user_id="u1")
for p in updated["participants"]:
assert p["thread_id"] is not None
@pytest.mark.asyncio
async def test_ensure_threads_reuses_existing(self, store):
svc = _svc(store)
session = await svc.create_session(
user_id="u1",
body={"task_text": "test", "participants": [{"agent_id": "a1", "thread_id": "existing-t1"}]},
)
run = await svc.create_run(session_id=session["id"], user_id="u1")
client = _mock_client()
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
thread_map = await adapter.ensure_threads(session["id"], run["id"], session["participants"])
assert thread_map["a1"] == "existing-t1"
@pytest.mark.asyncio
async def test_cancel_cascades_via_seat_runs(self, store):
svc = _svc(store)
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
await svc.store.seat_runs.create_seat_run(maf_run_id=run["id"], agent_id="a1", turn_index=1)
active = await svc.store.seat_runs.get_active_by_maf_run(run["id"])
seat_run_id = active[0]["id"]
await svc.store.seat_runs.mark_running(seat_run_id, deerflow_run_id="df-run-1")
client = _mock_client()
client._app.state.run_manager.cancel = AsyncMock()
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
result = await adapter.cancel(run["id"])
# Structured cancel result (Task 4): all_cancelled when every active
# sub-run was confirmed cancelled.
assert result["all_cancelled"] is True
assert result["cancelled_run_ids"] == [seat_run_id]
assert result["failed_run_ids"] == []
client._app.state.run_manager.cancel.assert_called_once_with("df-run-1", action="interrupt")
remaining = await svc.store.seat_runs.get_active_by_maf_run(run["id"])
assert len(remaining) == 0
@pytest.mark.asyncio
async def test_critical_event_failure_raises(self, store):
svc = _svc(store)
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
client = _mock_client()
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
original_append = svc.append_event
async def _failing_append(**kwargs):
raise RuntimeError("DB connection lost")
svc.append_event = _failing_append
with pytest.raises(AuditEventError):
await adapter._emit_critical(run["id"], make_turn_started("a1", 0))
svc.append_event = original_append
@pytest.mark.asyncio
async def test_noncritical_event_degrades_gracefully(self, store):
svc = _svc(store)
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
client = _mock_client()
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
async def _failing_append(**kwargs):
raise RuntimeError("DB connection lost")
svc.append_event = _failing_append
await adapter._emit(run["id"], {"event_type": "agent.message.delta", "agent_id": "a1", "turn_index": 0, "payload": {"text": "hi"}})
@pytest.mark.asyncio
async def test_seat_response_dataclass(self):
r = SeatResponse(agent_id="a1", turn_index=1, content="hello", tool_calls=[{"name": "search"}])
assert r.agent_id == "a1"
assert r.error is None
@pytest.mark.asyncio
async def test_latest_ai_text_extraction(self, store):
svc = _svc(store)
client = _mock_client()
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
messages = [
{"type": "human", "content": "hello"},
{"type": "ai", "content": "", "tool_calls": [{"name": "search", "args": {}, "id": "1"}]},
{"type": "ai", "content": "The answer is 42."},
]
assert adapter._latest_ai_text(messages) == "The answer is 42."
@pytest.mark.asyncio
async def test_tool_calls_since_last_human(self, store):
svc = _svc(store)
client = _mock_client()
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
messages = [
{"type": "human", "content": "old"},
{"type": "ai", "content": "", "tool_calls": [{"name": "old_tool", "args": {}, "id": "0"}]},
{"type": "human", "content": "new"},
{"type": "ai", "content": "", "tool_calls": [{"name": "search", "args": {"q": "x"}, "id": "1"}]},
]
calls = adapter._tool_calls_since_last_human(messages)
assert len(calls) == 1
assert calls[0]["name"] == "search"
# ── seat_runs persistence tests ───────────────────────────────────────────────
class TestSeatRunPersistence:
@pytest.mark.asyncio
async def test_create_and_lifecycle(self, store):
svc = _svc(store)
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
sr = await svc.store.seat_runs.create_seat_run(
maf_run_id=run["id"], agent_id="expert-a", turn_index=1, deerflow_thread_id="t1"
)
assert sr["status"] == "pending"
await svc.store.seat_runs.mark_running(sr["id"], deerflow_run_id="df-1")
active = await svc.store.seat_runs.get_active_by_maf_run(run["id"])
assert len(active) == 1
assert active[0]["deerflow_run_id"] == "df-1"
await svc.store.seat_runs.mark_completed(sr["id"], checkpoint_id="cp-1")
active = await svc.store.seat_runs.get_active_by_maf_run(run["id"])
assert len(active) == 0
@pytest.mark.asyncio
async def test_cancel_all_active(self, store):
svc = _svc(store)
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
sr1 = await svc.store.seat_runs.create_seat_run(maf_run_id=run["id"], agent_id="a1", turn_index=1)
sr2 = await svc.store.seat_runs.create_seat_run(maf_run_id=run["id"], agent_id="a2", turn_index=1)
await svc.store.seat_runs.mark_running(sr1["id"], deerflow_run_id="df-1")
await svc.store.seat_runs.mark_running(sr2["id"], deerflow_run_id="df-2")
cancelled = await svc.store.seat_runs.cancel_all_active(run["id"])
# Two-phase cancel: returns {seat_run_id, deerflow_run_id} dicts and
# leaves rows in 'cancelling' (caller finalizes to cancelled).
assert {c["deerflow_run_id"] for c in cancelled} == {"df-1", "df-2"}
assert {c["seat_run_id"] for c in cancelled} == {sr1["id"], sr2["id"]}
# 'cancelling' is not an active status, so active count drops to 0.
active = await svc.store.seat_runs.get_active_by_maf_run(run["id"])
assert len(active) == 0
# Rows are mid-cancel, not yet finalized.
all_runs = await svc.store.seat_runs.get_by_maf_run(run["id"])
assert {r["status"] for r in all_runs} == {"cancelling"}
@pytest.mark.asyncio
async def test_unique_constraint(self, store):
svc = _svc(store)
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
await svc.store.seat_runs.create_seat_run(maf_run_id=run["id"], agent_id="a1", turn_index=1)
with pytest.raises(Exception):
await svc.store.seat_runs.create_seat_run(maf_run_id=run["id"], agent_id="a1", turn_index=1)
# ── event flow integration tests ──────────────────────────────────────────────
class TestAdapterEventFlow:
@pytest.mark.asyncio
async def test_full_event_sequence_persisted(self, store):
svc = _svc(store)
session = await svc.create_session(user_id="u1", body={"task_text": "analyze"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
client = _mock_client()
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
await adapter._emit_critical(run["id"], make_turn_started("expert", 1))
await adapter._emit(run["id"], {"event_type": "tool.requested", "agent_id": "expert", "turn_index": 1, "payload": {"tool_name": "search"}})
await adapter._emit(run["id"], {"event_type": "tool.completed", "agent_id": "expert", "turn_index": 1, "payload": {"tool_name": "search", "status": "succeeded"}})
await adapter._emit_critical(run["id"], make_turn_completed("expert", 1, content="done"))
await adapter._emit_critical(run["id"], make_run_completed("expert", 1))
events, latest = await svc.list_events(run_id=run["id"], user_id="u1")
assert len(events) == 5
assert latest == 5
types = [e["event_type"] for e in events]
assert types == ["agent.turn_started", "tool.requested", "tool.completed", "agent.message.completed", "run.completed"]
@pytest.mark.asyncio
async def test_dedup_via_source_event_id(self, store):
svc = _svc(store)
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
client = _mock_client()
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
ev = {"event_type": "tool.completed", "agent_id": "a1", "turn_index": 1, "payload": {"tool_name": "x"}, "source_event_id": "tool:tc1"}
await adapter._emit(run["id"], ev)
await adapter._emit(run["id"], ev)
events, _ = await svc.list_events(run_id=run["id"], user_id="u1")
assert len(events) == 1
@@ -0,0 +1,669 @@
"""P0-1 acceptance tests for the harness approved-tool executor.
These exercise ``deerflow.tools.approved_tool_executor.execute_approved_tool``
directly on the real LangGraph ToolNode / ToolRuntime / middleware path with
controlled tools (no LLM, no DeerFlow runtime).
"""
from __future__ import annotations
from typing import Any
import pytest
from langchain_core.tools import StructuredTool
from langgraph.prebuilt import ToolRuntime
from deerflow.agents.middlewares.tool_error_handling_middleware import (
build_lead_runtime_middlewares,
)
from deerflow.agents.middlewares.write_guard_middleware import (
WriteGuardMiddleware,
)
from deerflow.config.write_guard_config import WriteGuardConfig
from deerflow.tools import approved_tool_executor
from deerflow.tools.approved_tool_executor import execute_approved_tool
# ?? fakes ?????????????????????????????????????????????????????????????????
class _FakeCheckpointTuple:
def __init__(self, channel_values: dict[str, Any]) -> None:
self.checkpoint = {"channel_values": channel_values}
class _FakeCheckpointer:
def __init__(self, channel_values: dict[str, Any]) -> None:
self._cv = channel_values
async def aget_tuple(self, config: dict[str, Any]) -> _FakeCheckpointTuple:
return _FakeCheckpointTuple(self._cv)
CHANNEL_VALUES: dict[str, Any] = {
"thread_data": {"marker": "THREAD_MARKER_42"},
"sandbox": {"sandbox_id": "local"},
"messages": [],
}
@pytest.fixture
def app_config():
from deerflow.config import get_app_config
return get_app_config()
def _snapshot(action_tool: str) -> dict[str, Any]:
meta = {"name": action_tool, "tool_name": action_tool, "side_effect": "write", "risk_level": "R3"}
return {
"version": 1,
"agents": [
{
"agent_id": "a1",
"allowed_tool_names": [action_tool],
"excluded_tools": [],
"tools": [meta],
}
],
"tools": [meta],
}
def _ctx(action_tool: str = "sync_write") -> dict[str, Any]:
"""Common trusted execution context + matching action record."""
return {
"thread_id": "thread-1",
"user_id": "u1",
"session_id": "s1",
"run_id": "r1",
"agent_id": "a1",
"turn_index": 2,
"model_name": None,
"excluded_tools": [],
"capability_snapshot": _snapshot(action_tool),
"write_policy": "confirm_high_risk",
"approved_action_id": "action-1",
"action_record": {
"id": "action-1",
"status": "running",
"run_id": "r1",
"session_id": "s1",
"agent_id": "a1",
"tool_name": action_tool,
"turn_index": 2,
},
}
# ?? 1. sync StructuredTool.func runs once ??succeeded ?????????????????????
@pytest.mark.asyncio
async def test_sync_tool_runs_once_succeeded(app_config, monkeypatch):
calls = {"n": 0}
def _fn(payload: str) -> str:
calls["n"] += 1
return f"sync-ok:{payload}"
tool = StructuredTool.from_function(func=_fn, name="sync_write", description="d")
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
ctx = _ctx("sync_write")
result = await execute_approved_tool(
app_config=app_config, checkpointer=_FakeCheckpointer(CHANNEL_VALUES), store=None,
tool_name="sync_write", params={"payload": "X"}, **ctx,
)
assert result["status"] == "succeeded", result
assert calls["n"] == 1, "sync tool must run exactly once"
# ?? 2. async tool runs normally ???????????????????????????????????????????
@pytest.mark.asyncio
async def test_async_tool_runs(app_config, monkeypatch):
calls = {"n": 0}
async def _fn(payload: str) -> str:
calls["n"] += 1
return f"async-ok:{payload}"
tool = StructuredTool.from_function(coroutine=_fn, name="async_write", description="d")
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
ctx = _ctx("async_write")
result = await execute_approved_tool(
app_config=app_config, checkpointer=_FakeCheckpointer(CHANNEL_VALUES), store=None,
tool_name="async_write", params={"payload": "Y"}, **ctx,
)
assert result["status"] == "succeeded", result
assert calls["n"] == 1
# ?? 3. tool needing ToolRuntime gets the REAL runtime + checkpoint state ??
@pytest.mark.asyncio
async def test_tool_receives_real_tool_runtime_and_state(app_config, monkeypatch):
seen: dict[str, Any] = {}
def _fn(payload: str, runtime: ToolRuntime[dict[str, Any], dict[str, Any]]) -> str:
# Typed ToolRuntime context avoids PydanticSerializationUnexpectedValue
# (bare ToolRuntime defaults context to None).
seen["runtime_type"] = type(runtime).__name__
seen["is_real_toolruntime"] = isinstance(runtime, ToolRuntime)
seen["marker"] = runtime.state.get("thread_data", {}).get("marker")
seen["context_run_id"] = runtime.context.get("maf_run_id")
seen["approved_tool"] = runtime.context.get("maf_approved_tool")
seen["context_action_id"] = runtime.context.get("maf_approved_action_id")
return "rt-ok"
tool = StructuredTool.from_function(func=_fn, name="rt_write", description="d")
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
ctx = _ctx("rt_write")
result = await execute_approved_tool(
app_config=app_config, checkpointer=_FakeCheckpointer(CHANNEL_VALUES), store=None,
tool_name="rt_write", params={"payload": "Z"}, **ctx,
)
assert result["status"] == "succeeded", result
# A real ToolRuntime, not a duck-typed SimpleNamespace.
assert seen["is_real_toolruntime"] is True
assert seen["runtime_type"] != "SimpleNamespace"
# Real checkpoint state flowed through (sandbox / thread_data recovered).
assert seen["marker"] == "THREAD_MARKER_42"
# Trusted MAF context injected by the server (via Runtime on __pregel_runtime).
assert seen["context_run_id"] == "r1"
assert seen["approved_tool"] == "rt_write"
assert seen["context_action_id"] == "action-1"
# ?? 4. unauthorized / disabled / nonexistent ??0 executions ???????????????
@pytest.mark.asyncio
async def test_nonexistent_tool_executes_zero_times(app_config, monkeypatch):
calls = {"n": 0}
def _fn(payload: str) -> str:
calls["n"] += 1
return "ok"
tool = StructuredTool.from_function(func=_fn, name="sync_write", description="d")
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
ctx = _ctx("ghost_tool") # action says ghost_tool; not in the allow-list
result = await execute_approved_tool(
app_config=app_config, checkpointer=_FakeCheckpointer(CHANNEL_VALUES), store=None,
tool_name="ghost_tool", params={"payload": "X"}, **ctx,
)
assert result["status"] == "failed"
assert calls["n"] == 0
@pytest.mark.asyncio
async def test_disabled_tool_executes_zero_times(app_config, monkeypatch):
calls = {"n": 0}
def _fn(payload: str) -> str:
calls["n"] += 1
return "ok"
tool = StructuredTool.from_function(func=_fn, name="sync_write", description="d")
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
ctx = _ctx("sync_write")
ctx["capability_snapshot"]["agents"][0]["excluded_tools"] = ["sync_write"]
result = await execute_approved_tool(
app_config=app_config, checkpointer=_FakeCheckpointer(CHANNEL_VALUES), store=None,
tool_name="sync_write", params={"payload": "X"}, **ctx,
)
assert result["status"] == "failed"
assert calls["n"] == 0
@pytest.mark.asyncio
async def test_context_mismatch_executes_zero_times(app_config, monkeypatch):
calls = {"n": 0}
def _fn(payload: str) -> str:
calls["n"] += 1
return "ok"
tool = StructuredTool.from_function(func=_fn, name="sync_write", description="d")
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
ctx = _ctx("sync_write")
# The stored action belongs to a DIFFERENT run ??must be refused.
ctx["action_record"] = {
"id": "action-1",
"status": "running",
"run_id": "OTHER_RUN",
"session_id": "s1",
"agent_id": "a1",
"tool_name": "sync_write",
"turn_index": 2,
}
result = await execute_approved_tool(
app_config=app_config, checkpointer=_FakeCheckpointer(CHANNEL_VALUES), store=None,
tool_name="sync_write", params={"payload": "X"}, **ctx,
)
assert result["status"] == "failed"
assert calls["n"] == 0
# ?? 5. LLM is never involved ??????????????????????????????????????????????
@pytest.mark.asyncio
async def test_llm_never_involved(app_config, monkeypatch):
llm_calls = {"n": 0}
def _boom(*args: Any, **kwargs: Any) -> Any:
llm_calls["n"] += 1
raise AssertionError("model factory must never be called by the approved executor")
# Guard every plausible model-construction entry point.
import deerflow.models.factory as factory
monkeypatch.setattr(factory, "create_chat_model", _boom)
calls = {"n": 0}
def _fn(payload: str) -> str:
calls["n"] += 1
return "ok"
tool = StructuredTool.from_function(func=_fn, name="sync_write", description="d")
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
ctx = _ctx("sync_write")
result = await execute_approved_tool(
app_config=app_config, checkpointer=_FakeCheckpointer(CHANNEL_VALUES), store=None,
tool_name="sync_write", params={"payload": "X"}, **ctx,
)
assert result["status"] == "succeeded", result
assert calls["n"] == 1
assert llm_calls["n"] == 0, "the LLM must never be involved"
# ?? 6. inconclusive external errors ??unknown (no auto-retry) ???????????????
@pytest.mark.asyncio
async def test_timeout_error_records_unknown(app_config, monkeypatch):
"""P1c: a timeout (external result unknowable) is recorded ``unknown`` ?? never ``failed`` and never auto-retried."""
async def _fn(payload: str) -> str:
raise TimeoutError("upstream timed out")
tool = StructuredTool.from_function(coroutine=_fn, name="timeout_write", description="d")
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
ctx = _ctx("timeout_write")
result = await execute_approved_tool(
app_config=app_config, checkpointer=_FakeCheckpointer(CHANNEL_VALUES), store=None,
tool_name="timeout_write", params={"payload": "X"}, **ctx,
)
assert result["status"] == "unknown", result
@pytest.mark.asyncio
async def test_connection_error_records_unknown(app_config, monkeypatch):
"""P1c: a dropped/reset connection is likewise inconclusive ??``unknown``."""
async def _fn(payload: str) -> str:
raise ConnectionResetError("connection reset by peer")
tool = StructuredTool.from_function(coroutine=_fn, name="conn_write", description="d")
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
ctx = _ctx("conn_write")
result = await execute_approved_tool(
app_config=app_config, checkpointer=_FakeCheckpointer(CHANNEL_VALUES), store=None,
tool_name="conn_write", params={"payload": "X"}, **ctx,
)
assert result["status"] == "unknown", result
@pytest.mark.asyncio
async def test_ordinary_error_records_failed(app_config, monkeypatch):
"""P1c: a deterministic validation error (external write definitely did NOT
happen) is ``failed``, not ``unknown``."""
async def _fn(payload: str) -> str:
raise ValueError("invalid payload value")
tool = StructuredTool.from_function(coroutine=_fn, name="bad_write", description="d")
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
ctx = _ctx("bad_write")
result = await execute_approved_tool(
app_config=app_config, checkpointer=_FakeCheckpointer(CHANNEL_VALUES), store=None,
tool_name="bad_write", params={"payload": "X"}, **ctx,
)
assert result["status"] == "failed", result
assert result.get("error") == "tool_execution_failed"
@pytest.mark.asyncio
async def test_tool_error_never_leaks_secrets(app_config, monkeypatch, caplog):
secret = "TOP_SECRET_123"
def _fn(payload: str) -> str:
raise ValueError(f"token={secret}")
tool = StructuredTool.from_function(func=_fn, name="bad_write", description="d")
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
ctx = _ctx("bad_write")
with caplog.at_level("ERROR"):
result = await execute_approved_tool(
app_config=app_config,
checkpointer=_FakeCheckpointer(CHANNEL_VALUES),
store=None,
tool_name="bad_write",
params={"payload": "X"},
**ctx,
)
assert result["status"] == "failed"
assert secret not in str(result)
assert secret not in caplog.text
@pytest.mark.asyncio
async def test_frozen_allow_list_blocks_extra_registry_tool(app_config, monkeypatch):
calls = {"n": 0}
def _allowed(payload: str) -> str:
calls["n"] += 1
return "ok"
def _blocked(payload: str) -> str:
calls["n"] += 1
return "no"
allowed = StructuredTool.from_function(func=_allowed, name="tool_a", description="d")
blocked = StructuredTool.from_function(func=_blocked, name="tool_b", description="d")
monkeypatch.setattr(
approved_tool_executor, "get_available_tools", lambda **kw: [allowed, blocked]
)
snap = _snapshot("tool_a")
snap["agents"][0]["allowed_tool_names"] = ["tool_a"]
ctx = _ctx("tool_b")
ctx["capability_snapshot"] = snap
ctx["action_record"]["tool_name"] = "tool_b"
ctx["approved_action_id"] = "action-1"
result = await execute_approved_tool(
app_config=app_config,
checkpointer=_FakeCheckpointer(CHANNEL_VALUES),
store=None,
tool_name="tool_b",
params={"payload": "X"},
**ctx,
)
assert result["status"] == "failed"
assert calls["n"] == 0
@pytest.mark.asyncio
async def test_action_not_running_executes_zero_times(app_config, monkeypatch):
calls = {"n": 0}
def _fn(payload: str) -> str:
calls["n"] += 1
return "ok"
tool = StructuredTool.from_function(func=_fn, name="sync_write", description="d")
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
ctx = _ctx("sync_write")
ctx["action_record"]["status"] = "approved"
result = await execute_approved_tool(
app_config=app_config,
checkpointer=_FakeCheckpointer(CHANNEL_VALUES),
store=None,
tool_name="sync_write",
params={"payload": "X"},
**ctx,
)
assert result["status"] == "failed"
assert calls["n"] == 0
def _enabled_write_guard_app_config(app_config):
enabled_guard = WriteGuardConfig.model_validate(
{
"enabled": True,
"provider": {
"policy_use": "app.maf_roundtable.write_guard_bridge:make_policy",
"interceptor_use": "app.maf_roundtable.write_guard_bridge:make_interceptor",
"config": {},
},
}
)
return app_config.model_copy(update={"write_guard": enabled_guard})
async def _writeguard_store_fixture(tmp_path, monkeypatch):
"""Create a real MAF store + service with WriteGuard bridge registered.
Uses monkeypatch so ``write_guard_bridge._maf_service`` is restored when the
test ends (avoids leaking a disposed sqlite service into later tests).
"""
from maf_test_helpers import make_maf_service
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
import deerflow.persistence.maf_roundtable.model # noqa: F401
from app.maf_roundtable import write_guard_bridge
from app.maf_roundtable.write_guard_bridge import register_write_guard_service
from deerflow.persistence.base import Base
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'wg.db'}")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
store = make_maf_roundtable_store(sf)
svc = make_maf_service(store)
# Capture the pre-test service *before* registration. Registering first
# would make monkeypatch restore this disposable sqlite-backed service at
# teardown and leak it into the next test.
monkeypatch.setattr(write_guard_bridge, "_maf_service", None)
register_write_guard_service(svc)
return engine, store, svc
@pytest.mark.asyncio
async def test_approved_tool_executor_with_writeguard_enabled(app_config, monkeypatch, tmp_path):
"""P1: real middleware chain with WriteGuard enabled passes approved execution."""
from app.maf_roundtable.write_guard_bridge import MafWriteGuardInterceptor
enabled_app_config = _enabled_write_guard_app_config(app_config)
middlewares = build_lead_runtime_middlewares(app_config=enabled_app_config)
assert any(isinstance(item, WriteGuardMiddleware) for item in middlewares)
engine, store, svc = await _writeguard_store_fixture(tmp_path, monkeypatch)
validate_spy_calls = {"n": 0}
original_validate = MafWriteGuardInterceptor.validate_approved_action
async def _spy_validate(self, *, tool_name: str, run_context: dict) -> bool:
validate_spy_calls["n"] += 1
return await original_validate(self, tool_name=tool_name, run_context=run_context)
monkeypatch.setattr(MafWriteGuardInterceptor, "validate_approved_action", _spy_validate)
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
action = await store.actions.create_action(
{
"session_id": session["id"],
"run_id": run["id"],
"agent_id": "a1",
"tool_name": "sync_write",
"idempotency_key": "wg-test-1",
"risk_level": "R3",
"status": "running",
"turn_index": 2,
}
)
calls = {"n": 0}
def _fn(payload: str) -> str:
calls["n"] += 1
return "wg-ok"
tool = StructuredTool.from_function(func=_fn, name="sync_write", description="d")
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
ctx = _ctx("sync_write")
ctx["session_id"] = session["id"]
ctx["run_id"] = run["id"]
ctx["approved_action_id"] = action["id"]
ctx["action_record"] = {
"id": action["id"],
"status": "running",
"run_id": run["id"],
"session_id": session["id"],
"agent_id": "a1",
"tool_name": "sync_write",
"turn_index": 2,
}
try:
result = await execute_approved_tool(
app_config=enabled_app_config,
checkpointer=_FakeCheckpointer(CHANNEL_VALUES),
store=None,
tool_name="sync_write",
params={"payload": "X"},
**ctx,
)
finally:
await engine.dispose()
assert result["status"] == "succeeded", result
assert calls["n"] == 1
assert validate_spy_calls["n"] == 1
assert result["result"] == {"executed": True}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"db_mismatch",
[
"status",
"run_id",
"session_id",
"agent_id",
"tool_name",
],
)
async def test_writeguard_rejects_db_mismatched_action(
app_config, monkeypatch, tmp_path, db_mismatch
):
"""Second-line defense: Guard re-reads the DB action and rejects mismatches.
``action_record`` passed to the executor stays consistent so the first-layer
field check passes; only the DB row is wrong.
"""
from app.maf_roundtable.write_guard_bridge import MafWriteGuardInterceptor
enabled_app_config = _enabled_write_guard_app_config(app_config)
middlewares = build_lead_runtime_middlewares(app_config=enabled_app_config)
assert any(isinstance(item, WriteGuardMiddleware) for item in middlewares)
engine, store, svc = await _writeguard_store_fixture(tmp_path, monkeypatch)
validate_spy_calls = {"n": 0}
original_validate = MafWriteGuardInterceptor.validate_approved_action
async def _spy_validate(self, *, tool_name: str, run_context: dict) -> bool:
validate_spy_calls["n"] += 1
return await original_validate(self, tool_name=tool_name, run_context=run_context)
monkeypatch.setattr(MafWriteGuardInterceptor, "validate_approved_action", _spy_validate)
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
other = await svc.create_session(user_id="u1", body={"task_text": "other"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
db_fields = {
"session_id": session["id"],
"run_id": run["id"],
"agent_id": "a1",
"tool_name": "sync_write",
"idempotency_key": f"wg-mismatch-{db_mismatch}",
"risk_level": "R3",
"status": "running",
"turn_index": 2,
}
if db_mismatch == "status":
db_fields["status"] = "approved"
elif db_mismatch == "run_id":
# No second create_run (session allows only one active run) ? fake id is enough.
db_fields["run_id"] = "other-run-id"
elif db_mismatch == "session_id":
db_fields["session_id"] = other["id"]
elif db_mismatch == "agent_id":
db_fields["agent_id"] = "other-agent"
elif db_mismatch == "tool_name":
db_fields["tool_name"] = "other_tool"
action = await store.actions.create_action(db_fields)
tool_calls = {"n": 0}
def _fn(payload: str) -> str:
tool_calls["n"] += 1
return "should-not-run"
tool = StructuredTool.from_function(func=_fn, name="sync_write", description="d")
monkeypatch.setattr(approved_tool_executor, "get_available_tools", lambda **kw: [tool])
# Executor-facing record looks correct ? Guard must catch the DB mismatch.
ctx = _ctx("sync_write")
ctx["session_id"] = session["id"]
ctx["run_id"] = run["id"]
ctx["approved_action_id"] = action["id"]
ctx["action_record"] = {
"id": action["id"],
"status": "running",
"run_id": run["id"],
"session_id": session["id"],
"agent_id": "a1",
"tool_name": "sync_write",
"turn_index": 2,
}
try:
result = await execute_approved_tool(
app_config=enabled_app_config,
checkpointer=_FakeCheckpointer(CHANNEL_VALUES),
store=None,
tool_name="sync_write",
params={"payload": "X"},
**ctx,
)
finally:
await engine.dispose()
assert tool_calls["n"] == 0
assert validate_spy_calls["n"] == 1
assert result["status"] == "failed"
@@ -0,0 +1,110 @@
"""Acceptance tests for the structured cancel cascade (Task 4).
Verifies that when DeerFlow cancel fails, the parent run is NOT marked
``cancelled`` —it stays non-terminal (``cancel_failed``) so the failure is
visible and retryable. A failed cancel must never masquerade as cancelled.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.maf_roundtable.deerflow_agent_adapter import DeerFlowAgentAdapter
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
from deerflow.persistence.maf_roundtable.model import Base
@pytest_asyncio.fixture
async def store():
engine = create_async_engine("sqlite+aiosqlite://", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
s = make_maf_roundtable_store(sf)
assert s is not None
yield s
await engine.dispose()
@pytest.fixture
def svc(store):
from maf_test_helpers import make_maf_service
return make_maf_service(store, enabled=True, admin_diagnostic_access=True)
def _mock_client(*, cancel_succeeds: bool) -> MagicMock:
client = MagicMock()
client._app.state.run_manager.cancel = AsyncMock(return_value=None)
client.cancel_run = AsyncMock(return_value=cancel_succeeds)
return client
@pytest.mark.asyncio
async def test_cancel_all_succeeds_marks_cancelled(svc, store):
"""When every sub-run cancels, all_cancelled is True."""
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
await svc.store.seat_runs.create_seat_run(maf_run_id=run["id"], agent_id="a1", turn_index=1)
active = await svc.store.seat_runs.get_active_by_maf_run(run["id"])
await svc.store.seat_runs.mark_running(active[0]["id"], deerflow_run_id="df-1")
adapter = DeerFlowAgentAdapter(_mock_client(cancel_succeeds=True), service=svc, user_id="u1")
result = await adapter.cancel(run["id"])
assert result["all_cancelled"] is True
assert result["failed_run_ids"] == []
assert result["cancelled_run_ids"] == [active[0]["id"]]
@pytest.mark.asyncio
async def test_cancel_failure_keeps_run_nonterminal(svc, store):
"""When DeerFlow cancel fails, the parent run must NOT be marked cancelled.
The adapter returns all_cancelled=False; the router would then mark the
run ``cancel_failed`` (non-terminal). This test verifies the adapter-level
signal that drives that decision.
"""
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
await svc.store.seat_runs.create_seat_run(maf_run_id=run["id"], agent_id="a1", turn_index=1)
active = await svc.store.seat_runs.get_active_by_maf_run(run["id"])
await svc.store.seat_runs.mark_running(active[0]["id"], deerflow_run_id="df-2")
adapter = DeerFlowAgentAdapter(_mock_client(cancel_succeeds=False), service=svc, user_id="u1")
result = await adapter.cancel(run["id"])
assert result["all_cancelled"] is False
assert result["failed_run_ids"] == [active[0]["id"]]
assert result["cancelled_run_ids"] == []
@pytest.mark.asyncio
async def test_mark_cancel_failed_service_method(svc, store):
"""The service ``mark_cancel_failed`` transitions a cancelling run to
``cancel_failed`` (non-terminal), recording a sanitized reason."""
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
# Put the run into cancelling via start_cancel.
await svc.start_cancel(run_id=run["id"], user_id="u1")
updated = await svc.mark_cancel_failed(
run_id=run["id"], user_id="u1", reason="1 sub-run failed to cancel"
)
assert updated["status"] == "cancel_failed"
assert updated["error_code"] == "CancelCascadeIncomplete"
assert "failed to cancel" in updated["error_message_safe"]
@pytest.mark.asyncio
async def test_cancel_no_active_runs_is_all_cancelled(svc, store):
"""With no active seat runs, cancel is trivially all_cancelled (nothing to do)."""
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
adapter = DeerFlowAgentAdapter(_mock_client(cancel_succeeds=True), service=svc, user_id="u1")
result = await adapter.cancel(run["id"])
assert result["all_cancelled"] is True
assert result["cancelled_run_ids"] == []
@@ -0,0 +1,88 @@
"""P0-1: trusted capability snapshot builder tests."""
from __future__ import annotations
import pytest
from maf_test_helpers import FakeAgentStore
from app.maf_roundtable.capability_snapshot import (
AgentNotAccessibleError,
build_trusted_capability_snapshot,
)
from deerflow.config import get_app_config
from deerflow.tools.tool_metadata import resolve_tool_metadata
@pytest.mark.asyncio
async def test_client_forged_side_effect_not_stored(monkeypatch):
app_config = get_app_config()
def _fake_build_seat(*, app_config, agent_id, session_model_name, session_reasoning_effort, requested_excluded):
meta = resolve_tool_metadata("write_file")
return (["write_file"], [], [meta])
monkeypatch.setattr(
"app.maf_roundtable.capability_snapshot._seat_tools_for_agent",
_fake_build_seat,
)
snap = await build_trusted_capability_snapshot(
user_id="u1",
participants=[
{
"agent_id": "seat-a",
"role": "seat",
"excluded_tools": [],
# Client forgery ??must be ignored (field not read).
"capability_snapshot": {
"tools": [{"name": "write_file", "side_effect": "read", "risk_level": "R0"}],
},
}
],
app_config=app_config,
agent_store=FakeAgentStore(),
)
tool = snap["agents"][0]["tools"][0]
assert tool["side_effect"] == "write"
assert tool["risk_level"] == "R3"
@pytest.mark.asyncio
async def test_frozen_allow_list_excludes_registry_only_tool(monkeypatch):
app_config = get_app_config()
def _fake_build_seat(*, app_config, agent_id, session_model_name, session_reasoning_effort, requested_excluded):
return (["tool_a"], [], [resolve_tool_metadata("tool_a")])
monkeypatch.setattr(
"app.maf_roundtable.capability_snapshot._seat_tools_for_agent",
_fake_build_seat,
)
snap = await build_trusted_capability_snapshot(
user_id="u1",
participants=[{"agent_id": "seat-a", "role": "seat"}],
app_config=app_config,
agent_store=FakeAgentStore(),
)
assert snap["agents"][0]["allowed_tool_names"] == ["tool_a"]
assert "tool_b" not in snap["agents"][0]["allowed_tool_names"]
@pytest.mark.asyncio
async def test_inaccessible_agent_rejected():
app_config = get_app_config()
class _DenyStore:
async def get_visible(self, agent_id: str, user_id: str):
return None
with pytest.raises(AgentNotAccessibleError):
await build_trusted_capability_snapshot(
user_id="u1",
participants=[{"agent_id": "missing-agent", "role": "seat"}],
app_config=app_config,
agent_store=_DenyStore(),
)
@@ -0,0 +1,231 @@
"""Acceptance tests for the Phase-2 migration script (Task 5).
Verifies:
1. The migration creates ``maf_roundtable_seat_runs`` + ``source_event_id``
column + the unique index + ``maf_roundtable_action_secrets`` on SQLite.
2. Running the migration twice is idempotent (no error, no duplicate objects).
3. Phase-1 data (an existing event row) remains readable after migration.
"""
from __future__ import annotations
import importlib.util
from pathlib import Path
import pytest
from sqlalchemy import inspect as sa_inspect
from sqlalchemy import text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
from deerflow.persistence.maf_roundtable.model import Base
def _load_migration_module():
"""Load scripts/migrate_maf_roundtable_phase2.py as a module."""
here = Path(__file__).resolve().parent.parent
script = here / "scripts" / "migrate_maf_roundtable_phase2.py"
spec = importlib.util.spec_from_file_location("migrate_maf_roundtable_phase2", script)
assert spec and spec.loader, "could not load migration script"
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@pytest.mark.asyncio
async def test_migration_creates_phase2_objects_and_is_idempotent():
"""Run the migration on a fresh Phase-1 schema, then re-run it; both must
succeed and produce the expected Phase-2 objects."""
mod = _load_migration_module()
engine = create_async_engine("sqlite+aiosqlite://", echo=False)
# Build the Phase-1 schema (everything except the Phase-2 additions).
# We create the base tables the migration expects to already exist.
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Drop the Phase-2 tables if create_all created them, so we test creation.
await conn.execute(text("DROP TABLE IF EXISTS maf_roundtable_seat_runs"))
await conn.execute(text("DROP TABLE IF EXISTS maf_roundtable_action_secrets"))
# Seed a Phase-1 event row so we can verify it survives migration.
# Use the ORM (not raw SQL) so column Python-defaults apply.
from datetime import datetime
from deerflow.persistence.maf_roundtable.model import (
MafRoundtableEventRow,
MafRoundtableRunRow,
MafRoundtableSessionRow,
)
sf = async_sessionmaker(engine, expire_on_commit=False)
now = datetime(2026, 1, 1)
async with sf() as session:
session.add(MafRoundtableSessionRow(
id="s1", user_id="u1", title="t", task_text="t",
mode="structured", status="draft", write_policy="confirm_high_risk",
max_rounds=8, created_at=now, updated_at=now,
))
session.add(MafRoundtableRunRow(
id="r1", session_id="s1", status="pending",
created_at=now, updated_at=now,
))
session.add(MafRoundtableEventRow(
id="e1", session_id="s1", run_id="r1", seq=1,
event_type="run.started", created_at=now,
))
await session.commit()
# Run the migration's sync step directly on a connection (dry_run=False).
async with engine.begin() as conn:
await conn.run_sync(mod._do_migrate_sync, dry_run=False, logger=mod.logger, changes_ref=[0])
# Verify all Phase-2 objects exist.
def _check(sync_conn):
insp = sa_inspect(sync_conn)
tables = set(insp.get_table_names())
assert "maf_roundtable_seat_runs" in tables
assert "maf_roundtable_action_secrets" in tables
event_cols = {c["name"] for c in insp.get_columns("maf_roundtable_events")}
assert "source_event_id" in event_cols
# Unique indexes.
idx_names: set[str] = set()
for t in insp.get_table_names():
for idx in insp.get_indexes(t):
idx_names.add(idx.get("name"))
assert "uq_maf_events_run_source" in idx_names
assert "uq_maf_seat_runs_run_agent_turn" in idx_names
assert "uq_maf_action_secrets_action" in idx_names
async with engine.begin() as conn:
await conn.run_sync(_check)
# Phase-1 data still readable + the new column is nullable.
async with sf() as session:
rows = (await session.execute(text("SELECT id, source_event_id FROM maf_roundtable_events"))).all()
assert len(rows) == 1
assert rows[0][0] == "e1"
assert rows[0][1] is None # old row keeps NULL
# Re-run the migration ??must be idempotent (no error).
async with engine.begin() as conn:
await conn.run_sync(mod._do_migrate_sync, dry_run=False, logger=mod.logger, changes_ref=[0])
# Dry-run must also not throw and must not write.
async with engine.begin() as conn:
await conn.run_sync(mod._do_migrate_sync, dry_run=True, logger=mod.logger, changes_ref=[0])
await engine.dispose()
@pytest.mark.asyncio
async def test_migration_adds_turn_index_to_legacy_actions_table():
"""Phase-1 actions table without turn_index is upgraded idempotently."""
mod = _load_migration_module()
engine = create_async_engine("sqlite+aiosqlite://", echo=False)
async with engine.begin() as conn:
await conn.execute(
text(
"""
CREATE TABLE maf_roundtable_actions (
id VARCHAR(64) NOT NULL PRIMARY KEY,
session_id VARCHAR(64) NOT NULL,
run_id VARCHAR(64),
agent_id VARCHAR(64),
tool_name VARCHAR(128) NOT NULL,
logical_action_id VARCHAR(128),
idempotency_key VARCHAR(191) NOT NULL UNIQUE,
target_key VARCHAR(255),
risk_level VARCHAR(8) NOT NULL,
status VARCHAR(32) NOT NULL,
arguments_redacted_json TEXT,
result_redacted_json TEXT,
external_object_id VARCHAR(255),
approval_id VARCHAR(64),
attempt_count INTEGER NOT NULL,
started_at DATETIME,
finished_at DATETIME,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
)
"""
)
)
async with engine.begin() as conn:
await conn.run_sync(mod._do_migrate_sync, dry_run=False, logger=mod.logger, changes_ref=[0])
def _has_turn_index(sync_conn):
insp = sa_inspect(sync_conn)
cols = {c["name"] for c in insp.get_columns("maf_roundtable_actions")}
assert "turn_index" in cols
async with engine.begin() as conn:
await conn.run_sync(_has_turn_index)
sf = async_sessionmaker(engine, expire_on_commit=False)
store = make_maf_roundtable_store(sf)
assert store is not None
from datetime import datetime
from deerflow.persistence.maf_roundtable.model import MafRoundtableSessionRow
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all, tables=[MafRoundtableSessionRow.__table__])
now = datetime(2026, 1, 1)
async with sf() as session:
session.add(
MafRoundtableSessionRow(
id="s1",
user_id="u1",
title="t",
task_text="t",
mode="structured",
status="draft",
write_policy="confirm_high_risk",
max_rounds=8,
created_at=now,
updated_at=now,
)
)
await session.commit()
row = await store.actions.create_action(
{
"session_id": "s1",
"tool_name": "write_file",
"idempotency_key": "legacy-1",
"turn_index": 1,
"risk_level": "R3",
"status": "pending",
}
)
assert row["turn_index"] == 1
async with engine.begin() as conn:
await conn.run_sync(mod._do_migrate_sync, dry_run=False, logger=mod.logger, changes_ref=[0])
await engine.dispose()
def test_ddl_add_action_turn_index_by_dialect():
mod = _load_migration_module()
assert "INTEGER NULL" in mod._ddl_add_action_turn_index("sqlite")
assert "INTEGER NULL" in mod._ddl_add_action_turn_index("postgresql")
mysql = mod._ddl_add_action_turn_index("mysql")
assert "INT NULL" in mysql
assert "INTEGER" not in mysql.replace("INT NULL", "")
def test_ddl_lists_are_single_statement():
"""The DDL constants are lists of single SQL statements (no ``;`` joining)."""
mod = _load_migration_module()
for stmt in mod._DDL_SEAT_RUNS + mod._DDL_ACTION_SECRETS:
# Each statement must not contain a statement separator mid-string
# (trailing/leading whitespace stripped). A CREATE TABLE followed by
# a semicolon + another CREATE is the bug we fixed.
stripped = stmt.strip().rstrip(";").strip()
assert stripped.count(";") == 0, f"DDL statement contains ';': {stmt[:80]!r}"
# The events index is a single statement string.
assert mod._DDL_EVENTS_SOURCE_INDEX.count(";") <= 1
@@ -0,0 +1,188 @@
"""Regression test: legacy single-agent runs are unaffected by WriteGuard.
The WriteGuard middleware is registered unconditionally (when configured) but
must be a pure pass-through when the run context has no ``maf_run_id`` flag.
This test proves a write tool call in a legacy run executes normally (0
interceptions, real handler invoked), so the new middleware introduces zero
behaviour change for non-MAF runs.
"""
from __future__ import annotations
import json
from typing import Any
from unittest.mock import MagicMock
import pytest
from deerflow.agents.middlewares.write_guard_middleware import WriteGuardMiddleware
class _StubPolicy:
def should_intercept(self, tool_name: str, args: Any, run_context: Any = None) -> bool: # noqa: ANN401
return True # would intercept — but only if active
class _StubInterceptor:
async def intercept(self, *, tool_name: str, args: Any, tool_call_id: str, run_context: dict) -> dict: # noqa: ANN401
raise AssertionError("interceptor must NOT be called in a legacy run")
async def validate_approved_action(self, *, tool_name: str, run_context: dict) -> bool:
# Legacy stub: no real store. The approved-passthrough tests supply
# their own validating interceptor; this default fails closed.
return False
class _ValidatingStubInterceptor:
"""Approved-passthrough interceptor whose action validation is configurable."""
def __init__(self, *, valid: bool) -> None:
self._valid = valid
self.validated_with: dict | None = None
async def intercept(self, *, tool_name: str, args: Any, tool_call_id: str, run_context: dict) -> dict: # noqa: ANN401
return {"status": "approval_required", "action_id": "a?", "tool_name": tool_name}
async def validate_approved_action(self, *, tool_name: str, run_context: dict) -> bool:
self.validated_with = {"tool_name": tool_name, "run_context": run_context}
return self._valid
def _request(run_context: dict | None = None):
runtime = MagicMock()
runtime.context = run_context or {}
req = MagicMock()
req.tool_call = {"name": "write_anything", "args": {"x": 1}, "id": "c1"}
req.runtime = runtime
return req
@pytest.mark.asyncio
async def test_legacy_run_write_tool_not_intercepted():
"""No ``maf_run_id`` → middleware passes through, real handler runs."""
mw = WriteGuardMiddleware(policy=_StubPolicy(), interceptor=_StubInterceptor())
called = {"n": 0}
async def handler(request):
called["n"] += 1
return MagicMock(content="ok")
# No maf_run_id in context = legacy run.
result = await mw.awrap_tool_call(_request({}), handler)
assert called["n"] == 1
assert result.content == "ok"
@pytest.mark.asyncio
async def test_maf_run_write_tool_intercepted():
"""With ``maf_run_id`` set, the same policy DOES intercept (sanity check)."""
called = {"n": 0}
async def handler(request):
called["n"] += 1
return MagicMock(content="ok")
# Active MAF run → interceptor would be called, but ours raises to prove it.
# Use a real interceptor here to avoid the assertion.
class _Real:
async def intercept(self, *, tool_name, args, tool_call_id, run_context):
return {"status": "approval_required", "action_id": "a1", "approval_id": "p1"}
mw2 = WriteGuardMiddleware(policy=_StubPolicy(), interceptor=_Real())
result = await mw2.awrap_tool_call(_request({"maf_run_id": "r1"}), handler)
assert called["n"] == 0 # handler NOT called
envelope = json.loads(result.content)
assert envelope["status"] == "approval_required"
@pytest.mark.asyncio
async def test_approved_passthrough_executes():
"""An approved-tool run (maf_approved_tool + maf_approved_action_id bound and
the action validates) lets that one tool through (P0-4 action-id security)."""
interceptor = _ValidatingStubInterceptor(valid=True)
mw = WriteGuardMiddleware(policy=_StubPolicy(), interceptor=interceptor)
called = {"n": 0}
async def handler(request):
called["n"] += 1
return MagicMock(content="approved-exec")
ctx = {
"maf_run_id": "r1",
"maf_session_id": "s1",
"agent_id": "seat-1",
"maf_approved_tool": "write_anything",
"maf_approved_action_id": "action-1",
}
result = await mw.awrap_tool_call(_request(ctx), handler)
assert called["n"] == 1
assert result.content == "approved-exec"
# The async path actually validated the bound action (not just field presence).
assert interceptor.validated_with is not None
assert interceptor.validated_with["tool_name"] == "write_anything"
@pytest.mark.asyncio
async def test_approved_passthrough_fails_closed_when_validation_false():
"""P0-4: if the bound action does NOT validate (status/ownership/tool mismatch),
the approved passthrough fails closed — the tool does not run."""
interceptor = _ValidatingStubInterceptor(valid=False)
mw = WriteGuardMiddleware(policy=_StubPolicy(), interceptor=interceptor)
called = {"n": 0}
async def handler(request):
called["n"] += 1
return MagicMock(content="should-not-run")
ctx = {
"maf_run_id": "r1",
"maf_approved_tool": "write_anything",
"maf_approved_action_id": "action-bad",
}
result = await mw.awrap_tool_call(_request(ctx), handler)
assert called["n"] == 0, "invalid approved action must fail closed"
envelope = json.loads(result.content)
assert envelope["status"] == "approval_invalid"
@pytest.mark.asyncio
async def test_approved_passthrough_requires_action_id():
"""P0-4: maf_approved_tool WITHOUT maf_approved_action_id no longer passes
through — action-id binding is mandatory."""
mw = WriteGuardMiddleware(policy=_StubPolicy(), interceptor=_ValidatingStubInterceptor(valid=True))
called = {"n": 0}
async def handler(request):
called["n"] += 1
return MagicMock(content="ok")
ctx = {"maf_run_id": "r1", "maf_approved_tool": "write_anything"} # no action id
result = await mw.awrap_tool_call(_request(ctx), handler)
assert called["n"] == 0
envelope = json.loads(result.content)
assert envelope["status"] in ("approval_required", "intercept_failed")
@pytest.mark.asyncio
async def test_approved_passthrough_only_for_named_tool():
"""When maf_approved_tool is set but the call is a DIFFERENT tool, it is
still intercepted (only the approved tool passes through)."""
class _Real:
async def intercept(self, *, tool_name, args, tool_call_id, run_context):
return {"status": "approval_required", "action_id": "a2"}
mw = WriteGuardMiddleware(policy=_StubPolicy(), interceptor=_Real())
called = {"n": 0}
async def handler(request):
called["n"] += 1
return MagicMock(content="ok")
ctx = {"maf_run_id": "r1", "maf_approved_tool": "update_task"}
# The request is for a different tool → not allowed through.
req = _request(ctx)
req.tool_call = {"name": "delete_thing", "args": {}, "id": "c2"}
result = await mw.awrap_tool_call(req, handler)
assert called["n"] == 0 # intercepted, not executed
envelope = json.loads(result.content)
assert envelope["status"] == "approval_required"
File diff suppressed because it is too large. Load diff
@@ -1,14 +1,14 @@
"""P1 admin write-boundary + P2 config-default tests for the MAF roundtable.
P1 — admin diagnostic access is **read-only**:
P1 —admin diagnostic access is **read-only**:
- An admin with ``admin_diagnostic_access=True`` can *read* another user's
session / run / action / approval (cross-user diagnostics).
- The same admin **cannot** *write* another user's resources: update / delete
session, start / cancel run, append event, decide approval all raise
``MafForbiddenError`` (→ HTTP 403), never silently succeeding.
``MafForbiddenError`` (→HTTP 403), never silently succeeding.
- Normal users remain fully isolated from each other.
P2 — config-driven create defaults:
P2 —config-driven create defaults:
- Omitted ``mode`` / ``write_policy`` / ``max_rounds`` fall back to the deployed
``MafRoundtableConfig`` values.
- An explicit request value overrides the config default.
@@ -18,13 +18,10 @@ from __future__ import annotations
import pytest
import pytest_asyncio
from maf_test_helpers import make_maf_service
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.maf_roundtable.service import (
MafForbiddenError,
MafNotFoundError,
MafRoundtableService,
)
from app.maf_roundtable.service import MafForbiddenError, MafNotFoundError
from deerflow.config.maf_roundtable_config import MafRoundtableConfig
from deerflow.persistence.base import Base
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
@@ -43,7 +40,7 @@ async def store(tmp_path):
def _svc(store, *, enabled=True, admin_diagnostic_access=True, config=None):
return MafRoundtableService(
return make_maf_service(
store,
enabled=enabled,
admin_diagnostic_access=admin_diagnostic_access,
@@ -165,7 +162,7 @@ async def test_admin_diagnostic_off_cannot_even_read_cross_user(store):
async def test_normal_users_remain_fully_isolated(store):
svc = _svc(store, admin_diagnostic_access=True)
s = await svc.create_session(user_id="alice", body={"task_text": "t"})
# bob is a non-admin → isolated on both read and write.
# bob is a non-admin →isolated on both read and write.
with pytest.raises(MafNotFoundError):
await svc.get_session(session_id=s["id"], user_id="bob")
with pytest.raises(MafNotFoundError):
@@ -1,16 +1,16 @@
"""Tests for the MAF roundtable persistence + service layer (阶段一骨架).
"""Tests for the MAF roundtable persistence + service layer (?????).
Covers the Phase 1 exit criteria:
- Session CRUD with participants + capability snapshot.
- Run lifecycle (create / get / list / cancel).
- Monotonic per-run event seq under concurrent appends (MAF-01-06).
- Event 断线补拉 (list_events after_seq=N).
- Event ???? (list_events after_seq=N).
- Action idempotency-key uniqueness.
- Approval decision transition + "already decided" guard.
- User isolation (MAF-01-07) — non-owner gets None / 404, not 403.
- User isolation (MAF-01-07) ??non-owner gets None / 404, not 403.
- Admin diagnostic access bypass.
- Feature flag (MAF-01-04) — writes raise MafDisabledError when off; reads open.
- Run gating — running session rejects config edits.
- Feature flag (MAF-01-04) ??writes raise MafDisabledError when off; reads open.
- Run gating ??running session rejects config edits.
- capability_snapshot frozen at creation.
- MAF tables have no FK to legacy roundtable_* tables.
"""
@@ -21,12 +21,12 @@ import asyncio
import pytest
import pytest_asyncio
from maf_test_helpers import make_maf_service
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.maf_roundtable.service import (
MafConflictError,
MafNotFoundError,
MafRoundtableService,
)
from deerflow.persistence.base import Base
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
@@ -45,7 +45,7 @@ async def store(tmp_path):
def _svc(store, *, enabled=True, admin_diagnostic_access=True):
return MafRoundtableService(store, enabled=enabled, admin_diagnostic_access=admin_diagnostic_access)
return make_maf_service(store, enabled=enabled, admin_diagnostic_access=admin_diagnostic_access)
@pytest.mark.asyncio
@@ -54,23 +54,26 @@ async def test_create_and_get_session_round_trips(store):
created = await svc.create_session(
user_id="u1",
body={
"title": "舆情会商",
"task_text": "分析暑期文旅舆情",
"title": "????",
"task_text": "????????",
"mode": "structured",
"model_name": "glm-5",
"max_rounds": 5,
"write_policy": "confirm_high_risk",
"capability_snapshot": {"agents": ["a1"]},
"participants": [
{"agent_id": "mgr", "display_name": "主持人", "role": "manager", "sort_order": 0},
{"agent_id": "seat-a", "display_name": "席A", "role": "seat", "sort_order": 1},
{"agent_id": "mgr", "display_name": "host", "role": "manager", "sort_order": 0},
{"agent_id": "seat-a", "display_name": "?A", "role": "seat", "sort_order": 1},
],
},
)
assert created["user_id"] == "u1"
assert created["status"] == "draft"
assert created["mode"] == "structured"
assert created["capability_snapshot"] == {"agents": ["a1"]}
snap = created["capability_snapshot"]
assert snap is not None
assert snap.get("version") == 1
assert isinstance(snap.get("agents"), list)
assert len(snap["agents"]) == 2
assert len(created["participants"]) == 2
assert created["participants"][0]["agent_id"] == "mgr"
@@ -102,9 +105,9 @@ async def test_update_session_partial_and_participants_replace(store):
updated = await svc.update_session(
session_id=s["id"],
user_id="u1",
fields={"title": "新标题", "participants": [{"agent_id": "a2", "role": "seat"}]},
fields={"title": "new-title", "participants": [{"agent_id": "a2", "role": "seat"}]},
)
assert updated["title"] == "新标题"
assert updated["title"] == "new-title"
assert updated["task_text"] == "orig"
assert {p["agent_id"] for p in updated["participants"]} == {"a2"}
@@ -112,11 +115,12 @@ async def test_update_session_partial_and_participants_replace(store):
@pytest.mark.asyncio
async def test_update_session_freezes_capability_snapshot(store):
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t", "capability_snapshot": {"v": 1}})
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
original = s["capability_snapshot"]
updated = await svc.update_session(
session_id=s["id"], user_id="u1", fields={"capability_snapshot": {"v": 999}}
)
assert updated["capability_snapshot"] == {"v": 1}
assert updated["capability_snapshot"] == original
@pytest.mark.asyncio
@@ -130,7 +134,7 @@ async def test_delete_session_owner_scoped(store):
await svc.get_session(session_id=s["id"], user_id="u1")
# ── run lifecycle ──────────────────────────────────────────────────────────
# ?? run lifecycle ??????????????????????????????????????????????????????????
@pytest.mark.asyncio
@@ -175,7 +179,7 @@ async def test_cancel_run_marks_session_cancelled(store):
assert refreshed["status"] == "cancelled"
# ── events: monotonic seq + concurrent appends (MAF-01-06) ────────────────
# ?? events: monotonic seq + concurrent appends (MAF-01-06) ????????????????
@pytest.mark.asyncio
@@ -193,8 +197,8 @@ async def test_append_event_monotonic_seq(store):
@pytest.mark.asyncio
async def test_concurrent_appends_produce_dense_seq_set(store):
"""Many parallel appends must produce a dense 1..N seq set — no gaps, no
collisions — exercising the MAX+1 retry on the (run_id, seq) unique
"""Many parallel appends must produce a dense 1..N seq set ??no gaps, no
collisions ??exercising the MAX+1 retry on the (run_id, seq) unique
constraint (MAF-01-06)."""
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
@@ -218,7 +222,7 @@ async def test_concurrent_appends_produce_dense_seq_set(store):
@pytest.mark.asyncio
async def test_list_events_after_seq_pull(store):
"""断线补拉: events with seq > after_seq in ascending order."""
"""????: events with seq > after_seq in ascending order."""
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="u1")
@@ -229,7 +233,7 @@ async def test_list_events_after_seq_pull(store):
assert latest == 5
# ── actions: idempotency key ──────────────────────────────────────────────
# ?? actions: idempotency key ??????????????????????????????????????????????
@pytest.mark.asyncio
@@ -264,7 +268,7 @@ async def test_action_idempotency_key_unique(store):
assert found["tool_name"] == "update_task"
# ── approvals: decision transition ────────────────────────────────────────
# ?? approvals: decision transition ????????????????????????????????????????
@pytest.mark.asyncio
@@ -273,7 +277,7 @@ async def test_approval_decision_and_already_decided_guard(store):
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="u1")
appr = await store.approvals.create_approval(
{"session_id": s["id"], "run_id": run["id"], "summary": "高风险写入", "status": "pending"}
{"session_id": s["id"], "run_id": run["id"], "summary": "approve write", "status": "pending"}
)
decided = await svc.decide_approval(approval_id=appr["id"], decision="approved", user_id="u1")
assert decided["status"] == "approved"
@@ -1,13 +1,13 @@
"""P1 concurrency + single-transaction launch tests for MAF roundtable.
Covers:
- **Concurrent run launch**: ``asyncio.gather`` of ≥8 ``create_run`` calls must
- **Concurrent run launch**: ``asyncio.gather`` of ≥ ``create_run`` calls must
yield exactly one active run; the rest get ``MafConflictError``.
- **Single-transaction atomicity**: flush / pre-commit failures roll back fully —
session stays at its prior status, no orphan run, no residual ``starting``.
- **Success shape**: session ``running``, unique run ``pending``.
- **Cancel then re-launch** works.
- **Concurrent approval decision**: parallel approve + reject → exactly one winner.
- **Concurrent approval decision**: parallel approve + reject →exactly one winner.
"""
from __future__ import annotations
@@ -21,7 +21,6 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
from app.maf_roundtable.service import (
MafConflictError,
MafRoundtableService,
)
from deerflow.persistence.base import Base
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
@@ -40,7 +39,9 @@ async def store(tmp_path):
def _svc(store, *, enabled=True, admin_diagnostic_access=True):
return MafRoundtableService(store, enabled=enabled, admin_diagnostic_access=admin_diagnostic_access)
from maf_test_helpers import make_maf_service
return make_maf_service(store, enabled=enabled, admin_diagnostic_access=admin_diagnostic_access)
# ── P1: concurrent run launch (single-tx CAS) ─────────────────────────────
@@ -48,7 +49,7 @@ def _svc(store, *, enabled=True, admin_diagnostic_access=True):
@pytest.mark.asyncio
async def test_concurrent_create_run_only_one_wins(store):
"""8 parallel ``create_run`` calls → exactly one active run, 7 conflicts."""
"""8 parallel ``create_run`` calls →exactly one active run, 7 conflicts."""
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
@@ -217,7 +218,7 @@ async def test_approval_decision_cannot_be_overwritten(store):
@pytest.mark.asyncio
async def test_cancel_run_flush_failure_rolls_back_run_and_session(store):
"""If the cancel transaction's flush fails, run stays pending and session
stays running — nothing is half-committed."""
stays running —nothing is half-committed."""
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="u1")
@@ -275,7 +276,7 @@ async def test_cancel_terminal_run_does_not_change_run(store):
@pytest.mark.asyncio
async def test_concurrent_cancel_same_run_consistent(store):
"""Two concurrent cancels of the same run → run and session both cancelled,
"""Two concurrent cancels of the same run →run and session both cancelled,
no exceptions, no inconsistent state."""
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
@@ -1,7 +1,7 @@
"""P2 deployment-config enum / range validation for MafRoundtableConfig.
Config and API schemas share ``MafMode`` / ``MafWritePolicy`` from
``deerflow.config.maf_roundtable_config`` — invalid deployment values must fail
``deerflow.config.maf_roundtable_config`` —invalid deployment values must fail
at load time, not silently become runtime bugs.
"""
@@ -9,7 +9,7 @@ Covers the full HTTP status code matrix:
- **503 Service Unavailable**: feature disabled, mock events disabled
Also verifies that identity comes from ``request.state.user`` (stamped by auth
middleware), not from body parameters — a client cannot forge ``user_id`` in the
middleware), not from body parameters —a client cannot forge ``user_id`` in the
request body to impersonate another user.
"""
@@ -19,10 +19,10 @@ import pytest
import pytest_asyncio
from fastapi import FastAPI, Request
from httpx import ASGITransport, AsyncClient
from maf_test_helpers import make_maf_service
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.gateway.routers.maf_roundtable import router as maf_router
from app.maf_roundtable import MafRoundtableService
from deerflow.config.maf_roundtable_config import MafRoundtableConfig
from deerflow.persistence.base import Base
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
@@ -48,14 +48,15 @@ async def app_and_client(tmp_path):
store = make_maf_roundtable_store(sf)
cfg = MafRoundtableConfig(enabled=True, admin_diagnostic_access=True)
svc = MafRoundtableService(store, enabled=True, admin_diagnostic_access=True, config=cfg)
svc = make_maf_service(store, enabled=True, admin_diagnostic_access=True, config=cfg)
app = FastAPI()
app.state.maf_roundtable_service = svc
app.state.maf_auto_dispatch = False
app.include_router(maf_router)
# Fake auth middleware: reads X-Test-User / X-Test-Role headers and stamps
# request.state.user. No header → 401 (unauthenticated).
# request.state.user. No header →401 (unauthenticated).
@app.middleware("http")
async def fake_auth(request: Request, call_next):
user_id = request.headers.get("X-Test-User")
@@ -99,7 +100,7 @@ async def test_403_admin_cannot_update_other_user_session(app_and_client):
assert resp.status_code == 200
session_id = resp.json()["id"]
# admin tries to update it → 403
# admin tries to update it →403
resp = await client.put(
f"/api/maf-roundtable/sessions/{session_id}",
json={"title": "hijack"},
@@ -146,7 +147,7 @@ async def test_404_other_user_session_not_leaked(app_and_client):
headers={"X-Test-User": "alice"},
)
session_id = resp.json()["id"]
# bob (non-admin) tries to read alice's session → 404 (not 403, so existence is not leaked)
# bob (non-admin) tries to read alice's session →404 (not 403, so existence is not leaked)
resp = await client.get(
f"/api/maf-roundtable/sessions/{session_id}",
headers={"X-Test-User": "bob"},
@@ -173,7 +174,7 @@ async def test_409_second_run_launch_conflicts(app_and_client):
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 200
# Second run launch → 409
# Second run launch →409
resp = await client.post(
f"/api/maf-roundtable/sessions/{session_id}/runs",
json={},
@@ -208,7 +209,7 @@ async def test_409_approval_already_decided(app_and_client):
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 200
# Second decision → 409
# Second decision →409
resp = await client.post(
f"/api/maf-roundtable/approvals/{appr['id']}/decision",
json={"decision": "rejected"},
@@ -268,10 +269,11 @@ async def test_503_feature_disabled(tmp_path):
store = make_maf_roundtable_store(sf)
cfg = MafRoundtableConfig(enabled=False)
svc = MafRoundtableService(store, enabled=False, admin_diagnostic_access=True, config=cfg)
svc = make_maf_service(store, enabled=False, admin_diagnostic_access=True, config=cfg)
app = FastAPI()
app.state.maf_roundtable_service = svc
app.state.maf_auto_dispatch = False
app.include_router(maf_router)
@app.middleware("http")
@@ -289,7 +291,7 @@ async def test_503_feature_disabled(tmp_path):
@pytest.mark.asyncio
async def test_404_post_events_endpoint_removed(app_and_client):
"""POST /runs/{id}/events is deliberately absent — no public mock write path."""
"""POST /runs/{id}/events is deliberately absent —no public mock write path."""
_, client, _ = app_and_client
resp = await client.post(
"/api/maf-roundtable/sessions",
@@ -3,7 +3,7 @@
These tests focus on the security boundary: a non-owner cannot read or mutate
another user's session / run / action / approval, and the six MAF tables have
no foreign-key relationship to the legacy ``roundtable_*`` tables (the
experiment is fully isolated — see implementation plan §2.3 "不改原有的工程定义").
experiment is fully isolated —see implementation plan §2.3 "不改原有的工程定义).
"""
from __future__ import annotations
@@ -13,15 +13,17 @@ import pytest_asyncio
from sqlalchemy import inspect
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.maf_roundtable.service import MafNotFoundError, MafRoundtableService
from app.maf_roundtable.service import MafNotFoundError
from deerflow.persistence.base import Base
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
from deerflow.persistence.maf_roundtable.model import (
MafRoundtableActionRow,
MafRoundtableActionSecretRow,
MafRoundtableApprovalRow,
MafRoundtableEventRow,
MafRoundtableParticipantRow,
MafRoundtableRunRow,
MafRoundtableSeatRunRow,
MafRoundtableSessionRow,
)
@@ -39,7 +41,9 @@ async def store(tmp_path):
def _svc(store, *, enabled=True, admin_diagnostic_access=True):
return MafRoundtableService(store, enabled=enabled, admin_diagnostic_access=admin_diagnostic_access)
from maf_test_helpers import make_maf_service
return make_maf_service(store, enabled=enabled, admin_diagnostic_access=admin_diagnostic_access)
# ── user isolation: non-owner cannot read ────────────────────────────────
@@ -146,7 +150,7 @@ async def test_admin_diagnostic_access_off_keeps_owner_scope(store):
@pytest.mark.asyncio
async def test_maf_tables_isolated_from_legacy_roundtable(store):
"""The six MAF tables must exist and none of their FKs may target a legacy
roundtable_* table (MAF-01-02 / 退出标准: MAF 表和旧 roundtable 表完全无关联外键)."""
roundtable_* table (MAF-01-02 / 退出标净 MAF 表和旀roundtable 表完全无关联外键)."""
_, engine = store
async with engine.connect() as conn:
table_names = await conn.run_sync(lambda sync_conn: set(inspect(sync_conn).get_table_names()))
@@ -159,6 +163,8 @@ async def test_maf_tables_isolated_from_legacy_roundtable(store):
"maf_roundtable_events",
"maf_roundtable_actions",
"maf_roundtable_approvals",
"maf_roundtable_seat_runs",
"maf_roundtable_action_secrets",
}
# Every FK defined on an MAF model must reference another maf_roundtable_* table.
@@ -168,7 +174,9 @@ async def test_maf_tables_isolated_from_legacy_roundtable(store):
MafRoundtableRunRow,
MafRoundtableEventRow,
MafRoundtableActionRow,
MafRoundtableActionSecretRow,
MafRoundtableApprovalRow,
MafRoundtableSeatRunRow,
]
for model in maf_models:
for fk in model.__table__.foreign_keys:
@@ -197,7 +205,7 @@ async def test_disabled_feature_keeps_reads_open(store):
svc_on = _svc(store[0], enabled=True)
s = await svc_on.create_session(user_id="u1", body={"task_text": "t"})
svc_off = _svc(store[0], enabled=False)
# Reads do not raise — admin can review history while the feature is off.
# Reads do not raise —admin can review history while the feature is off.
fetched = await svc_off.get_session(session_id=s["id"], user_id="u1")
assert fetched["id"] == s["id"]
# But a write through the disabled service still raises.
@@ -0,0 +1,955 @@
"""Acceptance tests for the WriteGuard middleware + MAF bridge (Task 1).
Verifies the core security invariants:
1. Write tools stay visible to the model (NOT excluded) but the real tool
executes **0 times** before approval —the middleware intercepts and
returns an ``approval_required`` ToolMessage instead.
2. After approval, the ToolExecutor runs the tool **exactly once** with the
approved original params (model deviation is ignored).
3. Two concurrent ``resume()``/occupy calls →only one executor wins.
4. The single-transaction action bundle rolls back if any step fails.
5. ``auto`` policy →write tools pass through (no interception).
6. Legacy runs (no ``maf_run_id`` in context) →middleware is a no-op.
These tests exercise the middleware + interceptor in isolation (no LLM, no
DeerFlow runtime) so the security properties are deterministic and fast.
"""
from __future__ import annotations
import asyncio
import json
from collections.abc import Awaitable, Callable
from typing import Any
from unittest.mock import MagicMock
import pytest
import pytest_asyncio
from maf_test_helpers import make_maf_service
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.maf_roundtable.action_coordinator import ActionCoordinator
from app.maf_roundtable.write_guard_bridge import (
MafWriteGuardInterceptor,
MafWriteGuardPolicy,
make_interceptor,
make_policy,
register_write_guard_service,
)
from deerflow.agents.middlewares.write_guard_middleware import WriteGuardMiddleware
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
from deerflow.persistence.maf_roundtable.model import Base
@pytest_asyncio.fixture
async def store():
engine = create_async_engine("sqlite+aiosqlite://", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
s = make_maf_roundtable_store(sf)
assert s is not None
yield s
await engine.dispose()
@pytest.fixture
def svc(store):
return make_maf_service(store, enabled=True, admin_diagnostic_access=True)
@pytest.fixture(autouse=True)
def _wire_bridge(svc):
register_write_guard_service(svc)
def _capability_snapshot(*, write_policy: str = "confirm_all_writes") -> dict[str, Any]:
"""A snapshot with one read tool and one write tool."""
return {
"tools": [
{"name": "read_data", "side_effect": "read", "risk_level": "R0"},
{"name": "update_task", "side_effect": "write", "risk_level": "R3"},
],
}
def _runtime_context(*, active: bool = True, write_policy: str = "confirm_all_writes") -> dict[str, Any]:
"""A run context mimicking what runtime_client.start_agent_run injects."""
ctx: dict[str, Any] = {
"agent_id": "seat-1",
"turn_index": 1,
"write_policy": write_policy,
"capability_snapshot": _capability_snapshot(write_policy=write_policy),
}
if active:
ctx["maf_run_id"] = "run-1"
ctx["maf_session_id"] = "sess-1"
ctx["maf_user_id"] = "u1"
return ctx
def _tool_call_request(
name: str,
args: dict[str, Any] | None = None,
*,
run_context: dict[str, Any] | None = None,
):
"""Build a ToolCallRequest-like object with the fields the middleware reads."""
runtime = MagicMock()
runtime.context = run_context or {}
req = MagicMock()
req.tool_call = {"name": name, "args": args or {}, "id": f"call-{name}"}
req.runtime = runtime
return req
def _real_tool_handler(call_counter: dict[str, int]) -> Callable[[Any], Awaitable[Any]]:
"""An async handler that records each invocation —proves the real tool ran."""
async def handler(request):
name = request.tool_call["name"]
call_counter[name] = call_counter.get(name, 0) + 1
msg = MagicMock()
msg.content = f"executed {name}"
return msg
return handler
# ── 1. Real tool executes 0 times before approval ──────────────────────────
@pytest.mark.asyncio
async def test_write_tool_never_executes_before_approval(svc, store):
"""The real tool handler is NOT called for a write tool awaiting approval.
The middleware returns an approval_required ToolMessage and short-circuits.
"""
call_counter: dict[str, int] = {}
policy = MafWriteGuardPolicy()
interceptor = MafWriteGuardInterceptor()
mw = WriteGuardMiddleware(policy=policy, interceptor=interceptor)
# Need a session + run for the bundle to reference.
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
await svc.create_run(session_id=session["id"], user_id="u1")
ctx = _runtime_context()
ctx["maf_session_id"] = session["id"]
request = _tool_call_request("update_task", {"title": "A"}, run_context=ctx)
handler = _real_tool_handler(call_counter)
result = await mw.awrap_tool_call(request, handler)
# Real tool never ran.
assert call_counter.get("update_task", 0) == 0
# An approval_required message was returned.
envelope = json.loads(result.content)
assert envelope["status"] == "approval_required"
assert envelope["action_id"]
assert envelope["tool_name"] == "update_task"
@pytest.mark.asyncio
async def test_read_tool_passes_through(svc, store):
"""A read tool is NOT intercepted —it executes normally even in an active run."""
call_counter: dict[str, int] = {}
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
request = _tool_call_request("read_data", {"q": "x"}, run_context=_runtime_context())
await mw.awrap_tool_call(request, _real_tool_handler(call_counter))
assert call_counter.get("read_data", 0) == 1
# ── 5. auto policy →no interception ────────────────────────────────────────
@pytest.mark.asyncio
async def test_auto_policy_write_tool_executes(svc, store):
"""Under ``auto`` policy, a write tool passes through without interception."""
call_counter: dict[str, int] = {}
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
ctx = _runtime_context(write_policy="auto")
request = _tool_call_request("update_task", {"title": "A"}, run_context=ctx)
await mw.awrap_tool_call(request, _real_tool_handler(call_counter))
assert call_counter.get("update_task", 0) == 1
# ── 6. Legacy runs (no maf_run_id) →no-op ──────────────────────────────────
@pytest.mark.asyncio
async def test_legacy_run_noop():
"""Without ``maf_run_id`` in the context, the middleware passes through."""
call_counter: dict[str, int] = {}
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
request = _tool_call_request("update_task", {"title": "A"}, run_context=_runtime_context(active=False))
await mw.awrap_tool_call(request, _real_tool_handler(call_counter))
assert call_counter.get("update_task", 0) == 1
# ── 3. At-most-once: concurrent occupy →only one winner ────────────────────
@pytest.mark.asyncio
async def test_concurrent_occupy_only_one_winner(svc, store):
"""Two concurrent claim_for_execution calls →exactly one returns "claimed";
the loser reflects the real state (in_progress), not a blanket skip (P0-3)."""
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
# Create an action in 'approved' state directly.
action = await store.actions.create_action({
"session_id": session["id"], "run_id": run["id"], "agent_id": "a1",
"tool_name": "update_task", "idempotency_key": "k1",
"risk_level": "R3", "status": "approved",
})
coord = ActionCoordinator(svc, user_id="u1")
# Two concurrent occupies.
r1, r2 = await asyncio.gather(
coord.occupy_for_execution(action["id"]),
coord.occupy_for_execution(action["id"]),
)
claimed = [r for r in (r1, r2) if r == "claimed"]
assert len(claimed) == 1, f"expected exactly one 'claimed', got {(r1, r2)}"
# The loser sees the action is already being executed (real state), so the
# parent run can stay running instead of treating it as a no-op skip.
losers = [r for r in (r1, r2) if r != "claimed"]
assert losers == ["in_progress"], f"loser must reflect in_progress, got {losers}"
# ── 3b. record_terminal_result is state-machine constrained ─────────────────
@pytest.mark.asyncio
async def test_record_result_only_from_running(svc, store):
"""record_terminal_result only transitions running →terminal."""
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
# Action in 'running' state.
action = await store.actions.create_action({
"session_id": session["id"], "run_id": run["id"], "agent_id": "a1",
"tool_name": "update_task", "idempotency_key": "k2",
"risk_level": "R3", "status": "running",
})
# First record →succeeds, returns the updated row.
res1 = await store.actions.record_terminal_result(action["id"], status="succeeded", result={"ok": True})
assert res1 is not None
assert res1["status"] == "succeeded"
# Second record (already terminal) →no-op, returns None, does NOT overwrite.
res2 = await store.actions.record_terminal_result(action["id"], status="failed", result={"ok": False})
assert res2 is None
final = await store.actions.get_action(action["id"])
assert final["status"] == "succeeded" # not overwritten
# ── 2. ToolExecutor runs once with approved params (direct tool execution) ──
@pytest.mark.asyncio
async def test_tool_executor_runs_once_with_approved_params(svc, store):
"""The ToolExecutor calls execute_tool_directly exactly once with the
approved params, and records the terminal result. The LLM is never involved.
P0-2 fix: raw params never enter a prompt —they go directly to the tool
function via execute_tool_directly(). The adapter.run_seat_turn() is NOT
called (the LLM is bypassed).
"""
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
# Seed an approved action with encrypted params {title: "A"}.
from app.maf_roundtable.action_coordinator import encrypt_params
coord = ActionCoordinator(svc, user_id="u1")
enc = encrypt_params({"title": "A"})
action = await store.actions.create_action({
"session_id": session["id"], "run_id": run["id"], "agent_id": "a1",
"tool_name": "update_task", "idempotency_key": "k3",
"risk_level": "R3", "status": "approved",
})
await store.action_secrets.create(action_id=action["id"], arguments_encrypted=enc)
# Mock client.execute_tool_directly —this is what ToolExecutor now calls.
original_params_received = None
exec_count = {"n": 0}
async def _mock_execute_tool(*, thread_id, agent_id, tool_name, params, principal, **kwargs):
nonlocal original_params_received
original_params_received = params
exec_count["n"] += 1
return {"status": "succeeded", "result": {"output": f"called {tool_name}"}}
mock_client = MagicMock()
mock_client.execute_tool_directly = _mock_execute_tool
mock_adapter = MagicMock()
mock_adapter._client = mock_client
from app.maf_roundtable.tool_executor import ToolExecutor
executor = ToolExecutor(adapter=mock_adapter, action_coordinator=coord, user_id="u1")
# P0-2: no approved_params kwarg —the executor recovers the params itself
# from the encrypted secret stored above.
outcome = await executor.execute(
action_id=action["id"], session_id=session["id"], run_id=run["id"],
agent_id="a1", thread_id="t1",
approved_tool="update_task",
)
assert outcome["status"] == "succeeded"
# The tool received the exact approved params (title=A, not something the
# LLM modified).
assert original_params_received == {"title": "A"}
# The action is now terminal.
final = await store.actions.get_action(action["id"])
assert final["status"] == "succeeded"
# A second execution attempt reflects the terminal state (P0-3: no blanket
# "skipped") and —critically —does NOT re-execute the tool (at-most-once).
outcome2 = await executor.execute(
action_id=action["id"], session_id=session["id"], run_id=run["id"],
agent_id="a1", thread_id="t1",
approved_tool="update_task",
)
assert outcome2["status"] == "succeeded" # reflects the real terminal status
assert outcome2.get("action") is not None
assert exec_count["n"] == 1, "tool must execute exactly once (at-most-once)"
# ── 2b. P0-2: secret unavailable ⇀hard fail, zero tool executions ─────────
@pytest.mark.asyncio
async def test_executor_missing_secret_fails_without_calling_tool(svc, store):
"""P0-2: an approved action whose secret row is missing must FAIL with the
tool executing **0 times** —never fall back to empty params."""
from app.maf_roundtable.action_coordinator import encrypt_params
from app.maf_roundtable.tool_executor import ToolExecutor
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
coord = ActionCoordinator(svc, user_id="u1")
action = await store.actions.create_action({
"session_id": session["id"], "run_id": run["id"], "agent_id": "a1",
"tool_name": "update_task", "idempotency_key": "k-missing",
"risk_level": "R3", "status": "approved",
})
# Seed then DELETE the secret so the row is gone.
await store.action_secrets.create(
action_id=action["id"], arguments_encrypted=encrypt_params({"title": "A"})
)
await store.action_secrets.delete_by_action(action["id"])
assert await store.action_secrets.get_by_action(action["id"]) is None
calls = {"n": 0}
async def _boom(*, thread_id, agent_id, tool_name, params, principal):
calls["n"] += 1
return {"status": "succeeded", "result": {}}
mock_client = MagicMock()
mock_client.execute_tool_directly = _boom
mock_adapter = MagicMock()
mock_adapter._client = mock_client
executor = ToolExecutor(adapter=mock_adapter, action_coordinator=coord, user_id="u1")
outcome = await executor.execute(
action_id=action["id"], session_id=session["id"], run_id=run["id"],
agent_id="a1", thread_id="t1", approved_tool="update_task",
)
assert calls["n"] == 0, "tool must NOT run when the secret is missing"
assert outcome["status"] == "failed"
final = await store.actions.get_action(action["id"])
assert final["status"] == "failed"
assert final["status"] != "succeeded"
@pytest.mark.asyncio
async def test_executor_corrupt_secret_fails_without_calling_tool(svc, store):
"""P0-2: an undecryptable secret (wrong key / garbage) must FAIL with the
tool executing **0 times** —not run with empty params."""
from app.maf_roundtable.tool_executor import ToolExecutor
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
coord = ActionCoordinator(svc, user_id="u1")
action = await store.actions.create_action({
"session_id": session["id"], "run_id": run["id"], "agent_id": "a1",
"tool_name": "update_task", "idempotency_key": "k-corrupt",
"risk_level": "R3", "status": "approved",
})
# Garbage ciphertext that Fernet cannot decrypt.
await store.action_secrets.create(
action_id=action["id"], arguments_encrypted="not-a-valid-fernet-token"
)
calls = {"n": 0}
async def _boom(*, thread_id, agent_id, tool_name, params, principal):
calls["n"] += 1
return {"status": "succeeded", "result": {}}
mock_client = MagicMock()
mock_client.execute_tool_directly = _boom
mock_adapter = MagicMock()
mock_adapter._client = mock_client
executor = ToolExecutor(adapter=mock_adapter, action_coordinator=coord, user_id="u1")
outcome = await executor.execute(
action_id=action["id"], session_id=session["id"], run_id=run["id"],
agent_id="a1", thread_id="t1", approved_tool="update_task",
)
assert calls["n"] == 0, "tool must NOT run when the secret cannot be decrypted"
assert outcome["status"] == "failed"
final = await store.actions.get_action(action["id"])
assert final["status"] == "failed"
@pytest.mark.asyncio
async def test_get_approved_action_params_raises_with_reasons(svc, store):
"""P0-2: get_approved_action_params raises ActionSecretUnavailableError
(never returns None) with a distinct reason per failure mode."""
from app.maf_roundtable.action_coordinator import (
ActionSecretUnavailableError,
encrypt_params,
)
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
coord = ActionCoordinator(svc, user_id="u1")
# missing
a_missing = await store.actions.create_action({
"session_id": session["id"], "run_id": run["id"], "agent_id": "a1",
"tool_name": "update_task", "idempotency_key": "r-missing",
"risk_level": "R3", "status": "approved",
})
with pytest.raises(ActionSecretUnavailableError) as ei:
await coord.get_approved_action_params(a_missing["id"])
assert ei.value.reason == "missing"
# decrypt_failed
a_corrupt = await store.actions.create_action({
"session_id": session["id"], "run_id": run["id"], "agent_id": "a1",
"tool_name": "update_task", "idempotency_key": "r-corrupt",
"risk_level": "R3", "status": "approved",
})
await store.action_secrets.create(action_id=a_corrupt["id"], arguments_encrypted="garbage")
with pytest.raises(ActionSecretUnavailableError) as ei:
await coord.get_approved_action_params(a_corrupt["id"])
assert ei.value.reason == "decrypt_failed"
# invalid_type —valid Fernet token whose plaintext is a JSON list, not object
from cryptography.fernet import Fernet
from app.maf_roundtable.action_coordinator import _get_fernet_key
list_token = Fernet(_get_fernet_key()).encrypt(b"[1, 2, 3]").decode()
a_list = await store.actions.create_action({
"session_id": session["id"], "run_id": run["id"], "agent_id": "a1",
"tool_name": "update_task", "idempotency_key": "r-list",
"risk_level": "R3", "status": "approved",
})
await store.action_secrets.create(action_id=a_list["id"], arguments_encrypted=list_token)
with pytest.raises(ActionSecretUnavailableError) as ei:
await coord.get_approved_action_params(a_list["id"])
assert ei.value.reason == "invalid_type"
# happy path still returns the dict
a_ok = await store.actions.create_action({
"session_id": session["id"], "run_id": run["id"], "agent_id": "a1",
"tool_name": "update_task", "idempotency_key": "r-ok",
"risk_level": "R3", "status": "approved",
})
await store.action_secrets.create(
action_id=a_ok["id"], arguments_encrypted=encrypt_params({"title": "A"})
)
assert await coord.get_approved_action_params(a_ok["id"]) == {"title": "A"}
# ── 4. Single-transaction bundle atomicity + idempotency ──────────────────
@pytest.mark.asyncio
async def test_action_bundle_writes_all_four_atomically(svc, store):
"""A successful bundle writes action + secret + approval + audit event
together —none can exist without the others (single transaction)."""
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
coord = ActionCoordinator(svc, user_id="u1")
created = await coord.intercept_write_tool_calls(
run_id=run["id"], session_id=session["id"], agent_id="a1", turn_index=1,
tool_calls=[{"name": "update_task", "id": "c1", "args": {"title": "A"}}],
capability_snapshot=_capability_snapshot(), write_policy="confirm_all_writes",
)
assert len(created) == 1
action_id = created[0]["id"]
# All four rows exist (atomic success).
action = await store.actions.get_action(action_id)
assert action is not None and action["status"] == "waiting_approval"
assert action["approval_id"], "approval must be linked"
secret = await store.action_secrets.get_by_action(action_id)
assert secret is not None, "encrypted secret must exist"
approval = await store.approvals.get_approval(action["approval_id"])
assert approval is not None and approval["status"] == "pending"
events = await store.events.list_events(run_id=run["id"])
assert any(e["event_type"] == "action.waiting_approval" for e in events), \
"audit event must exist"
@pytest.mark.asyncio
async def test_action_bundle_idempotent_on_duplicate(svc, store):
"""A duplicate (same idempotency_key) submission re-reads and returns the
existing action instead of creating a second one —retries never fail the run."""
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
coord = ActionCoordinator(svc, user_id="u1")
args = [dict(run_id=run["id"], session_id=session["id"], agent_id="a1", turn_index=1,
tool_calls=[{"name": "update_task", "id": "c1", "args": {"title": "A"}}],
capability_snapshot=_capability_snapshot(), write_policy="confirm_all_writes")]
first = await coord.intercept_write_tool_calls(**args[0])
second = await coord.intercept_write_tool_calls(**args[0])
assert first[0]["id"] == second[0]["id"], "duplicate must return the same action id"
actions = await store.actions.list_actions(run_id=run["id"])
assert len(actions) == 1, f"expected 1 action, got {len(actions)}"
# ── Factory resolution ──────────────────────────────────────────────────────
def test_factories_return_singletons():
"""make_policy/make_interceptor return the bridge objects."""
p = make_policy()
i = make_interceptor()
assert isinstance(p, MafWriteGuardPolicy)
assert isinstance(i, MafWriteGuardInterceptor)
# ── P0-3: Strengthened WriteGuard passthrough ───────────────────────────────
@pytest.mark.asyncio
async def test_approved_passthrough_requires_action_id(svc, store):
"""When maf_approved_tool is set but maf_approved_action_id is missing,
the WriteGuard blocks the passthrough —tool name matching alone is
insufficient (P0-3 fix)."""
call_counter: dict[str, int] = {}
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
ctx = _runtime_context()
ctx["maf_approved_tool"] = "update_task"
# Deliberately omit maf_approved_action_id —should be blocked.
request = _tool_call_request("update_task", {"title": "A"}, run_context=ctx)
handler = _real_tool_handler(call_counter)
result = await mw.awrap_tool_call(request, handler)
# Real tool should NOT execute —the passthrough requires action_id.
assert call_counter.get("update_task", 0) == 0
# Should return an approval_required or error message, not pass through.
envelope = json.loads(result.content)
assert envelope["status"] in ("approval_required", "intercept_failed")
@pytest.mark.asyncio
async def test_approved_passthrough_allows_with_action_id(svc, store):
"""When both maf_approved_tool and maf_approved_action_id are set AND the
bound action validates (status running + run/session/agent + tool match),
the WriteGuard allows the passthrough (defense-in-depth)."""
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
await svc.create_run(session_id=session["id"], user_id="u1")
# Seed the bound action in the state the executor leaves it (running) with
# ownership matching the run context —validate_approved_action queries it.
await store.actions.create_action({
"id": "action-1", "session_id": session["id"], "run_id": "run-1",
"agent_id": "seat-1", "tool_name": "update_task",
"idempotency_key": "pt-allow", "risk_level": "R3", "status": "running",
})
call_counter: dict[str, int] = {}
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
ctx = _runtime_context()
ctx["maf_session_id"] = session["id"]
ctx["maf_approved_tool"] = "update_task"
ctx["maf_approved_action_id"] = "action-1"
request = _tool_call_request("update_task", {"title": "A"}, run_context=ctx)
handler = _real_tool_handler(call_counter)
await mw.awrap_tool_call(request, handler)
# Real tool should execute —passthrough is active and validated.
assert call_counter.get("update_task", 0) == 1
@pytest.mark.asyncio
async def test_approved_passthrough_blocks_when_action_not_running(svc, store):
"""P0-4: field presence is not enough —if the bound action is NOT in
``running`` status, the async passthrough fails closed (tool does not run)."""
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
await svc.create_run(session_id=session["id"], user_id="u1")
# Action exists but is still waiting_approval (not occupied/running).
await store.actions.create_action({
"id": "action-2", "session_id": session["id"], "run_id": "run-1",
"agent_id": "seat-1", "tool_name": "update_task",
"idempotency_key": "pt-notrunning", "risk_level": "R3", "status": "waiting_approval",
})
call_counter: dict[str, int] = {}
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
ctx = _runtime_context()
ctx["maf_session_id"] = session["id"]
ctx["maf_approved_tool"] = "update_task"
ctx["maf_approved_action_id"] = "action-2"
request = _tool_call_request("update_task", {"title": "A"}, run_context=ctx)
handler = _real_tool_handler(call_counter)
result = await mw.awrap_tool_call(request, handler)
assert call_counter.get("update_task", 0) == 0, "must fail closed when action is not running"
envelope = json.loads(result.content)
assert envelope["status"] == "approval_invalid"
@pytest.mark.asyncio
async def test_approved_passthrough_blocks_when_action_ownership_mismatches(svc, store):
"""P0-4: a running action belonging to a DIFFERENT run must not validate."""
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
await svc.create_run(session_id=session["id"], user_id="u1")
await store.actions.create_action({
"id": "action-3", "session_id": session["id"], "run_id": "OTHER-RUN",
"agent_id": "seat-1", "tool_name": "update_task",
"idempotency_key": "pt-ownership", "risk_level": "R3", "status": "running",
})
call_counter: dict[str, int] = {}
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
ctx = _runtime_context() # maf_run_id = "run-1"
ctx["maf_session_id"] = session["id"]
ctx["maf_approved_tool"] = "update_task"
ctx["maf_approved_action_id"] = "action-3"
request = _tool_call_request("update_task", {"title": "A"}, run_context=ctx)
handler = _real_tool_handler(call_counter)
result = await mw.awrap_tool_call(request, handler)
assert call_counter.get("update_task", 0) == 0
envelope = json.loads(result.content)
assert envelope["status"] == "approval_invalid"
@pytest.mark.asyncio
async def test_approved_passthrough_blocks_different_tool(svc, store):
"""When maf_approved_tool is 'update_task' but the LLM calls 'delete_task',
the passthrough blocks —different tool name (defense-in-depth)."""
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
await svc.create_run(session_id=session["id"], user_id="u1")
call_counter: dict[str, int] = {}
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
ctx = _runtime_context()
ctx["maf_session_id"] = session["id"]
ctx["maf_approved_tool"] = "update_task"
ctx["maf_approved_action_id"] = "action-1"
request = _tool_call_request("delete_task", {"id": "x"}, run_context=ctx)
handler = _real_tool_handler(call_counter)
result = await mw.awrap_tool_call(request, handler)
assert call_counter.get("delete_task", 0) == 0
envelope = json.loads(result.content)
assert envelope["status"] == "approval_required"
# ── P0-4: sync path distinguishes read (pass) vs write (fail-closed) ────────
def _sync_tool_handler(call_counter: dict[str, int]):
"""A sync handler that records each invocation —proves the real tool ran."""
def handler(request):
name = request.tool_call["name"]
call_counter[name] = call_counter.get(name, 0) + 1
msg = MagicMock()
msg.content = f"executed {name}"
return msg
return handler
def test_sync_read_tool_passes_through(svc, store):
"""P0-4: on the SYNC path a read tool is never intercepted —it runs."""
call_counter: dict[str, int] = {}
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
ctx = _runtime_context() # confirm_all_writes; read_data is side_effect=read
request = _tool_call_request("read_data", {"q": "x"}, run_context=ctx)
handler = _sync_tool_handler(call_counter)
mw.wrap_tool_call(request, handler)
assert call_counter.get("read_data", 0) == 1, "read tools must pass the sync path"
def test_sync_write_tool_fails_closed(svc, store):
"""P0-4: on the SYNC path a write tool that cannot be async-intercepted
fails closed (blocked), it does NOT run unapproved."""
call_counter: dict[str, int] = {}
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
ctx = _runtime_context() # confirm_all_writes; update_task is side_effect=write
request = _tool_call_request("update_task", {"title": "A"}, run_context=ctx)
handler = _sync_tool_handler(call_counter)
result = mw.wrap_tool_call(request, handler)
assert call_counter.get("update_task", 0) == 0, "write tools must fail closed on the sync path"
envelope = json.loads(result.content)
assert envelope["status"] == "intercept_blocked_sync"
def test_sync_approved_passthrough_runs(svc, store):
"""P0-4: an approved deterministic execution (maf_approved_tool bound)
passes the sync path without being blocked as a write."""
call_counter: dict[str, int] = {}
mw = WriteGuardMiddleware(policy=MafWriteGuardPolicy(), interceptor=MafWriteGuardInterceptor())
ctx = _runtime_context()
ctx["maf_approved_tool"] = "update_task"
ctx["maf_approved_action_id"] = "action-sync"
request = _tool_call_request("update_task", {"title": "A"}, run_context=ctx)
handler = _sync_tool_handler(call_counter)
mw.wrap_tool_call(request, handler)
assert call_counter.get("update_task", 0) == 1, "approved passthrough must run on the sync path"
# ── P0-4: startup prerequisite validation ───────────────────────────────────
def test_validate_maf_prerequisites_requires_write_guard(monkeypatch):
"""validate_maf_prerequisites raises when write_guard is disabled."""
from app.maf_roundtable.service import (
MafConfigurationError,
validate_maf_prerequisites,
)
from deerflow.config.write_guard_config import (
load_write_guard_config_from_dict,
reset_write_guard_config,
)
monkeypatch.setenv("MAF_ACTION_SECRET_KEY", "x" * 32)
try:
load_write_guard_config_from_dict({"enabled": False})
with pytest.raises(MafConfigurationError):
validate_maf_prerequisites()
finally:
reset_write_guard_config()
def test_validate_maf_prerequisites_passes_when_all_met(monkeypatch):
"""validate_maf_prerequisites passes when write_guard + provider + key present."""
from app.maf_roundtable.service import validate_maf_prerequisites
from deerflow.config.write_guard_config import (
load_write_guard_config_from_dict,
reset_write_guard_config,
)
monkeypatch.setenv("MAF_ACTION_SECRET_KEY", "x" * 32)
try:
load_write_guard_config_from_dict({
"enabled": True,
"provider": {
"policy_use": "app.maf_roundtable.write_guard_bridge:make_policy",
"interceptor_use": "app.maf_roundtable.write_guard_bridge:make_interceptor",
},
})
validate_maf_prerequisites() # must not raise
finally:
reset_write_guard_config()
def test_validate_maf_prerequisites_requires_secret_key(monkeypatch):
"""validate_maf_prerequisites raises when MAF_ACTION_SECRET_KEY is missing."""
from app.maf_roundtable.service import (
MafConfigurationError,
validate_maf_prerequisites,
)
from deerflow.config.write_guard_config import (
load_write_guard_config_from_dict,
reset_write_guard_config,
)
monkeypatch.delenv("MAF_ACTION_SECRET_KEY", raising=False)
try:
load_write_guard_config_from_dict({
"enabled": True,
"provider": {
"policy_use": "app.maf_roundtable.write_guard_bridge:make_policy",
"interceptor_use": "app.maf_roundtable.write_guard_bridge:make_interceptor",
},
})
with pytest.raises(MafConfigurationError):
validate_maf_prerequisites()
finally:
reset_write_guard_config()
# ── P1b: SeatExecutor precise pending-approval matching ─────────────────────
class _FakeSeatResponse:
def __init__(self, tool_calls, content="ok", error=None, usage=None):
self.tool_calls = tool_calls
self.content = content
self.error = error
self.usage = usage or {}
class _FakeSeatAdapter:
"""Minimal adapter: returns a canned seat response, exposes a real store."""
def __init__(self, store, response):
from types import SimpleNamespace
self._service = SimpleNamespace(store=store)
self._response = response
async def run_seat_turn(self, **kwargs):
return self._response
async def _emit_critical(self, *args, **kwargs):
pass
class _FakeSeatCtx:
def __init__(self):
self.sent = []
async def send_message(self, msg):
self.sent.append(msg)
def _seat_state(session_id, run_id):
from app.maf_roundtable.workflow_executors import RoundtableState
state = RoundtableState(
session_id=session_id, run_id=run_id, task_text="t", mode="structured",
model_name=None, reasoning_effort=None, max_rounds=8,
write_policy="confirm_all_writes", manager_agent_id=None,
seat_agent_ids=["seat-1"], thread_map={"seat-1": "thread-x"},
capability_snapshot=_capability_snapshot(),
)
state.current_round = 1
return state
@pytest.mark.asyncio
async def test_seat_matching_scopes_by_turn_index(svc, store):
"""P1b: a stale waiting action from an EARLIER turn is NOT mixed into the
current turn's pending approval —matching keys on turn_index + tool_call_id."""
from app.maf_roundtable.workflow_executors import SeatExecutor
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
# Stale action from turn 0 (same agent/run, different turn + call id).
stale = await store.actions.create_action({
"session_id": session["id"], "run_id": run["id"], "agent_id": "seat-1",
"tool_name": "update_task", "turn_index": 0, "logical_action_id": "old-call",
"idempotency_key": "stale", "risk_level": "R3", "status": "waiting_approval",
})
# Current action from turn 1 matching the response's tool_call id.
current = await store.actions.create_action({
"session_id": session["id"], "run_id": run["id"], "agent_id": "seat-1",
"tool_name": "update_task", "turn_index": 1, "logical_action_id": "call-1",
"idempotency_key": "current", "risk_level": "R3", "status": "waiting_approval",
})
state = _seat_state(session["id"], run["id"])
response = _FakeSeatResponse(tool_calls=[{"name": "update_task", "id": "call-1", "args": {}}])
executor = SeatExecutor(_FakeSeatAdapter(store, response), state, "seat-1",
action_coordinator=ActionCoordinator(svc, user_id="u1"))
await executor.handle("msg", _FakeSeatCtx())
assert len(state.pending_approvals) == 1
assert state.pending_approvals[0]["action_id"] == current["id"]
assert state.pending_approvals[0]["action_id"] != stale["id"]
assert state.pending_approvals[0]["turn_index"] == 1
@pytest.mark.asyncio
async def test_seat_matching_count_mismatch_fails(svc, store):
"""P1b: a pending tool call that does not match EXACTLY one waiting action
(by tool_call_id) fails the turn —the executor never guesses."""
from app.maf_roundtable.workflow_executors import SeatExecutor
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
# One waiting action for turn 1, but its logical_action_id != the response's.
await store.actions.create_action({
"session_id": session["id"], "run_id": run["id"], "agent_id": "seat-1",
"tool_name": "update_task", "turn_index": 1, "logical_action_id": "other-call",
"idempotency_key": "mismatch", "risk_level": "R3", "status": "waiting_approval",
})
state = _seat_state(session["id"], run["id"])
response = _FakeSeatResponse(tool_calls=[{"name": "update_task", "id": "call-1", "args": {}}])
executor = SeatExecutor(_FakeSeatAdapter(store, response), state, "seat-1",
action_coordinator=ActionCoordinator(svc, user_id="u1"))
with pytest.raises(RuntimeError, match="expected exactly 1"):
await executor.handle("msg", _FakeSeatCtx())
# ── P0-1: WriteGuard middleware loads from config ────────────────────────────
def test_write_guard_middleware_in_chain_when_enabled():
"""When write_guard.enabled is True and provider is configured,
build_lead_runtime_middlewares includes WriteGuardMiddleware."""
from deerflow.agents.middlewares.tool_error_handling_middleware import (
_build_runtime_middlewares,
)
from deerflow.config.write_guard_config import (
WriteGuardConfig,
WriteGuardProviderConfig,
load_write_guard_config_from_dict,
reset_write_guard_config,
)
try:
load_write_guard_config_from_dict({
"enabled": True,
"provider": {
"policy_use": "app.maf_roundtable.write_guard_bridge:make_policy",
"interceptor_use": "app.maf_roundtable.write_guard_bridge:make_interceptor",
},
})
# Build a minimal AppConfig with write_guard enabled.
# Use model_construct to skip full validation (we only need
# write_guard and sandbox fields for this middleware test).
from deerflow.config import get_app_config
cfg = get_app_config().__class__.model_construct(
sandbox={"use": "deerflow.sandbox.local:LocalSandboxProvider"},
write_guard=WriteGuardConfig(
enabled=True,
provider=WriteGuardProviderConfig(
policy_use="app.maf_roundtable.write_guard_bridge:make_policy",
interceptor_use="app.maf_roundtable.write_guard_bridge:make_interceptor",
),
),
)
middlewares = _build_runtime_middlewares(
app_config=cfg,
include_uploads=False,
include_dangling_tool_call_patch=False,
)
from deerflow.agents.middlewares.write_guard_middleware import WriteGuardMiddleware
assert any(isinstance(mw, WriteGuardMiddleware) for mw in middlewares), (
"WriteGuardMiddleware should be in the chain when write_guard is enabled"
)
finally:
reset_write_guard_config()