feat(maf): add secure roundtable workflow runtime
This commit is contained in:
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()
|
||||
Reference in new issue
Block a user