[eric] backend: browser tools follow whether a renderer is attached, not the headless flag

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015q1TVSLNNsXAoyM7sJuVKP
This commit is contained in:
ciregenz
2026-07-31 19:01:03 -07:00
co-authored by Claude Opus 5
parent 0f6e110a86
commit 949655e86c
7 changed files with 134 additions and 29 deletions
+3
View File
@@ -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)."""
@@ -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"
@@ -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"
+26 -2
View File
@@ -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"
}
)
)
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),
)
+52 -11
View File
@@ -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 `<webview>` 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 <webview>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}}
+47 -10
View File
@@ -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