fix(desktop): report a stock Chrome user agent in the browser tab (#6695)

Electron's default user agent carries Sim/<version> and Electron/<version> tokens, and the detection libraries sites gate on test for Electron before Chrome — so the browser read as "Electron", which is on no site's supported list. Ashby warned "Ashby does not support this browser"; stricter sites refuse to render.

Rebuild the string as the desktop form Chrome's user-agent reduction specifies — same platform token and Chromium major version, the rest zeroed, no application or Electron token — and apply it to both the browser partition session and each tab's WebContents. Service workers do not inherit a tab's user agent, so without the session-level call a worker's script request still announced Electron.

Scoped to the browser partition: app.userAgentFallback is left alone so the Sim shell's own user agent is unchanged.
This commit is contained in:
Waleed
2026-08-13 23:50:20 -07:00
committed by GitHub
parent b2b6e55a39
commit 0239db80c6
5 changed files with 116 additions and 0 deletions
@@ -21,6 +21,7 @@ interface MockView {
setPermissionCheckHandler: ReturnType<typeof vi.fn>
}
on: ReturnType<typeof vi.fn>
setUserAgent: ReturnType<typeof vi.fn>
setWindowOpenHandler: ReturnType<typeof vi.fn>
loadURL: ReturnType<typeof vi.fn>
reload: ReturnType<typeof vi.fn>
@@ -169,6 +170,18 @@ describe('browser-agent session', () => {
expect(onTabNavigated).toHaveBeenCalledWith(contents, true)
})
it('gives every tab a user agent with no Electron token in 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\//)
}
})
it('settles the tab spinner when only subresources are still loading', () => {
const tab = session.ensureTab()
const contents = (tab.view as unknown as MockView).webContents
@@ -62,6 +62,7 @@ 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 {
@@ -819,6 +820,11 @@ function configureAgentPartition(ses: Session): void {
configuredPartitions.add(ses)
ses.setPermissionRequestHandler((_wc, _permission, callback) => callback(false))
ses.setPermissionCheckHandler(() => false)
// 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
@@ -1101,6 +1107,10 @@ function createTabView(): WebContentsView {
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, false)),
@@ -0,0 +1,43 @@
import { app } from 'electron'
import { describe, expect, it, vi } from 'vitest'
import { browserUserAgent, stockChromeUserAgent } from '@/main/browser-agent/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'
describe('stockChromeUserAgent', () => {
it('drops the application and Electron tokens a browser allowlist rejects', () => {
const agent = stockChromeUserAgent(ELECTRON_DEFAULT)
expect(agent).not.toMatch(/Electron/)
expect(agent).not.toMatch(/Sim\//)
})
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'
)
})
it('keeps the platform token of the machine it is running on', () => {
const windowsDefault =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Sim/1.0.0 Chrome/140.0.7339.207 Electron/43.1.1 Safari/537.36'
expect(stockChromeUserAgent(windowsDefault)).toContain('(Windows NT 10.0; Win64; x64)')
})
it('passes through a string that is not a Chromium user agent', () => {
expect(stockChromeUserAgent('curl/8.4.0')).toBe('curl/8.4.0')
expect(stockChromeUserAgent('')).toBe('')
})
})
describe('browserUserAgent', () => {
it('derives from the string Electron would otherwise have sent', () => {
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'
)
})
})
@@ -0,0 +1,46 @@
/**
* The user agent the browser resource presents to sites.
*
* Electron's default string carries two tokens no browser sends —
* `Sim/<version>` and `Electron/<version>`. Chromium's own token sits right
* beside them, but that does not save it: the detection libraries sites gate on
* test for Electron BEFORE Chrome (bowser matches `/electron/i` several
* descriptors ahead of its Chrome one, ua-parser-js reports `Electron` as the
* browser name), so the browser reads as "Electron", which is on nobody's
* supported list. Ashby warns "Ashby does not support this browser"; stricter
* sites refuse to render at all.
*
* Reporting stock Chrome is accurate rather than a disguise — the engine is the
* Chromium build the token already names, and Electron's user-agent client
* hints (`Sec-CH-UA`, `navigator.userAgentData`) only ever carried a Chromium
* brand, so dropping the token makes the header and the hints agree instead of
* contradicting each other.
*/
import { app } from 'electron'
/** Platform token, then the Chromium major version, in the order a Chromium user agent lists them. */
const CHROMIUM_USER_AGENT = /^Mozilla\/5\.0 \(([^)]*)\).* Chrome\/(\d+)\./
/**
* Rebuilds the default user agent as the string Chrome itself sends. Chrome's
* user-agent reduction fixes the desktop form at
* `Mozilla/5.0 (<platform>) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/<major>.0.0.0 Safari/537.36`,
* so keeping the platform token and the Chromium major version — and zeroing
* the rest — reproduces it exactly, with no room left for an application or
* Electron token. A string that is not a Chromium user agent is returned
* unchanged rather than replaced with a guess.
*/
export function stockChromeUserAgent(defaultUserAgent: string): string {
const match = defaultUserAgent.match(CHROMIUM_USER_AGENT)
if (!match) return defaultUserAgent
const [, platform, chromeMajor] = match
return `Mozilla/5.0 (${platform}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeMajor}.0.0.0 Safari/537.36`
}
/**
* Derived from the string Electron would otherwise have sent, so the reported
* Chromium version tracks whatever Chromium the app actually ships.
*/
export function browserUserAgent(): string {
return stockChromeUserAgent(app.userAgentFallback)
}
+4
View File
@@ -11,6 +11,8 @@ import { vi } from 'vitest'
export const app = {
name: 'Sim',
isPackaged: false,
userAgentFallback:
'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',
getVersion: vi.fn(() => '1.0.0'),
getName: vi.fn(() => 'Sim'),
setName: vi.fn(),
@@ -152,6 +154,7 @@ function createWebContentsMock() {
findInPage: vi.fn(() => 1),
stopFindInPage: vi.fn(),
setBackgroundThrottling: vi.fn(),
setUserAgent: vi.fn(),
setIgnoreMenuShortcuts: vi.fn(),
getZoomFactor: vi.fn(() => 1),
setZoomFactor: vi.fn(),
@@ -185,6 +188,7 @@ function createWebContentsMock() {
session: {
setPermissionRequestHandler: vi.fn(),
setPermissionCheckHandler: vi.fn(),
setUserAgent: vi.fn(),
webRequest: { onBeforeRequest: vi.fn() },
on: vi.fn(),
},