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
19 changes: 19 additions & 0 deletions apps/desktop/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,13 @@ const PAGES: Record<string, string> = {
'/login': '<!doctype html><html><body><h1 id="login">fixture-login</h1></body></html>',
}

/** `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(';')
Expand Down Expand Up @@ -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()
Expand Down
8 changes: 4 additions & 4 deletions apps/desktop/src/main/browser-agent/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ interface MockView {
session: {
setPermissionRequestHandler: ReturnType<typeof vi.fn>
setPermissionCheckHandler: ReturnType<typeof vi.fn>
setUserAgent: ReturnType<typeof vi.fn>
webRequest: { onBeforeRequest: ReturnType<typeof vi.fn> }
}
on: ReturnType<typeof vi.fn>
Expand Down Expand Up @@ -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()
}
})

Expand Down
10 changes: 0 additions & 10 deletions apps/desktop/src/main/browser-agent/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 })),
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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', () => {
Expand All @@ -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()
Comment thread
waleedlatif1 marked this conversation as resolved.

expect(app.userAgentFallback).toBe(STOCK_CHROME)
})
})
Original file line number Diff line number Diff line change
@@ -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/<version>` and `Electron/<version>`. Chromium's own token sits right
Expand Down Expand Up @@ -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)
}
Loading