From 3f7dcc8146d1ddb0c06d4825c650e61a246f7383 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 25 Sep 2026 03:04:27 +0000 Subject: [PATCH 1/3] Fix Environment Browser Google sign-in and overlay blanking. Rebuild guest Chromium user-agent and Client Hints so Google no longer rejects Aiden Agent/Electron as an insecure browser. Hide the native page view only when floating chrome actually covers its slot, so agent steps and other chat popovers no longer blank the open site. Co-authored-by: Sambit Biswas --- docs/environment-browser.md | 4 +- main/services/browser/core.test.ts | 37 ++++++++++++++++ main/services/browser/core.ts | 42 ++++++++++++++++++ main/services/browser/service.ts | 21 +++++---- renderer/components/browser-panel.test.tsx | 7 ++- renderer/components/browser-panel.tsx | 9 ++-- .../components/composer-slash-palette.tsx | 1 + renderer/components/composer.test.tsx | 1 + renderer/lib/browser-ui-state.test.ts | 14 +++++- renderer/lib/browser-ui-state.ts | 44 +++++++++++++++++++ 10 files changed, 164 insertions(+), 16 deletions(-) diff --git a/docs/environment-browser.md b/docs/environment-browser.md index 57762469f..93eeb85ed 100644 --- a/docs/environment-browser.md +++ b/docs/environment-browser.md @@ -9,8 +9,8 @@ The browser is adapted from the local T3 Code reference. Its selector runtime is | Surface | Behavior | | --- | --- | | Navigation | Tabs, address field, back/forward, reload/stop, hard reload, loading/failure state, favicon, mute, ten recent URLs per workspace, and detected terminal dev-server URLs. Recents and open tabs are kept in memory for the app session. | -| Viewport | Fill, editable dimensions, the 17 reference device presets, rotation, ratio lock, drag handles, zoom, and system/light/dark page appearance. Presets change layout, not the browser user agent. | -| Capture | Screenshot copy/save, recording start/stop with a local WebM artifact, a draggable/resizable browser over chat, and a separate picture-in-picture window. | +| Viewport | Fill, editable dimensions, the 17 reference device presets, rotation, ratio lock, drag handles, zoom, and system/light/dark page appearance. Presets change layout, not the browser user agent. Guest pages use a Chromium user agent and Client Hints (Electron and Aiden product tokens are stripped) so ordinary site sign-in, including Google, is not rejected as an insecure embedded app. | +| Capture | Screenshot copy/save, recording start/stop with a local WebM artifact, a draggable/resizable browser over chat, and a separate picture-in-picture window. Native page views stay on screen when chat popovers, hover cards, or agent-step details do not cover the browser slot; full-window dialog overlays still hide the guest so it cannot intercept clicks. | | Profiles | Default, memory-only Incognito, and named persistent profiles. Profile changes apply to new tabs. Cookies/cache can be cleared separately. | | Cookie import | Explicit one-time import from supported locally discovered browsers. Quit the source browser first. Import copies cookies only, skips unsupported partitioned/encrypted records, and reports partial results. Safari requires the OS file permission. | | Links/files | Chat and terminal web links follow Browser settings; Cmd/Ctrl-click opens the system browser. Files offers Open in Browser for HTML/PDF. A scoped loopback server serves approved workspace documents and web assets without enabling unrestricted `file://` navigation. | diff --git a/main/services/browser/core.test.ts b/main/services/browser/core.test.ts index 1752b73bb..f861db864 100644 --- a/main/services/browser/core.test.ts +++ b/main/services/browser/core.test.ts @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + applyBrowserGuestIdentityHeaders, + browserGuestUserAgent, browserUrl, browserDisplayUrl, browserRedactPreviewUrls, @@ -29,6 +31,41 @@ test("navigation normalizes public and loopback hosts but refuses privileged sch assert.throws(() => browserUrl(url)); } }); +test("guest user-agent drops Electron and Aiden product tokens Google rejects", () => { + const electron = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Aiden Agent/0.43.0 Chrome/142.0.7444.175 Electron/43.1.1 Safari/537.36"; + const guest = browserGuestUserAgent(electron); + assert.equal( + guest, + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.7444.175 Safari/537.36", + ); + assert.doesNotMatch(guest, /Electron|Aiden/u); + const linux = browserGuestUserAgent( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) aiden-agent/0.43.0 Chrome/142.0.0.0 Electron/43.1.1 Safari/537.36", + ); + assert.match(linux, /Linux x86_64/u); + assert.doesNotMatch(linux, /Electron|aiden/iu); +}); + +test("guest identity headers rewrite Client Hints without dropping preview grants", () => { + const ua = browserGuestUserAgent( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Aiden Agent/0.43.0 Chrome/142.0.7444.175 Electron/43.1.1 Safari/537.36", + ); + const headers = applyBrowserGuestIdentityHeaders( + { "User-Agent": "Electron", Cookie: "session=1", "X-Aiden-Preview-Authorization": "grant" }, + ua, + "darwin", + ); + assert.equal(headers["User-Agent"], ua); + assert.equal(headers["X-Aiden-Preview-Authorization"], "grant"); + assert.equal(headers.Cookie, "session=1"); + assert.match(headers["sec-ch-ua"] ?? "", /Chromium";v="142"/u); + assert.doesNotMatch(headers["sec-ch-ua"] ?? "", /Electron/u); + assert.equal(headers["sec-ch-ua-platform"], '"macOS"'); + assert.equal(headers["sec-ch-ua-mobile"], "?0"); + const linux = applyBrowserGuestIdentityHeaders({}, ua, "linux"); + assert.equal(linux["sec-ch-ua-platform"], '"Linux"'); +}); + test("profile partitions isolate persistent, ephemeral, and unusual identity bytes", () => { assert.match(browserPartition("one", false), /^persist:aiden-browser-profile-/); assert.doesNotMatch(browserPartition("one", true), /^persist:/); diff --git a/main/services/browser/core.ts b/main/services/browser/core.ts index 91dfd90e6..dc9236b2b 100644 --- a/main/services/browser/core.ts +++ b/main/services/browser/core.ts @@ -86,6 +86,48 @@ export function browserPartition(profileId: string, incognito: boolean): string return `${incognito ? "" : "persist:"}aiden-browser-${incognito ? "private-" : "profile-"}${digest}`; } +/** Guest pages must look like Chromium, not Electron or Aiden, or Google rejects sign-in. */ +export function browserGuestUserAgent(raw: string): string { + const mozilla = raw.match(/^Mozilla\/[\d.]+/u)?.[0] ?? "Mozilla/5.0"; + const platform = raw.match(/\([^)]*\)/u)?.[0] ?? "(Macintosh; Intel Mac OS X 10_15_7)"; + const chrome = raw.match(/Chrome\/[\d.]+/u)?.[0] ?? "Chrome/142.0.0.0"; + return `${mozilla} ${platform} AppleWebKit/537.36 (KHTML, like Gecko) ${chrome} Safari/537.36`; +} + +function browserHeaderWithout(headers: Record, name: string): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() !== name.toLowerCase()) result[key] = value; + } + return result; +} + +/** Keep Client Hints aligned with the reconstructed Chromium user agent. */ +export function applyBrowserGuestIdentityHeaders( + headers: Record, + userAgent: string, + platform: NodeJS.Platform = process.platform, +): Record { + const major = userAgent.match(/Chrome\/(\d+)/u)?.[1] ?? "142"; + const full = userAgent.match(/Chrome\/([\d.]+)/u)?.[1] ?? `${major}.0.0.0`; + const chPlatform = + platform === "darwin" ? '"macOS"' : platform === "win32" ? '"Windows"' : '"Linux"'; + let next = browserHeaderWithout(headers, "User-Agent"); + next = browserHeaderWithout(next, "sec-ch-ua"); + next = browserHeaderWithout(next, "sec-ch-ua-mobile"); + next = browserHeaderWithout(next, "sec-ch-ua-platform"); + next = browserHeaderWithout(next, "sec-ch-ua-full-version"); + next = browserHeaderWithout(next, "sec-ch-ua-full-version-list"); + next["User-Agent"] = userAgent; + next["sec-ch-ua"] = `"Chromium";v="${major}", "Not=A?Brand";v="24", "Google Chrome";v="${major}"`; + next["sec-ch-ua-mobile"] = "?0"; + next["sec-ch-ua-platform"] = chPlatform; + next["sec-ch-ua-full-version"] = full; + next["sec-ch-ua-full-version-list"] = + `"Chromium";v="${full}", "Not=A?Brand";v="10.0.0.0", "Google Chrome";v="${full}"`; + return next; +} + export function browserBoundedNumber( value: unknown, min: number, diff --git a/main/services/browser/service.ts b/main/services/browser/service.ts index 8627aaec2..591f85d2c 100644 --- a/main/services/browser/service.ts +++ b/main/services/browser/service.ts @@ -47,6 +47,8 @@ import { browserDisplayUrl, browserRedactPreviewUrls, browserPageCaptureBounds, + applyBrowserGuestIdentityHeaders, + browserGuestUserAgent, browserLocalServers, BrowserActionQueue, BROWSER_MAX_TABS, @@ -383,12 +385,8 @@ export class BrowserService { let browserSession = this.sessions.get(partition); if (!browserSession) { browserSession = session.fromPartition(partition); - browserSession.setUserAgent( - browserSession - .getUserAgent() - .replace(/\s*Electron\/[\d.]+/g, "") - .replace(/\s*aiden[^\s]*\/[\d.]+/gi, ""), - ); + const guestUserAgent = browserGuestUserAgent(browserSession.getUserAgent()); + browserSession.setUserAgent(guestUserAgent); configureBrowserPermissionHandlers(browserSession, (contents) => { if (!contents || contents.isDestroyed()) return undefined; const owned = [...this.tabs.values()].some( @@ -449,7 +447,12 @@ export class BrowserService { } catch { // Navigation/disposal can invalidate frame handles synchronously. } - callback({ requestHeaders: browserPreviewRequestHeaders(details.requestHeaders, authorization) }); + callback({ + requestHeaders: applyBrowserGuestIdentityHeaders( + browserPreviewRequestHeaders(details.requestHeaders, authorization), + guestUserAgent, + ), + }); }); browserSession.on("will-download", (_event, item, wc) => { if (![...this.tabs.values()].some((t) => t.view.webContents === wc)) { @@ -796,9 +799,10 @@ export class BrowserService { if (workspace.state.tabs.length >= BROWSER_MAX_TABS) throw new Error(`Close a browser tab before opening more than ${BROWSER_MAX_TABS}.`); const normalized = browserUrl(url); + const browserSession = this.browserSession(profileId); const view = new WebContentsView({ webPreferences: { - session: this.browserSession(profileId), + session: browserSession, sandbox: true, contextIsolation: true, nodeIntegration: false, @@ -807,6 +811,7 @@ export class BrowserService { backgroundThrottling: false, }, }); + view.webContents.setUserAgent(browserSession.getUserAgent()); const state: BrowserTab = { id: `browser-${randomUUID()}`, workspaceId, diff --git a/renderer/components/browser-panel.test.tsx b/renderer/components/browser-panel.test.tsx index 29d926358..81888251f 100644 --- a/renderer/components/browser-panel.test.tsx +++ b/renderer/components/browser-panel.test.tsx @@ -35,11 +35,16 @@ test("native browser hides behind app overlays and never closes tabs on panel hi const source = readFileSync(new URL("./browser-panel.tsx", import.meta.url), "utf8"); assert.ok(source.includes('action: "present", tabId, visible: false')); assert.ok(source.includes('enqueueBrowserPresentation(presentationKey')); - assert.ok(source.includes('[role="dialog"], [role="alertdialog"], [data-slot="popover-content"]')); + assert.ok(source.includes("browserNativeViewObstructed")); + assert.ok(source.includes("visibleBrowserNativeOccluders")); assert.ok(source.includes('action: "annotate", tabId: tab.id, enabled: false')); assert.ok(source.includes('aria-keyshortcuts="Meta+."')); assert.ok(source.includes('aria-label="Browser tabs"')); assert.equal(DEFAULT_BROWSER_SETTINGS.agentAccess, "allow"); + const main = readFileSync(new URL("../../main/services/browser/service.ts", import.meta.url), "utf8"); + assert.ok(main.includes("browserGuestUserAgent")); + assert.ok(main.includes("applyBrowserGuestIdentityHeaders")); + assert.ok(main.includes("view.webContents.setUserAgent")); }); test("HTML and PDF files expose a saved workspace file preview through the browser service", () => { diff --git a/renderer/components/browser-panel.tsx b/renderer/components/browser-panel.tsx index 7c311c8f8..4cc97f61e 100644 --- a/renderer/components/browser-panel.tsx +++ b/renderer/components/browser-panel.tsx @@ -3,7 +3,7 @@ import { createPortal } from "react-dom"; import { ArrowLeft, ArrowRight, Camera, Check, ExternalLink, Globe, History, Link2, Loader2, MessageCirclePlus, Minus, MoreVertical, PictureInPicture2, Plus, RadioTower, RotateCw, Settings2, Square, Unlink2, Volume2, VolumeX, X } from "lucide-react"; import type { BrowserAnnotation, BrowserCommand, BrowserCommandResult, BrowserImage, BrowserSnapshot, BrowserState, BrowserStylePreview, BrowserTab, BrowserViewport } from "../shared/browser"; import { browserApi } from "../lib/ipc"; -import { acceptBrowserState, BROWSER_DEVICE_PRESETS, browserBoundsFromRect, enqueueBrowserPresentation, resizeBrowserViewport, savedBrowserScreenshotPath, validBrowserViewport } from "../lib/browser-ui-state"; +import { acceptBrowserState, BROWSER_DEVICE_PRESETS, browserBoundsFromRect, enqueueBrowserPresentation, resizeBrowserViewport, savedBrowserScreenshotPath, validBrowserViewport, visibleBrowserNativeOccluders, browserNativeViewObstructed } from "../lib/browser-ui-state"; import { Button, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, Input, Text, toast } from "./ui"; import { BrowserSettings } from "./browser-settings"; import { BrowserFloatingFrame } from "./browser-floating-frame"; @@ -131,8 +131,9 @@ export function BrowserPanel({ workspaceId, active, onDock }: { workspaceId: str frame = 0; if (disposed) return; const bounds = browserBoundsFromRect(host.getBoundingClientRect()); - const overlay = Array.from(document.querySelectorAll('[role="dialog"], [role="alertdialog"], [data-slot="popover-content"]')) - .some((element) => element.getBoundingClientRect().width > 0 && element.getAttribute("data-state") !== "closed"); + const overlay = bounds + ? browserNativeViewObstructed(bounds, visibleBrowserNativeOccluders().map((element) => element.getBoundingClientRect())) + : false; const visible = surfaceActive && ready && !annotation && !settingsOpen && !menuOpen && !profileMenuOpen && !overlay && Boolean(bounds); const command: BrowserCommand = { action: "present", tabId, visible, ...(bounds ? { bounds } : {}) }; const key = JSON.stringify(command); @@ -145,7 +146,7 @@ export function BrowserPanel({ workspaceId, active, onDock }: { workspaceId: str const resize = new ResizeObserver(schedule); resize.observe(host); const overlays = new MutationObserver(schedule); - overlays.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-state", "aria-hidden", "inert"] }); + overlays.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-state", "aria-hidden", "inert", "data-presence", "popover", "style"] }); window.addEventListener("resize", schedule); window.addEventListener("scroll", schedule, true); document.addEventListener("transitionend", schedule, true); diff --git a/renderer/components/composer-slash-palette.tsx b/renderer/components/composer-slash-palette.tsx index 93feed1d5..194b0d0f3 100644 --- a/renderer/components/composer-slash-palette.tsx +++ b/renderer/components/composer-slash-palette.tsx @@ -239,6 +239,7 @@ export function ComposerSlashPalette({
diff --git a/renderer/components/composer.test.tsx b/renderer/components/composer.test.tsx index 56aeabc34..1a3126dcd 100644 --- a/renderer/components/composer.test.tsx +++ b/renderer/components/composer.test.tsx @@ -154,6 +154,7 @@ test("composer slash palette is an overlaid textarea-owned accessible listbox", assert.match(composer, /event\.key === "PageDown"/u); assert.match(composer, /event\.key === "Home"/u); assert.match(palette, /role="listbox"/u); + assert.match(palette, /data-browser-occluder/u); assert.doesNotMatch(palette, /role="group"/u); assert.match(palette, /role="option"/u); assert.match(palette, /aria-live="polite"/u); diff --git a/renderer/lib/browser-ui-state.test.ts b/renderer/lib/browser-ui-state.test.ts index f93c24867..6f1c0b086 100644 --- a/renderer/lib/browser-ui-state.test.ts +++ b/renderer/lib/browser-ui-state.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { acceptBrowserState, BROWSER_DEVICE_PRESETS, browserAnnotationRegion, browserBoundsFromRect, browserElementAtPoint, enqueueBrowserPresentation, resizeBrowserViewport, savedBrowserScreenshotPath, validBrowserViewport } from "./browser-ui-state.js"; +import { acceptBrowserState, BROWSER_DEVICE_PRESETS, BROWSER_NATIVE_OCCLUDER_SELECTOR, browserAnnotationRegion, browserBoundsFromRect, browserElementAtPoint, browserNativeViewObstructed, enqueueBrowserPresentation, resizeBrowserViewport, savedBrowserScreenshotPath, validBrowserViewport } from "./browser-ui-state.js"; import type { BrowserElement, BrowserState } from "../shared/browser.js"; import { browserAnnotationCropBounds } from "./browser-annotation-capture.js"; @@ -32,6 +32,18 @@ test("annotations normalize reverse drags and pick the smallest enclosing elemen assert.equal(browserElementAtPoint([outer, inner], { x: 110, y: 110 }), null); }); +test("native browser stays visible unless an overlay actually covers its slot", () => { + const host = { x: 800, y: 40, width: 400, height: 600 }; + assert.equal(browserNativeViewObstructed(host, [{ x: 80, y: 420, width: 320, height: 220 }]), false); + assert.equal(browserNativeViewObstructed(host, [{ x: 790, y: 100, width: 40, height: 40 }]), true); + assert.equal(browserNativeViewObstructed(host, [{ x: 0, y: 0, width: 1920, height: 1080 }]), true); + assert.equal(browserNativeViewObstructed(host, [{ x: 800, y: 40, width: 0, height: 600 }]), false); + assert.match(BROWSER_NATIVE_OCCLUDER_SELECTOR, /data-slot="popover-content"/u); + assert.match(BROWSER_NATIVE_OCCLUDER_SELECTOR, /data-slot="dialog-overlay"/u); + assert.match(BROWSER_NATIVE_OCCLUDER_SELECTOR, /data-browser-occluder/u); + assert.match(BROWSER_NATIVE_OCCLUDER_SELECTOR, /\[role="listbox"\]/u); +}); + test("native presentation never receives empty or nonfinite geometry", () => { assert.equal(browserBoundsFromRect({ x: 0, y: 0, width: 0, height: 10 }), null); assert.equal(browserBoundsFromRect({ x: NaN, y: 0, width: 10, height: 10 }), null); diff --git a/renderer/lib/browser-ui-state.ts b/renderer/lib/browser-ui-state.ts index e689af578..2bdaef9a5 100644 --- a/renderer/lib/browser-ui-state.ts +++ b/renderer/lib/browser-ui-state.ts @@ -52,6 +52,50 @@ export function browserBoundsFromRect(rect: Pick, + overlays: Array>, +): boolean { + const page = browserBoundsFromRect(host); + if (!page) return false; + return overlays.some((overlay) => { + const cover = browserBoundsFromRect(overlay); + if (!cover) return false; + return page.x < cover.x + cover.width + && page.x + page.width > cover.x + && page.y < cover.y + cover.height + && page.y + page.height > cover.y; + }); +} + +export function visibleBrowserNativeOccluders(root: ParentNode = document): HTMLElement[] { + return Array.from(root.querySelectorAll(BROWSER_NATIVE_OCCLUDER_SELECTOR)).filter((element) => { + if (element.getAttribute("data-state") === "closed" || element.getAttribute("aria-hidden") === "true") return false; + if (element.hasAttribute("popover")) { + try { + if (!element.matches(":popover-open")) return false; + } catch { + return false; + } + } + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }); +} + /** Save-dialog cancellation is a successful command without a written file. */ export function savedBrowserScreenshotPath(result: BrowserCommandResult | null): string | null { const value = result?.value; From c139c093e7984ada02c5d85d15f675e953d3926f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 25 Sep 2026 03:27:18 +0000 Subject: [PATCH 2/3] Tighten guest Client Hints and skip hidden overlay occluders. Quote sec-ch-ua-full-version, keep GREASE versions consistent, and ignore closed, aria-hidden, or inert ancestor chrome when deciding whether the native Environment Browser view must hide. Co-authored-by: Sambit Biswas --- main/services/browser/core.test.ts | 2 ++ main/services/browser/core.ts | 4 ++-- renderer/lib/browser-ui-state.test.ts | 3 +++ renderer/lib/browser-ui-state.ts | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/main/services/browser/core.test.ts b/main/services/browser/core.test.ts index f861db864..ba1d0f1cb 100644 --- a/main/services/browser/core.test.ts +++ b/main/services/browser/core.test.ts @@ -62,6 +62,8 @@ test("guest identity headers rewrite Client Hints without dropping preview grant assert.doesNotMatch(headers["sec-ch-ua"] ?? "", /Electron/u); assert.equal(headers["sec-ch-ua-platform"], '"macOS"'); assert.equal(headers["sec-ch-ua-mobile"], "?0"); + assert.equal(headers["sec-ch-ua-full-version"], '"142.0.7444.175"'); + assert.match(headers["sec-ch-ua-full-version-list"] ?? "", /Not=A\?Brand";v="24\.0\.0\.0"/u); const linux = applyBrowserGuestIdentityHeaders({}, ua, "linux"); assert.equal(linux["sec-ch-ua-platform"], '"Linux"'); }); diff --git a/main/services/browser/core.ts b/main/services/browser/core.ts index dc9236b2b..604027f01 100644 --- a/main/services/browser/core.ts +++ b/main/services/browser/core.ts @@ -122,9 +122,9 @@ export function applyBrowserGuestIdentityHeaders( next["sec-ch-ua"] = `"Chromium";v="${major}", "Not=A?Brand";v="24", "Google Chrome";v="${major}"`; next["sec-ch-ua-mobile"] = "?0"; next["sec-ch-ua-platform"] = chPlatform; - next["sec-ch-ua-full-version"] = full; + next["sec-ch-ua-full-version"] = `"${full}"`; next["sec-ch-ua-full-version-list"] = - `"Chromium";v="${full}", "Not=A?Brand";v="10.0.0.0", "Google Chrome";v="${full}"`; + `"Chromium";v="${full}", "Not=A?Brand";v="24.0.0.0", "Google Chrome";v="${full}"`; return next; } diff --git a/renderer/lib/browser-ui-state.test.ts b/renderer/lib/browser-ui-state.test.ts index 6f1c0b086..4b3f499af 100644 --- a/renderer/lib/browser-ui-state.test.ts +++ b/renderer/lib/browser-ui-state.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import test from "node:test"; import { acceptBrowserState, BROWSER_DEVICE_PRESETS, BROWSER_NATIVE_OCCLUDER_SELECTOR, browserAnnotationRegion, browserBoundsFromRect, browserElementAtPoint, browserNativeViewObstructed, enqueueBrowserPresentation, resizeBrowserViewport, savedBrowserScreenshotPath, validBrowserViewport } from "./browser-ui-state.js"; import type { BrowserElement, BrowserState } from "../shared/browser.js"; @@ -42,6 +43,8 @@ test("native browser stays visible unless an overlay actually covers its slot", assert.match(BROWSER_NATIVE_OCCLUDER_SELECTOR, /data-slot="dialog-overlay"/u); assert.match(BROWSER_NATIVE_OCCLUDER_SELECTOR, /data-browser-occluder/u); assert.match(BROWSER_NATIVE_OCCLUDER_SELECTOR, /\[role="listbox"\]/u); + const occluderSource = readFileSync(new URL("./browser-ui-state.ts", import.meta.url), "utf8"); + assert.match(occluderSource, /closest\('\[data-state="closed"\], \[aria-hidden="true"\], \[inert\]'\)/u); }); test("native presentation never receives empty or nonfinite geometry", () => { diff --git a/renderer/lib/browser-ui-state.ts b/renderer/lib/browser-ui-state.ts index 2bdaef9a5..b9f58e1e0 100644 --- a/renderer/lib/browser-ui-state.ts +++ b/renderer/lib/browser-ui-state.ts @@ -83,7 +83,7 @@ export function browserNativeViewObstructed( export function visibleBrowserNativeOccluders(root: ParentNode = document): HTMLElement[] { return Array.from(root.querySelectorAll(BROWSER_NATIVE_OCCLUDER_SELECTOR)).filter((element) => { - if (element.getAttribute("data-state") === "closed" || element.getAttribute("aria-hidden") === "true") return false; + if (element.closest('[data-state="closed"], [aria-hidden="true"], [inert]')) return false; if (element.hasAttribute("popover")) { try { if (!element.matches(":popover-open")) return false; From beceae55d0601668d6853c6db9cc874c080bb9c1 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Fri, 25 Sep 2026 22:33:35 -0400 Subject: [PATCH 3/3] fix(browser): align guest Client Hints with renderer metadata and keep modal overlays occluding - Stop forging a "Google Chrome" brand, a hand-picked GREASE brand, and high-entropy hints. Electron sends no UA-CH headers for guests, while the renderer's navigator.userAgentData reports Chromium + GREASE only. Emit the default low-entropy trio derived from Chromium's own brand algorithm, only to potentially trustworthy URLs, and strip any other sec-ch-ua* headers. - Add an Electron E2E check that compares navigator.userAgentData with the headers a guest document and subresource actually send. - Radix hideOthers marks the modal overlay's portal aria-hidden with a data-aria-hidden marker; do not treat that isolation as visual hiding, so an open full-window dialog overlay still hides the native browser view. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GAkZRgQzUoKWGpgupF3a4D --- .memory/upgrade-18-browser.md | 6 +++ docs/environment-browser.md | 4 +- main/services/browser/core.test.ts | 37 +++++++++++--- main/services/browser/core.ts | 72 +++++++++++++++++++-------- main/services/browser/service.ts | 1 + renderer/lib/browser-ui-state.test.ts | 54 ++++++++++++++++++-- renderer/lib/browser-ui-state.ts | 5 +- tests/e2e/browser.spec.ts | 39 +++++++++++++++ 8 files changed, 184 insertions(+), 34 deletions(-) diff --git a/.memory/upgrade-18-browser.md b/.memory/upgrade-18-browser.md index 840eea8d0..21c5e9b32 100644 --- a/.memory/upgrade-18-browser.md +++ b/.memory/upgrade-18-browser.md @@ -80,3 +80,9 @@ Rejected candidate: capturing only starts emitted synchronously inside loadURL i New regressions hold the command before its start, then launch real renderer navigation, same-tab popup navigation, or another user command. Different-URL and same-URL replacements must remain loading, receive zero old-command Stops, and recover their held target after an actual renderer crash. Direct, redirected, and beforeunload native command controls must still Stop once and recover the original document after a later real crash. New tests release the real command deadline only after the native HTTP request arrives, avoiding a 500ms scheduling assumption. Final validation: all four supported-entry regressions fail exact c402 service on unexpected Stop (expected0/received1); baseline built with an esbuild source override without changing the reviewed source files. All seven admission-gated new cases pass. The final complete lifecycle/initial native run passes **28/28 in3.3min**, zero retries/flaky/skipped; includes a denied renderer-navigation control that preserves the owned timeout. Browser140/140, both TypeScript suites, lint, production build and diff checks pass. Evidence `/tmp/aiden18-supported-c402-red.log`, `/tmp/aiden18-intent-gated-green.log`, `/tmp/aiden18-final-native28.log`; durable copies in campaign `pr164-command-ownership-evidence/`. Current-head hosted checks and independent review are separate publication gates. + +## PR #249 Pullfrog follow-up: guest identity and modal overlay occlusion + +Probe on Electron 43.1.1 (Chromium 150): the guest renderer's `navigator.userAgentData` reports only `Not;A=Brand` v8 + `Chromium` v150 (no Electron brand), and Electron sends no `sec-ch-ua*` headers for guest partitions. The earlier header rewrite forged `Google Chrome` + a wrong GREASE brand + high-entropy hints on every request, contradicting the page. `applyBrowserGuestIdentityHeaders(headers, ua, url)` now strips any incoming `sec-ch-ua*`, and only for potentially trustworthy URLs (https/wss/loopback) emits the default low-entropy trio built by `browserGuestBrands` (Chromium's `GenerateBrandVersionList` GREASE spelling/order for an unbranded build). The Electron E2E `guest identity headers match the renderer's navigator.userAgentData` compares both surfaces and fails on the old rewrite; `/favicon.ico` is fetched by Aiden's own tab strip with the host UA, so the test ignores it. + +Radix `hideOthers(content)` sets `aria-hidden="true"` plus `data-aria-hidden="true"` on the modal overlay's portal and the app root. `visibleBrowserNativeOccluders` now ignores only aria-hidden ancestors without that isolation marker, so an open full-window `dialog-overlay` keeps the native view hidden even when the centered content misses the browser slot. diff --git a/docs/environment-browser.md b/docs/environment-browser.md index 93eeb85ed..a8dc94f3a 100644 --- a/docs/environment-browser.md +++ b/docs/environment-browser.md @@ -9,8 +9,8 @@ The browser is adapted from the local T3 Code reference. Its selector runtime is | Surface | Behavior | | --- | --- | | Navigation | Tabs, address field, back/forward, reload/stop, hard reload, loading/failure state, favicon, mute, ten recent URLs per workspace, and detected terminal dev-server URLs. Recents and open tabs are kept in memory for the app session. | -| Viewport | Fill, editable dimensions, the 17 reference device presets, rotation, ratio lock, drag handles, zoom, and system/light/dark page appearance. Presets change layout, not the browser user agent. Guest pages use a Chromium user agent and Client Hints (Electron and Aiden product tokens are stripped) so ordinary site sign-in, including Google, is not rejected as an insecure embedded app. | -| Capture | Screenshot copy/save, recording start/stop with a local WebM artifact, a draggable/resizable browser over chat, and a separate picture-in-picture window. Native page views stay on screen when chat popovers, hover cards, or agent-step details do not cover the browser slot; full-window dialog overlays still hide the guest so it cannot intercept clicks. | +| Viewport | Fill, editable dimensions, the 17 reference device presets, rotation, ratio lock, drag handles, zoom, and system/light/dark page appearance. Presets change layout, not the browser user agent. Guest pages use a Chromium user agent (Electron and Aiden product tokens are stripped) so ordinary site sign-in, including Google, is not rejected as an insecure embedded app. Electron sends no UA Client Hint headers itself, so requests to secure and loopback origins carry only the default `sec-ch-ua`, `sec-ch-ua-mobile`, and `sec-ch-ua-platform` hints, derived from the same Chromium brand list the page sees in `navigator.userAgentData`; no "Google Chrome" brand or high-entropy hint is forged. | +| Capture | Screenshot copy/save, recording start/stop with a local WebM artifact, a draggable/resizable browser over chat, and a separate picture-in-picture window. Native page views stay on screen when chat popovers, hover cards, or agent-step details do not cover the browser slot; full-window dialog overlays still hide the guest so it cannot intercept clicks, including when the modal's accessibility isolation marks its own overlay `aria-hidden`. | | Profiles | Default, memory-only Incognito, and named persistent profiles. Profile changes apply to new tabs. Cookies/cache can be cleared separately. | | Cookie import | Explicit one-time import from supported locally discovered browsers. Quit the source browser first. Import copies cookies only, skips unsupported partitioned/encrypted records, and reports partial results. Safari requires the OS file permission. | | Links/files | Chat and terminal web links follow Browser settings; Cmd/Ctrl-click opens the system browser. Files offers Open in Browser for HTML/PDF. A scoped loopback server serves approved workspace documents and web assets without enabling unrestricted `file://` navigation. | diff --git a/main/services/browser/core.test.ts b/main/services/browser/core.test.ts index ba1d0f1cb..98f0e25bc 100644 --- a/main/services/browser/core.test.ts +++ b/main/services/browser/core.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { applyBrowserGuestIdentityHeaders, + browserGuestBrands, browserGuestUserAgent, browserUrl, browserDisplayUrl, @@ -51,21 +52,45 @@ test("guest identity headers rewrite Client Hints without dropping preview grant "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Aiden Agent/0.43.0 Chrome/142.0.7444.175 Electron/43.1.1 Safari/537.36", ); const headers = applyBrowserGuestIdentityHeaders( - { "User-Agent": "Electron", Cookie: "session=1", "X-Aiden-Preview-Authorization": "grant" }, + { + "User-Agent": "Electron", + Cookie: "session=1", + "X-Aiden-Preview-Authorization": "grant", + "Sec-CH-UA": '"Electron";v="43"', + "Sec-CH-UA-Full-Version-List": '"Electron";v="43.1.1"', + }, ua, + "https://accounts.google.com/", "darwin", ); assert.equal(headers["User-Agent"], ua); assert.equal(headers["X-Aiden-Preview-Authorization"], "grant"); assert.equal(headers.Cookie, "session=1"); - assert.match(headers["sec-ch-ua"] ?? "", /Chromium";v="142"/u); - assert.doesNotMatch(headers["sec-ch-ua"] ?? "", /Electron/u); + // Exactly the brand list Chromium 142 exposes via navigator.userAgentData in + // an unbranded (Electron) build: no invented "Google Chrome" brand. + assert.equal(headers["sec-ch-ua"], '"Not_A Brand";v="99", "Chromium";v="142"'); assert.equal(headers["sec-ch-ua-platform"], '"macOS"'); assert.equal(headers["sec-ch-ua-mobile"], "?0"); - assert.equal(headers["sec-ch-ua-full-version"], '"142.0.7444.175"'); - assert.match(headers["sec-ch-ua-full-version-list"] ?? "", /Not=A\?Brand";v="24\.0\.0\.0"/u); - const linux = applyBrowserGuestIdentityHeaders({}, ua, "linux"); + // Stale or high-entropy hints are dropped, never forged or passed through. + assert.deepEqual( + Object.keys(headers).filter((name) => /^sec-ch-ua/iu.test(name)).sort(), + ["sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform"], + ); + const linux = applyBrowserGuestIdentityHeaders({}, ua, "http://localhost:3000/", "linux"); assert.equal(linux["sec-ch-ua-platform"], '"Linux"'); + // Chromium never sends UA-CH to insecure origins. + const insecure = applyBrowserGuestIdentityHeaders({ "sec-ch-ua": "x" }, ua, "http://example.com/", "darwin"); + assert.deepEqual(insecure, { "User-Agent": ua }); +}); + +test("guest brands follow Chromium's GREASE spelling and order for each major", () => { + const brands = (major: number) => browserGuestBrands(`Mozilla/5.0 (X11) Chrome/${major}.0.0.0 Safari/537.36`); + // Verified against Electron 43.1.1 (Chromium 150) navigator.userAgentData.brands. + assert.deepEqual(brands(150), [{ brand: "Not;A=Brand", version: "8" }, { brand: "Chromium", version: "150" }]); + // Known Chrome releases: the GREASE brand of 120 and 124. + assert.equal(brands(120)[0].brand, "Not_A Brand"); + assert.deepEqual(brands(124)[0], { brand: "Not-A.Brand", version: "99" }); + assert.deepEqual(brands(151).map(({ brand }) => brand), ["Chromium", "Not=A?Brand"]); }); test("profile partitions isolate persistent, ephemeral, and unusual identity bytes", () => { diff --git a/main/services/browser/core.ts b/main/services/browser/core.ts index 604027f01..be74531ad 100644 --- a/main/services/browser/core.ts +++ b/main/services/browser/core.ts @@ -94,37 +94,67 @@ export function browserGuestUserAgent(raw: string): string { return `${mozilla} ${platform} AppleWebKit/537.36 (KHTML, like Gecko) ${chrome} Safari/537.36`; } -function browserHeaderWithout(headers: Record, name: string): Record { - const result: Record = {}; - for (const [key, value] of Object.entries(headers)) { - if (key.toLowerCase() !== name.toLowerCase()) result[key] = value; +const BROWSER_GREASE_CHARS = [" ", "(", ":", "-", ".", "/", ")", ";", "=", "?", "_"]; +const BROWSER_GREASE_VERSIONS = ["8", "99", "24"]; + +/** + * The brand list Chromium itself exposes through `navigator.userAgentData` in + * an unbranded build such as Electron: one GREASE brand plus "Chromium", + * spelled and ordered from the major version as in Chromium's + * `GenerateBrandVersionList`. The guest renderer never claims "Google Chrome", + * so the wire headers must not either. + */ +export function browserGuestBrands(userAgent: string): Array<{ brand: string; version: string }> { + const major = Number(userAgent.match(/Chrome\/(\d+)/u)?.[1] ?? "0"); + const chars = BROWSER_GREASE_CHARS; + const grease = { + brand: `Not${chars[major % chars.length]}A${chars[(major + 1) % chars.length]}Brand`, + version: BROWSER_GREASE_VERSIONS[major % BROWSER_GREASE_VERSIONS.length], + }; + const chromium = { brand: "Chromium", version: String(major) }; + return major % 2 === 0 ? [grease, chromium] : [chromium, grease]; +} + +/** Chromium sends UA Client Hints only to potentially trustworthy origins. */ +function browserTrustworthyRequestUrl(value: string): boolean { + try { + const url = new URL(value); + if (url.protocol === "https:" || url.protocol === "wss:") return true; + if (url.protocol !== "http:" && url.protocol !== "ws:") return false; + const host = url.hostname.toLowerCase(); + return host === "localhost" || host.endsWith(".localhost") || + /^127(?:\.\d+){3}$/u.test(host) || host === "[::1]"; + } catch { + return false; } - return result; } -/** Keep Client Hints aligned with the reconstructed Chromium user agent. */ +/** + * Electron emits no UA Client Hint headers, while the guest renderer still + * exposes Chromium's `navigator.userAgentData`. Mirror only the default + * low-entropy hints from that same metadata so `User-Agent`, `sec-ch-ua*`, and + * page JavaScript describe one guest. High-entropy hints are never forged; + * pages read them from `navigator.userAgentData.getHighEntropyValues()`. + */ export function applyBrowserGuestIdentityHeaders( headers: Record, userAgent: string, + requestUrl: string, platform: NodeJS.Platform = process.platform, ): Record { - const major = userAgent.match(/Chrome\/(\d+)/u)?.[1] ?? "142"; - const full = userAgent.match(/Chrome\/([\d.]+)/u)?.[1] ?? `${major}.0.0.0`; - const chPlatform = - platform === "darwin" ? '"macOS"' : platform === "win32" ? '"Windows"' : '"Linux"'; - let next = browserHeaderWithout(headers, "User-Agent"); - next = browserHeaderWithout(next, "sec-ch-ua"); - next = browserHeaderWithout(next, "sec-ch-ua-mobile"); - next = browserHeaderWithout(next, "sec-ch-ua-platform"); - next = browserHeaderWithout(next, "sec-ch-ua-full-version"); - next = browserHeaderWithout(next, "sec-ch-ua-full-version-list"); + const next: Record = {}; + for (const [key, value] of Object.entries(headers)) { + const name = key.toLowerCase(); + if (name !== "user-agent" && !name.startsWith("sec-ch-ua")) next[key] = value; + } next["User-Agent"] = userAgent; - next["sec-ch-ua"] = `"Chromium";v="${major}", "Not=A?Brand";v="24", "Google Chrome";v="${major}"`; + if (!browserTrustworthyRequestUrl(requestUrl)) return next; + next["sec-ch-ua"] = browserGuestBrands(userAgent) + .map(({ brand, version }) => `"${brand}";v="${version}"`) + .join(", "); next["sec-ch-ua-mobile"] = "?0"; - next["sec-ch-ua-platform"] = chPlatform; - next["sec-ch-ua-full-version"] = `"${full}"`; - next["sec-ch-ua-full-version-list"] = - `"Chromium";v="${full}", "Not=A?Brand";v="24.0.0.0", "Google Chrome";v="${full}"`; + next["sec-ch-ua-platform"] = + platform === "darwin" ? '"macOS"' : platform === "win32" ? '"Windows"' : '"Linux"'; return next; } diff --git a/main/services/browser/service.ts b/main/services/browser/service.ts index 591f85d2c..6a670f721 100644 --- a/main/services/browser/service.ts +++ b/main/services/browser/service.ts @@ -451,6 +451,7 @@ export class BrowserService { requestHeaders: applyBrowserGuestIdentityHeaders( browserPreviewRequestHeaders(details.requestHeaders, authorization), guestUserAgent, + details.url, ), }); }); diff --git a/renderer/lib/browser-ui-state.test.ts b/renderer/lib/browser-ui-state.test.ts index 4b3f499af..aa4e00e2b 100644 --- a/renderer/lib/browser-ui-state.test.ts +++ b/renderer/lib/browser-ui-state.test.ts @@ -1,7 +1,6 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; import test from "node:test"; -import { acceptBrowserState, BROWSER_DEVICE_PRESETS, BROWSER_NATIVE_OCCLUDER_SELECTOR, browserAnnotationRegion, browserBoundsFromRect, browserElementAtPoint, browserNativeViewObstructed, enqueueBrowserPresentation, resizeBrowserViewport, savedBrowserScreenshotPath, validBrowserViewport } from "./browser-ui-state.js"; +import { acceptBrowserState, BROWSER_DEVICE_PRESETS, BROWSER_NATIVE_OCCLUDER_SELECTOR, browserAnnotationRegion, browserBoundsFromRect, browserElementAtPoint, browserNativeViewObstructed, enqueueBrowserPresentation, resizeBrowserViewport, savedBrowserScreenshotPath, validBrowserViewport, visibleBrowserNativeOccluders } from "./browser-ui-state.js"; import type { BrowserElement, BrowserState } from "../shared/browser.js"; import { browserAnnotationCropBounds } from "./browser-annotation-capture.js"; @@ -43,8 +42,55 @@ test("native browser stays visible unless an overlay actually covers its slot", assert.match(BROWSER_NATIVE_OCCLUDER_SELECTOR, /data-slot="dialog-overlay"/u); assert.match(BROWSER_NATIVE_OCCLUDER_SELECTOR, /data-browser-occluder/u); assert.match(BROWSER_NATIVE_OCCLUDER_SELECTOR, /\[role="listbox"\]/u); - const occluderSource = readFileSync(new URL("./browser-ui-state.ts", import.meta.url), "utf8"); - assert.match(occluderSource, /closest\('\[data-state="closed"\], \[aria-hidden="true"\], \[inert\]'\)/u); +}); + +type FakeRect = { x: number; y: number; width: number; height: number }; +class FakeElement { + readonly children: FakeElement[] = []; + constructor(readonly attrs: Record, readonly rect: FakeRect, readonly parent: FakeElement | null = null) { + parent?.children.push(this); + } + hasAttribute(name: string) { return name in this.attrs; } + getBoundingClientRect() { return this.rect; } + // Attribute-only CSS subset used by the occluder filter: lists, `[a]`, `[a="v"]`, `:not(...)`. + matches(selector: string): boolean { + if (selector === ":popover-open") return false; + return selector.split(/,\s*(?![^()]*\))/u).some((compound) => { + const parts = compound.trim().match(/:not\((\[[^\]]+\])\)|\[[^\]]+\]/gu) ?? []; + return parts.length > 0 && parts.every((part) => part.startsWith(":not(") + ? !this.matches(part.slice(5, -1)) + : (([, name, value]) => name in this.attrs && (value === undefined || this.attrs[name] === value))( + part.match(/^\[([\w-]+)(?:="([^"]*)")?\]$/u) ?? [], + )); + }); + } + closest(selector: string): FakeElement | null { + for (let node: FakeElement | null = this; node; node = node.parent) if (node.matches(selector)) return node; + return null; + } + querySelectorAll(selector: string): FakeElement[] { + return this.children.flatMap((child) => [...(child.matches(selector) ? [child] : []), ...child.querySelectorAll(selector)]); + } +} + +test("a modal's aria-hidden full-window overlay still occludes a browser outside the dialog", () => { + const viewport = { x: 0, y: 0, width: 1600, height: 900 }; + const body = new FakeElement({}, viewport); + // Radix `hideOthers(content)` marks the overlay's portal and the app root. + const overlayPortal = new FakeElement({ "aria-hidden": "true", "data-aria-hidden": "true" }, viewport, body); + new FakeElement({ "data-slot": "dialog-overlay", "data-state": "open" }, viewport, overlayPortal); + const contentPortal = new FakeElement({}, viewport, body); + const content = new FakeElement({ role: "dialog", "data-slot": "dialog-content", "data-state": "open" }, { x: 560, y: 300, width: 480, height: 300 }, contentPortal); + const app = new FakeElement({ "aria-hidden": "true", "data-aria-hidden": "true" }, viewport, body); + // A genuinely aria-hidden surface (e.g. an exiting slash palette) never occludes. + new FakeElement({ "data-browser-occluder": "", "aria-hidden": "true" }, { x: 1200, y: 600, width: 300, height: 200 }, app); + new FakeElement({ role: "menu", "data-state": "closed" }, { x: 1200, y: 100, width: 200, height: 200 }, body); + + const browserSlot = { x: 1100, y: 40, width: 480, height: 820 }; + assert.equal(browserNativeViewObstructed(browserSlot, [content.getBoundingClientRect()]), false); + const occluders = visibleBrowserNativeOccluders(body as unknown as ParentNode) as unknown as FakeElement[]; + assert.deepEqual(occluders.map((element) => element.attrs["data-slot"]), ["dialog-overlay", "dialog-content"]); + assert.equal(browserNativeViewObstructed(browserSlot, occluders.map((element) => element.getBoundingClientRect())), true); }); test("native presentation never receives empty or nonfinite geometry", () => { diff --git a/renderer/lib/browser-ui-state.ts b/renderer/lib/browser-ui-state.ts index b9f58e1e0..d973d7f3c 100644 --- a/renderer/lib/browser-ui-state.ts +++ b/renderer/lib/browser-ui-state.ts @@ -83,7 +83,10 @@ export function browserNativeViewObstructed( export function visibleBrowserNativeOccluders(root: ParentNode = document): HTMLElement[] { return Array.from(root.querySelectorAll(BROWSER_NATIVE_OCCLUDER_SELECTOR)).filter((element) => { - if (element.closest('[data-state="closed"], [aria-hidden="true"], [inert]')) return false; + // A modal's `hideOthers` isolation (aria-hidden plus its `data-aria-hidden` + // marker) only hides siblings from assistive tech; they remain painted, + // including the modal's own full-window overlay, so they still occlude. + if (element.closest('[data-state="closed"], [aria-hidden="true"]:not([data-aria-hidden="true"]), [inert]')) return false; if (element.hasAttribute("popover")) { try { if (!element.matches(":popover-open")) return false; diff --git a/tests/e2e/browser.spec.ts b/tests/e2e/browser.spec.ts index 82a606810..999afa0ef 100644 --- a/tests/e2e/browser.spec.ts +++ b/tests/e2e/browser.spec.ts @@ -357,3 +357,42 @@ test("browser recording produces a real bounded WebM artifact", async ({ aiden } } await command(page, { action: "close", tabId }); }); + +test("guest identity headers match the renderer's navigator.userAgentData", async ({ aiden }) => { + const seen: Array> = []; + const server = createServer((request, response) => { + // /favicon.ico is fetched by Aiden's own tab strip, not by the guest page. + if (request.url === "/" || request.url === "/pixel") seen.push(request.headers); + response.writeHead(200, { "content-type": "text/html" }); + response.end("Identity fixture"); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/`; + try { + const { page } = aiden; + await finishLmStudioOnboarding(page); + const opened = await command(page, { action: "create", url }); + const tabId = opened.state.activeTabId!; + await expect.poll(async () => (await state(page)).tabs.find(tab => tab.id === tabId)?.title).toBe("Identity fixture"); + await expect.poll(() => seen.length).toBeGreaterThanOrEqual(2); + const renderer = (await command(page, { + action: "evaluate", + tabId, + expression: "({ userAgent: navigator.userAgent, brands: navigator.userAgentData.brands.map(({ brand, version }) => ({ brand, version })), mobile: navigator.userAgentData.mobile, platform: navigator.userAgentData.platform })", + })).value as { userAgent: string; brands: Array<{ brand: string; version: string }>; mobile: boolean; platform: string }; + expect(renderer.userAgent).not.toMatch(/Electron|Aiden/iu); + expect(renderer.brands.map(({ brand }) => brand)).not.toContain("Electron"); + const expectedBrands = renderer.brands.map(({ brand, version }) => `"${brand}";v="${version}"`).join(", "); + // Document and subresource requests carry the identity the page's JavaScript sees. + for (const headers of seen) { + expect(headers["user-agent"]).toBe(renderer.userAgent); + expect(headers["sec-ch-ua"]).toBe(expectedBrands); + expect(headers["sec-ch-ua-mobile"]).toBe(renderer.mobile ? "?1" : "?0"); + expect(headers["sec-ch-ua-platform"]).toBe(`"${renderer.platform}"`); + expect(Object.keys(headers).filter(name => name.startsWith("sec-ch-ua")).sort()).toEqual(["sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform"]); + } + await command(page, { action: "close", tabId }); + } finally { + server.close(); + } +});