mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
164 lines
7.4 KiB
Python
164 lines
7.4 KiB
Python
"""Headless gating: the tools that dead-end must be gone from the effective tool surface, and an
|
|
'ask' must deny on the spot instead of parking on the 600s approval timeout. Two gates, not one:
|
|
ShowUI/AskUI and AskUserQuestion need a person, so OPENSWARM_HEADLESS=1 alone kills them, while
|
|
browser/app delegation only needs a window, so a headless box that boots one (the cloud runner
|
|
under Xvfb) keeps them. Every case is paired with its twin, because a gate that can't be seen
|
|
switching off proves nothing."""
|
|
|
|
import pytest
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
from backend.apps.agents.core.models import AgentSession
|
|
from backend.apps.agents.manager.permissions import workflow_approval
|
|
from backend.apps.agents.manager.permissions.build_effective_tool_lists import build_effective_tool_lists
|
|
from backend.apps.agents.manager.register_builtin_mcp_servers import register_builtin_mcp_servers
|
|
from backend.apps.agents.manager.streaming.HookContext import HookContext
|
|
from backend.apps.agents.core.ws_manager import ws_manager
|
|
from backend.config.headless import HUMAN_BOUND_TOOLS, RENDERER_BOUND_TOOLS, denied_tools
|
|
|
|
BROWSER_DELEGATION = ("CreateBrowserAgent", "BrowserAgent", "BrowserAgents", "AppAgent")
|
|
|
|
|
|
def p_session():
|
|
session = AgentSession(name="t", model="sonnet", dashboard_id="d")
|
|
session.allowed_tools = ["Read", "Bash", "AskUserQuestion"]
|
|
return session
|
|
|
|
|
|
def p_run_the_real_pipeline():
|
|
"""Registration then tool-list build, in the order the agent loop runs them."""
|
|
session = p_session()
|
|
mcp_servers = {}
|
|
browser_tools, invoke_tools = register_builtin_mcp_servers(
|
|
mcp_servers, session, {}, None, None)
|
|
allowed, disallowed = build_effective_tool_lists(
|
|
session, mcp_servers, {}, False, browser_tools, invoke_tools)
|
|
return mcp_servers, allowed, disallowed
|
|
|
|
|
|
def p_ctx() -> HookContext:
|
|
session = p_session()
|
|
return HookContext(
|
|
session=session,
|
|
session_id=session.id,
|
|
prompt="hi",
|
|
builtin_perms={},
|
|
policy_defaults={},
|
|
sessions={},
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def no_renderer(monkeypatch):
|
|
"""No window has ever attached, the state a container starts in."""
|
|
monkeypatch.setattr(ws_manager, "renderer_ever_attached", False, raising=False)
|
|
|
|
|
|
@pytest.fixture
|
|
def renderer_attached(monkeypatch):
|
|
"""A window registered on the dashboard socket, the state the runner waits for."""
|
|
monkeypatch.setattr(ws_manager, "renderer_ever_attached", True, raising=False)
|
|
|
|
|
|
def test_the_two_denied_sets_split_by_what_they_actually_need():
|
|
assert RENDERER_BOUND_TOOLS == frozenset(BROWSER_DELEGATION)
|
|
assert HUMAN_BOUND_TOOLS == frozenset({"ShowUI", "AskUserQuestion"})
|
|
|
|
|
|
def test_a_renderer_buys_back_the_browser_tools_but_never_the_human_ones(monkeypatch, renderer_attached):
|
|
monkeypatch.setenv("OPENSWARM_HEADLESS", "1")
|
|
assert denied_tools() == HUMAN_BOUND_TOOLS
|
|
|
|
|
|
def test_a_desktop_launch_denies_nothing_even_before_its_window_loads(monkeypatch, no_renderer):
|
|
monkeypatch.delenv("OPENSWARM_HEADLESS", raising=False)
|
|
assert denied_tools() == frozenset()
|
|
|
|
|
|
def test_headless_with_a_renderer_offers_the_browser_server_again(monkeypatch, renderer_attached):
|
|
monkeypatch.setenv("OPENSWARM_HEADLESS", "1")
|
|
mcp_servers, allowed, disallowed = p_run_the_real_pipeline()
|
|
assert "browser" in mcp_servers["openswarm-core"]["env"]["OSW_MCP_MODULES"].split(",")
|
|
for tool in BROWSER_DELEGATION:
|
|
assert f"mcp__openswarm-core__{tool}" in allowed
|
|
# Still nobody to answer, so the human-bound pair stays gone.
|
|
assert "ui" not in mcp_servers["openswarm-core"]["env"]["OSW_MCP_MODULES"].split(",")
|
|
assert "AskUserQuestion" in disallowed
|
|
|
|
|
|
def test_headless_drops_the_renderer_bound_servers_and_tools(monkeypatch, no_renderer):
|
|
monkeypatch.setenv("OPENSWARM_HEADLESS", "1")
|
|
mcp_servers, allowed, disallowed = p_run_the_real_pipeline()
|
|
assert "browser" not in mcp_servers["openswarm-core"]["env"]["OSW_MCP_MODULES"].split(",")
|
|
assert "ui" not in mcp_servers["openswarm-core"]["env"]["OSW_MCP_MODULES"].split(",")
|
|
for tool in BROWSER_DELEGATION:
|
|
assert f"mcp__openswarm-core__{tool}" not in allowed
|
|
for ui_tool in ("ShowUI", "AskUI"):
|
|
assert f"mcp__openswarm-core__{ui_tool}" not in allowed
|
|
assert "AskUserQuestion" not in allowed
|
|
assert "AskUserQuestion" in disallowed
|
|
# The rest of the surface is untouched; headless prunes the renderer, it doesn't lobotomise the agent.
|
|
assert "Read" in allowed and "Bash" in allowed
|
|
assert "invoke" in mcp_servers["openswarm-core"]["env"]["OSW_MCP_MODULES"].split(",")
|
|
assert "openswarm-core" in mcp_servers
|
|
|
|
|
|
def test_without_headless_every_one_of_them_is_offered(monkeypatch, no_renderer):
|
|
monkeypatch.delenv("OPENSWARM_HEADLESS", raising=False)
|
|
mcp_servers, allowed, _ = p_run_the_real_pipeline()
|
|
assert "browser" in mcp_servers["openswarm-core"]["env"]["OSW_MCP_MODULES"].split(",")
|
|
assert "ui" in mcp_servers["openswarm-core"]["env"]["OSW_MCP_MODULES"].split(",")
|
|
for tool in BROWSER_DELEGATION:
|
|
assert f"mcp__openswarm-core__{tool}" in allowed
|
|
for ui_tool in ("ShowUI", "AskUI"):
|
|
assert f"mcp__openswarm-core__{ui_tool}" in allowed
|
|
|
|
|
|
def test_askuserquestion_survives_when_the_ui_server_is_absent(monkeypatch):
|
|
# With no openswarm-ui registered nothing else denies AskUserQuestion, so this isolates the headless gate.
|
|
monkeypatch.delenv("OPENSWARM_HEADLESS", raising=False)
|
|
allowed, disallowed = build_effective_tool_lists(p_session(), {}, {}, False, [], [])
|
|
assert "AskUserQuestion" in allowed and "AskUserQuestion" not in disallowed
|
|
monkeypatch.setenv("OPENSWARM_HEADLESS", "1")
|
|
allowed, disallowed = build_effective_tool_lists(p_session(), {}, {}, False, [], [])
|
|
assert "AskUserQuestion" not in allowed and "AskUserQuestion" in disallowed
|
|
|
|
|
|
def test_only_the_exact_flag_value_turns_headless_on(monkeypatch, no_renderer):
|
|
monkeypatch.setenv("OPENSWARM_HEADLESS", "0")
|
|
_, allowed, _ = p_run_the_real_pipeline()
|
|
assert "mcp__openswarm-core__ShowUI" in allowed
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_headless_denies_an_ask_without_ever_prompting(monkeypatch):
|
|
monkeypatch.setenv("OPENSWARM_HEADLESS", "1")
|
|
ask = AsyncMock()
|
|
with patch.object(workflow_approval, "request_user_approval", new=ask):
|
|
decision = await workflow_approval.resolve_ask(p_ctx(), "Bash", {"command": "ls"}, None)
|
|
assert decision.behavior == "deny"
|
|
assert not ask.called # never broadcast into the void, so never a 600s park
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_without_headless_an_ask_still_prompts(monkeypatch):
|
|
monkeypatch.delenv("OPENSWARM_HEADLESS", raising=False)
|
|
ask = AsyncMock(return_value=workflow_approval.ApprovalDecision(behavior="allow"))
|
|
with patch.object(workflow_approval, "request_user_approval", new=ask):
|
|
decision = await workflow_approval.resolve_ask(p_ctx(), "Bash", {"command": "ls"}, None)
|
|
assert decision.behavior == "allow"
|
|
assert ask.called
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_headless_still_honors_a_remembered_allow(monkeypatch):
|
|
monkeypatch.setenv("OPENSWARM_HEADLESS", "1")
|
|
ctx = p_ctx()
|
|
workflow_approval.set_workflow_approval_memory(
|
|
ctx.session_id, decisions={"Bash": "allow"}, step_usage={}, remember=None, ask_timeout=5.0)
|
|
try:
|
|
decision = await workflow_approval.resolve_ask(ctx, "Bash", {"command": "ls"}, None)
|
|
finally:
|
|
workflow_approval.clear_workflow_approval_memory(ctx.session_id)
|
|
assert decision.behavior == "allow"
|