-
Notifications
You must be signed in to change notification settings - Fork 0
Browser Manager & Guest Runtime
A browser node is not an iframe and not a simulated browser: it is a real Chromium webContents guest created by a <webview> element in the canvas, rendered inline next to terminal/sticky/editor nodes. Because the host app is itself Electron, every guest is a potential confused-deputy attack surface — a page loaded inside a node must never be able to reach window.termsprawl or Node. src/main/browser/manager.ts is the main-process enforcement point for that property, and src/main/browser/runtime.ts is the (now deliberately empty) Chromium debugging surface that the rest of the system must not re-open.
There are five distinct moments in a browser guest's life, and they are enforced by different parts of the manager:
-
Process boot, before any webview exists.
installBrowserSecurity()registers a singleapp.on('web-contents-created')listener. The module comment requires this to run once, at module top level inindex.ts, and to run before any webview is created — otherwise a guest could be attached before the hook exists. -
Attach time, while preferences are still mutable. For every created
webContents, the boot hook installs awill-attach-webviewhandler that callssanitizeAttachedWebview(webPreferences). This is the only point where a preload path can be deleted and isolation flags forced, because by the timeweb-contents-createdfires for the guest, itswebPreferencesare already baked. The sanitizer stripspreloadand forcesnodeIntegration: false,contextIsolation: true,sandbox: true,webSecurity: true,allowRunningInsecureContent: false,experimentalFeatures: false. The hook is attached to everywebContents, not just recognized parents, so a stray<webview>anywhere in the app is still locked down. -
Guest creation. If the created content's type is
webview, it is passed tosecureBrowserContents(), which installs the navigation guards and window-open policy that live for the guest's whole life. -
Registration. The renderer (or whatever creates the guest) calls
registerBrowserGuest(nodeId, tabId, guestId)so the main process can route commands by stable canvas id instead of by transientwebContentsid. Teardown callsunregisterBrowserGuest. -
Steady state: navigation and CDP. All navigation funnels through
navigateBrowserNode()so the policy is checked in one place. The CDP facade (src/main/browser/cdp-facade.ts) queriesbrowserGuestIds()to enumerate driveable guests and authenticates with the per-boot token frombrowserRuntime.
flowchart TD
A["installBrowserSecurity() at boot"] --> B["app 'web-contents-created'"]
B --> C{"contents.getType() == 'webview'?"}
C -->|"any webContents"| D["will-attach-webview -> sanitizeAttachedWebview()"]
C -->|"yes"| E["secureBrowserContents(guest)"]
E --> F["will-navigate / will-redirect guard"]
F --> G{"isAllowedNavUrl(url)?"}
G -->|"no"| H["event.preventDefault()"]
E --> I["setWindowOpenHandler"]
I --> J{"allowPopups AND allowed url?"}
J -->|"no"| K["deny"]
J -->|"yes"| L["allow hardened popup sharing contents.session"]
L --> M["did-create-window -> secureBrowserContents(popup, false)"]
M --> N["popup close -> flush cookies, remove parent listener"]
D --> O["registerBrowserGuest(nodeId, tabId, guestId)"]
O --> P[("guests: 'nodeId::tabId' -> guestId")]
P --> Q["navigateBrowserNode()"]
Q --> R{"entry + webContents alive?"}
R -->|"no"| S["reap entry -> UNKNOWN_NODE"]
R -->|"yes"| T{"isAllowedNavUrl(url)?"}
T -->|"no"| U["DENIED"]
T -->|"yes"| V["contents.loadURL(url)"]
P --> W["browserGuestIds() reaps dead ids"]
W --> X["CDP facade (token-gated)"]
Key nodes: the sanitizer (D) is the only place prefs are rewritten, and it runs for every webContents; the dark path (E→H) is what keeps a guest from navigating to file:, data:, or other non-web origins; the registry (P) is the single routing table both the toolbar IPC path and the CDP facade read; W is the lazy garbage collector that keeps the map from advertising crashed guests as driveable.
This is the whole policy + bookkeeping layer; it is not split by concern, so a change to guest security and a change to node routing both land here.
Process-wide hooks — installBrowserSecurity(). Registers the web-contents-created listener described above. Exported and side-effect-free until called; the call site is expected to be index.ts.
Preference sanitization — sanitizeAttachedWebview(). Module-private, so the only supported way to apply it is through installBrowserSecurity(). Mutates the Electron.WebPreferences object in place.
Per-content hardening — secureBrowserContents(contents, allowPopups = true). Exported because it is reusable, not just for webviews. It captures contents.session once as browserSession, then installs:
- a shared
guardforwill-navigateandwill-redirectthat callsevent.preventDefault()whenisAllowedNavUrl(url)is false (fromsrc/core/browser-policy); - a
setWindowOpenHandlerthat denies whenallowPopupsis false or the target URL is not allowed, and otherwise allows a popup window with fixed dimensions (600×760),autoHideMenuBar, a parent resolved viaBrowserWindow.fromWebContents(contents.hostWebContents ?? contents), and a hardenedwebPreferencesthat reusesbrowserSessionwhile settingnodeIntegration: false,contextIsolation: true,sandbox: true,webSecurity: true,allowRunningInsecureContent: false,webviewTag: false; - a
did-create-windowlistener that recursively callssecureBrowserContents(popup.webContents, false)— so popups of popups cannot spawn more windows — ties popup closure to the parent'sdestroyedevent, and flushes the shared session's cookie store when the popup closes.
Registry state. A module-level Map<string, number> of 'nodeId::tabId' → guest webContents id. Values are not WebContents objects, so every consumer must re-resolve via webContents.fromId(); this is deliberate (see the stale-id handling in navigateBrowserNode). guestKey() is the only place the key format is constructed. guestIdForNode(nodeId, tabId?) supports the legacy case where a browser node has no persisted tab id yet and returns the first key with the nodeId:: prefix. browserGuestIds() is both the read API and the reaper: it probes webContents.fromId(guestId) for each entry, verifies !isDestroyed(), and deletes entries that fail — this is why the CDP facade never lists a crashed guest.
Navigation entry point — navigateBrowserNode(nodeId, tabId, url). Returns a discriminated result so callers can distinguish a routing failure from a policy denial:
-
{ ok: false, reason: 'UNKNOWN_NODE' }when the map has no entry, or when the id resolves to a missing/destroyedwebContents(in that case the stale entry is also deleted); -
{ ok: false, reason: 'DENIED' }whenisAllowedNavUrl(url)rejects the URL; -
{ ok: true }aftercontents.loadURL(url), including when the load throws — the catch treats host-unreachable/TLS failures as transport errors, not policy denials, so a caller must not infer "blocked" from a rejected navigation.
Note the ordering: node existence is checked before policy, so an unknown node always reports UNKNOWN_NODE even for a disallowed URL.
A tiny, security-critical module. It exports one mutable object:
browserRuntime = { port: 0, token: randomBytes(24).toString('hex'), wsUrl: '' }Its header documents an audit (2026-09-06) in which a raw Chromium --remote-debugging-port exposed the main window — with the full preload bridge — to any local process on an unauthenticated random port, giving arbitrary file read/write and shell RCE. The raw port was removed. The invariants that follow:
-
portis permanently0and exists only sobrowser:cdp-infoconsumers that expect aportfield keep type-checking. This module must never put--remote-debugging-porton Chromium's command line. -
tokenis regenerated per boot and is the credentialstartCdpFacaderequires; the only CDP surface is the facade, which proxies guests only. -
wsUrlstarts empty and is populated by the facade, not by this module.
runtime.ts has no imports beyond node:crypto and no Electron dependency, so it is safe to read from anywhere in the main process.
They meet at the CDP boundary, not at guest creation. manager.ts owns which guests exist and what they may do; runtime.ts owns whether anyone outside the process can attach to them, and with what credential. browserGuestIds() deliberately reaps dead entries at the moment the facade asks for targets, which means the facade's list is derived from the same registry the renderer populates — there is no second source of truth to drift. A guest hidden from the map is invisible to CDP even if its webContents is still alive; a guest in the map that has crashed is removed on the next enumeration.
The renderer side is intentionally thin: it creates/destroys the <webview> element and tells main the guest's ids. Guest teardown is driven by DOM removal — Electron ties a guest's lifetime to its embedder, and there is no main-side destroy API for guests — so unregisterBrowserGuest() only deletes the map entry; it must be called on tab close or the map accumulates dead ids until browserGuestIds() or a navigation reaps them.
-
guestsis process-lifetime, in-memory, non-persisted, and keyed bynodeId::tabId. A browser node can hold several tabs, each with its own guest. - Guests always run with: no preload, no
nodeIntegration,contextIsolation,sandbox,webSecurity, no insecure-content allowance, no experimental features, nowebviewTag. - Popups share the embedded browser profile (
contents.session) and never the app bridge; popups cannot open further popups. -
browserRuntime.port === 0at all times; the token changes every boot. - Policy decisions all reduce to
isAllowedNavUrl()insrc/core/browser-policy— the same predicate gateswill-navigate,will-redirect, the window-open handler, andnavigateBrowserNode.
Edge cases to preserve when editing:
-
Navigation failure ≠ denial.
navigateBrowserNodereturns{ ok: true }onloadURLrejection. If you change this, every caller that distinguishes "blocked by policy" from "site is down" must be updated. -
Stale ids. Never cache a
WebContentsobject across calls; resolve by id each time, as bothnavigateBrowserNodeandbrowserGuestIdsdo. -
Legacy tabless nodes.
guestIdForNode(nodeId)without atabIdreturns an arbitrary matching guest. Keep the key format stable or this fallback silently breaks. -
Renderer-driven teardown. Main cannot destroy a guest. Any new lifecycle feature must go through the renderer's
<webview>removal plus an explicitunregisterBrowserGuest, and must tolerate a crash-before-unregister race. -
Policy is centralized. Adding a blocked scheme or host belongs in
isAllowedNavUrl, not in additional guards here; three call sites already share it. -
Do not add a debug port. New external-control capability belongs behind the facade and the
browserRuntime.token.
Natural extension points: a new main-side command for a node should follow the navigateBrowserNode shape (key lookup → live-content resolution → reap on failure → typed result); a new hardened window type should reuse secureBrowserContents (that is why it is exported); a new external automation surface should extend src/main/browser/cdp-facade.ts rather than runtime.ts, using browserGuestIds() as the target list.
The two source excerpts cover the main-process manager and the CDP runtime only. The page description also names bounds syncing between the guest view and its canvas node, the renderer-side browser node component, the IPC handlers that call registerBrowserGuest / unregisterBrowserGuest / navigateBrowserNode and answer browser:cdp-info, and any explicit per-tab session partitioning. None of those appear in the provided files, so they are described here only as referenced interfaces. In particular, the only session evidence available is that popups inherit contents.session; no partition string or per-tab partitioning policy is visible in these excerpts, and the legacy no-tabId fallback in guestIdForNode suggests tab identity was added after browser nodes shipped. Treat the session-isolation story as incomplete until the renderer node component and the IPC layer are read.
Sources: src/main/browser/manager.ts, src/main/browser/runtime.ts
Generated from termsprawl at 0d4393be54c6200beedd91bb636e5296c30472c5.
App Shell & Platform Foundations
- Electron Main Process & Window Lifecycle
- Preload Bridge & IPC Contract
- Shared Domain Types and File/URL Helpers
- Renderer Bootstrap & App Composition
- Build Targets & TypeScript Configuration
Canvas, Nodes & Renderer State
- Infinite Canvas Surface & Viewport Interaction
- Workspace, Project & Tab State
- Node Links, Edges & Link Inspector
- Sticky, Group, Editor & Diff Nodes
- Keyboard Canvas Navigation & Cross-Panel Requests
- Theme, Accent & Visual Language
- Boot Overlay, Onboarding & Shared UI Kit
Terminals & Session Continuity
- PTY Lifecycle & Terminal Sessions
- tmux Session Naming & Reattach
- Scrollback Snapshots & Cold Replay
- Terminal Node Rendering (xterm.js)
- SSH Remote Projects, Terminals & Files
Persistence, Projects & Files
- Workspace Store & Project File Layout
- Project Scope, Deletion & Worktree Registry
- Workspace Bundle Export/Import
- File Service & File Tree UI
Agent Runtime & Tooling
- Agent Status Model & Hook Normalization
- Hook Server & CLI Hook Installers
- Agent Launch, CLI Probing & Managed Accounts
- Agent Tool Protocol & In-Process Server
- Agent Tool Client, CLI & MCP Entry
- Transcripts, Context Discovery & Context CLI
- Agent Canvas State & Status Badges
Chat Nodes & Model Providers
- Chat Runtime, Conversation & Cost
- Model Provider Adapters & Streaming
- Chat Tool Calling & Project Tools
- Chat Node UI
Git & Source Control
Embedded Browser Nodes
- Browser Manager & Guest Runtime
- CDP Facade & Browser Agent Server
- Browser Navigation Policy & Node UI
Server Edition
- Server Bootstrap & HTTP/WebSocket Entry
- RPC Dispatch, Handlers & Service Bridges
- Renderer Shim & Server Boundary
- Server Auth & Security Boundary
Relay & Remote Access
- Relay Hub & WebSocket Frame Routing
- Relay End-to-End Cryptography
- Relay Auth, Invites, Store & Admin API
- Relay Client, Pairing & Terminal Tunneling
- Relay Trust UI
Integrations & Secondary Surfaces
- Telegram Bot, Commands & Pairing
- A2A Peers: Protocol, Client & Server
- Node Link Engine, Registry & Scheduler
- Cloud Spaces, Snapshots & Sync
Settings, Updates & Maintenance