From 7ca9a4eea093c0918add42d2f059f2ab8ca46933 Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Mon, 6 Jul 2026 10:47:38 -0400 Subject: [PATCH 01/17] feat(browser): in-app browser tab with webview security hardening - Globe button in panel tab bars opens an embedded browser tab (Electron ), gated behind posthog-code-browser-tab flag - Main-process hardening: preload/node stripped from guests, scheme allowlist (http/https/about), link-local metadata range blocked, powerful permissions denied, popups routed http(s)-only to OS browser - Address bar normalizes input (scheme passthrough, host detection, search fallback); disallowed schemes become searches - Last committed url persists on the tab for restore-on-reload Generated-By: PostHog Code Task-Id: 4bc7193a-bc2b-4365-8435-a6b20cd00c08 --- apps/code/src/main/window.ts | 112 +++++++ .../src/panels/panelLayoutTransforms.test.ts | 50 +++ .../core/src/panels/panelLayoutTransforms.ts | 67 +++- packages/core/src/panels/panelTypes.ts | 5 + packages/shared/src/constants.ts | 1 + packages/shared/src/flags.ts | 2 + .../src/features/browser/BrowserPanel.test.ts | 39 +++ .../ui/src/features/browser/BrowserPanel.tsx | 290 ++++++++++++++++++ .../panels/components/LeafNodeRenderer.tsx | 8 +- .../panels/components/PanelLayout.tsx | 15 +- .../panels/components/TabbedPanel.tsx | 39 ++- .../panels/hooks/usePanelLayoutHooks.tsx | 5 + .../src/features/panels/panelLayoutStore.ts | 30 ++ .../components/TabContentRenderer.tsx | 4 + .../task-detail/components/TaskBrowserTab.tsx | 31 ++ 15 files changed, 679 insertions(+), 19 deletions(-) create mode 100644 packages/ui/src/features/browser/BrowserPanel.test.ts create mode 100644 packages/ui/src/features/browser/BrowserPanel.tsx create mode 100644 packages/ui/src/features/task-detail/components/TaskBrowserTab.tsx diff --git a/apps/code/src/main/window.ts b/apps/code/src/main/window.ts index fef9bcaa16..6623e1c6f4 100644 --- a/apps/code/src/main/window.ts +++ b/apps/code/src/main/window.ts @@ -120,6 +120,116 @@ function setupExternalLinkHandlers(window: BrowserWindow): void { }); } +// The authoritative gate for the in-app browser guest: main process, where a +// guest page can't route around it. The renderer's normalizeAddress is only a +// convenience on top of this. +const ALLOWED_WEBVIEW_SCHEMES = new Set(["http:", "https:", "about:"]); + +// The link-local range (incl. cloud metadata 169.254.169.254) can hand out +// instance credentials. Loopback and LAN are deliberately allowed — reaching a +// local dev server is a first-class use of a coding tool's browser. +function isBlockedWebviewHost(hostname: string): boolean { + return /^169\.254\./.test(hostname); +} + +function safeProtocol(url: string): string { + try { + return new URL(url).protocol; + } catch { + return ""; + } +} + +function isAllowedWebviewNavigation(url: string): boolean { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + return ( + ALLOWED_WEBVIEW_SCHEMES.has(parsed.protocol) && + !isBlockedWebviewHost(parsed.hostname) + ); +} + +// The guest runs on a shared persisted profile, so a single grant would stick +// across every tab and task — deny powerful permissions outright. +const DENIED_WEBVIEW_PERMISSIONS = new Set([ + "media", // camera + microphone + "geolocation", + "notifications", + "midi", + "midiSysex", + "hid", + "serial", + "usb", + "pointerLock", + "idle-detection", + "openExternal", // popups are already routed through our own handler +]); + +// setPermissionRequestHandler replaces (not composes with) any previous +// handler on the session, and guests share one persisted session — install +// once per session so a future per-guest divergence can't silently drop an +// earlier handler. +const hardenedWebviewSessions = new WeakSet(); + +function hardenWebviewSession(session: Electron.Session): void { + if (hardenedWebviewSessions.has(session)) return; + hardenedWebviewSessions.add(session); + + // Deny at both request time (prompts) and check time (sync fast-paths like + // navigator.permissions.query). + session.setPermissionRequestHandler((_wc, permission, callback) => { + callback(!DENIED_WEBVIEW_PERMISSIONS.has(permission)); + }); + session.setPermissionCheckHandler( + (_wc, permission) => !DENIED_WEBVIEW_PERMISSIONS.has(permission), + ); +} + +// Hardens guests used by the in-app browser tab. The guest renders +// arbitrary untrusted web content inside a privileged app window. +function setupWebviewHandlers(window: BrowserWindow): void { + // Strip any preload / node access an attacker page might request. + window.webContents.on("will-attach-webview", (_event, webPreferences) => { + webPreferences.preload = undefined; + webPreferences.nodeIntegration = false; + webPreferences.contextIsolation = true; + }); + + window.webContents.on("did-attach-webview", (_event, guest) => { + hardenWebviewSession(guest.session); + + guest.setWindowOpenHandler(({ url }) => { + // http(s)-only: a hostile page must not launch external protocol + // handlers (smb:, file:, custom app URIs) via window.open. + if (/^https?:$/i.test(safeProtocol(url))) { + shell.openExternal(url); + } else { + log.warn("Blocked webview popup to non-http(s) target", { url }); + } + return { action: "deny" }; + }); + + const guard = ( + event: { preventDefault: () => void }, + url: string, + ): void => { + if (!isAllowedWebviewNavigation(url)) { + event.preventDefault(); + log.warn("Blocked disallowed webview navigation", { url }); + } + }; + // will-navigate + will-redirect cover top-level loads and redirect chains + // (the SSRF-to-metadata vector); will-frame-navigate covers sub-frames. + guest.on("will-navigate", guard); + guest.on("will-redirect", guard); + guest.on("will-frame-navigate", (details) => guard(details, details.url)); + }); +} + function setupCrashLogging(window: BrowserWindow): void { window.webContents.on("render-process-gone", (_event, details) => { log.error("Renderer process gone", { @@ -230,6 +340,7 @@ export function createWindow(): void { webPreferences: { nodeIntegration: false, contextIsolation: true, + webviewTag: true, preload: path.join(__dirname, "preload.js"), enableBlinkFeatures: "GetDisplayMedia", partition: "persist:main", @@ -312,6 +423,7 @@ export function createWindow(): void { }); setupExternalLinkHandlers(mainWindow); + setupWebviewHandlers(mainWindow); setupEditableContextMenu(mainWindow); setupCrashLogging(mainWindow); buildApplicationMenu(); diff --git a/packages/core/src/panels/panelLayoutTransforms.test.ts b/packages/core/src/panels/panelLayoutTransforms.test.ts index a7a2c3a2cd..76ba721e37 100644 --- a/packages/core/src/panels/panelLayoutTransforms.test.ts +++ b/packages/core/src/panels/panelLayoutTransforms.test.ts @@ -1,9 +1,11 @@ import { beforeEach, describe, expect, it } from "vitest"; import { + addBrowserTab, addRecentFile, closeTab, createInitialTaskLayout, openTab, + updateBrowserTabUrl, } from "./panelLayoutTransforms"; import { createFileTabId, resetPanelIdCounter } from "./panelStoreHelpers"; import { findTabInTree } from "./panelTree"; @@ -75,6 +77,54 @@ describe("panelLayoutTransforms", () => { }); }); + describe("addBrowserTab", () => { + it("adds a browser tab carrying the initial url", () => { + const layout = createInitialTaskLayout(); + const next = applyUpdates( + layout, + addBrowserTab(layout, "main-panel", "https://posthog.com"), + ); + + expect(next.panelTree.type).toBe("leaf"); + if (next.panelTree.type !== "leaf") return; + const browserTab = next.panelTree.content.tabs.find( + (t) => t.data.type === "browser", + ); + expect(browserTab).toBeDefined(); + expect(browserTab?.data).toEqual({ + type: "browser", + url: "https://posthog.com", + }); + }); + }); + + describe("updateBrowserTabUrl", () => { + it("updates the url of an existing browser tab", () => { + const layout = createInitialTaskLayout(); + const added = applyUpdates( + layout, + addBrowserTab(layout, "main-panel", "about:blank"), + ); + expect(added.panelTree.type).toBe("leaf"); + if (added.panelTree.type !== "leaf") return; + const tabId = added.panelTree.content.tabs.find( + (t) => t.data.type === "browser", + )?.id; + if (!tabId) throw new Error("expected browser tab"); + + const next = applyUpdates( + added, + updateBrowserTabUrl(added, tabId, "https://example.com"), + ); + + const location = findTabInTree(next.panelTree, tabId); + expect(location?.tab.data).toEqual({ + type: "browser", + url: "https://example.com", + }); + }); + }); + describe("addRecentFile", () => { it("dedupes and prepends, capping at the max", () => { const result = addRecentFile(["b", "a"], "a"); diff --git a/packages/core/src/panels/panelLayoutTransforms.ts b/packages/core/src/panels/panelLayoutTransforms.ts index af2cc91643..c0a174941c 100644 --- a/packages/core/src/panels/panelLayoutTransforms.ts +++ b/packages/core/src/panels/panelLayoutTransforms.ts @@ -687,17 +687,34 @@ export function setActiveTab( return { panelTree: updatedTree }; } +// Tab ids key the whole tree; the monotonic suffix stops two adds within the +// same millisecond from colliding. +let tabIdSeq = 0; +function uniqueTabId(prefix: string): string { + return `${prefix}-${Date.now()}-${tabIdSeq++}`; +} + export function addTerminalTab( layout: TaskLayout, panelId: string, ): Partial { - const tabId = `shell-${Date.now()}`; + const tabId = uniqueTabId("shell"); + return appendTab(layout, panelId, { + id: tabId, + label: "Terminal", + data: { type: "terminal", terminalId: tabId, cwd: "" }, + }); +} + +function appendTab( + layout: TaskLayout, + panelId: string, + tab: { id: string; label: string; data: TabData }, +): Partial { const updatedTree = updateTreeNode(layout.panelTree, panelId, (panel) => { if (panel.type !== "leaf") return panel; return addTabToPanel(panel, { - id: tabId, - label: "Terminal", - data: { type: "terminal", terminalId: tabId, cwd: "" }, + ...tab, component: null, draggable: true, closeable: true, @@ -707,6 +724,48 @@ export function addTerminalTab( return { panelTree: updatedTree }; } +export function addBrowserTab( + layout: TaskLayout, + panelId: string, + url: string, +): Partial { + return appendTab(layout, panelId, { + id: uniqueTabId("browser"), + label: "Browser", + data: { type: "browser", url }, + }); +} + +export function updateBrowserTabUrl( + layout: TaskLayout, + tabId: string, + url: string, +): Partial { + const tabLocation = findTabInTree(layout.panelTree, tabId); + if (!tabLocation) return {}; + + const updatedTree = updateTreeNode( + layout.panelTree, + tabLocation.panelId, + (panel) => { + if (panel.type !== "leaf") return panel; + + const updatedTabs = panel.content.tabs.map((tab) => + tab.id === tabId && tab.data.type === "browser" + ? { ...tab, data: { ...tab.data, url } } + : tab, + ); + + return { + ...panel, + content: { ...panel.content, tabs: updatedTabs }, + }; + }, + ); + + return { panelTree: updatedTree }; +} + export function addActionTab( layout: TaskLayout, panelId: string, diff --git a/packages/core/src/panels/panelTypes.ts b/packages/core/src/panels/panelTypes.ts index 082ed291fb..26292d6274 100644 --- a/packages/core/src/panels/panelTypes.ts +++ b/packages/core/src/panels/panelTypes.ts @@ -44,6 +44,11 @@ export type TabData = | { type: "autoresearch"; } + | { + // `url` is the last committed location, so the tab restores on reload. + type: "browser"; + url: string; + } | { type: "other"; }; diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index b0a3336e29..b944f65c46 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1,5 +1,6 @@ export { BILLING_FLAG, + BROWSER_TAB_FLAG, DISCOVERY_RUN_FLAG, EXPERIMENT_SUGGESTIONS_FLAG, HOME_TAB_FLAG, diff --git a/packages/shared/src/flags.ts b/packages/shared/src/flags.ts index c97a4d7da3..7088728cf6 100644 --- a/packages/shared/src/flags.ts +++ b/packages/shared/src/flags.ts @@ -11,3 +11,5 @@ export const DISCOVERY_RUN_FLAG = "posthog-code-discovery-run"; export const PROJECT_BLUEBIRD_FLAG = "project-bluebird"; export const TASKS_PREWARM_SANDBOX_FLAG = "tasks-prewarm-sandbox"; export const GLM_MODEL_FLAG = "posthog-code-glm-model"; +// Gates the in-app browser tab (the Globe "+" affordance in panel tab bars). +export const BROWSER_TAB_FLAG = "posthog-code-browser-tab"; diff --git a/packages/ui/src/features/browser/BrowserPanel.test.ts b/packages/ui/src/features/browser/BrowserPanel.test.ts new file mode 100644 index 0000000000..f29aa2f830 --- /dev/null +++ b/packages/ui/src/features/browser/BrowserPanel.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { normalizeAddress } from "./BrowserPanel"; + +describe("normalizeAddress", () => { + it.each([ + ["", "about:blank"], + [" ", "about:blank"], + ["about:blank", "about:blank"], + ["https://posthog.com", "https://posthog.com"], + ["http://example.com/path", "http://example.com/path"], + ["example.com", "https://example.com"], + ["example.com/path?q=1", "https://example.com/path?q=1"], + ["localhost:3000", "http://localhost:3000"], + ["localhost", "http://localhost"], + ["localhost/dashboard", "http://localhost/dashboard"], + ["127.0.0.1:8000", "http://127.0.0.1:8000"], + [ + "how to center a div", + "https://www.google.com/search?q=how%20to%20center%20a%20div", + ], + ["posthog", "https://www.google.com/search?q=posthog"], + ])("normalizes %j to %j", (input, expected) => { + expect(normalizeAddress(input)).toBe(expected); + }); + + it.each([ + ["file:///etc/passwd", "file%3A%2F%2F%2Fetc%2Fpasswd"], + ["chrome://settings", "chrome%3A%2F%2Fsettings"], + [ + "data:text/html,

hi

", + "data%3Atext%2Fhtml%2C%3Ch1%3Ehi%3C%2Fh1%3E", + ], + ["javascript:alert(1)", "javascript%3Aalert(1)"], + ])("routes disallowed scheme %j to search", (input, encoded) => { + expect(normalizeAddress(input)).toBe( + `https://www.google.com/search?q=${encoded}`, + ); + }); +}); diff --git a/packages/ui/src/features/browser/BrowserPanel.tsx b/packages/ui/src/features/browser/BrowserPanel.tsx new file mode 100644 index 0000000000..43adb91800 --- /dev/null +++ b/packages/ui/src/features/browser/BrowserPanel.tsx @@ -0,0 +1,290 @@ +import { + ArrowClockwise, + ArrowLeft, + ArrowRight, + Globe, +} from "@phosphor-icons/react"; +import { BROWSER_TAB_FLAG } from "@posthog/shared/constants"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { Box, Flex, Text } from "@radix-ui/themes"; +import type React from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +export function useBrowserEnabled(): boolean { + return useFeatureFlag(BROWSER_TAB_FLAG) || import.meta.env.DEV; +} + +// Declared locally so @posthog/ui doesn't depend on electron types. +interface WebviewElement extends HTMLElement { + getURL(): string; + loadURL(url: string): Promise; + reload(): void; + // TODO: goBack/goForward/canGoBack/canGoForward are deprecated in Electron 41 + // in favour of webContents.navigationHistory.*; migrate before an Electron + // bump removes them, otherwise the nav buttons silently no-op. + goBack(): void; + goForward(): void; + canGoBack(): boolean; + canGoForward(): boolean; +} + +const DEFAULT_URL = "about:blank"; + +const LOOPBACK_HOST = /^(localhost|127\.0\.0\.1)(?=[:/]|$)/i; + +// Anything else (file:, chrome:, data:, javascript:, ...) becomes a search. +// Keep in sync with the authoritative main-process guard (setupWebviewHandlers +// in window.ts) — this is convenience, that is the security boundary. +const ALLOWED_SCHEME = /^(https?):\/\//i; + +// Loopback defaults to http since dev servers rarely serve https. +export function normalizeAddress(input: string): string { + const trimmed = input.trim(); + if (!trimmed) return DEFAULT_URL; + if (ALLOWED_SCHEME.test(trimmed) || trimmed === "about:blank") { + return trimmed; + } + // A schemeless "host:port" (e.g. localhost:3000) is a host, not a scheme. + const hasDisallowedScheme = + /^[a-z][a-z0-9+.-]*:/i.test(trimmed) && + !/^[^/]+:\d+(?:[/?#]|$)/.test(trimmed); + const looksLikeHost = + !hasDisallowedScheme && + !/\s/.test(trimmed) && + (trimmed.includes(".") || LOOPBACK_HOST.test(trimmed)); + if (looksLikeHost) { + const scheme = LOOPBACK_HOST.test(trimmed) ? "http" : "https"; + return `${scheme}://${trimmed}`; + } + return `https://www.google.com/search?q=${encodeURIComponent(trimmed)}`; +} + +// Electron DOM events carry their payload as extra props on Event. +type WebviewNavigateEvent = Event & { url: string; isMainFrame?: boolean }; +type WebviewTitleEvent = Event & { title: string }; +type WebviewFailLoadEvent = Event & { + errorCode: number; + errorDescription: string; + isMainFrame: boolean; +}; + +interface BrowserPanelProps { + url: string; + // Debounced settled main-frame url, for hosts that persist location. + onUrlChange?: (url: string) => void; + // Deduped page title, for hosts that show a label. + onTitleChange?: (title: string) => void; +} + +export function BrowserPanel({ + url, + onUrlChange, + onTitleChange, +}: BrowserPanelProps) { + const webviewRef = useRef(null); + // src is set once so re-renders never reload the page; the value comes off + // disk and must not be trusted as a raw src. + const initialUrl = useRef(normalizeAddress(url)); + const [address, setAddress] = useState(url || ""); + const [canGoBack, setCanGoBack] = useState(false); + const [canGoForward, setCanGoForward] = useState(false); + const [loadError, setLoadError] = useState(null); + + // Refs so the debounce timer and event effect don't re-arm every render. + const onUrlChangeRef = useRef(onUrlChange); + onUrlChangeRef.current = onUrlChange; + const onTitleChangeRef = useRef(onTitleChange); + onTitleChangeRef.current = onTitleChange; + + const persistTimer = useRef | null>(null); + const pendingUrl = useRef(null); + const lastLabel = useRef(null); + + // Hosts persist to disk on every write and SPAs fire many navigation events; + // coalesce so only the settled url hits storage. + const persistUrl = useCallback((next: string) => { + pendingUrl.current = next; + if (persistTimer.current) clearTimeout(persistTimer.current); + persistTimer.current = setTimeout(() => { + if (pendingUrl.current !== null) { + onUrlChangeRef.current?.(pendingUrl.current); + } + pendingUrl.current = null; + persistTimer.current = null; + }, 500); + }, []); + + // Flush on unmount so the last location isn't lost inside the debounce window. + useEffect( + () => () => { + if (persistTimer.current) clearTimeout(persistTimer.current); + if (pendingUrl.current !== null) { + onUrlChangeRef.current?.(pendingUrl.current); + } + }, + [], + ); + + useEffect(() => { + const webview = webviewRef.current; + if (!webview) return; + + const onNavigate = (e: Event) => { + const ev = e as WebviewNavigateEvent; + // Subframe navigations must not hijack the address bar or persisted url. + if (ev.isMainFrame === false) return; + const next = ev.url ?? webview.getURL(); + setAddress(next); + setCanGoBack(webview.canGoBack()); + setCanGoForward(webview.canGoForward()); + setLoadError(null); + persistUrl(next); + }; + + const onTitle = (e: Event) => { + const { title } = e as WebviewTitleEvent; + // SPAs rewrite the title constantly; skip the host write when unchanged. + if (title && title !== lastLabel.current) { + lastLabel.current = title; + onTitleChangeRef.current?.(title); + } + }; + + const onFailLoad = (e: Event) => { + const ev = e as WebviewFailLoadEvent; + // Ignore subframe failures and user-aborted loads (errorCode -3). + if (ev.isMainFrame === false || ev.errorCode === -3) return; + setLoadError(ev.errorDescription || "Failed to load page"); + }; + + webview.addEventListener("did-navigate", onNavigate); + webview.addEventListener("did-navigate-in-page", onNavigate); + webview.addEventListener("page-title-updated", onTitle); + webview.addEventListener("did-fail-load", onFailLoad); + + return () => { + webview.removeEventListener("did-navigate", onNavigate); + webview.removeEventListener("did-navigate-in-page", onNavigate); + webview.removeEventListener("page-title-updated", onTitle); + webview.removeEventListener("did-fail-load", onFailLoad); + }; + }, [persistUrl]); + + const navigate = useCallback((raw: string) => { + const webview = webviewRef.current; + if (!webview) return; + setLoadError(null); + // Aborted / guard-vetoed loads already surface via did-fail-load. + webview.loadURL(normalizeAddress(raw)).catch(() => {}); + }, []); + + const onSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + navigate(address); + }, + [address, navigate], + ); + + return ( + + + webviewRef.current?.goBack()} + > + + + webviewRef.current?.goForward()} + > + + + webviewRef.current?.reload()} + > + + +
+ setAddress(e.target.value)} + placeholder="Search or enter address" + spellCheck={false} + className="h-[24px] w-full rounded-(--radius-2) border-0 bg-(--gray-3) px-2 text-(--gray-12) text-[12px] outline-none focus:bg-(--gray-4)" + /> +
+
+ + + {loadError && ( + + + + {loadError} + + + )} + {/* Shared persisted profile across all browser tabs/tasks is intentional + (stay logged in to e.g. GitHub); trade-off: shared cookies/storage. + No `allowpopups` — popups are denied and routed to the OS browser by + the guest's window-open handler (window.ts). */} + } + src={initialUrl.current} + partition="persist:browser" + style={{ height: "100%", width: "100%" }} + /> + +
+ ); +} + +interface NavButtonProps { + ariaLabel: string; + dataAttr: string; + onClick: () => void; + disabled?: boolean; + children: React.ReactNode; +} + +function NavButton({ + ariaLabel, + dataAttr, + onClick, + disabled, + children, +}: NavButtonProps) { + return ( + + ); +} diff --git a/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx b/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx index 0d6568a6b9..f29c23798e 100644 --- a/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx +++ b/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx @@ -7,7 +7,7 @@ import { useIsWorkspaceCloudRun } from "../../workspace/useWorkspace"; import { useTabInjection } from "../hooks/usePanelLayoutHooks"; import type { SplitDirection } from "../panelLayoutStore"; import type { LeafPanel } from "../panelTypes"; -import { TabbedPanel } from "./TabbedPanel"; +import { type AddableTabKind, TabbedPanel } from "./TabbedPanel"; interface LeafNodeRendererProps { node: LeafPanel; @@ -21,7 +21,7 @@ interface LeafNodeRendererProps { draggingTabPanelId: string | null; onActiveTabChange: (panelId: string, tabId: string) => void; onPanelFocus: (panelId: string) => void; - onAddTerminal: (panelId: string) => void; + onAddTab: (panelId: string, kind: AddableTabKind) => void; onSplitPanel: (panelId: string, direction: SplitDirection) => void; } @@ -37,7 +37,7 @@ export const LeafNodeRenderer: React.FC = ({ draggingTabPanelId, onActiveTabChange, onPanelFocus, - onAddTerminal, + onAddTab, onSplitPanel, }) => { const isCloud = useIsWorkspaceCloudRun(taskId); @@ -90,7 +90,7 @@ export const LeafNodeRenderer: React.FC = ({ onPanelFocus={onPanelFocus} draggingTabId={draggingTabId} draggingTabPanelId={draggingTabPanelId} - onAddTerminal={isCloud ? undefined : () => onAddTerminal(node.id)} + onAddTab={isCloud ? undefined : (kind) => onAddTab(node.id, kind)} onSplitPanel={(direction) => onSplitPanel(node.id, direction)} emptyState={cloudEmptyState} /> diff --git a/packages/ui/src/features/panels/components/PanelLayout.tsx b/packages/ui/src/features/panels/components/PanelLayout.tsx index 9141acdbc2..852608f617 100644 --- a/packages/ui/src/features/panels/components/PanelLayout.tsx +++ b/packages/ui/src/features/panels/components/PanelLayout.tsx @@ -14,6 +14,7 @@ import { usePanelLayoutStore } from "../panelLayoutStore"; import type { PanelNode } from "../panelTypes"; import { GroupNodeRenderer } from "./GroupNodeRenderer"; import { LeafNodeRenderer } from "./LeafNodeRenderer"; +import type { AddableTabKind } from "./TabbedPanel"; interface PanelLayoutProps { taskId: string; @@ -65,9 +66,13 @@ const PanelLayoutRenderer: React.FC<{ [layoutState, taskId], ); - const handleAddTerminal = useCallback( - (panelId: string) => { - layoutState.addTerminalTab(taskId, panelId); + const handleAddTab = useCallback( + (panelId: string, kind: AddableTabKind) => { + if (kind === "browser") { + layoutState.addBrowserTab(taskId, panelId, "about:blank"); + } else { + layoutState.addTerminalTab(taskId, panelId); + } }, [layoutState, taskId], ); @@ -127,7 +132,7 @@ const PanelLayoutRenderer: React.FC<{ draggingTabPanelId={layoutState.draggingTabPanelId} onActiveTabChange={handleSetActiveTab} onPanelFocus={handlePanelFocus} - onAddTerminal={handleAddTerminal} + onAddTab={handleAddTab} onSplitPanel={handleSplitPanel} /> ); @@ -155,7 +160,7 @@ const PanelLayoutRenderer: React.FC<{ handleCloseTabsToRight, handleKeepTab, handlePanelFocus, - handleAddTerminal, + handleAddTab, handleSplitPanel, setGroupRef, handleLayout, diff --git a/packages/ui/src/features/panels/components/TabbedPanel.tsx b/packages/ui/src/features/panels/components/TabbedPanel.tsx index f36f35085f..320202a6f3 100644 --- a/packages/ui/src/features/panels/components/TabbedPanel.tsx +++ b/packages/ui/src/features/panels/components/TabbedPanel.tsx @@ -1,6 +1,7 @@ import { useDroppable } from "@dnd-kit/react"; -import { Plus, SquareSplitHorizontalIcon } from "@phosphor-icons/react"; +import { Globe, Plus, SquareSplitHorizontalIcon } from "@phosphor-icons/react"; import { useHostTRPCClient } from "@posthog/host-router/react"; +import { useBrowserEnabled } from "@posthog/ui/features/browser/BrowserPanel"; import { PanelDropZones } from "@posthog/ui/features/panels/components/PanelDropZones"; import type { SplitDirection } from "@posthog/ui/features/panels/panelLayoutStore"; import type { PanelContent } from "@posthog/ui/features/panels/panelTypes"; @@ -10,6 +11,10 @@ import type React from "react"; import { forwardRef, useCallback, useEffect, useRef, useState } from "react"; import { PanelTab } from "./PanelTab"; +// One kind-dispatching callback so new kinds don't thread another onAddX prop +// through every panel component. +export type AddableTabKind = "terminal" | "browser"; + const activeTabStyle: React.CSSProperties = { height: "100%", width: "100%", @@ -26,12 +31,16 @@ const hiddenTabStyle: React.CSSProperties = { interface TabBarButtonProps { ariaLabel: string; + dataAttr?: string; onClick: () => void; children: React.ReactNode; } const TabBarButton = forwardRef( - function TabBarButton({ ariaLabel, onClick, children, ...props }, ref) { + function TabBarButton( + { ariaLabel, dataAttr, onClick, children, ...props }, + ref, + ) { const [isHovered, setIsHovered] = useState(false); return ( @@ -39,6 +48,7 @@ const TabBarButton = forwardRef( ref={ref} type="button" aria-label={ariaLabel} + data-attr={dataAttr} onClick={onClick} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} @@ -64,7 +74,7 @@ interface TabbedPanelProps { onPanelFocus?: (panelId: string) => void; draggingTabId?: string | null; draggingTabPanelId?: string | null; - onAddTerminal?: () => void; + onAddTab?: (kind: AddableTabKind) => void; onSplitPanel?: (direction: SplitDirection) => void; rightContent?: React.ReactNode; emptyState?: React.ReactNode; @@ -80,13 +90,15 @@ export const TabbedPanel: React.FC = ({ onPanelFocus, draggingTabId = null, draggingTabPanelId = null, - onAddTerminal, + onAddTab, onSplitPanel, rightContent, emptyState, }) => { const hostClient = useHostTRPCClient(); + const browserEnabled = useBrowserEnabled(); + const handleSplitClick = async () => { const result = await hostClient.contextMenu.showSplitContextMenu.mutate(); const direction = (result.direction as SplitDirection | null) ?? null; @@ -195,13 +207,28 @@ export const TabbedPanel: React.FC = ({ badge={tab.badge} /> ))} - {content.droppable && onAddTerminal && ( + {content.droppable && onAddTab && ( - + onAddTab("terminal")} + > )} + {content.droppable && onAddTab && browserEnabled && ( + + onAddTab("browser")} + > + + + + )} {/* Spacer to increase DND area */} {content.droppable && ( diff --git a/packages/ui/src/features/panels/hooks/usePanelLayoutHooks.tsx b/packages/ui/src/features/panels/hooks/usePanelLayoutHooks.tsx index e08c176990..154ba8fcb8 100644 --- a/packages/ui/src/features/panels/hooks/usePanelLayoutHooks.tsx +++ b/packages/ui/src/features/panels/hooks/usePanelLayoutHooks.tsx @@ -2,6 +2,7 @@ import { ChartLineUp, ChatCenteredText, FileText, + Globe, Scroll, Terminal, } from "@phosphor-icons/react"; @@ -27,6 +28,7 @@ export interface PanelLayoutState { keepTab: (taskId: string, panelId: string, tabId: string) => void; setFocusedPanel: (taskId: string, panelId: string) => void; addTerminalTab: (taskId: string, panelId: string) => void; + addBrowserTab: (taskId: string, panelId: string, url: string) => void; splitPanel: ( taskId: string, tabId: string, @@ -51,6 +53,7 @@ export function usePanelLayoutState(taskId: string): PanelLayoutState { keepTab: state.keepTab, setFocusedPanel: state.setFocusedPanel, addTerminalTab: state.addTerminalTab, + addBrowserTab: state.addBrowserTab, splitPanel: state.splitPanel, draggingTabId: state.getLayout(taskId)?.draggingTabId ?? null, draggingTabPanelId: state.getLayout(taskId)?.draggingTabPanelId ?? null, @@ -119,6 +122,8 @@ export function useTabInjection( icon = ; } else if (tab.data.type === "autoresearch") { icon = ; + } else if (tab.data.type === "browser") { + icon = ; } } diff --git a/packages/ui/src/features/panels/panelLayoutStore.ts b/packages/ui/src/features/panels/panelLayoutStore.ts index 5607c32a5a..aca4ed5ba9 100644 --- a/packages/ui/src/features/panels/panelLayoutStore.ts +++ b/packages/ui/src/features/panels/panelLayoutStore.ts @@ -1,6 +1,7 @@ import { addRecentFile, addActionTab as coreAddActionTab, + addBrowserTab as coreAddBrowserTab, addTerminalTab as coreAddTerminalTab, closeOtherTabs as coreCloseOtherTabs, closeTab as coreCloseTab, @@ -12,6 +13,7 @@ import { openTabInSplit as coreOpenTabInSplit, reorderTabs as coreReorderTabs, setActiveTab as coreSetActiveTab, + updateBrowserTabUrl as coreUpdateBrowserTabUrl, updateSizes as coreUpdateSizes, updateTabLabel as coreUpdateTabLabel, updateTabMetadata as coreUpdateTabMetadata, @@ -105,6 +107,8 @@ export interface PanelLayoutStore { updateTabLabel: (taskId: string, tabId: string, label: string) => void; setFocusedPanel: (taskId: string, panelId: string) => void; addTerminalTab: (taskId: string, panelId: string) => void; + addBrowserTab: (taskId: string, panelId: string, url: string) => void; + updateBrowserTabUrl: (taskId: string, tabId: string, url: string) => void; addActionTab: ( taskId: string, panelId: string, @@ -484,6 +488,32 @@ export const usePanelLayoutStore = createWithEqualityFn()( ); }, + addBrowserTab: (taskId, panelId, url) => { + set((state) => + updateTaskLayout( + state, + taskId, + (layout) => + coreAddBrowserTab(layout, panelId, url) as Partial, + ), + ); + }, + + updateBrowserTabUrl: (taskId, tabId, url) => { + set((state) => + updateTaskLayout( + state, + taskId, + (layout) => + coreUpdateBrowserTabUrl( + layout, + tabId, + url, + ) as Partial, + ), + ); + }, + addActionTab: (taskId, panelId, action) => { set((state) => updateTaskLayout( diff --git a/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx b/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx index c59affe899..9267d11b7f 100644 --- a/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx +++ b/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx @@ -12,6 +12,7 @@ import { CanvasInstructionsTab } from "./CanvasInstructionsTab"; import { ChangesPanel } from "./ChangesPanel"; import { ChannelContextTab } from "./ChannelContextTab"; import { FileTreePanel } from "./FileTreePanel"; +import { TaskBrowserTab } from "./TaskBrowserTab"; import { TaskLogsPanel } from "./TaskLogsPanel"; import { TaskShellPanel } from "./TaskShellPanel"; @@ -76,6 +77,9 @@ export function TabContentRenderer({ case "autoresearch": return ; + case "browser": + return ; + case "other": switch (tab.id) { case "files": diff --git a/packages/ui/src/features/task-detail/components/TaskBrowserTab.tsx b/packages/ui/src/features/task-detail/components/TaskBrowserTab.tsx new file mode 100644 index 0000000000..5606464634 --- /dev/null +++ b/packages/ui/src/features/task-detail/components/TaskBrowserTab.tsx @@ -0,0 +1,31 @@ +import { BrowserPanel } from "@posthog/ui/features/browser/BrowserPanel"; +import { useCallback } from "react"; +import { usePanelLayoutStore } from "../../panels/panelLayoutStore"; + +interface TaskBrowserTabProps { + url: string; + tabId: string; + taskId: string; +} + +export function TaskBrowserTab({ url, tabId, taskId }: TaskBrowserTabProps) { + const updateBrowserTabUrl = usePanelLayoutStore((s) => s.updateBrowserTabUrl); + const updateTabLabel = usePanelLayoutStore((s) => s.updateTabLabel); + + const onUrlChange = useCallback( + (next: string) => updateBrowserTabUrl(taskId, tabId, next), + [updateBrowserTabUrl, taskId, tabId], + ); + const onTitleChange = useCallback( + (title: string) => updateTabLabel(taskId, tabId, title), + [updateTabLabel, taskId, tabId], + ); + + return ( + + ); +} From 36c33c97be076dfd2fead952643357d83e89b271 Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Mon, 6 Jul 2026 11:10:24 -0400 Subject: [PATCH 02/17] feat(browser): add-tab dropdown in panel tab bars Single "+" opens a Terminal/Browser menu instead of a row of icon buttons; falls back to the direct add-terminal button when the browser flag is off (a one-item menu is worse than a plain button). Generated-By: PostHog Code Task-Id: 4bc7193a-bc2b-4365-8435-a6b20cd00c08 --- .../panels/components/TabbedPanel.tsx | 58 +++++++++++++++---- 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/features/panels/components/TabbedPanel.tsx b/packages/ui/src/features/panels/components/TabbedPanel.tsx index 320202a6f3..d64ab63c2a 100644 --- a/packages/ui/src/features/panels/components/TabbedPanel.tsx +++ b/packages/ui/src/features/panels/components/TabbedPanel.tsx @@ -1,6 +1,17 @@ import { useDroppable } from "@dnd-kit/react"; -import { Globe, Plus, SquareSplitHorizontalIcon } from "@phosphor-icons/react"; +import { + Globe, + Plus, + SquareSplitHorizontalIcon, + Terminal, +} from "@phosphor-icons/react"; import { useHostTRPCClient } from "@posthog/host-router/react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@posthog/quill"; import { useBrowserEnabled } from "@posthog/ui/features/browser/BrowserPanel"; import { PanelDropZones } from "@posthog/ui/features/panels/components/PanelDropZones"; import type { SplitDirection } from "@posthog/ui/features/panels/panelLayoutStore"; @@ -32,7 +43,9 @@ const hiddenTabStyle: React.CSSProperties = { interface TabBarButtonProps { ariaLabel: string; dataAttr?: string; - onClick: () => void; + // Optional so the button can serve as a DropdownMenuTrigger render target, + // where the trigger injects its own click handling. + onClick?: () => void; children: React.ReactNode; } @@ -207,7 +220,9 @@ export const TabbedPanel: React.FC = ({ badge={tab.badge} /> ))} - {content.droppable && onAddTab && ( + {/* With only one addable kind a menu is pointless — the "+" adds + a terminal directly, as it always has. */} + {content.droppable && onAddTab && !browserEnabled && ( = ({ )} {content.droppable && onAddTab && browserEnabled && ( - - onAddTab("browser")} + + + + + } + /> + - - - + onAddTab("terminal")} + > + + Terminal + + onAddTab("browser")} + > + + Browser + + + )} {/* Spacer to increase DND area */} {content.droppable && ( From 851fac65d8479afc25adf2479d67f0a8b0743f6d Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Mon, 6 Jul 2026 12:59:22 -0400 Subject: [PATCH 03/17] feat(browser): loading indicator while pages load - Indeterminate top bar (shared quill-section-loading swoop) over the page area during loads - Reload button becomes a stop button mid-load Generated-By: PostHog Code Task-Id: 4bc7193a-bc2b-4365-8435-a6b20cd00c08 --- .../ui/src/features/browser/BrowserPanel.tsx | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/features/browser/BrowserPanel.tsx b/packages/ui/src/features/browser/BrowserPanel.tsx index 43adb91800..7c6f0564ca 100644 --- a/packages/ui/src/features/browser/BrowserPanel.tsx +++ b/packages/ui/src/features/browser/BrowserPanel.tsx @@ -3,6 +3,7 @@ import { ArrowLeft, ArrowRight, Globe, + X, } from "@phosphor-icons/react"; import { BROWSER_TAB_FLAG } from "@posthog/shared/constants"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; @@ -19,6 +20,7 @@ interface WebviewElement extends HTMLElement { getURL(): string; loadURL(url: string): Promise; reload(): void; + stop(): void; // TODO: goBack/goForward/canGoBack/canGoForward are deprecated in Electron 41 // in favour of webContents.navigationHistory.*; migrate before an Electron // bump removes them, otherwise the nav buttons silently no-op. @@ -89,6 +91,7 @@ export function BrowserPanel({ const [canGoBack, setCanGoBack] = useState(false); const [canGoForward, setCanGoForward] = useState(false); const [loadError, setLoadError] = useState(null); + const [isLoading, setIsLoading] = useState(false); // Refs so the debounce timer and event effect don't re-arm every render. const onUrlChangeRef = useRef(onUrlChange); @@ -157,16 +160,23 @@ export function BrowserPanel({ setLoadError(ev.errorDescription || "Failed to load page"); }; + const onStartLoading = () => setIsLoading(true); + const onStopLoading = () => setIsLoading(false); + webview.addEventListener("did-navigate", onNavigate); webview.addEventListener("did-navigate-in-page", onNavigate); webview.addEventListener("page-title-updated", onTitle); webview.addEventListener("did-fail-load", onFailLoad); + webview.addEventListener("did-start-loading", onStartLoading); + webview.addEventListener("did-stop-loading", onStopLoading); return () => { webview.removeEventListener("did-navigate", onNavigate); webview.removeEventListener("did-navigate-in-page", onNavigate); webview.removeEventListener("page-title-updated", onTitle); webview.removeEventListener("did-fail-load", onFailLoad); + webview.removeEventListener("did-start-loading", onStartLoading); + webview.removeEventListener("did-stop-loading", onStopLoading); }; }, [persistUrl]); @@ -211,11 +221,15 @@ export function BrowserPanel({ webviewRef.current?.reload()} + onClick={() => + isLoading + ? webviewRef.current?.stop() + : webviewRef.current?.reload() + } > - + {isLoading ? : }
+
{loadError && ( Date: Mon, 6 Jul 2026 13:10:02 -0400 Subject: [PATCH 04/17] feat(browser): focus address bar and show placeholder on blank tabs Generated-By: PostHog Code Task-Id: 4bc7193a-bc2b-4365-8435-a6b20cd00c08 --- packages/ui/src/features/browser/BrowserPanel.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/browser/BrowserPanel.tsx b/packages/ui/src/features/browser/BrowserPanel.tsx index 7c6f0564ca..f3d6994b58 100644 --- a/packages/ui/src/features/browser/BrowserPanel.tsx +++ b/packages/ui/src/features/browser/BrowserPanel.tsx @@ -87,7 +87,7 @@ export function BrowserPanel({ // src is set once so re-renders never reload the page; the value comes off // disk and must not be trusted as a raw src. const initialUrl = useRef(normalizeAddress(url)); - const [address, setAddress] = useState(url || ""); + const [address, setAddress] = useState(url === DEFAULT_URL ? "" : url); const [canGoBack, setCanGoBack] = useState(false); const [canGoForward, setCanGoForward] = useState(false); const [loadError, setLoadError] = useState(null); @@ -137,7 +137,7 @@ export function BrowserPanel({ // Subframe navigations must not hijack the address bar or persisted url. if (ev.isMainFrame === false) return; const next = ev.url ?? webview.getURL(); - setAddress(next); + setAddress(next === DEFAULT_URL ? "" : next); setCanGoBack(webview.canGoBack()); setCanGoForward(webview.canGoForward()); setLoadError(null); @@ -235,6 +235,8 @@ export function BrowserPanel({ setAddress(e.target.value)} placeholder="Search or enter address" From d850a44091bc2ef64ce49ed61a5174c17394f0ea Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Mon, 6 Jul 2026 16:11:35 -0400 Subject: [PATCH 05/17] refactor(browser): quill nav buttons, clearer webview security comments Generated-By: PostHog Code Task-Id: 29a0c450-6d5c-454e-a2c0-608d280d4737 --- apps/code/src/main/window.ts | 28 +++++---- .../ui/src/features/browser/BrowserPanel.tsx | 57 ++++++------------- 2 files changed, 34 insertions(+), 51 deletions(-) diff --git a/apps/code/src/main/window.ts b/apps/code/src/main/window.ts index 6623e1c6f4..609f5241af 100644 --- a/apps/code/src/main/window.ts +++ b/apps/code/src/main/window.ts @@ -122,12 +122,15 @@ function setupExternalLinkHandlers(window: BrowserWindow): void { // The authoritative gate for the in-app browser guest: main process, where a // guest page can't route around it. The renderer's normalizeAddress is only a -// convenience on top of this. +// convenience on top of this. "about:" is allowed solely for about:blank — +// the src a new blank browser tab mounts with before the user enters a url. const ALLOWED_WEBVIEW_SCHEMES = new Set(["http:", "https:", "about:"]); -// The link-local range (incl. cloud metadata 169.254.169.254) can hand out -// instance credentials. Loopback and LAN are deliberately allowed — reaching a -// local dev server is a first-class use of a coding tool's browser. +// Blocks the IPv4 link-local range 169.254.0.0/16. On cloud VMs, requests to +// 169.254.169.254 hit the instance-metadata endpoint, which returns IAM / +// service-account credentials to any local caller — a hostile page redirecting +// the webview there could exfiltrate them. Loopback and LAN are deliberately +// allowed: browsing a local dev server is a first-class use of this browser. function isBlockedWebviewHost(hostname: string): boolean { return /^169\.254\./.test(hostname); } @@ -169,18 +172,23 @@ const DENIED_WEBVIEW_PERMISSIONS = new Set([ "openExternal", // popups are already routed through our own handler ]); -// setPermissionRequestHandler replaces (not composes with) any previous -// handler on the session, and guests share one persisted session — install -// once per session so a future per-guest divergence can't silently drop an -// earlier handler. +// Every browser webview shares the one persist:browser session, and Electron's +// setPermissionRequestHandler REPLACES the session's previous handler rather +// than stacking. Re-installing on every webview attach would mean the latest +// attach silently wins; this WeakSet makes installation once-per-session so +// that can never happen. const hardenedWebviewSessions = new WeakSet(); function hardenWebviewSession(session: Electron.Session): void { if (hardenedWebviewSessions.has(session)) return; hardenedWebviewSessions.add(session); - // Deny at both request time (prompts) and check time (sync fast-paths like - // navigator.permissions.query). + // Chromium consults two hooks when a page uses a permission-gated API: + // setPermissionRequestHandler decides explicit requests (the ones that would + // show a prompt, e.g. getUserMedia), and setPermissionCheckHandler answers + // synchronous status probes (navigator.permissions.query). Both must deny + // the same list, otherwise a page could see "granted" via the check path + // while actual requests are refused, or vice versa. session.setPermissionRequestHandler((_wc, permission, callback) => { callback(!DENIED_WEBVIEW_PERMISSIONS.has(permission)); }); diff --git a/packages/ui/src/features/browser/BrowserPanel.tsx b/packages/ui/src/features/browser/BrowserPanel.tsx index f3d6994b58..292fb4b4c6 100644 --- a/packages/ui/src/features/browser/BrowserPanel.tsx +++ b/packages/ui/src/features/browser/BrowserPanel.tsx @@ -5,6 +5,7 @@ import { Globe, X, } from "@phosphor-icons/react"; +import { Button } from "@posthog/quill"; import { BROWSER_TAB_FLAG } from "@posthog/shared/constants"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { Box, Flex, Text } from "@radix-ui/themes"; @@ -204,25 +205,28 @@ export function BrowserPanel({ px="2" className="h-[36px] shrink-0 border-b border-b-(--gray-6)" > - webviewRef.current?.goBack()} > - - + ); } - -interface NavButtonProps { - ariaLabel: string; - dataAttr: string; - onClick: () => void; - disabled?: boolean; - children: React.ReactNode; -} - -function NavButton({ - ariaLabel, - dataAttr, - onClick, - disabled, - children, -}: NavButtonProps) { - return ( - - ); -} From a26ba238a20dd69a1800a0fd05d0c81cca498b7f Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Mon, 6 Jul 2026 16:17:00 -0400 Subject: [PATCH 06/17] docs(browser): correct stale TODO on webview nav method deprecation Generated-By: PostHog Code Task-Id: 29a0c450-6d5c-454e-a2c0-608d280d4737 --- packages/ui/src/features/browser/BrowserPanel.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/features/browser/BrowserPanel.tsx b/packages/ui/src/features/browser/BrowserPanel.tsx index 292fb4b4c6..b00f1ce13d 100644 --- a/packages/ui/src/features/browser/BrowserPanel.tsx +++ b/packages/ui/src/features/browser/BrowserPanel.tsx @@ -22,9 +22,11 @@ interface WebviewElement extends HTMLElement { loadURL(url: string): Promise; reload(): void; stop(): void; - // TODO: goBack/goForward/canGoBack/canGoForward are deprecated in Electron 41 - // in favour of webContents.navigationHistory.*; migrate before an Electron - // bump removes them, otherwise the nav buttons silently no-op. + // The webContents.navigationHistory.* deprecation does not apply here: these + // are element methods, still non-deprecated in Electron 42, and the + // element exposes no navigationHistory. Revisit only if Electron deprecates + // the tag methods themselves (the fix would be a main-process hop, not a + // rename). goBack(): void; goForward(): void; canGoBack(): boolean; From 24af02733b155550108d0bcb57b357d0861bfe2f Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Mon, 6 Jul 2026 16:23:44 -0400 Subject: [PATCH 07/17] fix(browser): close metadata-host SSRF bypasses in webview guard Block the IPv6-mapped metadata address and GCP's metadata DNS name, which the IPv4-only host check let through. Extract the pure navigation guard to a testable module with regression coverage. Generated-By: PostHog Code Task-Id: 29a0c450-6d5c-454e-a2c0-608d280d4737 --- .../utils/webview-navigation-guard.test.ts | 45 +++++++++++++++ .../main/utils/webview-navigation-guard.ts | 55 +++++++++++++++++++ apps/code/src/main/window.ts | 40 ++------------ 3 files changed, 104 insertions(+), 36 deletions(-) create mode 100644 apps/code/src/main/utils/webview-navigation-guard.test.ts create mode 100644 apps/code/src/main/utils/webview-navigation-guard.ts diff --git a/apps/code/src/main/utils/webview-navigation-guard.test.ts b/apps/code/src/main/utils/webview-navigation-guard.test.ts new file mode 100644 index 0000000000..4b1a3f69a1 --- /dev/null +++ b/apps/code/src/main/utils/webview-navigation-guard.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { + isAllowedWebviewNavigation, + isBlockedWebviewHost, +} from "./webview-navigation-guard"; + +describe("isBlockedWebviewHost", () => { + it.each([ + ["169.254.169.254", true], + ["169.254.0.1", true], + // WHATWG URL folds decimal/hex IPv4 to dotted form before this runs. + [new URL("http://2852039166/").hostname, true], + // IPv6-mapped IPv4: OS still connects to the v4 metadata service. + [new URL("http://[::ffff:169.254.169.254]/").hostname, true], + ["metadata.google.internal", true], + ["METADATA.GOOGLE.INTERNAL", true], + ["localhost", false], + ["127.0.0.1", false], + ["192.168.1.5", false], + ["posthog.com", false], + // Not the metadata address — a normal 169.253.x host is allowed. + ["169.253.1.1", false], + ])("host %j -> blocked %s", (hostname, blocked) => { + expect(isBlockedWebviewHost(hostname)).toBe(blocked); + }); +}); + +describe("isAllowedWebviewNavigation", () => { + it.each([ + ["https://posthog.com", true], + ["http://localhost:3000", true], + ["about:blank", true], + // Blocked schemes fall through to search in the renderer; the guard vetoes. + ["file:///etc/passwd", false], + ["chrome://settings", false], + ["javascript:alert(1)", false], + ["data:text/html,

hi

", false], + // Metadata endpoint over an allowed scheme is still blocked by host. + ["http://169.254.169.254/latest/meta-data/", false], + ["http://metadata.google.internal/computeMetadata/v1/", false], + ["not a url", false], + ])("url %j -> allowed %s", (url, allowed) => { + expect(isAllowedWebviewNavigation(url)).toBe(allowed); + }); +}); diff --git a/apps/code/src/main/utils/webview-navigation-guard.ts b/apps/code/src/main/utils/webview-navigation-guard.ts new file mode 100644 index 0000000000..3fc6f9c046 --- /dev/null +++ b/apps/code/src/main/utils/webview-navigation-guard.ts @@ -0,0 +1,55 @@ +// The authoritative gate for the in-app browser guest. It runs in the main +// process, where a guest page can't route around it; the renderer's +// normalizeAddress is only a convenience on top of this. "about:" is allowed +// solely for about:blank — the src a new blank browser tab mounts with before +// the user enters a url. +const ALLOWED_WEBVIEW_SCHEMES = new Set(["http:", "https:", "about:"]); + +// Blocks the cloud instance-metadata endpoint. On cloud VMs it returns IAM / +// service-account credentials to any local caller, so a hostile page that +// redirects the webview there could exfiltrate them. Loopback and LAN stay +// allowed: browsing a local dev server is a first-class use of this browser. +// +// WHATWG URL canonicalizes decimal/hex/octal IPv4 (http://2852039166) back to +// dotted form before this runs, so those are covered by the v4 range. The +// entries below close the forms it does NOT fold together: the IPv6-mapped +// address (the OS still connects to the v4 metadata service) and GCP's +// metadata DNS name. +// +// Residual gap this cannot close: DNS rebinding — an attacker domain that +// resolves to 169.254.169.254 passes, because the real defense is checking the +// *resolved* IP at connect time, which will-navigate doesn't expose. Egress +// network policy on the sandbox is the actual boundary for that. +const BLOCKED_METADATA_HOST = /^169\.254\./; +const BLOCKED_METADATA_HOST_V6 = /^\[::ffff:a9fe:a9fe\]$/i; +const BLOCKED_METADATA_HOSTNAMES = new Set(["metadata.google.internal"]); + +export function isBlockedWebviewHost(hostname: string): boolean { + const host = hostname.toLowerCase(); + return ( + BLOCKED_METADATA_HOST.test(host) || + BLOCKED_METADATA_HOST_V6.test(host) || + BLOCKED_METADATA_HOSTNAMES.has(host) + ); +} + +export function safeProtocol(url: string): string { + try { + return new URL(url).protocol; + } catch { + return ""; + } +} + +export function isAllowedWebviewNavigation(url: string): boolean { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + return ( + ALLOWED_WEBVIEW_SCHEMES.has(parsed.protocol) && + !isBlockedWebviewHost(parsed.hostname) + ); +} diff --git a/apps/code/src/main/window.ts b/apps/code/src/main/window.ts index 609f5241af..dd857cefeb 100644 --- a/apps/code/src/main/window.ts +++ b/apps/code/src/main/window.ts @@ -30,6 +30,10 @@ import { type WindowStateSchema, windowStateStore, } from "./utils/store"; +import { + isAllowedWebviewNavigation, + safeProtocol, +} from "./utils/webview-navigation-guard"; const log = logger.scope("window"); const trpcLog = logger.scope("host-trpc"); @@ -120,42 +124,6 @@ function setupExternalLinkHandlers(window: BrowserWindow): void { }); } -// The authoritative gate for the in-app browser guest: main process, where a -// guest page can't route around it. The renderer's normalizeAddress is only a -// convenience on top of this. "about:" is allowed solely for about:blank — -// the src a new blank browser tab mounts with before the user enters a url. -const ALLOWED_WEBVIEW_SCHEMES = new Set(["http:", "https:", "about:"]); - -// Blocks the IPv4 link-local range 169.254.0.0/16. On cloud VMs, requests to -// 169.254.169.254 hit the instance-metadata endpoint, which returns IAM / -// service-account credentials to any local caller — a hostile page redirecting -// the webview there could exfiltrate them. Loopback and LAN are deliberately -// allowed: browsing a local dev server is a first-class use of this browser. -function isBlockedWebviewHost(hostname: string): boolean { - return /^169\.254\./.test(hostname); -} - -function safeProtocol(url: string): string { - try { - return new URL(url).protocol; - } catch { - return ""; - } -} - -function isAllowedWebviewNavigation(url: string): boolean { - let parsed: URL; - try { - parsed = new URL(url); - } catch { - return false; - } - return ( - ALLOWED_WEBVIEW_SCHEMES.has(parsed.protocol) && - !isBlockedWebviewHost(parsed.hostname) - ); -} - // The guest runs on a shared persisted profile, so a single grant would stick // across every tab and task — deny powerful permissions outright. const DENIED_WEBVIEW_PERMISSIONS = new Set([ From 675f339f6ae0df7990ed42106b1fce4fb50b2166 Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Mon, 6 Jul 2026 16:29:06 -0400 Subject: [PATCH 08/17] refactor(panels): collapse three update-one-tab transforms into one helper updateTabLabel/updateTabMetadata/updateBrowserTabUrl shared the same find-tab-walk-and-map dance. Extract updateTabById; add coverage for the two transforms that lacked it. Generated-By: PostHog Code Task-Id: 29a0c450-6d5c-454e-a2c0-608d280d4737 --- .../src/panels/panelLayoutTransforms.test.ts | 50 +++++++++++++ .../core/src/panels/panelLayoutTransforms.ts | 73 +++++-------------- 2 files changed, 69 insertions(+), 54 deletions(-) diff --git a/packages/core/src/panels/panelLayoutTransforms.test.ts b/packages/core/src/panels/panelLayoutTransforms.test.ts index 76ba721e37..c42dbb9630 100644 --- a/packages/core/src/panels/panelLayoutTransforms.test.ts +++ b/packages/core/src/panels/panelLayoutTransforms.test.ts @@ -6,6 +6,8 @@ import { createInitialTaskLayout, openTab, updateBrowserTabUrl, + updateTabLabel, + updateTabMetadata, } from "./panelLayoutTransforms"; import { createFileTabId, resetPanelIdCounter } from "./panelStoreHelpers"; import { findTabInTree } from "./panelTree"; @@ -123,6 +125,54 @@ describe("panelLayoutTransforms", () => { url: "https://example.com", }); }); + + it("leaves a non-browser tab untouched", () => { + const layout = createInitialTaskLayout(); + const next = applyUpdates( + layout, + updateBrowserTabUrl(layout, "shell", "https://example.com"), + ); + // The shell tab has no url; the guard must not graft one on. + expect(findTabInTree(next.panelTree, "shell")?.tab.data).toEqual( + findTabInTree(layout.panelTree, "shell")?.tab.data, + ); + }); + }); + + describe("updateTabLabel", () => { + it("renames an existing tab", () => { + const layout = createInitialTaskLayout(); + const next = applyUpdates( + layout, + updateTabLabel(layout, "shell", "Renamed"), + ); + expect(findTabInTree(next.panelTree, "shell")?.tab.label).toBe("Renamed"); + }); + + it("no-ops when the tab is gone", () => { + const layout = createInitialTaskLayout(); + expect(updateTabLabel(layout, "missing", "X")).toEqual({}); + }); + }); + + describe("updateTabMetadata", () => { + it("merges metadata into an existing tab", () => { + const layout = createInitialTaskLayout(); + const next = applyUpdates( + layout, + updateTabMetadata(layout, "shell", { hasUnsavedChanges: true }), + ); + expect( + findTabInTree(next.panelTree, "shell")?.tab.hasUnsavedChanges, + ).toBe(true); + }); + + it("no-ops when the tab is gone", () => { + const layout = createInitialTaskLayout(); + expect( + updateTabMetadata(layout, "missing", { hasUnsavedChanges: true }), + ).toEqual({}); + }); }); describe("addRecentFile", () => { diff --git a/packages/core/src/panels/panelLayoutTransforms.ts b/packages/core/src/panels/panelLayoutTransforms.ts index c0a174941c..74dd227442 100644 --- a/packages/core/src/panels/panelLayoutTransforms.ts +++ b/packages/core/src/panels/panelLayoutTransforms.ts @@ -609,10 +609,13 @@ export function updateSizes( return { panelTree: updatedTree }; } -export function updateTabMetadata( +// Locates a tab by id and replaces it with `update(tab)`, leaving every other +// tab and the tree structure untouched. No-ops if the tab is gone. The three +// update-one-tab transforms below all share this walk, so it lives once here. +function updateTabById( layout: TaskLayout, tabId: string, - metadata: Partial>, + update: (tab: Tab) => Tab, ): Partial { const tabLocation = findTabInTree(layout.panelTree, tabId); if (!tabLocation) return {}; @@ -622,16 +625,13 @@ export function updateTabMetadata( tabLocation.panelId, (panel) => { if (panel.type !== "leaf") return panel; - - const updatedTabs = panel.content.tabs.map((tab) => - tab.id === tabId ? { ...tab, ...metadata } : tab, - ); - return { ...panel, content: { ...panel.content, - tabs: updatedTabs, + tabs: panel.content.tabs.map((tab) => + tab.id === tabId ? update(tab) : tab, + ), }, }; }, @@ -640,35 +640,20 @@ export function updateTabMetadata( return { panelTree: updatedTree }; } +export function updateTabMetadata( + layout: TaskLayout, + tabId: string, + metadata: Partial>, +): Partial { + return updateTabById(layout, tabId, (tab) => ({ ...tab, ...metadata })); +} + export function updateTabLabel( layout: TaskLayout, tabId: string, label: string, ): Partial { - const tabLocation = findTabInTree(layout.panelTree, tabId); - if (!tabLocation) return {}; - - const updatedTree = updateTreeNode( - layout.panelTree, - tabLocation.panelId, - (panel) => { - if (panel.type !== "leaf") return panel; - - const updatedTabs = panel.content.tabs.map((tab) => - tab.id === tabId ? { ...tab, label } : tab, - ); - - return { - ...panel, - content: { - ...panel.content, - tabs: updatedTabs, - }, - }; - }, - ); - - return { panelTree: updatedTree }; + return updateTabById(layout, tabId, (tab) => ({ ...tab, label })); } export function setActiveTab( @@ -741,29 +726,9 @@ export function updateBrowserTabUrl( tabId: string, url: string, ): Partial { - const tabLocation = findTabInTree(layout.panelTree, tabId); - if (!tabLocation) return {}; - - const updatedTree = updateTreeNode( - layout.panelTree, - tabLocation.panelId, - (panel) => { - if (panel.type !== "leaf") return panel; - - const updatedTabs = panel.content.tabs.map((tab) => - tab.id === tabId && tab.data.type === "browser" - ? { ...tab, data: { ...tab.data, url } } - : tab, - ); - - return { - ...panel, - content: { ...panel.content, tabs: updatedTabs }, - }; - }, + return updateTabById(layout, tabId, (tab) => + tab.data.type === "browser" ? { ...tab, data: { ...tab.data, url } } : tab, ); - - return { panelTree: updatedTree }; } export function addActionTab( From d057a5d157b9de86a5d13cc5f5272d9f3c12237b Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Fri, 17 Jul 2026 10:59:10 -0400 Subject: [PATCH 09/17] fix(browser): make host and security policies explicit Generated-By: PostHog Code Task-Id: b23f944d-dd7f-465b-9fed-1a1d028f35e1 --- .../utils/webview-navigation-guard.test.ts | 2 + .../main/utils/webview-navigation-guard.ts | 7 +- .../utils/webview-permission-policy.test.ts | 15 ++ .../main/utils/webview-permission-policy.ts | 5 + apps/code/src/main/window.ts | 21 +-- apps/code/src/renderer/desktop-services.ts | 9 ++ apps/code/src/renderer/di/bindings.ts | 5 + .../electron-browser-view.tsx | 82 ++++++++++ .../ui/src/features/browser/BrowserPanel.tsx | 151 +++++++----------- .../ui/src/features/browser/identifiers.ts | 30 ++++ 10 files changed, 212 insertions(+), 115 deletions(-) create mode 100644 apps/code/src/main/utils/webview-permission-policy.test.ts create mode 100644 apps/code/src/main/utils/webview-permission-policy.ts create mode 100644 apps/code/src/renderer/platform-adapters/electron-browser-view.tsx create mode 100644 packages/ui/src/features/browser/identifiers.ts diff --git a/apps/code/src/main/utils/webview-navigation-guard.test.ts b/apps/code/src/main/utils/webview-navigation-guard.test.ts index 4b1a3f69a1..637535013f 100644 --- a/apps/code/src/main/utils/webview-navigation-guard.test.ts +++ b/apps/code/src/main/utils/webview-navigation-guard.test.ts @@ -30,6 +30,8 @@ describe("isAllowedWebviewNavigation", () => { ["https://posthog.com", true], ["http://localhost:3000", true], ["about:blank", true], + ["about:srcdoc", false], + ["about:config", false], // Blocked schemes fall through to search in the renderer; the guard vetoes. ["file:///etc/passwd", false], ["chrome://settings", false], diff --git a/apps/code/src/main/utils/webview-navigation-guard.ts b/apps/code/src/main/utils/webview-navigation-guard.ts index 3fc6f9c046..ec3a3dd495 100644 --- a/apps/code/src/main/utils/webview-navigation-guard.ts +++ b/apps/code/src/main/utils/webview-navigation-guard.ts @@ -1,9 +1,9 @@ // The authoritative gate for the in-app browser guest. It runs in the main // process, where a guest page can't route around it; the renderer's -// normalizeAddress is only a convenience on top of this. "about:" is allowed -// solely for about:blank — the src a new blank browser tab mounts with before +// normalizeAddress is only a convenience on top of this. about:blank is allowed +// solely as the src a new blank browser tab mounts with before // the user enters a url. -const ALLOWED_WEBVIEW_SCHEMES = new Set(["http:", "https:", "about:"]); +const ALLOWED_WEBVIEW_SCHEMES = new Set(["http:", "https:"]); // Blocks the cloud instance-metadata endpoint. On cloud VMs it returns IAM / // service-account credentials to any local caller, so a hostile page that @@ -48,6 +48,7 @@ export function isAllowedWebviewNavigation(url: string): boolean { } catch { return false; } + if (parsed.href === "about:blank") return true; return ( ALLOWED_WEBVIEW_SCHEMES.has(parsed.protocol) && !isBlockedWebviewHost(parsed.hostname) diff --git a/apps/code/src/main/utils/webview-permission-policy.test.ts b/apps/code/src/main/utils/webview-permission-policy.test.ts new file mode 100644 index 0000000000..9cc1543637 --- /dev/null +++ b/apps/code/src/main/utils/webview-permission-policy.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { isAllowedWebviewPermission } from "./webview-permission-policy"; + +describe("isAllowedWebviewPermission", () => { + it.each([ + "clipboard-read", + "media", + "geolocation", + "notifications", + "openExternal", + "future-electron-permission", + ])("denies %s", (permission) => { + expect(isAllowedWebviewPermission(permission)).toBe(false); + }); +}); diff --git a/apps/code/src/main/utils/webview-permission-policy.ts b/apps/code/src/main/utils/webview-permission-policy.ts new file mode 100644 index 0000000000..401a6b3602 --- /dev/null +++ b/apps/code/src/main/utils/webview-permission-policy.ts @@ -0,0 +1,5 @@ +const ALLOWED_WEBVIEW_PERMISSIONS: ReadonlySet = new Set(); + +export function isAllowedWebviewPermission(permission: string): boolean { + return ALLOWED_WEBVIEW_PERMISSIONS.has(permission); +} diff --git a/apps/code/src/main/window.ts b/apps/code/src/main/window.ts index fa1fb8387e..995c56793e 100644 --- a/apps/code/src/main/window.ts +++ b/apps/code/src/main/window.ts @@ -37,6 +37,7 @@ import { isAllowedWebviewNavigation, safeProtocol, } from "./utils/webview-navigation-guard"; +import { isAllowedWebviewPermission } from "./utils/webview-permission-policy"; import { setupWindowZoom } from "./zoom"; const log = logger.scope("window"); @@ -125,20 +126,6 @@ export function focusMainWindow(reason: string): void { } } -const DENIED_WEBVIEW_PERMISSIONS = new Set([ - "media", - "geolocation", - "notifications", - "midi", - "midiSysex", - "hid", - "serial", - "usb", - "pointerLock", - "idle-detection", - "openExternal", -]); - const hardenedWebviewSessions = new WeakSet(); function hardenWebviewSession(session: Electron.Session): void { @@ -146,10 +133,10 @@ function hardenWebviewSession(session: Electron.Session): void { hardenedWebviewSessions.add(session); session.setPermissionRequestHandler((_wc, permission, callback) => { - callback(!DENIED_WEBVIEW_PERMISSIONS.has(permission)); + callback(isAllowedWebviewPermission(permission)); }); - session.setPermissionCheckHandler( - (_wc, permission) => !DENIED_WEBVIEW_PERMISSIONS.has(permission), + session.setPermissionCheckHandler((_wc, permission) => + isAllowedWebviewPermission(permission), ); } diff --git a/apps/code/src/renderer/desktop-services.ts b/apps/code/src/renderer/desktop-services.ts index 140113505c..b6ee1ca066 100644 --- a/apps/code/src/renderer/desktop-services.ts +++ b/apps/code/src/renderer/desktop-services.ts @@ -70,6 +70,10 @@ import { type IAuthSideEffects, } from "@posthog/ui/features/auth/identifiers"; import { authKeys } from "@posthog/ui/features/auth/useCurrentUser"; +import { + BROWSER_VIEW_COMPONENT, + type BrowserViewComponent, +} from "@posthog/ui/features/browser/identifiers"; import { FEATURE_FLAGS, type FeatureFlags, @@ -121,6 +125,7 @@ import { import { ELEVENLABS_API_KEY_STORE_KEY } from "@posthog/workspace-server/services/speech/identifiers"; import { container } from "@renderer/di/container"; import { RendererAuthSideEffects } from "@renderer/platform-adapters/auth-side-effects"; +import { ElectronBrowserView } from "@renderer/platform-adapters/electron-browser-view"; import { gitCacheKeyProvider } from "@renderer/platform-adapters/git-cache-keys"; import { RendererHedgehogModeHost } from "@renderer/platform-adapters/hedgehog-mode-host"; import { setupStore } from "@renderer/platform-adapters/setup"; @@ -446,6 +451,10 @@ container container.bind(SETUP_STORE).toConstantValue(setupStore); +container + .bind(BROWSER_VIEW_COMPONENT) + .toConstantValue(ElectronBrowserView); + container .bind(HOST_CAPABILITIES) .toConstantValue({ localWorkspaces: true } satisfies HostCapabilities); diff --git a/apps/code/src/renderer/di/bindings.ts b/apps/code/src/renderer/di/bindings.ts index 89926b3b7f..a34bbca4cb 100644 --- a/apps/code/src/renderer/di/bindings.ts +++ b/apps/code/src/renderer/di/bindings.ts @@ -160,6 +160,10 @@ import { AUTH_SIDE_EFFECTS, type IAuthSideEffects, } from "@posthog/ui/features/auth/identifiers"; +import { + BROWSER_VIEW_COMPONENT, + type BrowserViewComponent, +} from "@posthog/ui/features/browser/identifiers"; import { BROWSER_TABS_CLIENT, type BrowserTabsClient, @@ -351,6 +355,7 @@ export interface RendererBindings { [FILE_WATCHER_CLIENT]: FileWatcherClient; [FEATURE_FLAGS]: FeatureFlags; [AUTH_SIDE_EFFECTS]: IAuthSideEffects; + [BROWSER_VIEW_COMPONENT]: BrowserViewComponent; [SETUP_STORE]: ISetupStore; [HOST_CAPABILITIES]: HostCapabilities; diff --git a/apps/code/src/renderer/platform-adapters/electron-browser-view.tsx b/apps/code/src/renderer/platform-adapters/electron-browser-view.tsx new file mode 100644 index 0000000000..a42463863d --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/electron-browser-view.tsx @@ -0,0 +1,82 @@ +import type { + BrowserViewHandle, + BrowserViewProps, +} from "@posthog/ui/features/browser/identifiers"; +import { useEffect, useRef } from "react"; + +interface ElectronWebviewElement extends HTMLElement, BrowserViewHandle { + getURL(): string; + canGoBack(): boolean; + canGoForward(): boolean; +} + +type WebviewNavigateEvent = Event & { url: string; isMainFrame?: boolean }; +type WebviewTitleEvent = Event & { title: string }; +type WebviewFailLoadEvent = Event & { + errorCode: number; + errorDescription: string; + isMainFrame: boolean; +}; + +export function ElectronBrowserView({ + initialUrl, + onReady, + onNavigate, + onTitleChange, + onLoadError, + onLoadingChange, +}: BrowserViewProps) { + const webviewRef = useRef(null); + + useEffect(() => { + const webview = webviewRef.current; + if (!webview) return; + + const handleNavigate = (event: Event) => { + const navigation = event as WebviewNavigateEvent; + if (navigation.isMainFrame === false) return; + onNavigate({ + url: navigation.url ?? webview.getURL(), + canGoBack: webview.canGoBack(), + canGoForward: webview.canGoForward(), + }); + }; + const handleTitle = (event: Event) => { + onTitleChange((event as WebviewTitleEvent).title); + }; + const handleFailLoad = (event: Event) => { + const failure = event as WebviewFailLoadEvent; + if (failure.isMainFrame === false || failure.errorCode === -3) return; + onLoadError(failure.errorDescription || "Failed to load page"); + }; + const handleStartLoading = () => onLoadingChange(true); + const handleStopLoading = () => onLoadingChange(false); + + onReady(webview); + webview.addEventListener("did-navigate", handleNavigate); + webview.addEventListener("did-navigate-in-page", handleNavigate); + webview.addEventListener("page-title-updated", handleTitle); + webview.addEventListener("did-fail-load", handleFailLoad); + webview.addEventListener("did-start-loading", handleStartLoading); + webview.addEventListener("did-stop-loading", handleStopLoading); + + return () => { + onReady(null); + webview.removeEventListener("did-navigate", handleNavigate); + webview.removeEventListener("did-navigate-in-page", handleNavigate); + webview.removeEventListener("page-title-updated", handleTitle); + webview.removeEventListener("did-fail-load", handleFailLoad); + webview.removeEventListener("did-start-loading", handleStartLoading); + webview.removeEventListener("did-stop-loading", handleStopLoading); + }; + }, [onLoadError, onLoadingChange, onNavigate, onReady, onTitleChange]); + + return ( + + ); +} diff --git a/packages/ui/src/features/browser/BrowserPanel.tsx b/packages/ui/src/features/browser/BrowserPanel.tsx index b00f1ce13d..b390751319 100644 --- a/packages/ui/src/features/browser/BrowserPanel.tsx +++ b/packages/ui/src/features/browser/BrowserPanel.tsx @@ -5,32 +5,26 @@ import { Globe, X, } from "@phosphor-icons/react"; +import { useServiceOptional } from "@posthog/di/react"; import { Button } from "@posthog/quill"; import { BROWSER_TAB_FLAG } from "@posthog/shared/constants"; +import { + BROWSER_VIEW_COMPONENT, + type BrowserViewComponent, + type BrowserViewHandle, + type BrowserViewNavigation, +} from "@posthog/ui/features/browser/identifiers"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { Box, Flex, Text } from "@radix-ui/themes"; import type React from "react"; import { useCallback, useEffect, useRef, useState } from "react"; export function useBrowserEnabled(): boolean { - return useFeatureFlag(BROWSER_TAB_FLAG) || import.meta.env.DEV; -} - -// Declared locally so @posthog/ui doesn't depend on electron types. -interface WebviewElement extends HTMLElement { - getURL(): string; - loadURL(url: string): Promise; - reload(): void; - stop(): void; - // The webContents.navigationHistory.* deprecation does not apply here: these - // are element methods, still non-deprecated in Electron 42, and the - // element exposes no navigationHistory. Revisit only if Electron deprecates - // the tag methods themselves (the fix would be a main-process hop, not a - // rename). - goBack(): void; - goForward(): void; - canGoBack(): boolean; - canGoForward(): boolean; + const BrowserView = useServiceOptional( + BROWSER_VIEW_COMPONENT, + ); + const featureEnabled = useFeatureFlag(BROWSER_TAB_FLAG); + return BrowserView !== undefined && featureEnabled; } const DEFAULT_URL = "about:blank"; @@ -64,15 +58,6 @@ export function normalizeAddress(input: string): string { return `https://www.google.com/search?q=${encodeURIComponent(trimmed)}`; } -// Electron DOM events carry their payload as extra props on Event. -type WebviewNavigateEvent = Event & { url: string; isMainFrame?: boolean }; -type WebviewTitleEvent = Event & { title: string }; -type WebviewFailLoadEvent = Event & { - errorCode: number; - errorDescription: string; - isMainFrame: boolean; -}; - interface BrowserPanelProps { url: string; // Debounced settled main-frame url, for hosts that persist location. @@ -86,7 +71,10 @@ export function BrowserPanel({ onUrlChange, onTitleChange, }: BrowserPanelProps) { - const webviewRef = useRef(null); + const BrowserView = useServiceOptional( + BROWSER_VIEW_COMPONENT, + ); + const browserViewRef = useRef(null); // src is set once so re-renders never reload the page; the value comes off // disk and must not be trusted as a raw src. const initialUrl = useRef(normalizeAddress(url)); @@ -131,64 +119,37 @@ export function BrowserPanel({ [], ); - useEffect(() => { - const webview = webviewRef.current; - if (!webview) return; - - const onNavigate = (e: Event) => { - const ev = e as WebviewNavigateEvent; - // Subframe navigations must not hijack the address bar or persisted url. - if (ev.isMainFrame === false) return; - const next = ev.url ?? webview.getURL(); - setAddress(next === DEFAULT_URL ? "" : next); - setCanGoBack(webview.canGoBack()); - setCanGoForward(webview.canGoForward()); + const handleReady = useCallback((handle: BrowserViewHandle | null) => { + browserViewRef.current = handle; + }, []); + const handleNavigate = useCallback( + (navigation: BrowserViewNavigation) => { + setAddress(navigation.url === DEFAULT_URL ? "" : navigation.url); + setCanGoBack(navigation.canGoBack); + setCanGoForward(navigation.canGoForward); setLoadError(null); - persistUrl(next); - }; - - const onTitle = (e: Event) => { - const { title } = e as WebviewTitleEvent; - // SPAs rewrite the title constantly; skip the host write when unchanged. - if (title && title !== lastLabel.current) { - lastLabel.current = title; - onTitleChangeRef.current?.(title); - } - }; - - const onFailLoad = (e: Event) => { - const ev = e as WebviewFailLoadEvent; - // Ignore subframe failures and user-aborted loads (errorCode -3). - if (ev.isMainFrame === false || ev.errorCode === -3) return; - setLoadError(ev.errorDescription || "Failed to load page"); - }; - - const onStartLoading = () => setIsLoading(true); - const onStopLoading = () => setIsLoading(false); - - webview.addEventListener("did-navigate", onNavigate); - webview.addEventListener("did-navigate-in-page", onNavigate); - webview.addEventListener("page-title-updated", onTitle); - webview.addEventListener("did-fail-load", onFailLoad); - webview.addEventListener("did-start-loading", onStartLoading); - webview.addEventListener("did-stop-loading", onStopLoading); - - return () => { - webview.removeEventListener("did-navigate", onNavigate); - webview.removeEventListener("did-navigate-in-page", onNavigate); - webview.removeEventListener("page-title-updated", onTitle); - webview.removeEventListener("did-fail-load", onFailLoad); - webview.removeEventListener("did-start-loading", onStartLoading); - webview.removeEventListener("did-stop-loading", onStopLoading); - }; - }, [persistUrl]); + persistUrl(navigation.url); + }, + [persistUrl], + ); + const handleTitleChange = useCallback((title: string) => { + if (title && title !== lastLabel.current) { + lastLabel.current = title; + onTitleChangeRef.current?.(title); + } + }, []); + const handleLoadError = useCallback((message: string) => { + setLoadError(message); + }, []); + const handleLoadingChange = useCallback((loading: boolean) => { + setIsLoading(loading); + }, []); const navigate = useCallback((raw: string) => { - const webview = webviewRef.current; - if (!webview) return; + const browserView = browserViewRef.current; + if (!browserView) return; setLoadError(null); - // Aborted / guard-vetoed loads already surface via did-fail-load. - webview.loadURL(normalizeAddress(raw)).catch(() => {}); + browserView.loadURL(normalizeAddress(raw)).catch(() => {}); }, []); const onSubmit = useCallback( @@ -199,6 +160,8 @@ export function BrowserPanel({ [address, navigate], ); + if (!BrowserView) return null; + return ( webviewRef.current?.goBack()} + onClick={() => browserViewRef.current?.goBack()} > @@ -221,7 +184,7 @@ export function BrowserPanel({ aria-label="Forward" data-attr="browser-tab-forward" disabled={!canGoForward} - onClick={() => webviewRef.current?.goForward()} + onClick={() => browserViewRef.current?.goForward()} > @@ -231,8 +194,8 @@ export function BrowserPanel({ data-attr="browser-tab-reload" onClick={() => isLoading - ? webviewRef.current?.stop() - : webviewRef.current?.reload() + ? browserViewRef.current?.stop() + : browserViewRef.current?.reload() } > {isLoading ? : } @@ -274,15 +237,13 @@ export function BrowserPanel({ )} - {/* Shared persisted profile across all browser tabs/tasks is intentional - (stay logged in to e.g. GitHub); trade-off: shared cookies/storage. - No `allowpopups` — popups are denied and routed to the OS browser by - the guest's window-open handler (window.ts). */} - } - src={initialUrl.current} - partition="persist:browser" - style={{ height: "100%", width: "100%" }} + diff --git a/packages/ui/src/features/browser/identifiers.ts b/packages/ui/src/features/browser/identifiers.ts new file mode 100644 index 0000000000..b8adce2cce --- /dev/null +++ b/packages/ui/src/features/browser/identifiers.ts @@ -0,0 +1,30 @@ +import type { ComponentType } from "react"; + +export interface BrowserViewHandle { + loadURL(url: string): Promise; + reload(): void; + stop(): void; + goBack(): void; + goForward(): void; +} + +export interface BrowserViewNavigation { + url: string; + canGoBack: boolean; + canGoForward: boolean; +} + +export interface BrowserViewProps { + initialUrl: string; + onReady: (handle: BrowserViewHandle | null) => void; + onNavigate: (navigation: BrowserViewNavigation) => void; + onTitleChange: (title: string) => void; + onLoadError: (message: string) => void; + onLoadingChange: (isLoading: boolean) => void; +} + +export type BrowserViewComponent = ComponentType; + +export const BROWSER_VIEW_COMPONENT = Symbol.for( + "posthog.ui.BrowserViewComponent", +); From 9f4b61052f5f2f95b7966b334955b03f442fc17a Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Fri, 17 Jul 2026 11:10:01 -0400 Subject: [PATCH 10/17] fix(browser): address QA security and capability gaps Generated-By: PostHog Code Task-Id: b23f944d-dd7f-465b-9fed-1a1d028f35e1 --- .../main/utils/webview-attach-policy.test.ts | 53 ++++++++++++ .../src/main/utils/webview-attach-policy.ts | 41 ++++++++++ .../utils/webview-navigation-guard.test.ts | 5 ++ .../main/utils/webview-navigation-guard.ts | 14 ++-- apps/code/src/main/window.ts | 23 ++++-- .../electron-browser-view.tsx | 3 +- packages/shared/src/constants.ts | 2 + .../ui/src/features/browser/BrowserPanel.tsx | 17 ++-- .../panels/components/LeafNodeRenderer.tsx | 37 +++++++-- .../panels/components/PanelLayout.tsx | 2 +- .../panels/components/TabbedPanel.tsx | 81 ++++++++++++------- .../features/panels/tabAvailability.test.ts | 49 +++++++++++ .../ui/src/features/panels/tabAvailability.ts | 27 +++++++ .../components/TabContentRenderer.tsx | 3 + 14 files changed, 303 insertions(+), 54 deletions(-) create mode 100644 apps/code/src/main/utils/webview-attach-policy.test.ts create mode 100644 apps/code/src/main/utils/webview-attach-policy.ts create mode 100644 packages/ui/src/features/panels/tabAvailability.test.ts create mode 100644 packages/ui/src/features/panels/tabAvailability.ts diff --git a/apps/code/src/main/utils/webview-attach-policy.test.ts b/apps/code/src/main/utils/webview-attach-policy.test.ts new file mode 100644 index 0000000000..a51f8f7c89 --- /dev/null +++ b/apps/code/src/main/utils/webview-attach-policy.test.ts @@ -0,0 +1,53 @@ +import { BROWSER_WEBVIEW_PARTITION } from "@posthog/shared/constants"; +import { describe, expect, it } from "vitest"; +import { + hardenWebviewPreferences, + isAllowedWebviewAttachment, +} from "./webview-attach-policy"; + +describe("isAllowedWebviewAttachment", () => { + it.each([ + ["https://posthog.com", BROWSER_WEBVIEW_PARTITION, true], + ["about:blank", BROWSER_WEBVIEW_PARTITION, true], + ["http://localhost:3000", BROWSER_WEBVIEW_PARTITION, true], + ["file:///etc/passwd", BROWSER_WEBVIEW_PARTITION, false], + ["https://posthog.com", "persist:attacker", false], + ["https://posthog.com", "", false], + ])("src %j partition %j -> allowed %s", (src, partition, allowed) => { + expect(isAllowedWebviewAttachment({ src, partition })).toBe(allowed); + }); +}); + +describe("hardenWebviewPreferences", () => { + it("overrides security-sensitive guest preferences", () => { + const preferences = { + preload: "/tmp/attacker.js", + nodeIntegration: true, + nodeIntegrationInSubFrames: true, + nodeIntegrationInWorker: true, + contextIsolation: false, + sandbox: false, + webSecurity: false, + allowRunningInsecureContent: true, + experimentalFeatures: true, + enableBlinkFeatures: "Serial", + webviewTag: true, + }; + + hardenWebviewPreferences(preferences); + + expect(preferences).toEqual({ + preload: undefined, + nodeIntegration: false, + nodeIntegrationInSubFrames: false, + nodeIntegrationInWorker: false, + contextIsolation: true, + sandbox: true, + webSecurity: true, + allowRunningInsecureContent: false, + experimentalFeatures: false, + enableBlinkFeatures: undefined, + webviewTag: false, + }); + }); +}); diff --git a/apps/code/src/main/utils/webview-attach-policy.ts b/apps/code/src/main/utils/webview-attach-policy.ts new file mode 100644 index 0000000000..c46089e8e2 --- /dev/null +++ b/apps/code/src/main/utils/webview-attach-policy.ts @@ -0,0 +1,41 @@ +import { BROWSER_WEBVIEW_PARTITION } from "@posthog/shared/constants"; +import { isAllowedWebviewNavigation } from "./webview-navigation-guard"; + +interface WebviewSecurityPreferences { + preload?: string; + nodeIntegration?: boolean; + nodeIntegrationInSubFrames?: boolean; + nodeIntegrationInWorker?: boolean; + contextIsolation?: boolean; + sandbox?: boolean; + webSecurity?: boolean; + allowRunningInsecureContent?: boolean; + experimentalFeatures?: boolean; + enableBlinkFeatures?: string; + webviewTag?: boolean; +} + +export function isAllowedWebviewAttachment( + params: Record, +): boolean { + return ( + params.partition === BROWSER_WEBVIEW_PARTITION && + isAllowedWebviewNavigation(params.src) + ); +} + +export function hardenWebviewPreferences( + preferences: WebviewSecurityPreferences, +): void { + preferences.preload = undefined; + preferences.nodeIntegration = false; + preferences.nodeIntegrationInSubFrames = false; + preferences.nodeIntegrationInWorker = false; + preferences.contextIsolation = true; + preferences.sandbox = true; + preferences.webSecurity = true; + preferences.allowRunningInsecureContent = false; + preferences.experimentalFeatures = false; + preferences.enableBlinkFeatures = undefined; + preferences.webviewTag = false; +} diff --git a/apps/code/src/main/utils/webview-navigation-guard.test.ts b/apps/code/src/main/utils/webview-navigation-guard.test.ts index 637535013f..568d7c954d 100644 --- a/apps/code/src/main/utils/webview-navigation-guard.test.ts +++ b/apps/code/src/main/utils/webview-navigation-guard.test.ts @@ -29,6 +29,11 @@ describe("isAllowedWebviewNavigation", () => { it.each([ ["https://posthog.com", true], ["http://localhost:3000", true], + ["http://127.0.0.2:3000", true], + ["http://0.0.0.0:3000", true], + ["http://[::1]:3000", true], + ["http://posthog.com", false], + ["http://192.168.1.5", false], ["about:blank", true], ["about:srcdoc", false], ["about:config", false], diff --git a/apps/code/src/main/utils/webview-navigation-guard.ts b/apps/code/src/main/utils/webview-navigation-guard.ts index ec3a3dd495..4a352c53e6 100644 --- a/apps/code/src/main/utils/webview-navigation-guard.ts +++ b/apps/code/src/main/utils/webview-navigation-guard.ts @@ -3,7 +3,8 @@ // normalizeAddress is only a convenience on top of this. about:blank is allowed // solely as the src a new blank browser tab mounts with before // the user enters a url. -const ALLOWED_WEBVIEW_SCHEMES = new Set(["http:", "https:"]); +const LOOPBACK_HOSTS = new Set(["localhost", "0.0.0.0", "[::1]"]); +const LOOPBACK_IPV4 = /^127\./; // Blocks the cloud instance-metadata endpoint. On cloud VMs it returns IAM / // service-account credentials to any local caller, so a hostile page that @@ -41,6 +42,10 @@ export function safeProtocol(url: string): string { } } +function isLoopbackHost(hostname: string): boolean { + return LOOPBACK_HOSTS.has(hostname) || LOOPBACK_IPV4.test(hostname); +} + export function isAllowedWebviewNavigation(url: string): boolean { let parsed: URL; try { @@ -49,8 +54,7 @@ export function isAllowedWebviewNavigation(url: string): boolean { return false; } if (parsed.href === "about:blank") return true; - return ( - ALLOWED_WEBVIEW_SCHEMES.has(parsed.protocol) && - !isBlockedWebviewHost(parsed.hostname) - ); + if (isBlockedWebviewHost(parsed.hostname)) return false; + if (parsed.protocol === "https:") return true; + return parsed.protocol === "http:" && isLoopbackHost(parsed.hostname); } diff --git a/apps/code/src/main/window.ts b/apps/code/src/main/window.ts index 995c56793e..5dd622f134 100644 --- a/apps/code/src/main/window.ts +++ b/apps/code/src/main/window.ts @@ -33,6 +33,10 @@ import { type WindowStateSchema, windowStateStore, } from "./utils/store"; +import { + hardenWebviewPreferences, + isAllowedWebviewAttachment, +} from "./utils/webview-attach-policy"; import { isAllowedWebviewNavigation, safeProtocol, @@ -141,11 +145,20 @@ function hardenWebviewSession(session: Electron.Session): void { } function setupWebviewHandlers(window: BrowserWindow): void { - window.webContents.on("will-attach-webview", (_event, webPreferences) => { - webPreferences.preload = undefined; - webPreferences.nodeIntegration = false; - webPreferences.contextIsolation = true; - }); + window.webContents.on( + "will-attach-webview", + (event, webPreferences, params) => { + if (!isAllowedWebviewAttachment(params)) { + event.preventDefault(); + log.warn("Blocked disallowed webview attachment", { + src: params.src, + partition: params.partition, + }); + return; + } + hardenWebviewPreferences(webPreferences); + }, + ); window.webContents.on("did-attach-webview", (_event, guest) => { hardenWebviewSession(guest.session); diff --git a/apps/code/src/renderer/platform-adapters/electron-browser-view.tsx b/apps/code/src/renderer/platform-adapters/electron-browser-view.tsx index a42463863d..af0086f01b 100644 --- a/apps/code/src/renderer/platform-adapters/electron-browser-view.tsx +++ b/apps/code/src/renderer/platform-adapters/electron-browser-view.tsx @@ -1,3 +1,4 @@ +import { BROWSER_WEBVIEW_PARTITION } from "@posthog/shared/constants"; import type { BrowserViewHandle, BrowserViewProps, @@ -75,7 +76,7 @@ export function ElectronBrowserView({ ); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index dd832451cc..d7c0b9eea8 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -7,6 +7,8 @@ export { SYNC_CLOUD_TASKS_FLAG, } from "./flags"; +export const BROWSER_WEBVIEW_PARTITION = "persist:browser"; + export const SELF_DRIVING_SETUP_TASK_FLAG = "posthog-code-self-driving-setup-task"; export const BRANCH_PREFIX = "posthog-code/"; diff --git a/packages/ui/src/features/browser/BrowserPanel.tsx b/packages/ui/src/features/browser/BrowserPanel.tsx index b390751319..f47a522775 100644 --- a/packages/ui/src/features/browser/BrowserPanel.tsx +++ b/packages/ui/src/features/browser/BrowserPanel.tsx @@ -144,6 +144,17 @@ export function BrowserPanel({ const handleLoadingChange = useCallback((loading: boolean) => { setIsLoading(loading); }, []); + const handleReload = useCallback(() => { + setLoadError(null); + browserViewRef.current?.reload(); + }, []); + const handleReloadOrStop = useCallback(() => { + if (isLoading) { + browserViewRef.current?.stop(); + return; + } + handleReload(); + }, [handleReload, isLoading]); const navigate = useCallback((raw: string) => { const browserView = browserViewRef.current; @@ -192,11 +203,7 @@ export function BrowserPanel({ size="icon-sm" aria-label={isLoading ? "Stop loading" : "Reload"} data-attr="browser-tab-reload" - onClick={() => - isLoading - ? browserViewRef.current?.stop() - : browserViewRef.current?.reload() - } + onClick={handleReloadOrStop} > {isLoading ? : } diff --git a/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx b/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx index b79a4a96ed..9af5a22ba5 100644 --- a/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx +++ b/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx @@ -4,11 +4,17 @@ import { Flex, Text } from "@radix-ui/themes"; import type React from "react"; import { useMemo } from "react"; import { useHostCapabilities } from "../../../shell/useHostCapabilities"; +import { useBrowserEnabled } from "../../browser/BrowserPanel"; import { useIsWorkspaceCloudRun } from "../../workspace/useWorkspace"; import { useTabInjection } from "../hooks/usePanelLayoutHooks"; import type { SplitDirection } from "../panelLayoutStore"; import type { LeafPanel } from "../panelTypes"; -import { type AddableTabKind, TabbedPanel } from "./TabbedPanel"; +import { + type AddableTabKind, + getAddableTabKinds, + isPanelTabAvailable, +} from "../tabAvailability"; +import { TabbedPanel } from "./TabbedPanel"; interface LeafNodeRendererProps { node: LeafPanel; @@ -43,14 +49,24 @@ export const LeafNodeRenderer: React.FC = ({ }) => { const isCloud = useIsWorkspaceCloudRun(taskId); const { localWorkspaces } = useHostCapabilities(); - // Hide the terminal for cloud runs, and on cloud-only hosts (web). - const hideTerminal = isCloud || !localWorkspaces; + const browserEnabled = useBrowserEnabled(); + const availability = useMemo( + () => ({ + browserEnabled, + terminalEnabled: !isCloud && localWorkspaces, + }), + [browserEnabled, isCloud, localWorkspaces], + ); + const addableTabKinds = useMemo( + () => getAddableTabKinds(availability), + [availability], + ); const inputTabs = useMemo( () => - hideTerminal - ? node.content.tabs.filter((t) => t.data.type !== "terminal") - : node.content.tabs, - [node.content.tabs, hideTerminal], + node.content.tabs.filter((tab) => + isPanelTabAvailable(tab.data.type, availability), + ), + [node.content.tabs, availability], ); const tabs = useTabInjection(inputTabs, node.id, taskId, task, closeTab); const activeTabId = tabs.some((t) => t.id === node.content.activeTabId) @@ -94,7 +110,12 @@ export const LeafNodeRenderer: React.FC = ({ onPanelFocus={onPanelFocus} draggingTabId={draggingTabId} draggingTabPanelId={draggingTabPanelId} - onAddTab={hideTerminal ? undefined : (kind) => onAddTab(node.id, kind)} + onAddTab={ + addableTabKinds.length > 0 + ? (kind) => onAddTab(node.id, kind) + : undefined + } + addableTabKinds={addableTabKinds} onSplitPanel={(direction) => onSplitPanel(node.id, direction)} emptyState={cloudEmptyState} /> diff --git a/packages/ui/src/features/panels/components/PanelLayout.tsx b/packages/ui/src/features/panels/components/PanelLayout.tsx index 852608f617..750b22908e 100644 --- a/packages/ui/src/features/panels/components/PanelLayout.tsx +++ b/packages/ui/src/features/panels/components/PanelLayout.tsx @@ -12,9 +12,9 @@ import { import type { SplitDirection } from "../panelLayoutStore"; import { usePanelLayoutStore } from "../panelLayoutStore"; import type { PanelNode } from "../panelTypes"; +import type { AddableTabKind } from "../tabAvailability"; import { GroupNodeRenderer } from "./GroupNodeRenderer"; import { LeafNodeRenderer } from "./LeafNodeRenderer"; -import type { AddableTabKind } from "./TabbedPanel"; interface PanelLayoutProps { taskId: string; diff --git a/packages/ui/src/features/panels/components/TabbedPanel.tsx b/packages/ui/src/features/panels/components/TabbedPanel.tsx index d64ab63c2a..ed7018b8f9 100644 --- a/packages/ui/src/features/panels/components/TabbedPanel.tsx +++ b/packages/ui/src/features/panels/components/TabbedPanel.tsx @@ -12,20 +12,16 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@posthog/quill"; -import { useBrowserEnabled } from "@posthog/ui/features/browser/BrowserPanel"; import { PanelDropZones } from "@posthog/ui/features/panels/components/PanelDropZones"; import type { SplitDirection } from "@posthog/ui/features/panels/panelLayoutStore"; import type { PanelContent } from "@posthog/ui/features/panels/panelTypes"; +import type { AddableTabKind } from "@posthog/ui/features/panels/tabAvailability"; import { Tooltip } from "@posthog/ui/primitives/Tooltip"; import { Box, Flex } from "@radix-ui/themes"; import type React from "react"; import { forwardRef, useCallback, useEffect, useRef, useState } from "react"; import { PanelTab } from "./PanelTab"; -// One kind-dispatching callback so new kinds don't thread another onAddX prop -// through every panel component. -export type AddableTabKind = "terminal" | "browser"; - const activeTabStyle: React.CSSProperties = { height: "100%", width: "100%", @@ -88,6 +84,7 @@ interface TabbedPanelProps { draggingTabId?: string | null; draggingTabPanelId?: string | null; onAddTab?: (kind: AddableTabKind) => void; + addableTabKinds?: readonly AddableTabKind[]; onSplitPanel?: (direction: SplitDirection) => void; rightContent?: React.ReactNode; emptyState?: React.ReactNode; @@ -104,13 +101,18 @@ export const TabbedPanel: React.FC = ({ draggingTabId = null, draggingTabPanelId = null, onAddTab, + addableTabKinds = [], onSplitPanel, rightContent, emptyState, }) => { const hostClient = useHostTRPCClient(); - const browserEnabled = useBrowserEnabled(); + const singleAddableTabKind = + addableTabKinds.length === 1 ? addableTabKinds[0] : undefined; + const hasMultipleAddableTabKinds = addableTabKinds.length > 1; + const canAddTerminal = addableTabKinds.includes("terminal"); + const canAddBrowser = addableTabKinds.includes("browser"); const handleSplitClick = async () => { const result = await hostClient.contextMenu.showSplitContextMenu.mutate(); @@ -220,20 +222,37 @@ export const TabbedPanel: React.FC = ({ badge={tab.badge} /> ))} - {/* With only one addable kind a menu is pointless — the "+" adds - a terminal directly, as it always has. */} - {content.droppable && onAddTab && !browserEnabled && ( - + {content.droppable && onAddTab && singleAddableTabKind && ( + onAddTab("terminal")} + ariaLabel={ + singleAddableTabKind === "terminal" + ? "Add terminal" + : "Add browser tab" + } + dataAttr={ + singleAddableTabKind === "terminal" + ? "panel-add-terminal" + : "panel-add-browser-tab" + } + onClick={() => onAddTab(singleAddableTabKind)} > - + {singleAddableTabKind === "terminal" ? ( + + ) : ( + + )} )} - {content.droppable && onAddTab && browserEnabled && ( + {content.droppable && onAddTab && hasMultipleAddableTabKinds && ( = ({ sideOffset={4} className="min-w-[140px]" > - onAddTab("terminal")} - > - - Terminal - - onAddTab("browser")} - > - - Browser - + {canAddTerminal && ( + onAddTab("terminal")} + > + + Terminal + + )} + {canAddBrowser && ( + onAddTab("browser")} + > + + Browser + + )} )} diff --git a/packages/ui/src/features/panels/tabAvailability.test.ts b/packages/ui/src/features/panels/tabAvailability.test.ts new file mode 100644 index 0000000000..0e55cf3d53 --- /dev/null +++ b/packages/ui/src/features/panels/tabAvailability.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { + getAddableTabKinds, + isPanelTabAvailable, + type TabAvailability, +} from "./tabAvailability"; + +describe("tab availability", () => { + it.each<[string, TabAvailability, string[], boolean, boolean]>([ + [ + "desktop local", + { browserEnabled: true, terminalEnabled: true }, + ["terminal", "browser"], + true, + true, + ], + [ + "desktop cloud", + { browserEnabled: true, terminalEnabled: false }, + ["browser"], + false, + true, + ], + [ + "web", + { browserEnabled: false, terminalEnabled: false }, + [], + false, + false, + ], + [ + "browser flag disabled", + { browserEnabled: false, terminalEnabled: true }, + ["terminal"], + true, + false, + ], + ])( + "%s exposes only supported tab kinds", + (_name, availability, addableKinds, terminalVisible, browserVisible) => { + expect(getAddableTabKinds(availability)).toEqual(addableKinds); + expect(isPanelTabAvailable("terminal", availability)).toBe( + terminalVisible, + ); + expect(isPanelTabAvailable("browser", availability)).toBe(browserVisible); + expect(isPanelTabAvailable("logs", availability)).toBe(true); + }, + ); +}); diff --git a/packages/ui/src/features/panels/tabAvailability.ts b/packages/ui/src/features/panels/tabAvailability.ts new file mode 100644 index 0000000000..56a4f0f05e --- /dev/null +++ b/packages/ui/src/features/panels/tabAvailability.ts @@ -0,0 +1,27 @@ +import type { Tab } from "./panelTypes"; + +export type AddableTabKind = "terminal" | "browser"; + +export interface TabAvailability { + browserEnabled: boolean; + terminalEnabled: boolean; +} + +export function getAddableTabKinds({ + browserEnabled, + terminalEnabled, +}: TabAvailability): AddableTabKind[] { + const kinds: AddableTabKind[] = []; + if (terminalEnabled) kinds.push("terminal"); + if (browserEnabled) kinds.push("browser"); + return kinds; +} + +export function isPanelTabAvailable( + type: Tab["data"]["type"], + availability: TabAvailability, +): boolean { + if (type === "terminal") return availability.terminalEnabled; + if (type === "browser") return availability.browserEnabled; + return true; +} diff --git a/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx b/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx index 9267d11b7f..a61b021be1 100644 --- a/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx +++ b/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx @@ -1,5 +1,6 @@ import type { Task } from "@posthog/shared/domain-types"; import { AutoresearchPanel } from "../../autoresearch/AutoresearchPanel"; +import { useBrowserEnabled } from "../../browser/BrowserPanel"; import { CodeEditorPanel } from "../../code-editor/components/CodeEditorPanel"; import { LazyCloudReviewPage as CloudReviewPage, @@ -28,6 +29,7 @@ export function TabContentRenderer({ task, }: TabContentRendererProps) { const isCloud = useIsWorkspaceCloudRun(taskId); + const browserEnabled = useBrowserEnabled(); const { data } = tab; switch (data.type) { @@ -78,6 +80,7 @@ export function TabContentRenderer({ return ; case "browser": + if (!browserEnabled) return null; return ; case "other": From 8f8d5520c5e383726e1a91be63c667637f9da1a5 Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Fri, 17 Jul 2026 11:16:42 -0400 Subject: [PATCH 11/17] refactor(panels): extract add tab control Generated-By: PostHog Code Task-Id: b23f944d-dd7f-465b-9fed-1a1d028f35e1 --- .../panels/components/TabbedPanel.tsx | 146 +++++++++--------- 1 file changed, 75 insertions(+), 71 deletions(-) diff --git a/packages/ui/src/features/panels/components/TabbedPanel.tsx b/packages/ui/src/features/panels/components/TabbedPanel.tsx index ed7018b8f9..14180964b6 100644 --- a/packages/ui/src/features/panels/components/TabbedPanel.tsx +++ b/packages/ui/src/features/panels/components/TabbedPanel.tsx @@ -73,6 +73,76 @@ const TabBarButton = forwardRef( }, ); +interface AddTabControlProps { + addableTabKinds: readonly AddableTabKind[]; + onAddTab: (kind: AddableTabKind) => void; +} + +function AddTabControl({ addableTabKinds, onAddTab }: AddTabControlProps) { + const singleAddableTabKind = + addableTabKinds.length === 1 ? addableTabKinds[0] : undefined; + + if (singleAddableTabKind) { + const isTerminal = singleAddableTabKind === "terminal"; + return ( + + onAddTab(singleAddableTabKind)} + > + {isTerminal ? : } + + + ); + } + + if (addableTabKinds.length === 0) return null; + + const canAddTerminal = addableTabKinds.includes("terminal"); + const canAddBrowser = addableTabKinds.includes("browser"); + + return ( + + + + + } + /> + + {canAddTerminal && ( + onAddTab("terminal")} + > + + Terminal + + )} + {canAddBrowser && ( + onAddTab("browser")} + > + + Browser + + )} + + + ); +} + interface TabbedPanelProps { panelId: string; content: PanelContent; @@ -108,12 +178,6 @@ export const TabbedPanel: React.FC = ({ }) => { const hostClient = useHostTRPCClient(); - const singleAddableTabKind = - addableTabKinds.length === 1 ? addableTabKinds[0] : undefined; - const hasMultipleAddableTabKinds = addableTabKinds.length > 1; - const canAddTerminal = addableTabKinds.includes("terminal"); - const canAddBrowser = addableTabKinds.includes("browser"); - const handleSplitClick = async () => { const result = await hostClient.contextMenu.showSplitContextMenu.mutate(); const direction = (result.direction as SplitDirection | null) ?? null; @@ -222,71 +286,11 @@ export const TabbedPanel: React.FC = ({ badge={tab.badge} /> ))} - {content.droppable && onAddTab && singleAddableTabKind && ( - - onAddTab(singleAddableTabKind)} - > - {singleAddableTabKind === "terminal" ? ( - - ) : ( - - )} - - - )} - {content.droppable && onAddTab && hasMultipleAddableTabKinds && ( - - - - - } - /> - - {canAddTerminal && ( - onAddTab("terminal")} - > - - Terminal - - )} - {canAddBrowser && ( - onAddTab("browser")} - > - - Browser - - )} - - + {content.droppable && onAddTab && ( + )} {/* Spacer to increase DND area */} {content.droppable && ( From 30f9e87a893070e6884fd389a9707df1887bf762 Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Fri, 17 Jul 2026 11:18:37 -0400 Subject: [PATCH 12/17] refactor(panels): split add tab components Generated-By: PostHog Code Task-Id: b23f944d-dd7f-465b-9fed-1a1d028f35e1 --- .../panels/components/AddTabControl.tsx | 83 ++++++++++++ .../panels/components/TabBarButton.tsx | 37 ++++++ .../panels/components/TabbedPanel.tsx | 124 +----------------- 3 files changed, 124 insertions(+), 120 deletions(-) create mode 100644 packages/ui/src/features/panels/components/AddTabControl.tsx create mode 100644 packages/ui/src/features/panels/components/TabBarButton.tsx diff --git a/packages/ui/src/features/panels/components/AddTabControl.tsx b/packages/ui/src/features/panels/components/AddTabControl.tsx new file mode 100644 index 0000000000..52194a8786 --- /dev/null +++ b/packages/ui/src/features/panels/components/AddTabControl.tsx @@ -0,0 +1,83 @@ +import { Globe, Plus, Terminal } from "@phosphor-icons/react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@posthog/quill"; +import type { AddableTabKind } from "@posthog/ui/features/panels/tabAvailability"; +import { Tooltip } from "@posthog/ui/primitives/Tooltip"; +import { TabBarButton } from "./TabBarButton"; + +interface AddTabControlProps { + addableTabKinds: readonly AddableTabKind[]; + onAddTab: (kind: AddableTabKind) => void; +} + +export function AddTabControl({ + addableTabKinds, + onAddTab, +}: AddTabControlProps) { + const singleAddableTabKind = + addableTabKinds.length === 1 ? addableTabKinds[0] : undefined; + + if (singleAddableTabKind) { + const isTerminal = singleAddableTabKind === "terminal"; + return ( + + onAddTab(singleAddableTabKind)} + > + {isTerminal ? : } + + + ); + } + + if (addableTabKinds.length === 0) return null; + + const canAddTerminal = addableTabKinds.includes("terminal"); + const canAddBrowser = addableTabKinds.includes("browser"); + + return ( + + + + + } + /> + + {canAddTerminal && ( + onAddTab("terminal")} + > + + Terminal + + )} + {canAddBrowser && ( + onAddTab("browser")} + > + + Browser + + )} + + + ); +} diff --git a/packages/ui/src/features/panels/components/TabBarButton.tsx b/packages/ui/src/features/panels/components/TabBarButton.tsx new file mode 100644 index 0000000000..7f77150052 --- /dev/null +++ b/packages/ui/src/features/panels/components/TabBarButton.tsx @@ -0,0 +1,37 @@ +import type React from "react"; +import { forwardRef, useState } from "react"; + +interface TabBarButtonProps { + ariaLabel: string; + dataAttr?: string; + onClick?: () => void; + children: React.ReactNode; +} + +export const TabBarButton = forwardRef( + function TabBarButton( + { ariaLabel, dataAttr, onClick, children, ...props }, + ref, + ) { + const [isHovered, setIsHovered] = useState(false); + + return ( + + ); + }, +); diff --git a/packages/ui/src/features/panels/components/TabbedPanel.tsx b/packages/ui/src/features/panels/components/TabbedPanel.tsx index 14180964b6..3fca73cb66 100644 --- a/packages/ui/src/features/panels/components/TabbedPanel.tsx +++ b/packages/ui/src/features/panels/components/TabbedPanel.tsx @@ -1,17 +1,6 @@ import { useDroppable } from "@dnd-kit/react"; -import { - Globe, - Plus, - SquareSplitHorizontalIcon, - Terminal, -} from "@phosphor-icons/react"; +import { SquareSplitHorizontalIcon } from "@phosphor-icons/react"; import { useHostTRPCClient } from "@posthog/host-router/react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@posthog/quill"; import { PanelDropZones } from "@posthog/ui/features/panels/components/PanelDropZones"; import type { SplitDirection } from "@posthog/ui/features/panels/panelLayoutStore"; import type { PanelContent } from "@posthog/ui/features/panels/panelTypes"; @@ -19,8 +8,10 @@ import type { AddableTabKind } from "@posthog/ui/features/panels/tabAvailability import { Tooltip } from "@posthog/ui/primitives/Tooltip"; import { Box, Flex } from "@radix-ui/themes"; import type React from "react"; -import { forwardRef, useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef } from "react"; +import { AddTabControl } from "./AddTabControl"; import { PanelTab } from "./PanelTab"; +import { TabBarButton } from "./TabBarButton"; const activeTabStyle: React.CSSProperties = { height: "100%", @@ -36,113 +27,6 @@ const hiddenTabStyle: React.CSSProperties = { pointerEvents: "none", }; -interface TabBarButtonProps { - ariaLabel: string; - dataAttr?: string; - // Optional so the button can serve as a DropdownMenuTrigger render target, - // where the trigger injects its own click handling. - onClick?: () => void; - children: React.ReactNode; -} - -const TabBarButton = forwardRef( - function TabBarButton( - { ariaLabel, dataAttr, onClick, children, ...props }, - ref, - ) { - const [isHovered, setIsHovered] = useState(false); - - return ( - - ); - }, -); - -interface AddTabControlProps { - addableTabKinds: readonly AddableTabKind[]; - onAddTab: (kind: AddableTabKind) => void; -} - -function AddTabControl({ addableTabKinds, onAddTab }: AddTabControlProps) { - const singleAddableTabKind = - addableTabKinds.length === 1 ? addableTabKinds[0] : undefined; - - if (singleAddableTabKind) { - const isTerminal = singleAddableTabKind === "terminal"; - return ( - - onAddTab(singleAddableTabKind)} - > - {isTerminal ? : } - - - ); - } - - if (addableTabKinds.length === 0) return null; - - const canAddTerminal = addableTabKinds.includes("terminal"); - const canAddBrowser = addableTabKinds.includes("browser"); - - return ( - - - - - } - /> - - {canAddTerminal && ( - onAddTab("terminal")} - > - - Terminal - - )} - {canAddBrowser && ( - onAddTab("browser")} - > - - Browser - - )} - - - ); -} - interface TabbedPanelProps { panelId: string; content: PanelContent; From 393dccc68411e6b89be8cd85ba19acd48003dbfe Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Fri, 17 Jul 2026 11:23:30 -0400 Subject: [PATCH 13/17] fix(browser): enable supported local dev hosts Generated-By: PostHog Code Task-Id: b23f944d-dd7f-465b-9fed-1a1d028f35e1 --- packages/ui/src/features/browser/BrowserPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/features/browser/BrowserPanel.tsx b/packages/ui/src/features/browser/BrowserPanel.tsx index f47a522775..6fc7f1fec4 100644 --- a/packages/ui/src/features/browser/BrowserPanel.tsx +++ b/packages/ui/src/features/browser/BrowserPanel.tsx @@ -23,7 +23,7 @@ export function useBrowserEnabled(): boolean { const BrowserView = useServiceOptional( BROWSER_VIEW_COMPONENT, ); - const featureEnabled = useFeatureFlag(BROWSER_TAB_FLAG); + const featureEnabled = useFeatureFlag(BROWSER_TAB_FLAG, import.meta.env.DEV); return BrowserView !== undefined && featureEnabled; } From 18fed73b32b2ef7be96ac104e385d70f848ecd5e Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Fri, 17 Jul 2026 11:27:45 -0400 Subject: [PATCH 14/17] fix(browser): submit address on Enter Generated-By: PostHog Code Task-Id: b23f944d-dd7f-465b-9fed-1a1d028f35e1 --- .../browser/BrowserPanel.interaction.test.tsx | 55 +++++++++++++++++++ .../ui/src/features/browser/BrowserPanel.tsx | 9 +++ 2 files changed, 64 insertions(+) create mode 100644 packages/ui/src/features/browser/BrowserPanel.interaction.test.tsx diff --git a/packages/ui/src/features/browser/BrowserPanel.interaction.test.tsx b/packages/ui/src/features/browser/BrowserPanel.interaction.test.tsx new file mode 100644 index 0000000000..51cdea7a11 --- /dev/null +++ b/packages/ui/src/features/browser/BrowserPanel.interaction.test.tsx @@ -0,0 +1,55 @@ +import { ServiceProvider } from "@posthog/di/react"; +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Container } from "inversify"; +import { useEffect } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { BrowserPanel } from "./BrowserPanel"; +import { + BROWSER_VIEW_COMPONENT, + type BrowserViewHandle, + type BrowserViewProps, +} from "./identifiers"; + +const loadURL = vi.fn<(url: string) => Promise>(); + +const browserViewHandle: BrowserViewHandle = { + loadURL, + reload: vi.fn(), + stop: vi.fn(), + goBack: vi.fn(), + goForward: vi.fn(), +}; + +function FakeBrowserView({ onReady }: BrowserViewProps) { + useEffect(() => { + onReady(browserViewHandle); + return () => onReady(null); + }, [onReady]); + return
; +} + +describe("BrowserPanel", () => { + it("navigates when Enter is pressed in the address input", async () => { + loadURL.mockResolvedValue(); + const container = new Container(); + container.bind(BROWSER_VIEW_COMPONENT).toConstantValue(FakeBrowserView); + const user = userEvent.setup(); + + render( + + + + + , + ); + + await user.type( + screen.getByRole("textbox", { name: "Address" }), + "posthog.com{Enter}", + ); + + expect(loadURL).toHaveBeenCalledWith("https://posthog.com"); + }); +}); diff --git a/packages/ui/src/features/browser/BrowserPanel.tsx b/packages/ui/src/features/browser/BrowserPanel.tsx index 6fc7f1fec4..1ec86668ea 100644 --- a/packages/ui/src/features/browser/BrowserPanel.tsx +++ b/packages/ui/src/features/browser/BrowserPanel.tsx @@ -170,6 +170,14 @@ export function BrowserPanel({ }, [address, navigate], ); + const handleAddressKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key !== "Enter" || event.nativeEvent.isComposing) return; + event.preventDefault(); + navigate(address); + }, + [address, navigate], + ); if (!BrowserView) return null; @@ -215,6 +223,7 @@ export function BrowserPanel({ autoFocus={initialUrl.current === DEFAULT_URL} value={address} onChange={(e) => setAddress(e.target.value)} + onKeyDown={handleAddressKeyDown} placeholder="Search or enter address" spellCheck={false} className="h-[24px] w-full rounded-(--radius-2) border-0 bg-(--gray-3) px-2 text-(--gray-12) text-[12px] outline-none focus:bg-(--gray-4)" From 648d3b88d53efca4bdeaae0d89eeb780b9526b78 Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Fri, 17 Jul 2026 12:09:54 -0400 Subject: [PATCH 15/17] fix(browser): harden and restore webview navigation Generated-By: PostHog Code Task-Id: b23f944d-dd7f-465b-9fed-1a1d028f35e1 --- .../services/browser-view/service.test.ts | 30 +++++ .../src/main/services/browser-view/service.ts | 15 +++ apps/code/src/main/trpc/router.ts | 2 + .../src/main/trpc/routers/browser-view.ts | 9 ++ .../main/utils/webview-attach-policy.test.ts | 2 +- .../src/main/utils/webview-attach-policy.ts | 2 +- .../utils/webview-navigation-guard.test.ts | 20 +++ .../main/utils/webview-navigation-guard.ts | 22 ++++ apps/code/src/main/window.ts | 27 +++- .../browser-view.contribution.ts | 26 ++++ .../src/renderer/desktop-contributions.ts | 2 + .../electron-browser-view.tsx | 49 ++++++- apps/code/src/shared/browser-view.ts | 1 + .../tests/e2e/tests/browser-webview.spec.ts | 120 ++++++++++++++++++ packages/shared/src/constants.ts | 2 - .../browser/BrowserPanel.interaction.test.tsx | 62 ++++++++- .../src/features/browser/BrowserPanel.test.ts | 6 +- .../ui/src/features/browser/BrowserPanel.tsx | 63 ++++++--- .../features/browser/BrowserUnavailable.tsx | 24 ++++ .../panels/components/LeafNodeRenderer.tsx | 4 +- .../features/panels/tabAvailability.test.ts | 12 ++ .../ui/src/features/panels/tabAvailability.ts | 8 ++ .../components/TabContentRenderer.tsx | 3 +- 23 files changed, 471 insertions(+), 40 deletions(-) create mode 100644 apps/code/src/main/services/browser-view/service.test.ts create mode 100644 apps/code/src/main/services/browser-view/service.ts create mode 100644 apps/code/src/main/trpc/routers/browser-view.ts create mode 100644 apps/code/src/renderer/contributions/browser-view.contribution.ts create mode 100644 apps/code/src/shared/browser-view.ts create mode 100644 apps/code/tests/e2e/tests/browser-webview.spec.ts create mode 100644 packages/ui/src/features/browser/BrowserUnavailable.tsx diff --git a/apps/code/src/main/services/browser-view/service.test.ts b/apps/code/src/main/services/browser-view/service.test.ts new file mode 100644 index 0000000000..7762d2972e --- /dev/null +++ b/apps/code/src/main/services/browser-view/service.test.ts @@ -0,0 +1,30 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("browserViewService", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.each([ + ["true", true], + ["false", false], + ])("defaults to %s in development mode", async (isDev, expected) => { + vi.stubEnv("POSTHOG_CODE_IS_DEV", isDev); + const { browserViewService } = await import("./service"); + + expect(browserViewService.isEnabled()).toBe(expected); + }); + + it("updates the attachment gate", async () => { + vi.stubEnv("POSTHOG_CODE_IS_DEV", "false"); + const { browserViewService } = await import("./service"); + + browserViewService.setEnabled(true); + + expect(browserViewService.isEnabled()).toBe(true); + }); +}); diff --git a/apps/code/src/main/services/browser-view/service.ts b/apps/code/src/main/services/browser-view/service.ts new file mode 100644 index 0000000000..3f709e956a --- /dev/null +++ b/apps/code/src/main/services/browser-view/service.ts @@ -0,0 +1,15 @@ +import { isDevBuild } from "../../utils/env"; + +class BrowserViewService { + private enabled = isDevBuild(); + + isEnabled(): boolean { + return this.enabled; + } + + setEnabled(enabled: boolean): void { + this.enabled = enabled; + } +} + +export const browserViewService = new BrowserViewService(); diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index 68ef63321c..ef17443ebc 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -50,6 +50,7 @@ import { uiRouter } from "@posthog/host-router/routers/ui.router"; import { updatesRouter } from "@posthog/host-router/routers/updates.router"; import { usageMonitorRouter } from "@posthog/host-router/routers/usage-monitor.router"; import { workspaceRouter } from "@posthog/host-router/routers/workspace.router"; +import { browserViewRouter } from "./routers/browser-view"; import { devRouter } from "./routers/dev"; import { discordPresenceRouter } from "./routers/discord-presence"; import { encryptionRouter } from "./routers/encryption"; @@ -64,6 +65,7 @@ export const trpcRouter = router({ auth: authRouter, autoresearch: autoresearchRouter, browserTabs: browserTabsRouter, + browserView: browserViewRouter, canvasData: canvasDataRouter, canvasTemplates: canvasTemplatesRouter, channelTasks: channelTasksRouter, diff --git a/apps/code/src/main/trpc/routers/browser-view.ts b/apps/code/src/main/trpc/routers/browser-view.ts new file mode 100644 index 0000000000..451e53612f --- /dev/null +++ b/apps/code/src/main/trpc/routers/browser-view.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; +import { browserViewService } from "../../services/browser-view/service"; +import { publicProcedure, router } from "../trpc"; + +export const browserViewRouter = router({ + setEnabled: publicProcedure + .input(z.object({ enabled: z.boolean() })) + .mutation(({ input }) => browserViewService.setEnabled(input.enabled)), +}); diff --git a/apps/code/src/main/utils/webview-attach-policy.test.ts b/apps/code/src/main/utils/webview-attach-policy.test.ts index a51f8f7c89..77a1ceacb7 100644 --- a/apps/code/src/main/utils/webview-attach-policy.test.ts +++ b/apps/code/src/main/utils/webview-attach-policy.test.ts @@ -1,4 +1,4 @@ -import { BROWSER_WEBVIEW_PARTITION } from "@posthog/shared/constants"; +import { BROWSER_WEBVIEW_PARTITION } from "@shared/browser-view"; import { describe, expect, it } from "vitest"; import { hardenWebviewPreferences, diff --git a/apps/code/src/main/utils/webview-attach-policy.ts b/apps/code/src/main/utils/webview-attach-policy.ts index c46089e8e2..0ce55a7832 100644 --- a/apps/code/src/main/utils/webview-attach-policy.ts +++ b/apps/code/src/main/utils/webview-attach-policy.ts @@ -1,4 +1,4 @@ -import { BROWSER_WEBVIEW_PARTITION } from "@posthog/shared/constants"; +import { BROWSER_WEBVIEW_PARTITION } from "@shared/browser-view"; import { isAllowedWebviewNavigation } from "./webview-navigation-guard"; interface WebviewSecurityPreferences { diff --git a/apps/code/src/main/utils/webview-navigation-guard.test.ts b/apps/code/src/main/utils/webview-navigation-guard.test.ts index 568d7c954d..547e1c4935 100644 --- a/apps/code/src/main/utils/webview-navigation-guard.test.ts +++ b/apps/code/src/main/utils/webview-navigation-guard.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { isAllowedWebviewNavigation, + isAllowedWebviewRequest, isBlockedWebviewHost, } from "./webview-navigation-guard"; @@ -25,6 +26,25 @@ describe("isBlockedWebviewHost", () => { }); }); +describe("isAllowedWebviewRequest", () => { + it.each([ + ["https://posthog.com", "mainFrame", true], + ["http://posthog.com", "mainFrame", false], + ["https://posthog.com/app.js", "script", true], + ["http://localhost:3000/app.js", "script", true], + ["http://posthog.com/app.js", "script", false], + ["data:image/png;base64,AA==", "image", true], + ["blob:https://posthog.com/id", "xhr", true], + ["about:blank", "subFrame", true], + ["about:srcdoc", "subFrame", false], + ["file:///etc/passwd", "xhr", false], + ["custom-scheme://host/path", "xhr", false], + ["http://169.254.169.254/latest/meta-data/", "xhr", false], + ])("url %j resource %j -> allowed %s", (url, resourceType, allowed) => { + expect(isAllowedWebviewRequest(url, resourceType)).toBe(allowed); + }); +}); + describe("isAllowedWebviewNavigation", () => { it.each([ ["https://posthog.com", true], diff --git a/apps/code/src/main/utils/webview-navigation-guard.ts b/apps/code/src/main/utils/webview-navigation-guard.ts index 4a352c53e6..cd1671471b 100644 --- a/apps/code/src/main/utils/webview-navigation-guard.ts +++ b/apps/code/src/main/utils/webview-navigation-guard.ts @@ -58,3 +58,25 @@ export function isAllowedWebviewNavigation(url: string): boolean { if (parsed.protocol === "https:") return true; return parsed.protocol === "http:" && isLoopbackHost(parsed.hostname); } + +export function isAllowedWebviewRequest( + url: string, + resourceType: string, +): boolean { + if (resourceType === "mainFrame") { + return isAllowedWebviewNavigation(url); + } + + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + + if (parsed.href === "about:blank") return true; + if (isBlockedWebviewHost(parsed.hostname)) return false; + if (parsed.protocol === "data:" || parsed.protocol === "blob:") return true; + if (parsed.protocol === "https:") return true; + return parsed.protocol === "http:" && isLoopbackHost(parsed.hostname); +} diff --git a/apps/code/src/main/window.ts b/apps/code/src/main/window.ts index 5dd622f134..3125c179d0 100644 --- a/apps/code/src/main/window.ts +++ b/apps/code/src/main/window.ts @@ -6,6 +6,7 @@ import { DARK_APP_BACKGROUND_COLOR } from "@posthog/shared/constants"; import { app, BrowserWindow, + session as electronSession, Menu, type MenuItemConstructorOptions, screen, @@ -17,6 +18,7 @@ import { buildApplicationMenu } from "./menu"; import type { ElectronMainWindow } from "./platform-adapters/electron-main-window"; import { posthogNodeAnalytics } from "./platform-adapters/posthog-analytics"; import { POSTHOG_SESSION_ID_ARG } from "./posthog-session-arg"; +import { browserViewService } from "./services/browser-view/service"; import { encodeDevFlagsForArg, readDevFlagsSync, @@ -39,7 +41,7 @@ import { } from "./utils/webview-attach-policy"; import { isAllowedWebviewNavigation, - safeProtocol, + isAllowedWebviewRequest, } from "./utils/webview-navigation-guard"; import { isAllowedWebviewPermission } from "./utils/webview-permission-policy"; import { setupWindowZoom } from "./zoom"; @@ -142,13 +144,26 @@ function hardenWebviewSession(session: Electron.Session): void { session.setPermissionCheckHandler((_wc, permission) => isAllowedWebviewPermission(permission), ); + session.webRequest.onBeforeRequest((details, callback) => { + const allowed = isAllowedWebviewRequest(details.url, details.resourceType); + if (!allowed) { + log.warn("Blocked disallowed webview request", { + resourceType: details.resourceType, + url: details.url, + }); + } + callback({ cancel: !allowed }); + }); } function setupWebviewHandlers(window: BrowserWindow): void { window.webContents.on( "will-attach-webview", (event, webPreferences, params) => { - if (!isAllowedWebviewAttachment(params)) { + if ( + !browserViewService.isEnabled() || + !isAllowedWebviewAttachment(params) + ) { event.preventDefault(); log.warn("Blocked disallowed webview attachment", { src: params.src, @@ -156,6 +171,7 @@ function setupWebviewHandlers(window: BrowserWindow): void { }); return; } + hardenWebviewSession(electronSession.fromPartition(params.partition)); hardenWebviewPreferences(webPreferences); }, ); @@ -164,10 +180,10 @@ function setupWebviewHandlers(window: BrowserWindow): void { hardenWebviewSession(guest.session); guest.setWindowOpenHandler(({ url }) => { - if (/^https?:$/i.test(safeProtocol(url))) { - shell.openExternal(url); + if (isAllowedWebviewNavigation(url)) { + void shell.openExternal(url); } else { - log.warn("Blocked webview popup to non-http(s) target", { url }); + log.warn("Blocked disallowed webview popup", { url }); } return { action: "deny" }; }); @@ -318,6 +334,7 @@ export function createWindow(): void { webPreferences: { nodeIntegration: false, contextIsolation: true, + webviewTag: true, preload: path.join(__dirname, "preload.js"), enableBlinkFeatures: "GetDisplayMedia", partition: "persist:main", diff --git a/apps/code/src/renderer/contributions/browser-view.contribution.ts b/apps/code/src/renderer/contributions/browser-view.contribution.ts new file mode 100644 index 0000000000..b8c087b0bd --- /dev/null +++ b/apps/code/src/renderer/contributions/browser-view.contribution.ts @@ -0,0 +1,26 @@ +import type { Contribution } from "@posthog/di/contribution"; +import { BROWSER_TAB_FLAG } from "@posthog/shared/constants"; +import { + FEATURE_FLAGS, + type FeatureFlags, +} from "@posthog/ui/features/feature-flags/identifiers"; +import { trpcClient } from "@renderer/trpc/client"; +import { inject, injectable } from "inversify"; + +@injectable() +export class BrowserViewContribution implements Contribution { + constructor( + @inject(FEATURE_FLAGS) private readonly featureFlags: FeatureFlags, + ) {} + + start(): void { + const sync = (): void => { + const enabled = + import.meta.env.DEV || this.featureFlags.isEnabled(BROWSER_TAB_FLAG); + void trpcClient.browserView.setEnabled.mutate({ enabled }); + }; + + sync(); + this.featureFlags.onFlagsLoaded(sync); + } +} diff --git a/apps/code/src/renderer/desktop-contributions.ts b/apps/code/src/renderer/desktop-contributions.ts index c3565ea3eb..ef88309c42 100644 --- a/apps/code/src/renderer/desktop-contributions.ts +++ b/apps/code/src/renderer/desktop-contributions.ts @@ -27,6 +27,7 @@ import { AnalyticsBootContribution, InboxDemoDevContribution, } from "@renderer/contributions/app-boot.contributions"; +import { BrowserViewContribution } from "@renderer/contributions/browser-view.contribution"; import { container } from "@renderer/di/container"; export function registerDesktopContributions(): void { @@ -60,5 +61,6 @@ export function registerDesktopContributions(): void { } container.bind(CONTRIBUTION).to(AnalyticsBootContribution).inSingletonScope(); + container.bind(CONTRIBUTION).to(BrowserViewContribution).inSingletonScope(); container.bind(CONTRIBUTION).to(InboxDemoDevContribution).inSingletonScope(); } diff --git a/apps/code/src/renderer/platform-adapters/electron-browser-view.tsx b/apps/code/src/renderer/platform-adapters/electron-browser-view.tsx index af0086f01b..0c959536a9 100644 --- a/apps/code/src/renderer/platform-adapters/electron-browser-view.tsx +++ b/apps/code/src/renderer/platform-adapters/electron-browser-view.tsx @@ -1,9 +1,10 @@ -import { BROWSER_WEBVIEW_PARTITION } from "@posthog/shared/constants"; import type { BrowserViewHandle, BrowserViewProps, } from "@posthog/ui/features/browser/identifiers"; -import { useEffect, useRef } from "react"; +import { trpcClient } from "@renderer/trpc/client"; +import { BROWSER_WEBVIEW_PARTITION } from "@shared/browser-view"; +import { useEffect, useRef, useState } from "react"; interface ElectronWebviewElement extends HTMLElement, BrowserViewHandle { getURL(): string; @@ -28,11 +29,40 @@ export function ElectronBrowserView({ onLoadingChange, }: BrowserViewProps) { const webviewRef = useRef(null); + const [hostEnabled, setHostEnabled] = useState(false); useEffect(() => { + let active = true; + trpcClient.browserView.setEnabled + .mutate({ enabled: true }) + .then(() => { + if (active) setHostEnabled(true); + }) + .catch(() => { + if (active) onLoadError("Browser view is unavailable"); + }); + return () => { + active = false; + }; + }, [onLoadError]); + + useEffect(() => { + if (!hostEnabled) return; const webview = webviewRef.current; if (!webview) return; + let ready = false; + const readyTimeout = window.setTimeout(() => { + if (ready) return; + onReady(null); + onLoadError("Browser view failed to attach"); + }, 10_000); + const handleReady = () => { + if (ready) return; + ready = true; + window.clearTimeout(readyTimeout); + onReady(webview); + }; const handleNavigate = (event: Event) => { const navigation = event as WebviewNavigateEvent; if (navigation.isMainFrame === false) return; @@ -53,7 +83,7 @@ export function ElectronBrowserView({ const handleStartLoading = () => onLoadingChange(true); const handleStopLoading = () => onLoadingChange(false); - onReady(webview); + webview.addEventListener("dom-ready", handleReady); webview.addEventListener("did-navigate", handleNavigate); webview.addEventListener("did-navigate-in-page", handleNavigate); webview.addEventListener("page-title-updated", handleTitle); @@ -62,7 +92,9 @@ export function ElectronBrowserView({ webview.addEventListener("did-stop-loading", handleStopLoading); return () => { + window.clearTimeout(readyTimeout); onReady(null); + webview.removeEventListener("dom-ready", handleReady); webview.removeEventListener("did-navigate", handleNavigate); webview.removeEventListener("did-navigate-in-page", handleNavigate); webview.removeEventListener("page-title-updated", handleTitle); @@ -70,7 +102,16 @@ export function ElectronBrowserView({ webview.removeEventListener("did-start-loading", handleStartLoading); webview.removeEventListener("did-stop-loading", handleStopLoading); }; - }, [onLoadError, onLoadingChange, onNavigate, onReady, onTitleChange]); + }, [ + hostEnabled, + onLoadError, + onLoadingChange, + onNavigate, + onReady, + onTitleChange, + ]); + + if (!hostEnabled) return null; return ( { + const server = createServer((_request, response) => { + response.writeHead(200, { "content-type": "text/html" }); + response.end("Browser E2E
Browser works
"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address() as AddressInfo; + const url = `http://127.0.0.1:${address.port}`; + + try { + await window.evaluate(async () => { + const bridge = ( + window as unknown as { + electronTRPC: { + onMessage(callback: (message: unknown) => void): void; + sendMessage(message: unknown): void; + }; + } + ).electronTRPC; + const id = `browser-e2e-${crypto.randomUUID()}`; + await new Promise((resolve, reject) => { + bridge.onMessage((message) => { + const response = message as { + id?: string; + error?: { message?: string }; + result?: { type: string }; + }; + if (response.id !== id) return; + if (response.error) { + reject( + new Error(response.error.message ?? "Failed to enable webview"), + ); + return; + } + resolve(); + }); + bridge.sendMessage({ + method: "request", + operation: { + context: {}, + id, + input: { enabled: true }, + path: "browserView.setEnabled", + type: "mutation", + }, + }); + }); + }); + + const result = await window.evaluate(async (targetUrl) => { + type TestWebview = HTMLElement & { + getURL(): string; + loadURL(url: string): Promise; + reload(): void; + }; + const webview = document.createElement("webview") as TestWebview; + webview.setAttribute("partition", "persist:browser"); + webview.setAttribute("src", "about:blank"); + webview.style.width = "400px"; + webview.style.height = "300px"; + document.body.appendChild(webview); + + await new Promise((resolve, reject) => { + const timeout = globalThis.setTimeout( + () => reject(new Error("Timed out waiting for dom-ready")), + 10_000, + ); + webview.addEventListener( + "dom-ready", + () => { + globalThis.clearTimeout(timeout); + resolve(); + }, + { once: true }, + ); + }); + + const upgraded = + typeof webview.loadURL === "function" && + typeof webview.getURL === "function"; + await webview.loadURL(targetUrl); + const navigatedUrl = webview.getURL(); + + let unsafeRejected = false; + try { + await webview.loadURL("file:///etc/passwd"); + } catch { + unsafeRejected = true; + } + + const reloaded = new Promise((resolve) => { + webview.addEventListener("did-stop-loading", () => resolve(), { + once: true, + }); + }); + webview.reload(); + await reloaded; + + return { + navigatedUrl, + recoveredUrl: webview.getURL(), + unsafeRejected, + upgraded, + }; + }, url); + + expect(result.upgraded).toBe(true); + expect(result.navigatedUrl).toBe(`${url}/`); + expect(result.unsafeRejected).toBe(true); + expect(result.recoveredUrl).toBe(`${url}/`); + } finally { + server.close(); + } +}); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index d7c0b9eea8..dd832451cc 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -7,8 +7,6 @@ export { SYNC_CLOUD_TASKS_FLAG, } from "./flags"; -export const BROWSER_WEBVIEW_PARTITION = "persist:browser"; - export const SELF_DRIVING_SETUP_TASK_FLAG = "posthog-code-self-driving-setup-task"; export const BRANCH_PREFIX = "posthog-code/"; diff --git a/packages/ui/src/features/browser/BrowserPanel.interaction.test.tsx b/packages/ui/src/features/browser/BrowserPanel.interaction.test.tsx index 51cdea7a11..9e3f4542da 100644 --- a/packages/ui/src/features/browser/BrowserPanel.interaction.test.tsx +++ b/packages/ui/src/features/browser/BrowserPanel.interaction.test.tsx @@ -1,10 +1,10 @@ import { ServiceProvider } from "@posthog/di/react"; import { Theme } from "@radix-ui/themes"; -import { render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { Container } from "inversify"; import { useEffect } from "react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { BrowserPanel } from "./BrowserPanel"; import { BROWSER_VIEW_COMPONENT, @@ -30,7 +30,19 @@ function FakeBrowserView({ onReady }: BrowserViewProps) { return
; } +let reportDeferredReady: (() => void) | undefined; + +function DeferredBrowserView({ onReady }: BrowserViewProps) { + reportDeferredReady = () => onReady(browserViewHandle); + return
; +} + describe("BrowserPanel", () => { + beforeEach(() => { + loadURL.mockReset(); + reportDeferredReady = undefined; + }); + it("navigates when Enter is pressed in the address input", async () => { loadURL.mockResolvedValue(); const container = new Container(); @@ -52,4 +64,50 @@ describe("BrowserPanel", () => { expect(loadURL).toHaveBeenCalledWith("https://posthog.com"); }); + + it("surfaces synchronous navigation failures", async () => { + loadURL.mockImplementation(() => { + throw new Error("webview unavailable"); + }); + const container = new Container(); + container.bind(BROWSER_VIEW_COMPONENT).toConstantValue(FakeBrowserView); + const user = userEvent.setup(); + + render( + + + + + , + ); + + await user.type( + screen.getByRole("textbox", { name: "Address" }), + "posthog.com{Enter}", + ); + + expect(screen.getByText("Failed to load page")).toBeInTheDocument(); + }); + + it("queues Enter navigation until the browser view is ready", async () => { + loadURL.mockResolvedValue(); + const container = new Container(); + container.bind(BROWSER_VIEW_COMPONENT).toConstantValue(DeferredBrowserView); + + render( + + + + + , + ); + + const address = screen.getByRole("textbox", { name: "Address" }); + fireEvent.change(address, { target: { value: "posthog.com" } }); + fireEvent.submit(address.closest("form") as HTMLFormElement); + + expect(loadURL).not.toHaveBeenCalled(); + act(() => reportDeferredReady?.()); + expect(loadURL).toHaveBeenCalledWith("https://posthog.com"); + }); }); diff --git a/packages/ui/src/features/browser/BrowserPanel.test.ts b/packages/ui/src/features/browser/BrowserPanel.test.ts index f29aa2f830..45dd45b686 100644 --- a/packages/ui/src/features/browser/BrowserPanel.test.ts +++ b/packages/ui/src/features/browser/BrowserPanel.test.ts @@ -7,7 +7,11 @@ describe("normalizeAddress", () => { [" ", "about:blank"], ["about:blank", "about:blank"], ["https://posthog.com", "https://posthog.com"], - ["http://example.com/path", "http://example.com/path"], + ["http://example.com/path", "https://example.com/path"], + ["http://localhost:3000/path", "http://localhost:3000/path"], + ["http://127.0.0.2:8000", "http://127.0.0.2:8000"], + ["http://0.0.0.0:3000", "http://0.0.0.0:3000"], + ["http://[::1]:3000", "http://[::1]:3000"], ["example.com", "https://example.com"], ["example.com/path?q=1", "https://example.com/path?q=1"], ["localhost:3000", "http://localhost:3000"], diff --git a/packages/ui/src/features/browser/BrowserPanel.tsx b/packages/ui/src/features/browser/BrowserPanel.tsx index 1ec86668ea..1bb483e0bc 100644 --- a/packages/ui/src/features/browser/BrowserPanel.tsx +++ b/packages/ui/src/features/browser/BrowserPanel.tsx @@ -29,20 +29,27 @@ export function useBrowserEnabled(): boolean { const DEFAULT_URL = "about:blank"; -const LOOPBACK_HOST = /^(localhost|127\.0\.0\.1)(?=[:/]|$)/i; +const LOOPBACK_HOST = + /^(localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[::1\])(?=[:/]|$)/i; // Anything else (file:, chrome:, data:, javascript:, ...) becomes a search. // Keep in sync with the authoritative main-process guard (setupWebviewHandlers // in window.ts) — this is convenience, that is the security boundary. -const ALLOWED_SCHEME = /^(https?):\/\//i; +const HTTPS_SCHEME = /^https:\/\//i; +const HTTP_SCHEME = /^http:\/\//i; // Loopback defaults to http since dev servers rarely serve https. export function normalizeAddress(input: string): string { const trimmed = input.trim(); if (!trimmed) return DEFAULT_URL; - if (ALLOWED_SCHEME.test(trimmed) || trimmed === "about:blank") { + if (HTTPS_SCHEME.test(trimmed) || trimmed === "about:blank") { return trimmed; } + if (HTTP_SCHEME.test(trimmed)) { + const withoutScheme = trimmed.replace(HTTP_SCHEME, ""); + if (LOOPBACK_HOST.test(withoutScheme)) return trimmed; + return `https://${withoutScheme}`; + } // A schemeless "host:port" (e.g. localhost:3000) is a host, not a scheme. const hasDisallowedScheme = /^[a-z][a-z0-9+.-]*:/i.test(trimmed) && @@ -75,6 +82,7 @@ export function BrowserPanel({ BROWSER_VIEW_COMPONENT, ); const browserViewRef = useRef(null); + const pendingNavigationRef = useRef(null); // src is set once so re-renders never reload the page; the value comes off // disk and must not be trusted as a raw src. const initialUrl = useRef(normalizeAddress(url)); @@ -119,9 +127,26 @@ export function BrowserPanel({ [], ); - const handleReady = useCallback((handle: BrowserViewHandle | null) => { - browserViewRef.current = handle; + const loadUrl = useCallback((handle: BrowserViewHandle, target: string) => { + setLoadError(null); + try { + void handle.loadURL(target).catch(() => { + setLoadError("Failed to load page"); + }); + } catch { + setLoadError("Failed to load page"); + } }, []); + const handleReady = useCallback( + (handle: BrowserViewHandle | null) => { + browserViewRef.current = handle; + if (!handle || pendingNavigationRef.current === null) return; + const target = pendingNavigationRef.current; + pendingNavigationRef.current = null; + loadUrl(handle, target); + }, + [loadUrl], + ); const handleNavigate = useCallback( (navigation: BrowserViewNavigation) => { setAddress(navigation.url === DEFAULT_URL ? "" : navigation.url); @@ -156,12 +181,18 @@ export function BrowserPanel({ handleReload(); }, [handleReload, isLoading]); - const navigate = useCallback((raw: string) => { - const browserView = browserViewRef.current; - if (!browserView) return; - setLoadError(null); - browserView.loadURL(normalizeAddress(raw)).catch(() => {}); - }, []); + const navigate = useCallback( + (raw: string) => { + const target = normalizeAddress(raw); + const browserView = browserViewRef.current; + if (!browserView) { + pendingNavigationRef.current = target; + return; + } + loadUrl(browserView, target); + }, + [loadUrl], + ); const onSubmit = useCallback( (e: React.FormEvent) => { @@ -170,15 +201,6 @@ export function BrowserPanel({ }, [address, navigate], ); - const handleAddressKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (event.key !== "Enter" || event.nativeEvent.isComposing) return; - event.preventDefault(); - navigate(address); - }, - [address, navigate], - ); - if (!BrowserView) return null; return ( @@ -223,7 +245,6 @@ export function BrowserPanel({ autoFocus={initialUrl.current === DEFAULT_URL} value={address} onChange={(e) => setAddress(e.target.value)} - onKeyDown={handleAddressKeyDown} placeholder="Search or enter address" spellCheck={false} className="h-[24px] w-full rounded-(--radius-2) border-0 bg-(--gray-3) px-2 text-(--gray-12) text-[12px] outline-none focus:bg-(--gray-4)" diff --git a/packages/ui/src/features/browser/BrowserUnavailable.tsx b/packages/ui/src/features/browser/BrowserUnavailable.tsx new file mode 100644 index 0000000000..3de621382a --- /dev/null +++ b/packages/ui/src/features/browser/BrowserUnavailable.tsx @@ -0,0 +1,24 @@ +import { Globe } from "@phosphor-icons/react"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@posthog/quill"; + +export function BrowserUnavailable() { + return ( + + + + + + Browser unavailable + + This host does not support embedded browser tabs. + + + + ); +} diff --git a/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx b/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx index 9af5a22ba5..9c5e570e63 100644 --- a/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx +++ b/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx @@ -12,7 +12,7 @@ import type { LeafPanel } from "../panelTypes"; import { type AddableTabKind, getAddableTabKinds, - isPanelTabAvailable, + isPersistedPanelTabVisible, } from "../tabAvailability"; import { TabbedPanel } from "./TabbedPanel"; @@ -64,7 +64,7 @@ export const LeafNodeRenderer: React.FC = ({ const inputTabs = useMemo( () => node.content.tabs.filter((tab) => - isPanelTabAvailable(tab.data.type, availability), + isPersistedPanelTabVisible(tab.data.type, availability), ), [node.content.tabs, availability], ); diff --git a/packages/ui/src/features/panels/tabAvailability.test.ts b/packages/ui/src/features/panels/tabAvailability.test.ts index 0e55cf3d53..e825da6fb4 100644 --- a/packages/ui/src/features/panels/tabAvailability.test.ts +++ b/packages/ui/src/features/panels/tabAvailability.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { getAddableTabKinds, isPanelTabAvailable, + isPersistedPanelTabVisible, type TabAvailability, } from "./tabAvailability"; @@ -47,3 +48,14 @@ describe("tab availability", () => { }, ); }); + +describe("persisted tab visibility", () => { + it("keeps unsupported browser tabs visible", () => { + expect( + isPersistedPanelTabVisible("browser", { + browserEnabled: false, + terminalEnabled: true, + }), + ).toBe(true); + }); +}); diff --git a/packages/ui/src/features/panels/tabAvailability.ts b/packages/ui/src/features/panels/tabAvailability.ts index 56a4f0f05e..af9772258f 100644 --- a/packages/ui/src/features/panels/tabAvailability.ts +++ b/packages/ui/src/features/panels/tabAvailability.ts @@ -25,3 +25,11 @@ export function isPanelTabAvailable( if (type === "browser") return availability.browserEnabled; return true; } + +export function isPersistedPanelTabVisible( + type: Tab["data"]["type"], + availability: TabAvailability, +): boolean { + if (type === "browser") return true; + return isPanelTabAvailable(type, availability); +} diff --git a/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx b/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx index a61b021be1..274097ef85 100644 --- a/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx +++ b/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx @@ -1,6 +1,7 @@ import type { Task } from "@posthog/shared/domain-types"; import { AutoresearchPanel } from "../../autoresearch/AutoresearchPanel"; import { useBrowserEnabled } from "../../browser/BrowserPanel"; +import { BrowserUnavailable } from "../../browser/BrowserUnavailable"; import { CodeEditorPanel } from "../../code-editor/components/CodeEditorPanel"; import { LazyCloudReviewPage as CloudReviewPage, @@ -80,7 +81,7 @@ export function TabContentRenderer({ return ; case "browser": - if (!browserEnabled) return null; + if (!browserEnabled) return ; return ; case "other": From a1f18145f347233793353e5cc4d3c68c58e5fb4d Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Fri, 17 Jul 2026 12:13:14 -0400 Subject: [PATCH 16/17] fix(browser): keep flag sync at host seam Generated-By: PostHog Code Task-Id: b23f944d-dd7f-465b-9fed-1a1d028f35e1 --- .../contributions/browser-view.contribution.ts | 11 ++--------- apps/code/src/renderer/desktop-contributions.ts | 5 ++++- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/apps/code/src/renderer/contributions/browser-view.contribution.ts b/apps/code/src/renderer/contributions/browser-view.contribution.ts index b8c087b0bd..5e39bb7ea0 100644 --- a/apps/code/src/renderer/contributions/browser-view.contribution.ts +++ b/apps/code/src/renderer/contributions/browser-view.contribution.ts @@ -1,17 +1,10 @@ import type { Contribution } from "@posthog/di/contribution"; import { BROWSER_TAB_FLAG } from "@posthog/shared/constants"; -import { - FEATURE_FLAGS, - type FeatureFlags, -} from "@posthog/ui/features/feature-flags/identifiers"; +import type { FeatureFlags } from "@posthog/ui/features/feature-flags/identifiers"; import { trpcClient } from "@renderer/trpc/client"; -import { inject, injectable } from "inversify"; -@injectable() export class BrowserViewContribution implements Contribution { - constructor( - @inject(FEATURE_FLAGS) private readonly featureFlags: FeatureFlags, - ) {} + constructor(private readonly featureFlags: FeatureFlags) {} start(): void { const sync = (): void => { diff --git a/apps/code/src/renderer/desktop-contributions.ts b/apps/code/src/renderer/desktop-contributions.ts index ef88309c42..176572c91f 100644 --- a/apps/code/src/renderer/desktop-contributions.ts +++ b/apps/code/src/renderer/desktop-contributions.ts @@ -16,6 +16,7 @@ import { browserTabsUiModule } from "@posthog/ui/features/browser-tabs/browser-t import { cloneUiModule } from "@posthog/ui/features/clone/clone.module"; import { connectivityUiModule } from "@posthog/ui/features/connectivity/connectivity.module"; import { discordPresenceUiModule } from "@posthog/ui/features/discord-presence/discordPresence.module"; +import { FEATURE_FLAGS } from "@posthog/ui/features/feature-flags/identifiers"; import { fileWatcherUiModule } from "@posthog/ui/features/file-watcher/file-watcher.module"; import { focusUiModule } from "@posthog/ui/features/focus/focus.module"; import { notificationsUiModule } from "@posthog/ui/features/notifications/notifications.module"; @@ -61,6 +62,8 @@ export function registerDesktopContributions(): void { } container.bind(CONTRIBUTION).to(AnalyticsBootContribution).inSingletonScope(); - container.bind(CONTRIBUTION).to(BrowserViewContribution).inSingletonScope(); + container + .bind(CONTRIBUTION) + .toConstantValue(new BrowserViewContribution(container.get(FEATURE_FLAGS))); container.bind(CONTRIBUTION).to(InboxDemoDevContribution).inSingletonScope(); } From 78d5d9187f414e12771b76302414c4ebbe311542 Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Fri, 17 Jul 2026 15:50:43 -0400 Subject: [PATCH 17/17] feat(browser): spin up browser cells from the command center (#3182) --- packages/core/src/command-center/cells.ts | 12 ++ packages/core/src/command-center/grid.test.ts | 32 ++++ packages/core/src/command-center/grid.ts | 17 ++ .../command-center/commandCenterStore.ts | 26 +++ .../components/CommandCenterPanel.tsx | 149 ++++++++++++++---- .../components/TaskSelector.tsx | 18 +++ 6 files changed, 226 insertions(+), 28 deletions(-) diff --git a/packages/core/src/command-center/cells.ts b/packages/core/src/command-center/cells.ts index d7457526bc..529c615a13 100644 --- a/packages/core/src/command-center/cells.ts +++ b/packages/core/src/command-center/cells.ts @@ -1,9 +1,11 @@ import type { AgentSession, WorkspaceMode } from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; import { + getBrowserCellUrl, getTerminalCellCwd, getTerminalCellId, isBrainrotCell, + isBrowserCell, isTerminalCell, } from "./grid"; import { type CellStatus, deriveStatus, getRepoName } from "./status"; @@ -21,6 +23,7 @@ export interface CommandCenterCellData { // Standalone terminal slot, independent of any agent run. terminalId: string | null; terminalCwd: string | null; + browserUrl: string | null; } export interface BuildCellsInput { @@ -39,6 +42,7 @@ const EMPTY_CELL_DATA = { isBrainrot: false, terminalId: null, terminalCwd: null, + browserUrl: null, }; export function buildCommandCenterCells( @@ -60,6 +64,14 @@ export function buildCommandCenterCells( }; } + if (isBrowserCell(cellValue)) { + return { + ...EMPTY_CELL_DATA, + cellIndex, + browserUrl: getBrowserCellUrl(cellValue), + }; + } + const taskId = cellValue; const task = taskId ? taskById.get(taskId) : undefined; const session = taskId ? sessionByTaskId.get(taskId) : undefined; diff --git a/packages/core/src/command-center/grid.test.ts b/packages/core/src/command-center/grid.test.ts index e1af10334a..a886e5fb2c 100644 --- a/packages/core/src/command-center/grid.test.ts +++ b/packages/core/src/command-center/grid.test.ts @@ -2,13 +2,16 @@ import { describe, expect, it } from "vitest"; import { BRAINROT_CELL, clampZoom, + getBrowserCellUrl, getCellCount, getCellSessionId, getGridDimensions, getTerminalCellCwd, getTerminalCellId, isBrainrotCell, + isBrowserCell, isTerminalCell, + makeBrowserCellValue, makeTerminalCellValue, resizeCells, } from "./grid"; @@ -92,6 +95,35 @@ describe("terminal cells", () => { }); }); +describe("browser cells", () => { + it.each([ + "about:blank", + "https://posthog.com", + // A url containing the delimiter and prefix-like text must survive intact. + "https://example.com/x?to=__browser__:https://evil.com", + ])("round-trips %j through the cell value", (url) => { + const value = makeBrowserCellValue(url); + expect(isBrowserCell(value)).toBe(true); + expect(getBrowserCellUrl(value)).toBe(url); + }); + + it("round-trips an empty url (blank browser cell)", () => { + const value = makeBrowserCellValue(""); + expect(isBrowserCell(value)).toBe(true); + expect(getBrowserCellUrl(value)).toBe(""); + }); + + it.each([ + { value: "some-task-uuid", expected: false }, + { value: BRAINROT_CELL, expected: false }, + { value: makeTerminalCellValue("t1"), expected: false }, + { value: null, expected: false }, + ])("isBrowserCell($value) -> $expected", ({ value, expected }) => { + expect(isBrowserCell(value)).toBe(expected); + expect(getBrowserCellUrl(value)).toBeNull(); + }); +}); + describe("getCellSessionId", () => { it("formats the cell session id", () => { expect(getCellSessionId(2)).toBe("cc-cell-2"); diff --git a/packages/core/src/command-center/grid.ts b/packages/core/src/command-center/grid.ts index 7d2e5f8e16..6a4d8400a5 100644 --- a/packages/core/src/command-center/grid.ts +++ b/packages/core/src/command-center/grid.ts @@ -49,6 +49,23 @@ export function getTerminalCellCwd(value: string | null): string | null { return colon === -1 ? null : decodeURIComponent(rest.slice(colon + 1)); } +// Reserved prefix for standalone browser cells; the whole remainder is the +// url, so urls containing ":" or the prefix text are safe. Never collides with +// task ids (uuids), BRAINROT_CELL, or terminal cells. +export const BROWSER_CELL_PREFIX = "__browser__:"; + +export function isBrowserCell(value: string | null): value is string { + return value?.startsWith(BROWSER_CELL_PREFIX) ?? false; +} + +export function makeBrowserCellValue(url: string): string { + return `${BROWSER_CELL_PREFIX}${url}`; +} + +export function getBrowserCellUrl(value: string | null): string | null { + return isBrowserCell(value) ? value.slice(BROWSER_CELL_PREFIX.length) : null; +} + export function getGridDimensions(preset: LayoutPreset): GridDimensions { const [cols, rows] = preset.split("x").map(Number); return { cols, rows }; diff --git a/packages/ui/src/features/command-center/commandCenterStore.ts b/packages/ui/src/features/command-center/commandCenterStore.ts index 14f32f66ab..e490d76740 100644 --- a/packages/ui/src/features/command-center/commandCenterStore.ts +++ b/packages/ui/src/features/command-center/commandCenterStore.ts @@ -2,7 +2,9 @@ import { BRAINROT_CELL, clampZoom, getCellCount, + isBrowserCell, type LayoutPreset, + makeBrowserCellValue, makeTerminalCellValue, resizeCells, ZOOM_STEP, @@ -39,6 +41,8 @@ interface CommandCenterStoreActions { terminalId: string, cwd?: string, ) => void; + setBrowserCell: (cellIndex: number, url: string) => void; + updateBrowserCellUrl: (cellIndex: number, url: string) => void; autofillCells: (taskIds: string[]) => void; clearCell: (cellIndex: number) => void; removeTaskById: (taskId: string) => void; @@ -136,6 +140,28 @@ export const useCommandCenterStore = create()( }; }), + setBrowserCell: (cellIndex, url) => + set((state) => { + if (cellIndex < 0 || cellIndex >= state.cells.length) return state; + const cells = [...state.cells]; + cells[cellIndex] = makeBrowserCellValue(url); + return { + cells, + activeTaskId: null, + activeCellIndex: cellIndex, + creatingCells: state.creatingCells.filter((i) => i !== cellIndex), + hasAutofilled: true, + }; + }), + + updateBrowserCellUrl: (cellIndex, url) => + set((state) => { + if (!isBrowserCell(state.cells[cellIndex] ?? null)) return state; + const cells = [...state.cells]; + cells[cellIndex] = makeBrowserCellValue(url); + return { cells }; + }), + autofillCells: (taskIds) => set((state) => { // Grid already full: nothing to place, but the bootstrap is done. diff --git a/packages/ui/src/features/command-center/components/CommandCenterPanel.tsx b/packages/ui/src/features/command-center/components/CommandCenterPanel.tsx index db0efcd93a..b87a0b1686 100644 --- a/packages/ui/src/features/command-center/components/CommandCenterPanel.tsx +++ b/packages/ui/src/features/command-center/components/CommandCenterPanel.tsx @@ -4,6 +4,7 @@ import { Desktop, Folder, GitFork, + Globe, Lightning, Plus, Terminal, @@ -12,6 +13,10 @@ import { import { isBrainrotCell } from "@posthog/core/command-center/grid"; import { ANALYTICS_EVENTS, type WorkspaceMode } from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; +import { + BrowserPanel, + useBrowserEnabled, +} from "@posthog/ui/features/browser/BrowserPanel"; import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { destroyShellTerminal } from "@posthog/ui/features/terminal/destroyShellTerminal"; import { ShellTerminal } from "@posthog/ui/features/terminal/ShellTerminal"; @@ -19,7 +24,13 @@ import { openTask } from "@posthog/ui/router/useOpenTask"; import { track } from "@posthog/ui/shell/analytics"; import { secureRandomString } from "@posthog/ui/utils/random"; import { Flex, Spinner, Text } from "@radix-ui/themes"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; import { useFolders } from "../../folders/useFolders"; import { useCloudPrUrl } from "../../git-interaction/useCloudPrUrl"; import { useDraftStore } from "../../message-editor/draftStore"; @@ -116,11 +127,13 @@ function EmptyCell({ cellIndex }: { cellIndex: number }) { const assignTask = useCommandCenterStore((s) => s.assignTask); const setBrainrotCell = useCommandCenterStore((s) => s.setBrainrotCell); const setTerminalCell = useCommandCenterStore((s) => s.setTerminalCell); + const setBrowserCell = useCommandCenterStore((s) => s.setBrowserCell); const startCreating = useCommandCenterStore((s) => s.startCreating); const stopCreating = useCommandCenterStore((s) => s.stopCreating); const layout = useCommandCenterStore((s) => s.layout); const cells = useCommandCenterStore((s) => s.cells); const brainrotMode = useSettingsStore((s) => s.brainrotMode); + const browserEnabled = useBrowserEnabled(); const clearDraft = useDraftStore((s) => s.actions.setDraft); const sessionId = getCellSessionId(cellIndex); @@ -140,6 +153,10 @@ function EmptyCell({ cellIndex }: { cellIndex: number }) { [setTerminalCell, cellIndex], ); + const handleNewBrowser = useCallback(() => { + setBrowserCell(cellIndex, "about:blank"); + }, [setBrowserCell, cellIndex]); + const handleTaskCreated = useCallback( (task: Task) => { assignTask(cellIndex, task.id); @@ -199,6 +216,7 @@ function EmptyCell({ cellIndex }: { cellIndex: number }) { onOpenChange={setSelectorOpen} onNewTask={() => startCreating(cellIndex)} onNewTerminal={handleNewTerminal} + onNewBrowser={browserEnabled ? handleNewBrowser : undefined} onBrainrot={brainrotMode ? handleBrainrot : undefined} > + + + {children} + + + ); +} + function TerminalCell({ cellIndex, terminalId, @@ -300,37 +366,59 @@ function TerminalCell({ }, [stateKey, clearCell, cellIndex]); return ( - - - - - Terminal - - {folderName && ( + } + title="Terminal" + headerExtra={ + folderName ? ( {folderName} - )} - - - - - - + ) : undefined + } + onRemove={handleRemove} + > + + + ); +} + +function hostnameOf(url: string): string | null { + try { + return new URL(url).hostname || null; + } catch { + return null; + } +} + +function BrowserCell({ cellIndex, url }: { cellIndex: number; url: string }) { + const clearCell = useCommandCenterStore((s) => s.clearCell); + const updateBrowserCellUrl = useCommandCenterStore( + (s) => s.updateBrowserCellUrl, + ); + // Page title once loaded; before that (e.g. a just-restored cell) the + // persisted url's hostname beats a bare "Browser". + const [title, setTitle] = useState(null); + const label = title ?? hostnameOf(url) ?? "Browser"; + + const onUrlChange = useCallback( + (next: string) => updateBrowserCellUrl(cellIndex, next), + [updateBrowserCellUrl, cellIndex], + ); + + return ( + } + title={label} + onRemove={() => clearCell(cellIndex)} + > + + ); } @@ -426,6 +514,11 @@ export function CommandCenterPanel({ ); } + // Empty-string url is a valid (blank) browser cell, so check against null. + if (cell.browserUrl !== null) { + return ; + } + if (!cell.taskId || !cell.task) { return ; } diff --git a/packages/ui/src/features/command-center/components/TaskSelector.tsx b/packages/ui/src/features/command-center/components/TaskSelector.tsx index a3764fd826..02f6ac23b0 100644 --- a/packages/ui/src/features/command-center/components/TaskSelector.tsx +++ b/packages/ui/src/features/command-center/components/TaskSelector.tsx @@ -1,6 +1,7 @@ import { ArrowLeft, Folder, + Globe, Lightning, Plus, Terminal, @@ -19,6 +20,7 @@ interface TaskSelectorProps { onOpenChange: (open: boolean) => void; onNewTask?: () => void; onNewTerminal?: (cwd?: string) => void; + onNewBrowser?: () => void; onBrainrot?: () => void; children: ReactNode; } @@ -29,6 +31,7 @@ export function TaskSelector({ onOpenChange, onNewTask, onNewTerminal, + onNewBrowser, onBrainrot, children, }: TaskSelectorProps) { @@ -81,6 +84,11 @@ export function TaskSelector({ onBrainrot?.(); }, [handleOpenChange, onBrainrot]); + const handleNewBrowser = useCallback(() => { + handleOpenChange(false); + onNewBrowser?.(); + }, [handleOpenChange, onNewBrowser]); + return ( )} + {onNewBrowser && ( + + )} {onBrainrot && (