From a40d6c23f404ab6734cc98797370167feaebe5e5 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 22 Sep 2026 22:34:12 -0700 Subject: [PATCH 1/2] fix(desktop): set the browser user agent process-wide so Cloudflare Turnstile passes --- .../src/main/browser-agent/session.test.ts | 8 ++++---- .../desktop/src/main/browser-agent/session.ts | 10 ---------- apps/desktop/src/main/index.ts | 2 ++ .../{browser-agent => }/user-agent.test.ts | 19 ++++++++++--------- .../main/{browser-agent => }/user-agent.ts | 13 ++++++++----- 5 files changed, 24 insertions(+), 28 deletions(-) rename apps/desktop/src/main/{browser-agent => }/user-agent.test.ts (69%) rename apps/desktop/src/main/{browser-agent => }/user-agent.ts (77%) diff --git a/apps/desktop/src/main/browser-agent/session.test.ts b/apps/desktop/src/main/browser-agent/session.test.ts index 39e1bc2683b..a8f5603f594 100644 --- a/apps/desktop/src/main/browser-agent/session.test.ts +++ b/apps/desktop/src/main/browser-agent/session.test.ts @@ -37,6 +37,7 @@ interface MockView { session: { setPermissionRequestHandler: ReturnType setPermissionCheckHandler: ReturnType + setUserAgent: ReturnType webRequest: { onBeforeRequest: ReturnType } } on: ReturnType @@ -358,15 +359,14 @@ describe('browser-agent session', () => { expect(onTabNavigated).toHaveBeenCalledWith(contents, true) }) - it('gives every tab a user agent with no Electron token in it', () => { + it('leaves every tab on the process-wide user agent instead of overriding it', () => { const first = session.ensureTab() const second = session.addTab() for (const tab of [first, second]) { const contents = (tab.view as unknown as MockView).webContents - const agent = contents.setUserAgent.mock.calls.at(-1)?.[0] as string | undefined - expect(agent).toMatch(/^Mozilla\/5\.0 \(.+\) .*Chrome\/\d+\.0\.0\.0 Safari\/537\.36$/) - expect(agent).not.toMatch(/Electron|Sim\//) + expect(contents.setUserAgent).not.toHaveBeenCalled() + expect(contents.session.setUserAgent).not.toHaveBeenCalled() } }) diff --git a/apps/desktop/src/main/browser-agent/session.ts b/apps/desktop/src/main/browser-agent/session.ts index e183137c9a0..1e106142eae 100644 --- a/apps/desktop/src/main/browser-agent/session.ts +++ b/apps/desktop/src/main/browser-agent/session.ts @@ -67,7 +67,6 @@ import { isBlockedSubresourceUrl, subresourceNeedsResolution, } from '@/main/browser-agent/url-guard' -import { browserUserAgent } from '@/main/browser-agent/user-agent' import type { BrowserSessionSnapshot } from '@/main/desktop-chat-session-store' import { suggestedFilename, uniqueDownloadPath } from '@/main/downloads' import { @@ -1413,11 +1412,6 @@ function configureAgentPartition(ses: Session): void { } return ALLOWED_SITE_PERMISSIONS.has(permission) }) - // Service workers do not inherit a tab's user agent. With only the tab's set, - // the document request carries the browser string while the worker's own - // script request still announces Electron — and on a site that routes its - // fetches through a worker, that is the one the server sees. - ses.setUserAgent(browserUserAgent()) // SSRF choke point for the agent partition. Document navigations (top-level + // iframes) get the full DNS-resolving check — the one seam every navigation // passes through, including page-initiated ones the driver never sees (server @@ -1965,10 +1959,6 @@ function initializeTabView(view: WebContentsView, scopeId: string): WebContentsV const contents = view.webContents registerAgentWebContents(contents) configureAgentPartition(contents.session) - // The session default does not reach a WebContents that already exists, and - // the first tab is what brings the session into being, so each tab sets its - // own as well — otherwise tab one browses as Electron and the rest as Chrome. - contents.setUserAgent(browserUserAgent()) attachAgentContextMenu(contents, { addToChat: (text) => withBrowserScope(scopeId, () => addPageSelectionToChat(contents, text)), openTab: (url) => withBrowserScope(scopeId, () => openTabWithUrl(url, { agentOwned: false })), diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 30972db3c9e..18996aedcce 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -83,6 +83,7 @@ import { attachTelemetryPolicy } from '@/main/telemetry-policy' import { TerminalRegistry } from '@/main/terminal/registry' import { installTray, type TrayHandle } from '@/main/tray' import { checkForUpdatesInteractive, initUpdater, type UpdaterHandle } from '@/main/updater' +import { installBrowserUserAgent } from '@/main/user-agent' import { createMainWindow, setupPermissionHandlers } from '@/main/window' import { attachWindowOpenPolicy, isPopupContents } from '@/main/windows' @@ -899,6 +900,7 @@ app.setName(APP_NAME_FOR_CHANNEL[channelForOrigin(DEFAULT_ORIGIN)]) if (process.env.SIM_DESKTOP_USER_DATA) { app.setPath('userData', process.env.SIM_DESKTOP_USER_DATA) } +installBrowserUserAgent() // The scheme the offline page and server picker load from must be declared // before the app is ready; the per-session handlers attach later. diff --git a/apps/desktop/src/main/browser-agent/user-agent.test.ts b/apps/desktop/src/main/user-agent.test.ts similarity index 69% rename from apps/desktop/src/main/browser-agent/user-agent.test.ts rename to apps/desktop/src/main/user-agent.test.ts index db63efa7141..423ef6600d1 100644 --- a/apps/desktop/src/main/browser-agent/user-agent.test.ts +++ b/apps/desktop/src/main/user-agent.test.ts @@ -1,11 +1,13 @@ import { app } from 'electron' import { describe, expect, it, vi } from 'vitest' -import { browserUserAgent, stockChromeUserAgent } from '@/main/browser-agent/user-agent' +import { installBrowserUserAgent, stockChromeUserAgent } from '@/main/user-agent' vi.mock('electron', () => import('@/test/electron-mock')) const ELECTRON_DEFAULT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Sim/1.0.0 Chrome/140.0.7339.207 Electron/43.1.1 Safari/537.36' +const STOCK_CHROME = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36' describe('stockChromeUserAgent', () => { it('drops the application and Electron tokens a browser allowlist rejects', () => { @@ -15,9 +17,7 @@ describe('stockChromeUserAgent', () => { }) it('reproduces the desktop string Chrome sends under user-agent reduction', () => { - expect(stockChromeUserAgent(ELECTRON_DEFAULT)).toBe( - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36' - ) + expect(stockChromeUserAgent(ELECTRON_DEFAULT)).toBe(STOCK_CHROME) }) it('keeps the platform token of the machine it is running on', () => { @@ -32,12 +32,13 @@ describe('stockChromeUserAgent', () => { }) }) -describe('browserUserAgent', () => { - it('derives from the string Electron would otherwise have sent', () => { +describe('installBrowserUserAgent', () => { + it('idempotently makes stock Chrome the process-wide fallback every request path uses', () => { app.userAgentFallback = ELECTRON_DEFAULT - expect(browserUserAgent()).toBe( - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36' - ) + installBrowserUserAgent() + installBrowserUserAgent() + + expect(app.userAgentFallback).toBe(STOCK_CHROME) }) }) diff --git a/apps/desktop/src/main/browser-agent/user-agent.ts b/apps/desktop/src/main/user-agent.ts similarity index 77% rename from apps/desktop/src/main/browser-agent/user-agent.ts rename to apps/desktop/src/main/user-agent.ts index 9e988c67987..d14e5c80f1d 100644 --- a/apps/desktop/src/main/browser-agent/user-agent.ts +++ b/apps/desktop/src/main/user-agent.ts @@ -1,5 +1,6 @@ /** - * The user agent the browser resource presents to sites. + * The user agent the whole desktop process presents: the embedded browser and + * the app's own windows alike (desktop identity travels in `X-Sim-Client-Info`). * * Electron's default string carries two tokens no browser sends — * `Sim/` and `Electron/`. Chromium's own token sits right @@ -38,9 +39,11 @@ export function stockChromeUserAgent(defaultUserAgent: string): string { } /** - * Derived from the string Electron would otherwise have sent, so the reported - * Chromium version tracks whatever Chromium the app actually ships. + * Sets the stock Chrome identity as `app.userAgentFallback` before any session + * exists. Session and per-tab overrides miss some request paths (a cross-origin + * challenge frame still sends the process default), and a site that sees two + * user agents in one challenge rejects it as a spoof. Idempotent. */ -export function browserUserAgent(): string { - return stockChromeUserAgent(app.userAgentFallback) +export function installBrowserUserAgent(): void { + app.userAgentFallback = stockChromeUserAgent(app.userAgentFallback) } From fbb6ed9d7a90d45dd978c446256d13b924e0a5dc Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 22 Sep 2026 22:40:24 -0700 Subject: [PATCH 2/2] test(desktop): cover the process-wide user agent from the first launched request --- apps/desktop/e2e/smoke.spec.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/desktop/e2e/smoke.spec.ts b/apps/desktop/e2e/smoke.spec.ts index f6799b7a835..08ace958c77 100644 --- a/apps/desktop/e2e/smoke.spec.ts +++ b/apps/desktop/e2e/smoke.spec.ts @@ -21,9 +21,13 @@ const PAGES: Record = { '/login': '

fixture-login

', } +/** `User-Agent` of every request the fixture origin has served, in arrival order. */ +const requestUserAgents: string[] = [] + function startFixtureServer(): Promise<{ server: Server; origin: string }> { return new Promise((resolvePromise) => { const server = createServer((request, response) => { + requestUserAgents.push(request.headers['user-agent'] ?? '') const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname const sessionCookie = request.headers.cookie ?.split(';') @@ -85,6 +89,21 @@ test.describe('desktop shell smoke', () => { expect(window.url()).toBe(`${origin}/home`) }) + test('presents one stock Chrome user agent on every request from the first load', async () => { + requestUserAgents.length = 0 + app = await launchApp(origin) + const window = await app.firstWindow() + await expect(window.locator('#app')).toHaveText('fixture-app') + await window.evaluate(() => fetch('/home').then((response) => response.text())) + + const pageUserAgent = await window.evaluate(() => navigator.userAgent) + expect(pageUserAgent).toMatch( + /^Mozilla\/5\.0 \(.+\) AppleWebKit\/537\.36 \(KHTML, like Gecko\) Chrome\/\d+\.0\.0\.0 Safari\/537\.36$/ + ) + expect(requestUserAgents.length).toBeGreaterThanOrEqual(2) + expect(new Set(requestUserAgents)).toEqual(new Set([pageUserAgent])) + }) + test('internal window.open creates an independent full Sim window', async () => { app = await launchApp(origin) const window = await app.firstWindow()