From 949655e86ca053e0b379051bd811e1c8ac80b07f Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 31 Jul 2026 19:01:03 -0700 Subject: [PATCH 1/2] [eric] backend: browser tools follow whether a renderer is attached, not the headless flag Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015q1TVSLNNsXAoyM7sJuVKP --- backend/apps/agents/core/ws_manager.py | 3 + .../permissions/build_effective_tool_lists.py | 6 +- .../manager/register_builtin_mcp_servers.py | 6 +- backend/apps/health/health.py | 28 ++++++++- backend/config/headless.py | 63 +++++++++++++++---- backend/tests/test_headless_mode.py | 57 ++++++++++++++--- .../runner/{ => boot}/backend_process.py | 0 7 files changed, 134 insertions(+), 29 deletions(-) rename openswarm-runner/runner/{ => boot}/backend_process.py (100%) 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 From aa372dee135e202056aadc5ba9c5da0703663ef9 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 31 Jul 2026 19:01:10 -0700 Subject: [PATCH 2/2] [eric] runner: boot the real Electron shell under Xvfb so a cloud run drives a real browser Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015q1TVSLNNsXAoyM7sJuVKP --- openswarm-runner/Dockerfile | 73 +++++- openswarm-runner/Dockerfile.dockerignore | 15 ++ openswarm-runner/README.md | 54 +++- openswarm-runner/runner/boot/__init__.py | 0 .../runner/boot/renderer_process.py | 233 ++++++++++++++++++ openswarm-runner/runner/main.py | 25 +- openswarm-runner/runner/run_spec.py | 14 ++ openswarm-runner/runner/seed/data_root.py | 15 +- openswarm-runner/runner/workflow_run.py | 2 +- .../tests/test_renderer_process.py | 99 ++++++++ 10 files changed, 513 insertions(+), 17 deletions(-) create mode 100644 openswarm-runner/runner/boot/__init__.py create mode 100644 openswarm-runner/runner/boot/renderer_process.py create mode 100644 openswarm-runner/tests/test_renderer_process.py diff --git a/openswarm-runner/Dockerfile b/openswarm-runner/Dockerfile index d8d9e024..d5359e70 100644 --- a/openswarm-runner/Dockerfile +++ b/openswarm-runner/Dockerfile @@ -10,11 +10,23 @@ # /app/backend the FastAPI orchestrator # /app/router 9router's standalone server, found by p_find_9router_dir() # /app/python-env UV_PYTHON target probed by tools_lib/mcp_config.py +# +# Plus the renderer half, which exists so browser tools work the way they do on a +# laptop instead of being denied: +# /app/electron-runtime the same CastLabs Electron build the desktop app ships +# /app/electron the desktop shell's own main process, unmodified +# /app/frontend the production webpack bundle, served off loopback +# +# amd64 only. CastLabs publishes no linux-arm64 build, and running a DIFFERENT +# Electron than the desktop app ships would quietly undo the point of this image. ARG PYTHON_VERSION=3.13 ARG NODE_VERSION=20 ARG ROUTER_VERSION=0.3.60 ARG UV_VERSION=0.11.8 +# Must track electron/package.json's devDependency, or the container drives a different browser than the laptop does. +ARG ELECTRON_VERSION=42.3.3+wvcus +ARG ELECTRON_SHA256=5b6ce3a4d13f07fc63d79e884f6a40d1bc8a1cdf82cb2d130c26e8c1530649cb FROM node:${NODE_VERSION}-bookworm-slim AS node @@ -27,6 +39,35 @@ RUN printf '{"name":"router-stage","version":"0.0.0","private":true}\n' > packag && test -f node_modules/9router/app/server.js \ && test -z "$(find node_modules/9router -name '*.node' -print -quit)" +# Webpack output is architecture-independent, so this runs natively on the build host rather than under emulation. +FROM --platform=$BUILDPLATFORM node:${NODE_VERSION}-bookworm-slim AS frontend +WORKDIR /src +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci --no-audit --no-fund --silent +COPY frontend ./ +RUN npm run build && test -f dist/index.html + +# The shell's runtime deps only. --ignore-scripts leaves uiohook-napi without its prebuilt addon, which is correct: it taps a real keyboard, there isn't one here, and voiceHotkey already requires it inside a try. +FROM --platform=$BUILDPLATFORM node:${NODE_VERSION}-bookworm-slim AS shell-deps +WORKDIR /stage +COPY electron/package.json electron/package-lock.json ./ +RUN npm install --omit=dev --ignore-scripts --no-audit --no-fund --silent + +FROM debian:bookworm-slim AS electron +ARG ELECTRON_VERSION +ARG ELECTRON_SHA256 +ARG TARGETARCH +RUN set -eux; \ + test "${TARGETARCH}" = "amd64" || { echo "the renderer half is amd64-only: CastLabs ships no linux-${TARGETARCH} Electron" >&2; exit 1; }; \ + apt-get update && apt-get install -y --no-install-recommends curl ca-certificates unzip; \ + url="https://github.com/castlabs/electron-releases/releases/download/v${ELECTRON_VERSION}/electron-v${ELECTRON_VERSION}-linux-x64.zip"; \ + curl -fsSL -o /tmp/electron.zip "${url}"; \ + echo "${ELECTRON_SHA256} /tmp/electron.zip" | sha256sum -c -; \ + mkdir -p /stage; \ + unzip -q /tmp/electron.zip -d /stage; \ + rm /tmp/electron.zip; \ + test -x /stage/electron + FROM python:${PYTHON_VERSION}-slim-bookworm AS uv ARG UV_VERSION ARG TARGETARCH @@ -50,21 +91,33 @@ RUN pip install --no-cache-dir --require-hashes --only-binary=:all: \ FROM python:${PYTHON_VERSION}-slim-bookworm +# The X server plus every shared object `ldd` reports the Electron binary wanting, and the fonts without which every page renders as boxes. Derived from ldd on the real binary, not from a blog post. RUN set -eux; \ apt-get update; \ - apt-get install -y --no-install-recommends git ca-certificates; \ + apt-get install -y --no-install-recommends \ + git ca-certificates \ + xvfb fonts-liberation \ + libasound2 libatk-bridge2.0-0 libatk1.0-0 libatspi2.0-0 libcairo2 libcups2 \ + libdbus-1-3 libdrm2 libexpat1 libgbm1 libglib2.0-0 libgtk-3-0 libnss3 \ + libpango-1.0-0 libx11-6 libxcb1 libxcomposite1 libxdamage1 libxext6 \ + libxfixes3 libxkbcommon0 libxrandr2 libxtst6; \ rm -rf /var/lib/apt/lists/* COPY --from=node /usr/local/bin/node /usr/local/bin/node COPY --from=pydeps /opt/pydeps /usr/local -COPY --from=router /stage/node_modules/9router/app /app/router -COPY backend /app/backend -COPY openswarm-runner/runner /app/runner +# Numeric owner on every /app copy, because a `chown -R /app` afterwards rewrites the whole tree into a second layer and the image pays for it twice (that cost 493MB before this line existed). Numeric, not `runner`, because the user is created further down. +COPY --from=router --chown=10001:10001 /stage/node_modules/9router/app /app/router +COPY --chown=10001:10001 backend /app/backend +COPY --chown=10001:10001 openswarm-runner/runner /app/runner +COPY --chown=10001:10001 electron /app/electron +COPY --from=shell-deps --chown=10001:10001 /stage/node_modules /app/electron/node_modules +COPY --from=electron --chown=10001:10001 /stage /app/electron-runtime +COPY --from=frontend --chown=10001:10001 /src/dist /app/frontend # After backend/, never before: mcp_config.resolve_command probes uv-bin last, and the repo's own copy is Mach-O. -COPY --from=uv /stage/uv /app/backend/uv-bin/uv -COPY --from=uv /stage/uvx /app/backend/uv-bin/uvx +COPY --from=uv --chown=10001:10001 /stage/uv /app/backend/uv-bin/uv +COPY --from=uv --chown=10001:10001 /stage/uvx /app/backend/uv-bin/uvx RUN set -eux; \ if ls /app/backend/.env* >/dev/null 2>&1; then echo "a dotenv reached the image; fix Dockerfile.dockerignore" >&2; exit 1; fi; \ @@ -73,7 +126,10 @@ RUN set -eux; \ find /app/backend -name '__pycache__' -type d -prune -exec rm -rf {} +; \ useradd --create-home --uid 10001 --shell /usr/sbin/nologin runner; \ mkdir -p /data; \ - chown -R runner:runner /app /data + ln -s /data/openswarm /app/backend/data; \ + mkdir -p /tmp/.X11-unix; \ + chmod 1777 /tmp/.X11-unix; \ + chown runner:runner /data USER runner WORKDIR /app @@ -87,6 +143,7 @@ ENV HOME=/home/runner \ OPENSWARM_HOST=127.0.0.1 \ OPENSWARM_PORT=8324 \ DATA_DIR=/data/9router \ - NODE_ENV=production + NODE_ENV=production \ + ELECTRON_BIN=/app/electron-runtime/electron ENTRYPOINT ["python3", "-m", "runner.main"] diff --git a/openswarm-runner/Dockerfile.dockerignore b/openswarm-runner/Dockerfile.dockerignore index 96de9bb2..3eb6885b 100644 --- a/openswarm-runner/Dockerfile.dockerignore +++ b/openswarm-runner/Dockerfile.dockerignore @@ -1,6 +1,8 @@ * !backend !openswarm-runner/runner +!electron +!frontend # A developer's real OAuth client secrets live here; baking them into an image that # gets pushed to a registry is how a laptop leaks credentials. The Dockerfile asserts @@ -13,6 +15,19 @@ backend/.venv backend/uv-bin backend/tests backend/.pytest_cache + +# The bundle is built in a stage inside the image. A developer's stale local dist must +# never be what a cloud run renders. +frontend/dist +frontend/node_modules +electron/node_modules +electron/dist +electron/build-staging +electron/python-env +# Prebuilt Mach-O addons for the mac trackpad; main.js already skips a missing one. +electron/native +**/*.test.js + **/__pycache__ **/*.pyc **/.DS_Store diff --git a/openswarm-runner/README.md b/openswarm-runner/README.md index cb99f1d6..0bcdb45a 100644 --- a/openswarm-runner/README.md +++ b/openswarm-runner/README.md @@ -5,13 +5,17 @@ One Fly Firecracker machine per run, no state kept. ## Build -The build context is the **repo root**, not this directory (the image needs `backend/` -and `backend/requirements.lock`): +The build context is the **repo root**, not this directory (the image needs `backend/`, +`electron/`, `frontend/` and `backend/requirements.lock`). **amd64 only**, see the +renderer section: ```bash docker build --platform linux/amd64 -f openswarm-runner/Dockerfile -t openswarm-runner . ``` +Nothing has to be built on the host first: the frontend bundle and the shell's node +modules are built in their own stages inside the image. + ## Run The container is told everything it needs by one JSON run spec in `OPENSWARM_RUN_SPEC` @@ -29,12 +33,49 @@ The container is told everything it needs by one JSON run spec in `OPENSWARM_RUN ], "callback": { "url": "https://api.openswarm.com/api/cloud-runs/cr_01J.../report", "token": "" }, - "max_run_seconds": 1800 + "max_run_seconds": 1800, + "needs_browser": true } ``` Exit codes: `0` ok, `1` runner crash, `2` bad spec, `3` credential expired on arrival, -`4` backend never came up, `5` workflow failed, `6` wall-clock cap hit. +`4` backend never came up, `5` workflow failed, `6` wall-clock cap hit, +`7` no Electron window ever registered. + +## The renderer + +OpenSwarm's browser tier is not an HTTP client. Element serialization and every click, +type and scroll live in `frontend/src/shared/browserCommandHandler.ts` and drive a live +Electron ``; the backend only relays commands over the dashboard WebSocket. So +the container runs **the real desktop shell**, unmodified, on a virtual display: + +``` +Xvfb :99 -> Electron (ELECTRON_DEV=1, OPENSWARM_DEV_URL=#/dashboard/cloud-run) + -> registers on /ws/dashboard -> browser tools are live +``` + +`ELECTRON_DEV=1` is the same path `bash run.sh` uses: the shell attaches to the backend +already running here instead of spawning a second one. The bundle is served off loopback +on `:4173`, the same port the packaged app prefers, and deep-linked at the run's one +dashboard so no human has to click anything. + +Three things follow from this and are worth knowing before you touch it: + +- **amd64 only.** CastLabs (whose Electron the desktop app ships) publishes no + linux-arm64 build. Running a *different* Electron in the cloud than users run on their + laptops would quietly undo the point of the image, so the build refuses other arches. +- **`--no-sandbox`.** Chromium's setuid sandbox needs a root-owned binary and its + namespace sandbox needs unprivileged user namespaces; a non-root container under + Docker's default seccomp has neither. The wall this run relies on is the Firecracker VM + around the whole container, not Chromium's own layer. The flag lives in a named constant + in `runner/renderer_process.py` rather than inside a launch string, on purpose. +- **`needs_browser: false` skips it.** Boot costs roughly 15s and ~500MB of the run's + memory, so a workflow that never opens a page can opt out. Default is on: parity is the + reason this image exists, and opting out should be the thing you have to say. + +If Electron starts but no window ever registers, the run **fails** (exit 7) rather than +proceeding without a browser. A browser workflow that silently ran blind produces a +confident wrong answer, which is worse than no answer. ## The credential rule @@ -62,6 +103,11 @@ An access token that arrives expired fails the run (exit 3). The runner never re PYTHONPATH=.:openswarm-runner backend/.venv/bin/python3 -m pytest openswarm-runner/tests -q ``` +The Electron boot itself needs Linux and a display, so the tests pin the contract around +it (the deep link, the bundle check, the three ways "no window" ends) rather than the +boot. Proving the browser tier actually behaves means running a real page in both places +and comparing; see the parity matrix in the cloud-browser work notes. + ## Deploy Not deployed. `fly.toml` is written but never applied; read its header first, the app diff --git a/openswarm-runner/runner/boot/__init__.py b/openswarm-runner/runner/boot/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openswarm-runner/runner/boot/renderer_process.py b/openswarm-runner/runner/boot/renderer_process.py new file mode 100644 index 00000000..19267da0 --- /dev/null +++ b/openswarm-runner/runner/boot/renderer_process.py @@ -0,0 +1,233 @@ +"""Boot the real Electron shell inside the container so browser tools have a window to drive. + +The browser tier is not an HTTP client: the element serialization and every click, type and +scroll live in the frontend and drive a live Electron ``. There is no way to get +laptop-identical behaviour without the laptop's actual renderer, so this starts one on a +virtual display and points it at the backend that is already running in this container. + +The Electron process runs the same `ELECTRON_DEV=1` path a developer uses (`bash run.sh`): +the shell attaches to an existing backend on OPENSWARM_PORT instead of spawning its own, and +loads whatever OPENSWARM_DEV_URL says. Here that URL is the packaged frontend bundle served +off loopback, deep-linked straight at the run's dashboard so the window registers without a +human clicking anything. +""" + +import functools +import http.server +import logging +import os +import shutil +import socketserver +import subprocess +import threading +import time +from typing import Dict, List, Optional + +import httpx +from pydantic import BaseModel, ConfigDict, InstanceOf +from typeguard import typechecked + +logger = logging.getLogger("runner.renderer") + +HOST = "127.0.0.1" +RENDERER_HEALTH_PATH = "/api/health/renderer" +# Same port the packaged app prefers, so the renderer's origin (and therefore its localStorage) is the one the frontend was built expecting. +FRONTEND_PORT = 4173 +DISPLAY = ":99" +XVFB_SCREEN = "1920x1080x24" +# Cold Electron under a virtual display: Xvfb, Chromium boot, React mount, then the deferred dashboard socket. Measured in the low tens of seconds, so the budget is generous rather than tight. +RENDERER_TIMEOUT_SECONDS = 180.0 +XVFB_READY_TIMEOUT_SECONDS = 20.0 +SHUTDOWN_GRACE_SECONDS = 5.0 + +# Chromium's setuid sandbox needs a root-owned binary and its namespace sandbox needs unprivileged +# user namespaces, neither of which a non-root container under Docker's default seccomp profile has. +# The isolation this run relies on is the Firecracker VM around the whole container, not Chromium's +# own layer. Stated here rather than buried in a launch string, because dropping a sandbox is a +# choice and the reader deserves to see it made. +SANDBOX_FLAGS: List[str] = ["--no-sandbox"] +# /dev/shm defaults to 64MB in a container, which Chromium overruns and then crashes on. +CONTAINER_CHROMIUM_FLAGS: List[str] = ["--disable-dev-shm-usage", "--disable-gpu"] + + +class RendererUnavailable(RuntimeError): + """No Electron window ever registered, so browser tools would be dead this run.""" + + +class RendererProcess(BaseModel): + """The virtual display and the Electron shell drawing into it. Dies with the run.""" + + model_config = ConfigDict(validate_assignment=True) + + xvfb: InstanceOf[subprocess.Popen] + electron: InstanceOf[subprocess.Popen] + url: str + + @typechecked + def is_alive(self) -> bool: + return self.electron.poll() is None and self.xvfb.poll() is None + + +@typechecked +def dashboard_url(port: int, dashboard_id: str) -> str: + """The frontend deep-link that mounts a dashboard directly. HashRouter, so the route is a fragment.""" + return f"http://{HOST}:{port}/index.html#/dashboard/{dashboard_id}" + + +@typechecked +def serve_frontend(frontend_dir: str) -> int: + """Serve the built bundle off loopback in a daemon thread; returns the port it landed on. + + Falls back to an OS-assigned port if 4173 is held, exactly like the packaged shell does. + """ + if not os.path.isfile(os.path.join(frontend_dir, "index.html")): + raise RendererUnavailable( + f"no frontend bundle at {frontend_dir}; the image must be built with frontend/dist in it" + ) + + handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=frontend_dir) + + class p_Server(socketserver.ThreadingTCPServer): + daemon_threads = True + allow_reuse_address = True + + try: + server = p_Server((HOST, FRONTEND_PORT), handler) + except OSError: + server = p_Server((HOST, 0), handler) + port = server.server_address[1] + threading.Thread(target=server.serve_forever, daemon=True, name="frontend-server").start() + logger.info("frontend bundle served from %s on %s:%d", frontend_dir, HOST, port) + return port + + +@typechecked +def p_x_socket_ready(display: str) -> bool: + return os.path.exists(f"/tmp/.X11-unix/X{display.lstrip(':')}") + + +@typechecked +def start_xvfb(display: str = DISPLAY) -> subprocess.Popen: + """Bring up the virtual display and wait for its socket, so Electron never races it.""" + if shutil.which("Xvfb") is None: + raise RendererUnavailable("Xvfb is not installed in this image, so there is no display to draw on") + process = subprocess.Popen( + ["Xvfb", display, "-screen", "0", XVFB_SCREEN, "-nolisten", "tcp"], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + budget = time.monotonic() + XVFB_READY_TIMEOUT_SECONDS + while time.monotonic() < budget: + if process.poll() is not None: + raise RendererUnavailable(f"Xvfb exited immediately with code {process.returncode}") + if p_x_socket_ready(display): + logger.info("virtual display %s up (%s)", display, XVFB_SCREEN) + return process + time.sleep(0.1) + p_stop(process) + raise RendererUnavailable(f"Xvfb never created a socket for {display}") + + +@typechecked +def p_electron_env(backend_port: int, url: str, display: str) -> Dict[str, str]: + environment = dict(os.environ) + # The dev path: attach to the backend already running here rather than spawning a second one, and load the bundle we are serving instead of a webpack dev server. + environment["ELECTRON_DEV"] = "1" + environment["OPENSWARM_DEV_URL"] = url + environment["OPENSWARM_PORT"] = str(backend_port) + environment["DISPLAY"] = display + environment["ELECTRON_DISABLE_SECURITY_WARNINGS"] = "1" + environment.pop("OPENSWARM_PACKAGED", None) + return environment + + +@typechecked +def start_electron(app_root: str, backend_port: int, url: str, display: str = DISPLAY) -> subprocess.Popen: + binary = os.environ.get("ELECTRON_BIN", "/app/electron-runtime/electron") + if not os.path.isfile(binary): + raise RendererUnavailable(f"no Electron binary at {binary}; the image was built without a renderer") + app_dir = os.path.join(app_root, "electron") + command = [binary, app_dir, *SANDBOX_FLAGS, *CONTAINER_CHROMIUM_FLAGS] + logger.info("starting Electron: %s", " ".join(command)) + return subprocess.Popen(command, cwd=app_dir, env=p_electron_env(backend_port, url, display)) + + +@typechecked +def await_registration( + base_url: str, + headers: Dict[str, str], + electron: subprocess.Popen, + deadline: float, +) -> None: + """Block until the backend reports a renderer on its dashboard socket, or raise. + + Polls the backend rather than the Electron process because "the window is up" and "the + window can be driven" are different claims, and only the second one matters. + """ + budget = min(time.monotonic() + RENDERER_TIMEOUT_SECONDS, deadline) + with httpx.Client(timeout=5.0) as client: + while time.monotonic() < budget: + if electron.poll() is not None: + raise RendererUnavailable( + f"Electron exited with code {electron.returncode} before any window registered" + ) + try: + body = client.get(f"{base_url}{RENDERER_HEALTH_PATH}", headers=headers).json() + except (httpx.HTTPError, ValueError): + body = {} + if body.get("attached"): + logger.info("renderer registered (%s dashboard connection(s))", body.get("connections")) + return + time.sleep(0.5) + raise RendererUnavailable( + "Electron started but no renderer ever registered on the dashboard WebSocket within " + f"{RENDERER_TIMEOUT_SECONDS:.0f}s, so browser tools would be dead this run" + ) + + +@typechecked +def start_renderer( + app_root: str, + frontend_dir: str, + backend_base_url: str, + backend_headers: Dict[str, str], + backend_port: int, + dashboard_id: str, + deadline: float, +) -> RendererProcess: + """Display, bundle server, Electron, then block until the window is actually drivable.""" + url = dashboard_url(serve_frontend(frontend_dir), dashboard_id) + xvfb = start_xvfb() + try: + electron = start_electron(app_root, backend_port, url) + except BaseException: + p_stop(xvfb) + raise + try: + await_registration(backend_base_url, backend_headers, electron, deadline) + except BaseException: + p_stop(electron) + p_stop(xvfb) + raise + return RendererProcess(xvfb=xvfb, electron=electron, url=url) + + +@typechecked +def p_stop(process: Optional[subprocess.Popen]) -> None: + if process is None or process.poll() is not None: + return + process.terminate() + try: + process.wait(timeout=SHUTDOWN_GRACE_SECONDS) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=SHUTDOWN_GRACE_SECONDS) + + +@typechecked +def stop_renderer(renderer: Optional[RendererProcess]) -> None: + """Electron first, then the display it was drawing on.""" + if renderer is None: + return + p_stop(renderer.electron) + p_stop(renderer.xvfb) diff --git a/openswarm-runner/runner/main.py b/openswarm-runner/runner/main.py index 411359fb..6c7759cc 100644 --- a/openswarm-runner/runner/main.py +++ b/openswarm-runner/runner/main.py @@ -16,9 +16,10 @@ from typing import Optional from pydantic import BaseModel, ConfigDict from typeguard import typechecked -from runner.backend_process import BackendProcess, BackendUnavailable, start_backend, stop_backend +from runner.boot.backend_process import BackendProcess, BackendUnavailable, start_backend, stop_backend +from runner.boot.renderer_process import RendererProcess, RendererUnavailable, start_renderer, stop_renderer from runner.report import RunReport, send_report -from runner.run_spec import CallbackTarget, InvalidRunSpec, RunSpec, load_run_spec +from runner.run_spec import CLOUD_RUN_DASHBOARD_ID, CallbackTarget, InvalidRunSpec, RunSpec, load_run_spec from runner.seed.data_root import seed_data_root from runner.seed.router_credentials import write_router_db from runner.workflow_run import RunOutcome, RunProgress, WorkflowRunFailed, execute_workflow @@ -30,8 +31,10 @@ EXIT_CREDENTIAL_EXPIRED = 3 EXIT_BACKEND_UNAVAILABLE = 4 EXIT_WORKFLOW_FAILED = 5 EXIT_DEADLINE = 6 +EXIT_RENDERER_UNAVAILABLE = 7 DEFAULT_APP_ROOT = "/app" +DEFAULT_FRONTEND_DIR = "/app/frontend" DEFAULT_DATA_ROOT = "/data/openswarm" DEFAULT_ROUTER_DATA_DIR = "/data/9router" DEFAULT_PORT = 8324 @@ -138,11 +141,24 @@ def p_run(spec: RunSpec, deadline: float) -> int: backend: Optional[BackendProcess] = None process: Optional[subprocess.Popen] = None + renderer: Optional[RendererProcess] = None try: backend = start_backend(app_root, data_root, port, deadline) process = backend.process logger.info("backend healthy at %s", backend.base_url) + if spec.needs_browser: + renderer = start_renderer( + app_root=app_root, + frontend_dir=os.environ.get("OPENSWARM_FRONTEND_DIR", DEFAULT_FRONTEND_DIR), + backend_base_url=backend.base_url, + backend_headers=backend.headers(), + backend_port=port, + dashboard_id=CLOUD_RUN_DASHBOARD_ID, + deadline=deadline, + ) + logger.info("renderer attached at %s, browser tools are live", renderer.url) + send_report(spec.callback, RunReport(run_id=spec.run_id, phase="started", status="running")) heartbeat = Heartbeat( run_id=spec.run_id, @@ -152,9 +168,14 @@ def p_run(spec: RunSpec, deadline: float) -> int: outcome = execute_workflow(backend, spec.workflow.id, deadline, heartbeat.maybe_send) except BackendUnavailable as exc: return p_fail(spec, "failure", str(exc), EXIT_BACKEND_UNAVAILABLE) + except RendererUnavailable as exc: + # Loud, not silent: a browser workflow that quietly ran without a window produces a + # confident wrong answer, which is worse than no answer. + return p_fail(spec, "failure", str(exc), EXIT_RENDERER_UNAVAILABLE) except WorkflowRunFailed as exc: return p_fail(spec, "failure", str(exc), EXIT_WORKFLOW_FAILED) finally: + stop_renderer(renderer) stop_backend(process) code = p_exit_code_for(outcome) diff --git a/openswarm-runner/runner/run_spec.py b/openswarm-runner/runner/run_spec.py index 8a63318f..735b97c0 100644 --- a/openswarm-runner/runner/run_spec.py +++ b/openswarm-runner/runner/run_spec.py @@ -19,6 +19,11 @@ from backend.apps.workflows.models import Workflow SPEC_ENV = "OPENSWARM_RUN_SPEC" SPEC_FILE_ENV = "OPENSWARM_RUN_SPEC_FILE" +# The one dashboard a cloud run has. Fixed rather than generated so the Electron window can be +# deep-linked at it before the backend has even booted, and so a workflow arriving with the +# laptop dashboard id it was authored against gets repointed at a dashboard that exists here. +CLOUD_RUN_DASHBOARD_ID = "cloud-run" + # Headroom the access token must still have on arrival. The control plane refreshes right before dispatch; anything thinner than this means its clock or its queue is broken, and we must not paper over that by refreshing ourselves. MIN_TOKEN_LIFETIME = timedelta(minutes=2) @@ -88,6 +93,10 @@ class RunSpec(BaseModel): callback: Optional[CallbackTarget] = None # Hard wall-clock ceiling. Fly bills by machine-second, so an agent that wedges must cost a bounded amount. max_run_seconds: int = Field(default=1800, ge=60, le=7200) + # Boot Electron under a virtual display so browser steps work. On by default: parity is the + # point of running in a container at all, and a workflow that never touches a browser is the + # exception that should have to say so. Costs a few seconds and a few hundred MB when on. + needs_browser: bool = True @typechecked def expired_credentials(self, now: datetime) -> List[ProviderCredential]: @@ -106,8 +115,13 @@ class RunSpec(BaseModel): A cloud-executed workflow arrives with its schedule still configured. Left enabled, the container's own scheduler would fire it a second time inside the box, so the timer is stripped here rather than trusted to stay off. + + It also arrives pointing at whatever dashboard it was authored on, which does not + exist in this container; left alone, the first browser card would 404 looking for + it. Repointed at the one dashboard this run has. """ copy = self.workflow.model_copy(deep=True) + copy.dashboard_id = CLOUD_RUN_DASHBOARD_ID copy.schedule.enabled = False copy.deleted_at = None copy.draft_steps = None diff --git a/openswarm-runner/runner/seed/data_root.py b/openswarm-runner/runner/seed/data_root.py index 413bf53a..9d8e5979 100644 --- a/openswarm-runner/runner/seed/data_root.py +++ b/openswarm-runner/runner/seed/data_root.py @@ -13,8 +13,9 @@ from typing import Any, Dict from typeguard import typechecked +from backend.apps.dashboards.models import Dashboard from backend.apps.settings.models import AppSettings -from runner.run_spec import RunSpec +from runner.run_spec import CLOUD_RUN_DASHBOARD_ID, RunSpec # 9Router provider id -> the AppSettings field the backend reads a raw key from. API_KEY_SETTINGS_FIELD: Dict[str, str] = { @@ -69,7 +70,12 @@ def settings_for_run(spec: RunSpec) -> AppSettings: @typechecked def seed_data_root(data_root: str, spec: RunSpec) -> None: - """Write the workflow record and the settings file the backend will read at boot.""" + """Write the workflow, settings and dashboard records the backend will read at boot. + + The dashboard exists so the Electron window has somewhere to land and browser cards have + somewhere to render. Writing it here rather than letting the backend's first-boot migration + invent one keeps its id knowable before anything has started. + """ workflow = spec.workflow_for_disk() p_write_json( os.path.join(data_root, "workflows", f"{workflow.id}.json"), @@ -79,3 +85,8 @@ def seed_data_root(data_root: str, spec: RunSpec) -> None: os.path.join(data_root, "settings", "settings.json"), settings_for_run(spec).model_dump(mode="json"), ) + dashboard = Dashboard(id=CLOUD_RUN_DASHBOARD_ID, name=spec.workflow.title or "Cloud run") + p_write_json( + os.path.join(data_root, "dashboards", f"{dashboard.id}.json"), + dashboard.model_dump(mode="json"), + ) diff --git a/openswarm-runner/runner/workflow_run.py b/openswarm-runner/runner/workflow_run.py index 7e71f8b5..b196f1ac 100644 --- a/openswarm-runner/runner/workflow_run.py +++ b/openswarm-runner/runner/workflow_run.py @@ -13,7 +13,7 @@ import httpx from pydantic import BaseModel, ConfigDict, Field from typeguard import typechecked -from runner.backend_process import BackendProcess +from runner.boot.backend_process import BackendProcess TERMINAL_STATUSES = ("success", "failure", "ran_late", "skipped") POLL_INTERVAL_SECONDS = 1.0 diff --git a/openswarm-runner/tests/test_renderer_process.py b/openswarm-runner/tests/test_renderer_process.py new file mode 100644 index 00000000..f3eb79e2 --- /dev/null +++ b/openswarm-runner/tests/test_renderer_process.py @@ -0,0 +1,99 @@ +"""The renderer half: a run that asked for a browser must never quietly proceed without one. + +The Electron boot itself needs a Linux container and a real display, so what is pinned here is +the contract around it: the deep link the window opens on, the bundle check, and the three ways +"no window" is allowed to end (loudly, every time). +""" + +import subprocess + +import pytest + +from runner.boot import renderer_process +from runner.boot.renderer_process import ( + CONTAINER_CHROMIUM_FLAGS, + RendererUnavailable, + SANDBOX_FLAGS, + await_registration, + dashboard_url, + serve_frontend, + start_electron, +) +from runner.run_spec import CLOUD_RUN_DASHBOARD_ID + + +@pytest.fixture +def dead_electron(): + """A real Popen that has already exited; await_registration is typechecked on Popen.""" + process = subprocess.Popen(["/bin/sh", "-c", "exit 9"]) + process.wait() + return process + + +@pytest.fixture +def live_electron(): + """A real Popen that stays up long enough for a poll loop to run against it.""" + process = subprocess.Popen(["/bin/sh", "-c", "sleep 30"]) + yield process + process.kill() + process.wait() + + +def test_the_window_opens_on_the_run_dashboard_not_the_picker() -> None: + # HashRouter, so the route has to be a fragment or the static server 404s on it. + assert dashboard_url(4173, CLOUD_RUN_DASHBOARD_ID) == ( + "http://127.0.0.1:4173/index.html#/dashboard/cloud-run" + ) + + +def test_a_missing_bundle_says_so_instead_of_serving_an_empty_dir(tmp_path) -> None: + with pytest.raises(RendererUnavailable, match="no frontend bundle"): + serve_frontend(str(tmp_path)) + + +def test_a_missing_electron_binary_fails_the_run_rather_than_the_workflow(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("ELECTRON_BIN", str(tmp_path / "nope")) + with pytest.raises(RendererUnavailable, match="built without a renderer"): + start_electron(str(tmp_path), 8324, "http://127.0.0.1:4173/index.html") + + +def test_chromium_is_launched_unsandboxed_on_purpose_and_out_of_shared_memory() -> None: + # Dropping Chromium's own sandbox is a real tradeoff (the Firecracker VM is the wall that's + # left), so it lives in a named constant a reviewer trips over, not inside a launch string. + assert SANDBOX_FLAGS == ["--no-sandbox"] + assert "--disable-dev-shm-usage" in CONTAINER_CHROMIUM_FLAGS + + +def test_a_dead_electron_is_reported_as_dead_not_waited_out(monkeypatch, dead_electron) -> None: + monkeypatch.setattr(renderer_process, "RENDERER_TIMEOUT_SECONDS", 30.0) + with pytest.raises(RendererUnavailable, match="exited with code 9"): + await_registration("http://127.0.0.1:1", {}, dead_electron, deadline=1e9) + + +def test_a_window_that_never_registers_times_out_loudly(monkeypatch, live_electron) -> None: + monkeypatch.setattr(renderer_process, "RENDERER_TIMEOUT_SECONDS", 0.5) + with pytest.raises(RendererUnavailable, match="no renderer ever registered"): + await_registration("http://127.0.0.1:1", {}, live_electron, deadline=1e9) + + +def test_registration_is_believed_only_when_the_backend_says_a_socket_is_attached(monkeypatch, live_electron) -> None: + replies = [{"attached": False, "ever_attached": False, "connections": 0}, + {"attached": True, "ever_attached": True, "connections": 1}] + + class p_Response: + def json(self): + return replies.pop(0) + + class p_Client: + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def get(self, url, headers=None): + return p_Response() + + monkeypatch.setattr(renderer_process.httpx, "Client", lambda **kw: p_Client()) + await_registration("http://127.0.0.1:1", {}, live_electron, deadline=1e9) + assert replies == []