Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/desktop/src/main/browser-agent/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/main/browser-agent/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)),
Expand Down
43 changes: 43 additions & 0 deletions apps/desktop/src/main/browser-agent/user-agent.test.ts
Original file line number Diff line number Diff line change
@@ -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'
)
})
})
46 changes: 46 additions & 0 deletions apps/desktop/src/main/browser-agent/user-agent.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 4 additions & 0 deletions apps/desktop/src/test/electron-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -185,6 +188,7 @@ function createWebContentsMock() {
session: {
setPermissionRequestHandler: vi.fn(),
setPermissionCheckHandler: vi.fn(),
setUserAgent: vi.fn(),
webRequest: { onBeforeRequest: vi.fn() },
on: vi.fn(),
},
Expand Down
Loading