mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-28 04:24:51 +02:00
[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:
co-authored by
Claude Opus 5
parent
0f6e110a86
commit
949655e86c
@@ -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"
|
||||
|
||||
@@ -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
@@ -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}}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user