Skip to content

Browser Manager & Guest Runtime

dazeb edited this page Sep 17, 2026 · 2 revisions

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.

Runtime mechanism

There are five distinct moments in a browser guest's life, and they are enforced by different parts of the manager:

  1. Process boot, before any webview exists. installBrowserSecurity() registers a single app.on('web-contents-created') listener. The module comment requires this to run once, at module top level in index.ts, and to run before any webview is created — otherwise a guest could be attached before the hook exists.
  2. Attach time, while preferences are still mutable. For every created webContents, the boot hook installs a will-attach-webview handler that calls sanitizeAttachedWebview(webPreferences). This is the only point where a preload path can be deleted and isolation flags forced, because by the time web-contents-created fires for the guest, its webPreferences are already baked. The sanitizer strips preload and forces nodeIntegration: false, contextIsolation: true, sandbox: true, webSecurity: true, allowRunningInsecureContent: false, experimentalFeatures: false. The hook is attached to every webContents, not just recognized parents, so a stray <webview> anywhere in the app is still locked down.
  3. Guest creation. If the created content's type is webview, it is passed to secureBrowserContents(), which installs the navigation guards and window-open policy that live for the guest's whole life.
  4. 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 transient webContents id. Teardown calls unregisterBrowserGuest.
  5. 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) queries browserGuestIds() to enumerate driveable guests and authenticates with the per-boot token from browserRuntime.
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)"]
Loading

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.

File responsibilities

src/main/browser/manager.ts

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 guard for will-navigate and will-redirect that calls event.preventDefault() when isAllowedNavUrl(url) is false (from src/core/browser-policy);
  • a setWindowOpenHandler that denies when allowPopups is false or the target URL is not allowed, and otherwise allows a popup window with fixed dimensions (600×760), autoHideMenuBar, a parent resolved via BrowserWindow.fromWebContents(contents.hostWebContents ?? contents), and a hardened webPreferences that reuses browserSession while setting nodeIntegration: false, contextIsolation: true, sandbox: true, webSecurity: true, allowRunningInsecureContent: false, webviewTag: false;
  • a did-create-window listener that recursively calls secureBrowserContents(popup.webContents, false) — so popups of popups cannot spawn more windows — ties popup closure to the parent's destroyed event, 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/destroyed webContents (in that case the stale entry is also deleted);
  • { ok: false, reason: 'DENIED' } when isAllowedNavUrl(url) rejects the URL;
  • { ok: true } after contents.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.

src/main/browser/runtime.ts

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:

  • port is permanently 0 and exists only so browser:cdp-info consumers that expect a port field keep type-checking. This module must never put --remote-debugging-port on Chromium's command line.
  • token is regenerated per boot and is the credential startCdpFacade requires; the only CDP surface is the facade, which proxies guests only.
  • wsUrl starts 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.

How the two modules collaborate

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.

Key state and invariants

  • guests is process-lifetime, in-memory, non-persisted, and keyed by nodeId::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, no webviewTag.
  • Popups share the embedded browser profile (contents.session) and never the app bridge; popups cannot open further popups.
  • browserRuntime.port === 0 at all times; the token changes every boot.
  • Policy decisions all reduce to isAllowedNavUrl() in src/core/browser-policy — the same predicate gates will-navigate, will-redirect, the window-open handler, and navigateBrowserNode.

Boundaries, failure modes and extension points

Edge cases to preserve when editing:

  • Navigation failure ≠ denial. navigateBrowserNode returns { ok: true } on loadURL rejection. If you change this, every caller that distinguishes "blocked by policy" from "site is down" must be updated.
  • Stale ids. Never cache a WebContents object across calls; resolve by id each time, as both navigateBrowserNode and browserGuestIds do.
  • Legacy tabless nodes. guestIdForNode(nodeId) without a tabId returns 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 explicit unregisterBrowserGuest, 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.

Limitations of this page

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

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally