-
Notifications
You must be signed in to change notification settings - Fork 0
Browser Navigation Policy & Node UI
This page covers two cooperating halves of the embedded browser:
-
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 twoabout:start pages) and the deny-list (privileged/app schemes) live. -
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.
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 withnew URL, lets through exactlyabout:blank/about:srcdoc(string equality, not prefix), and lets throughhttp:/https:only whenurl.usernameandurl.passwordare both empty. Everything else — including parse failures,file:,javascript:,blob:— isfalse. 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 checksBLOCKED_SCHEMES(:9-18):file,termsprawl-file,javascript,data,devtools,chrome,vbscript,about.aboutis on the list precisely because only the two literal start pages are legal; any otherabout: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, returnsnullfor empty input, passes the twoabout:literals through, maps literallocalhost/localhost:PORTtohttp://, prefixeshttps://on anything without a scheme, and finally re-validates throughisAllowedNavUrl. Junk returnsnull, 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"]
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.
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
preloadattribute at all. -
allowpopups— the guest may callwindow.open; what happens to that request is decided in main, outside this excerpt. -
width/height: 100%,dataset.nodeId/dataset.tabIdfor DOM-level identification, anddisplay: nonefor 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.
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.
did-navigate, did-navigate-in-page, and page-title-updated all funnel into onDidNavigate (:190-217):
- Read the guest URL and compare with the stored tab URL.
- If unchanged, this is a title-only event — refresh
canBack/canForwardonly, no tab rewrite, no project write. - If changed, compute the next tab array with
setBrowserTabUrl, append tohistoryRef.currentwithpushBrowserHistory(most recent first),setTabs, thenpersist(next, active, activeTabUrl). - 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.
BrowserTab is { id, url }. Persisted node data carries url, tabs, activeTabId, history. Two compatibility behaviors matter:
- Pre-13.4 nodes with no
tabsare synthesized into a single tab fromdata.url(:73-75). - A stored
activeTabIdthat 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.
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.
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)
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).
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
-
Workspace state supplies the shared
BrowserTab/BrowserNodeDatatypes and the pure helpers used here:setBrowserTabUrl,pushBrowserHistory,nextBrowserTabId,resolveHomeUrl,DEFAULT_BROWSER_URL, plus the tab-strip helpersaddBrowserTab,closeBrowserTab,activateBrowserTab,browserTitle. -
Canvas context (
useCanvas) suppliesupdateNodeDataandcloseNode— 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.
| 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 |
- Only
http:/https:without embedded credentials and exactlyabout:blank/about:srcdocpassisAllowedNavUrl.about:is also on the deny-list, so anything else underabout:is denied twice over. -
isDeniedSchemeis case-insensitive;normalizeAddressmay returnnulland every caller must handle it. - The
localhostspecial case is literal lowercase only, and it takes precedence over scheme detection (localhost:3000must not be parsed as schemelocalhost:). - Unchanged-URL navigation events must not rewrite the project file — the title-only fast path is intentional.
- The webview
displaymust stayflex/inline-flexfor visible tabs;styles.cssmust 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-homeis a raw slot: validate on write and on use, withnormalizeAddress/isAllowedNavUrl.
-
New allowed scheme (e.g. a custom internal page): change both the allow branch of
isAllowedNavUrlandBLOCKED_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,
httpfor LAN hosts):normalizeAddressis 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
WebviewElementinterface, theBrowserTabshape 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 thereforeuseBrowserHome) decide the start URL. -
Making guests visible to other subsystems:
window.termsprawl.browser.register/unregisteris the mapping point; any new consumer should hang off that id pair instead of scanning the DOM.
- The
BrowserNode.tsxexcerpt covers lines 1–260; the tab strip, address bar, resize handle and overlay markup below that line are not shown, so which handlers calladdBrowserTab/closeBrowserTab/activateBrowserTab/browserTitleand 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 insrc/renderer/src/state/workspaceand are not part of this excerpt. -
installBrowserSecurity(), the navigation filter that consumesisDeniedScheme, thebrowser.registerhandler, 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
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