diff --git a/backend/apps/agents/core/ws_manager.py b/backend/apps/agents/core/ws_manager.py index 6996e5cc..78c0d7e3 100644 --- a/backend/apps/agents/core/ws_manager.py +++ b/backend/apps/agents/core/ws_manager.py @@ -66,6 +66,8 @@ class ConnectionManager: def __init__(self): self.connections: dict[str, list[WebSocket]] = {} self.global_connections: list[WebSocket] = [] + # Latched on the first renderer and never cleared: it answers "can a window reach this backend at all", which a momentary socket blip must not un-answer. Only the process dying resets it. + self.renderer_ever_attached: bool = False # Which dashboard each global socket is currently showing, keyed by id(websocket). active_dashboard_id is the last one activated (the window the user is looking at most recently); a scheduled run targets it so its browser card spawns where the renderer can render it. self.global_dashboard_ids: dict[int, str] = {} self.active_dashboard_id: Optional[str] = None @@ -83,6 +85,7 @@ class ConnectionManager: async def connect_global(self, websocket: WebSocket): await websocket.accept() self.global_connections.append(websocket) + self.renderer_ever_attached = True async def connect_main(self, websocket: WebSocket): """Register the single Electron-main bridge socket (replaces any stale prior one).""" diff --git a/backend/apps/agents/manager/permissions/build_effective_tool_lists.py b/backend/apps/agents/manager/permissions/build_effective_tool_lists.py index b2d44a6a..5a6f17d8 100644 --- a/backend/apps/agents/manager/permissions/build_effective_tool_lists.py +++ b/backend/apps/agents/manager/permissions/build_effective_tool_lists.py @@ -18,7 +18,7 @@ from backend.apps.tools_lib.tools_lib import ( load_all_tools as load_all_tools, sanitize_server_name as sanitize_server_name, ) -from backend.config.headless import apply_headless_denies +from backend.config.headless import apply_unreachable_denies # Mutation/exec tools a read-only session must never reach: Edit (rewrites files), Bash (rm/mv/overwrite), # NotebookEdit (rewrites notebooks). Write is intentionally NOT here, the audit needs its one report. @@ -34,8 +34,8 @@ def build_effective_tool_lists( browser_delegation_tools: List[str], invoke_agent_tools: List[str], ) -> Tuple[List[str], List[str]]: - # Same shadow the server registration takes: headless, the renderer-bound built-ins go straight onto disallowed instead of being offered and failing when called. - builtin_perms = apply_headless_denies(builtin_perms) + # Same shadow the server registration takes: anything that would dead-end goes straight onto disallowed instead of being offered and failing when called. + builtin_perms = apply_unreachable_denies(builtin_perms) effective_allowed = [ t for t in session.allowed_tools if t in FULL_TOOLS and builtin_perms.get(t, "always_allow") == "always_allow" diff --git a/backend/apps/agents/manager/register_builtin_mcp_servers.py b/backend/apps/agents/manager/register_builtin_mcp_servers.py index cc548092..aad257b2 100644 --- a/backend/apps/agents/manager/register_builtin_mcp_servers.py +++ b/backend/apps/agents/manager/register_builtin_mcp_servers.py @@ -12,7 +12,7 @@ from typeguard import typechecked from backend.apps.agents.core.models import AgentSession from backend.auth import get_auth_token -from backend.config.headless import apply_headless_denies +from backend.config.headless import apply_unreachable_denies @typechecked @@ -25,8 +25,8 @@ def register_builtin_mcp_servers( ) -> Tuple[List[str], List[str]]: import backend.apps.agents as p_agents_pkg agents_dir = os.path.dirname(p_agents_pkg.__file__) - # Headless has no renderer for a webview or a UI component, so we shadow the map once here and let the existing deny short-circuits skip those servers; nothing below may read the un-shadowed one. - builtin_perms = apply_headless_denies(builtin_perms) + # With no renderer for a webview and no human for a prompt, we shadow the map once here and let the existing deny short-circuits skip those servers; nothing below may read the un-shadowed one. + builtin_perms = apply_unreachable_denies(builtin_perms) browser_delegation_tools = ["CreateBrowserAgent", "BrowserAgent", "BrowserAgents", "AppAgent"] browser_all_denied = all( builtin_perms.get(t, "always_allow") == "deny" diff --git a/backend/apps/health/health.py b/backend/apps/health/health.py index 5637152c..ed555db0 100644 --- a/backend/apps/health/health.py +++ b/backend/apps/health/health.py @@ -1,6 +1,7 @@ from backend.config.Apps import SubApp from contextlib import asynccontextmanager from fastapi.responses import PlainTextResponse +from pydantic import BaseModel, ConfigDict from typeguard import typechecked from fastapi import status, HTTPException @@ -14,10 +15,33 @@ health = SubApp("health", health_lifespan) @typechecked async def check() -> PlainTextResponse: return PlainTextResponse( - content="OK", + content="OK", status_code=status.HTTP_200_OK, headers={ "Content-Type": "text/plain", "Content-Length": "2" } - ) \ No newline at end of file + ) + + +class RendererHealth(BaseModel): + """Whether an Electron window is driving this backend, which is what makes browser tools real.""" + + model_config = ConfigDict(validate_assignment=True) + + attached: bool + ever_attached: bool + connections: int + + +@health.router.get("/renderer") +@typechecked +async def renderer() -> RendererHealth: + """Renderer readiness. The cloud runner blocks on this before it fires a workflow, because a + browser step with no window behind it burns turns narrating timeouts at nothing.""" + from backend.apps.agents.core.ws_manager import ws_manager + return RendererHealth( + attached=bool(ws_manager.global_connections), + ever_attached=ws_manager.renderer_ever_attached, + connections=len(ws_manager.global_connections), + ) diff --git a/backend/config/headless.py b/backend/config/headless.py index ccdcbc55..55c086c1 100644 --- a/backend/config/headless.py +++ b/backend/config/headless.py @@ -1,18 +1,31 @@ -"""Headless mode: the backend running with no Electron renderer, no display, and no human -(a Linux container). Single source of truth for the flag and for the tools that dead-end at a -renderer, so they are dropped from the tool surface up front instead of hanging at call time.""" +"""What the backend can still offer when nobody is sitting in front of it. + +Two different absences, and they are not the same absence. `OPENSWARM_HEADLESS=1` says no +desktop shell owns this process, so no human will answer a prompt and no window arrives on +its own. A renderer that has never registered on the dashboard WebSocket says there is no +Electron window to drive a `` through. A cloud container starts as both and, once +it boots Electron under a virtual display, becomes only the first. + +The browser tools therefore hang off the renderer actually being there, not off the flag, +which is what lets the same code be right on a laptop, in the runner container, and in +whatever environment attaches a renderer next. +""" import os -from typing import Dict, FrozenSet +from typing import Dict, FrozenSet, Set from typeguard import typechecked -# Each of these ends at the Electron renderer: browser/app delegation drives live webviews, ShowUI (the same gate AskUI rides) draws into the transcript, and AskUserQuestion waits on a person who isn't there. -HEADLESS_DENIED_TOOLS: FrozenSet[str] = frozenset({ +# Each of these ends at a live Electron renderer: browser and app delegation drive real s that only the frontend can serialize and click. +RENDERER_BOUND_TOOLS: FrozenSet[str] = frozenset({ "CreateBrowserAgent", "BrowserAgent", "BrowserAgents", "AppAgent", +}) + +# Each of these ends at a person: ShowUI (the same gate AskUI rides) draws for someone to look at, and AskUserQuestion waits for someone to answer. A renderer nobody is watching does not bring them back. +HUMAN_BOUND_TOOLS: FrozenSet[str] = frozenset({ "ShowUI", "AskUserQuestion", }) @@ -26,9 +39,37 @@ def is_headless() -> bool: @typechecked -def apply_headless_denies(builtin_perms: Dict[str, str]) -> Dict[str, str]: - """The permission map with every renderer-bound tool forced to 'deny' when headless, and the - map itself untouched otherwise. Returns a copy so the mode never poisons the live snapshot.""" - if not is_headless(): +def renderer_reachable() -> bool: + """Whether an Electron renderer has ever registered on this backend's dashboard socket. + + Imported inside the call because config sits below apps in the import order; hoisting + ws_manager to module scope would close a cycle.""" + from backend.apps.agents.core.ws_manager import ws_manager + return ws_manager.renderer_ever_attached + + +@typechecked +def denied_tools() -> FrozenSet[str]: + """Every builtin that would dead-end in this process, given who is actually attached. + + The renderer-bound set drops only when the shell that would have brought a window is + absent AND no window ever showed up. A desktop launch keeps offering them across a + socket blip on purpose: browser_agent's dispatch gate is what waits out a reconnect, + and a tool pruned at session build never comes back for the life of that session. + """ + denied: Set[str] = set() + if is_headless(): + denied |= HUMAN_BOUND_TOOLS + if not renderer_reachable(): + denied |= RENDERER_BOUND_TOOLS + return frozenset(denied) + + +@typechecked +def apply_unreachable_denies(builtin_perms: Dict[str, str]) -> Dict[str, str]: + """The permission map with every currently-unreachable tool forced to 'deny'. Returns a + copy so the verdict never poisons the live snapshot.""" + denied = denied_tools() + if not denied: return builtin_perms - return {**builtin_perms, **{name: "deny" for name in HEADLESS_DENIED_TOOLS}} + return {**builtin_perms, **{name: "deny" for name in denied}} diff --git a/backend/tests/test_headless_mode.py b/backend/tests/test_headless_mode.py index fdfd07c0..2f884d1e 100644 --- a/backend/tests/test_headless_mode.py +++ b/backend/tests/test_headless_mode.py @@ -1,7 +1,9 @@ -"""OPENSWARM_HEADLESS=1 gating: the tools that dead-end at an Electron renderer (browser/app -delegation, ShowUI/AskUI, AskUserQuestion) must be gone from the effective tool surface, and an -'ask' must deny on the spot instead of parking on the 600s approval timeout. Every case is paired -with its headless-off twin, because a gate that can't be seen switching off proves nothing.""" +"""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 @@ -11,7 +13,8 @@ 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.config.headless import HEADLESS_DENIED_TOOLS +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") @@ -45,11 +48,45 @@ def p_ctx() -> HookContext: ) -def test_the_denied_set_is_exactly_the_renderer_bound_tools(): - assert HEADLESS_DENIED_TOOLS == frozenset(BROWSER_DELEGATION) | {"ShowUI", "AskUserQuestion"} +@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) -def test_headless_drops_the_renderer_bound_servers_and_tools(monkeypatch): +@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 "openswarm-browser-agent" in mcp_servers + for tool in BROWSER_DELEGATION: + assert f"mcp__openswarm-browser-agent__{tool}" in allowed + # Still nobody to answer, so the human-bound pair stays gone. + assert "openswarm-ui" not in mcp_servers + 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 "openswarm-browser-agent" not in mcp_servers @@ -66,7 +103,7 @@ def test_headless_drops_the_renderer_bound_servers_and_tools(monkeypatch): assert "openswarm-apps" in mcp_servers -def test_without_headless_every_one_of_them_is_offered(monkeypatch): +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 "openswarm-browser-agent" in mcp_servers @@ -87,7 +124,7 @@ def test_askuserquestion_survives_when_the_ui_server_is_absent(monkeypatch): assert "AskUserQuestion" not in allowed and "AskUserQuestion" in disallowed -def test_only_the_exact_flag_value_turns_headless_on(monkeypatch): +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-ui__ShowUI" in allowed diff --git a/openswarm-runner/runner/backend_process.py b/openswarm-runner/runner/boot/backend_process.py similarity index 100% rename from openswarm-runner/runner/backend_process.py rename to openswarm-runner/runner/boot/backend_process.py