Skip to content

Browser Navigation Policy & Node UI

dazeb edited this page Sep 17, 2026 · 2 revisions

Browser Navigation Policy & Node UI

This page covers two cooperating halves of the embedded browser:

  1. The policy layer — a pure, Electron-free module that answers "may this URL be loaded / navigated to at all?" (src/core/browser-policy.ts). It is the single place where the allow-list (http/https without credentials, plus the two about: start pages) and the deny-list (privileged/app schemes) live.
  2. The node layer — the React Flow node that creates one hardened <webview> guest per tab, wires guest events into workspace persistence, and hosts the tab/address UI (src/renderer/src/nodes/BrowserNode.tsx), with the configurable home page injected through a module-level store (src/renderer/src/state/browser-home.ts).

The main-process code that attaches the policy to live guest webContents (installBrowserSecurity(), the navigation filter, window.termsprawl.browser.register handler, and the CDP page targets) is out of scope here — see Browser Manager & Guest Runtime and CDP Facade & Browser Agent Server. This page relies on the contract those layers expose, not on their internals.

1. Runtime mechanics

1.1 Two entry shapes into the same policy

The policy module deliberately exposes two different doors:

  • isAllowedNavUrl(raw) (src/core/browser-policy.ts:23-40) is the allow-list check for an already-formed URL. It parses with new URL, lets through exactly about:blank / about:srcdoc (string equality, not prefix), and lets through http: / https: only when url.username and url.password are both empty. Everything else — including parse failures, file:, javascript:, blob: — is false. Starting from an allow-list means a scheme nobody thought about is denied by default.
  • isDeniedScheme(protocol) (:43-45) is the deny-list classifier used by the navigation filter. It lowercases the protocol and checks BLOCKED_SCHEMES (:9-18): file, termsprawl-file, javascript, data, devtools, chrome, vbscript, about. about is on the list precisely because only the two literal start pages are legal; any other about: route must be denied. It exists for callers that only have a protocol string and should not have to re-derive the whole URL decision.
  • normalizeAddress(input) (:53-63) is the address-bar/agent entry door. It trims, returns null for empty input, passes the two about: literals through, maps literal localhost / localhost:PORT to http://, prefixes https:// on anything without a scheme, and finally re-validates through isAllowedNavUrl. Junk returns null, never throws.

The localhost branch must run before the scheme regex (:57-60) because localhost:3000 looks like a URL with scheme localhost:; without the branch it would be rejected. Note the special case is literal lowercase localhost only — a bare 127.0.0.1:8080 falls through to the https:// prefix and is then allowed as https by the policy, so it will load (and fail TLS if the port is plain HTTP). That is the kind of behavior anyone editing address normalization needs to keep in mind.

flowchart TD
  IN["address string"] --> T{"trim() empty?"}
  T -->|yes| NULL["null - do not navigate"]
  T -->|no| AB1{"exactly about:blank / about:srcdoc?"}
  AB1 -->|yes| OK["return as-is"]
  AB1 -->|no| LH{"literal localhost or localhost:PORT?"}
  LH -->|yes| H1["prefix http://"]
  LH -->|no| SCH{"matches scheme regex?"}
  SCH -->|yes| K1["keep as typed"]
  SCH -->|no| H2["prefix https://"]
  H1 --> CHK["isAllowedNavUrl(...)"]
  K1 --> CHK
  H2 --> CHK
  CHK --> P{"new URL parses?"}
  P -->|no| NULL
  P -->|yes| AB2{"raw is about:blank / about:srcdoc?"}
  AB2 -->|yes| OK
  AB2 -->|no| S{"protocol http: or https:?"}
  S -->|no| NULL
  S -->|yes| C{"username/password present?"}
  C -->|yes| NULL
  C -->|no| LOAD["allowed - guest may load"]
Loading

Key nodes: the first about: test is an early pass-through that avoids the localhost/scheme heuristics entirely; the credential check is the "belt and braces" rule from the module comment (a hijacked page must never hand the browser a user:pass@ URL). The deny-list is not consulted on this path — the allow-list already excludes every blocked scheme.

1.2 Guest creation, hardening and registration

BrowserNode builds guests imperatively with document.createElement('webview') (src/renderer/src/nodes/BrowserNode.tsx:156-228) rather than JSX, to sidestep intrinsic typing. Each guest gets:

  • partition="persist:termsprawl-browser" — an isolated persistent profile, so cookies and logins survive app restarts and are shared across tabs of the app (not the default session).
  • webpreferences="contextIsolation=yes, sandbox=yes, nodeIntegration=no" — no Node in the guest.
  • no preload attribute at all.
  • allowpopups — the guest may call window.open; what happens to that request is decided in main, outside this excerpt.
  • width/height: 100%, dataset.nodeId / dataset.tabId for DOM-level identification, and display: none for non-active tabs.

The component comment (:59-65) is explicit that these renderer-side attributes are not the trust boundary: installBrowserSecurity() in main re-forces the §1.1 policy and the webPreferences at attach time. Treat the attributes here as defense in depth; the security guarantee is the main-process attach path plus the pure policy module.

On dom-ready the node reads getWebContentsId() and calls window.termsprawl.browser.register(nodeId, tabId, guestId) (:175-188), which is how the rest of the app (CDP facade, agent tooling) maps a live guest to a node/tab. Mirror of that call exists on unmount.

1.3 The display invariant that keeps the guest visible

WEBVIEW_VISIBLE_DISPLAY = 'flex' (:57) is load-bearing. A <webview> guest is sized by Electron's own flex layout: forcing the host element to display: block lets the element honor height: 100% while the guest stays at Chromium's 150px default viewport, leaving the rest of the node showing the host's black background. Hidden tabs use display: none; everything else must stay flex/inline-flex, and the styles.css webview rule must be kept in sync. The comment records the Electron 43.4 repro and why the bug was invisible at the older 320×240 node size.

1.4 Navigation bookkeeping and persistence

did-navigate, did-navigate-in-page, and page-title-updated all funnel into onDidNavigate (:190-217):

  1. Read the guest URL and compare with the stored tab URL.
  2. If unchanged, this is a title-only event — refresh canBack/canForward only, no tab rewrite, no project write.
  3. If changed, compute the next tab array with setBrowserTabUrl, append to historyRef.current with pushBrowserHistory (most recent first), setTabs, then persist(next, active, activeTabUrl).
  4. Only when the navigating tab is the active one does it touch the toolbar state (address, canBack, canForward).

persist() (:128-139) writes { url, tabs, activeTabId, history } back through updateNodeData(id, …, false) from the canvas context, so tabs, the active tab, the node-level URL, and history survive a reload. Background tabs still persist their URL — the active-tab guard applies only to toolbar state. The trailing false argument's semantics (undo/history participation) are defined by updateNodeData in the workspace store.

1.5 Tab model, legacy nodes and home state

BrowserTab is { id, url }. Persisted node data carries url, tabs, activeTabId, history. Two compatibility behaviors matter:

  • Pre-13.4 nodes with no tabs are synthesized into a single tab from data.url (:73-75).
  • A stored activeTabId that no longer exists in the tab list falls back to the first tab (:77-81).

initialTabsRef is captured once per mount, and the mount effect (:232-260) spawns guests only for that initial set. Later tab creation must also go through spawnWebview — that call site is in the part of the file not included in this page's excerpt.

The home/start URL lives in useBrowserHome (src/renderer/src/state/browser-home.ts): a zustand store with homeUrl: string | undefined (undefined = app default) and setHomeUrl. It exists at module level because React Flow custom nodes only receive NodeProps; App.tsx syncs it from app settings, and BrowserNode + the canvas read it when a node or tab is created. The store itself performs no validation — any writer must produce a loadable URL, and the only loadability check in this area is isAllowedNavUrl / normalizeAddress. New tabs read resolveHomeUrl with DEFAULT_BROWSER_URL as the fallback, both imported from the workspace module.

1.6 Drag-vs-interact overlay and crash state

Because the guest swallows mouse events, a transparent body overlay is armed by default (press = drag the node) and disarms after a BODY_DWELL_MS = 350 ms hover dwell so the page becomes interactive; leaving the node re-arms it (:87-116). This mirrors the terminal node's "drag = move, dwell = focus" pattern; the actual overlay JSX lives beyond the excerpt. Resizing uses NodeResizer from @reactflow/node-resizer.

render-process-gone sets crashed only if the dead tab is the active one (:219-221) — there is no per-tab crash map, so a background tab can die silently in the current design.

1.7 Teardown

Unmount (:236-258) unregisters every known guest through the bridge, then calls stop() and remove() inside individual try/catch blocks. The comment is explicit about why: React Flow detaches the node's DOM subtree before passive unmount effects run, so stop() throws ("The WebView must be attached to the DOM…") and an uncaught throw tears down the whole React tree. Every teardown step must be best-effort and must never propagate. Note also that unregister(...) explicitly swallows rejections while the dom-ready register(...) call is merely voided.

sequenceDiagram
  participant RN as BrowserNode
  participant WV as webview guest
  participant PB as preload bridge (window.termsprawl.browser)
  participant WS as workspace store
  RN->>WV: create + set partition/webpreferences/src, append to host
  WV-->>RN: dom-ready
  RN->>WV: getWebContentsId()
  RN->>PB: browser.register(nodeId, tabId, guestId)
  RN->>WV: insertCSS(hide scrollbars)
  WV-->>RN: did-navigate / did-navigate-in-page / page-title-updated
  RN->>WS: setBrowserTabUrl + pushBrowserHistory
  RN->>WS: updateNodeData({url, tabs, activeTabId, history}, false)
  WV-->>RN: render-process-gone
  RN->>RN: setCrashed(true) when tab is active
  RN->>PB: browser.unregister(nodeId, tabId) on unmount
  RN->>WV: stop() / remove() (best-effort, never throw)
Loading

The registration step is the handoff point between this page and the main-process runtime: before it, main knows nothing about the guest; after it, the guest is addressable by node and tab id (and appears as its own page target for the CDP surface).

2. File responsibilities and how they fit together

src/core/browser-policy.ts — owns the vocabulary of what is loadable. Pure, Electron-free, no imports, so it is unit-testable and reusable by the Server Edition (per the header comment). It is the only place BLOCKED_SCHEMES, the two legal about: literals, and the address-normalization heuristics may be edited.

src/renderer/src/nodes/BrowserNode.tsx — owns the guest DOM elements and their lifecycle: spawn per tab, register/unregister with main, mirror guest URL/back/forward state into React state and into updateNodeData, keep the node's tab array and history, render the tab strip / address bar / resize handle / drag overlay, and degrade legacy nodes to a single tab. It is the only component that knows about WebviewElement and the flex-display invariant.

src/renderer/src/state/browser-home.ts — a tiny global slot that decouples the settings UI from React Flow's prop-only node interface. It contains state only; policy and persistence stay elsewhere.

They collaborate through three seams (all of which are outside these three files):

flowchart LR
  Settings[App settings] -->|setHomeUrl| Home[browser-home store]
  Home -->|useBrowserHome| Node[BrowserNode]
  Home -->|useBrowserHome| Canvas[Canvas: node creation]
  Node -->|updateNodeData| WS[workspace store: node.data url/tabs/history]
  Node -->|browser.register / unregister| Bridge[preload bridge]
  Bridge --> Main[main process browser security + nav filter]
  Policy[browser-policy] --> Main
Loading
  • Workspace state supplies the shared BrowserTab/BrowserNodeData types and the pure helpers used here: setBrowserTabUrl, pushBrowserHistory, nextBrowserTabId, resolveHomeUrl, DEFAULT_BROWSER_URL, plus the tab-strip helpers addBrowserTab, closeBrowserTab, activateBrowserTab, browserTitle.
  • Canvas context (useCanvas) supplies updateNodeData and closeNode — the node never writes the project file directly.
  • Preload bridge (window.termsprawl.browser.register / unregister) is the only channel from guest lifecycle to main; see Preload Bridge & IPC Contract before adding anything else to it.
  • Main process enforces hardening and the navigation policy; see Browser Manager & Guest Runtime and CDP Facade & Browser Agent Server.

3. State ownership

State Lives in Lifetime Notes
data.url, data.tabs, data.activeTabId, data.history node data in the workspace store persisted with the project written only via persist()
tabs, activeTabId React state in BrowserNode node mount seeded from node data, mirrored into tabsRef/activeTabIdRef for event closures
address, canBack, canForward, guestId, crashed React state node mount active-tab only; refreshed by syncToolbar() and navigation events
webviewsRef: Map<tabId, WebviewElement> ref node mount live guest handles; removing the element destroys the guest
historyRef ref node mount most-recent-first URL list, persisted through persist()
bodyDragArmed, dwellTimer React state + ref node mount drag-vs-interact overlay, 350 ms dwell
homeUrl useBrowserHome store app session raw, unvalidated string; undefined = app default

4. Boundary conditions and invariants to preserve

  • Only http:/https: without embedded credentials and exactly about:blank / about:srcdoc pass isAllowedNavUrl. about: is also on the deny-list, so anything else under about: is denied twice over.
  • isDeniedScheme is case-insensitive; normalizeAddress may return null and every caller must handle it.
  • The localhost special case is literal lowercase only, and it takes precedence over scheme detection (localhost:3000 must not be parsed as scheme localhost:).
  • Unchanged-URL navigation events must not rewrite the project file — the title-only fast path is intentional.
  • The webview display must stay flex/inline-flex for visible tabs; styles.css must stay in sync.
  • Teardown must be best-effort: an exception in stop()/remove() crashes the whole renderer.
  • Non-active tabs are spawned hidden but still load and still persist their URL changes; only toolbar state and the crash flag are active-tab-scoped.
  • browser-home is a raw slot: validate on write and on use, with normalizeAddress/isAllowedNavUrl.

5. Extension points

  • New allowed scheme (e.g. a custom internal page): change both the allow branch of isAllowedNavUrl and BLOCKED_SCHEMES, because the navigation filter may only have a protocol string. Add cases to the policy tests rather than special-casing in the node.
  • Address-bar heuristics (search fallback, http for LAN hosts): normalizeAddress is the single choke point and its doc comment already covers user- and agent-entered addresses.
  • Per-tab capability (zoom, mute, find-in-page): extend the WebviewElement interface, the BrowserTab shape in the workspace module, and the persist path together, so the feature survives reload.
  • New-tab behavior: tab creation flows through the workspace tab helpers plus spawnWebview; resolveHomeUrl/DEFAULT_BROWSER_URL (and therefore useBrowserHome) decide the start URL.
  • Making guests visible to other subsystems: window.termsprawl.browser.register/unregister is the mapping point; any new consumer should hang off that id pair instead of scanning the DOM.

6. Limits of the evidence on this page

  • The BrowserNode.tsx excerpt covers lines 1–260; the tab strip, address bar, resize handle and overlay markup below that line are not shown, so which handlers call addBrowserTab / closeBrowserTab / activateBrowserTab / browserTitle and where new tabs are spawned is unverified here.
  • The workspace helpers (setBrowserTabUrl, pushBrowserHistory, nextBrowserTabId, resolveHomeUrl, DEFAULT_BROWSER_URL) are referenced by import; their implementations live in src/renderer/src/state/workspace and are not part of this excerpt.
  • installBrowserSecurity(), the navigation filter that consumes isDeniedScheme, the browser.register handler, and any guest permission-request handling are referenced from comments but live in the main process; the provided files contain navigation policy only, not a media/notification permission policy.
  • The semantics of the third argument passed to updateNodeData(id, data, false) are defined by the canvas/workspace store, not here.

Sources: src/core/browser-policy.ts, src/renderer/src/nodes/BrowserNode.tsx, src/renderer/src/state/browser-home.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