diff --git a/docs/site/content/docs/browser/overview.mdx b/docs/site/content/docs/browser/overview.mdx index d248318da69..93890cf98bd 100644 --- a/docs/site/content/docs/browser/overview.mdx +++ b/docs/site/content/docs/browser/overview.mdx @@ -62,4 +62,4 @@ The browser is also scriptable by agents via the [Orca CLI](/docs/cli/overview) ## Next steps - [Design Mode](/docs/browser/design-mode) — turn the browser into a pointer-to-code feedback loop. -- [Browser-use profiles](/docs/browser/profiles) — run the browser with a specific login, cookie jar, or user agent. +- [Browser-use profiles](/docs/browser/profiles) — run the browser with a specific login or cookie jar. diff --git a/docs/site/content/docs/browser/profiles.mdx b/docs/site/content/docs/browser/profiles.mdx index fd9af7176e2..26162fa94bc 100644 --- a/docs/site/content/docs/browser/profiles.mdx +++ b/docs/site/content/docs/browser/profiles.mdx @@ -2,16 +2,19 @@ title: Browser-use profiles --- -Browser-use profiles let you run the Orca browser with a specific identity — a logged-in user, a particular cookie jar, a custom user-agent. Useful when an agent needs to log in, reproduce a session-specific bug, or emulate multiple users. +Browser-use profiles let you run the Orca browser with a specific identity — a logged-in user or a particular cookie jar. Useful when an agent needs to log in, reproduce a session-specific bug, or emulate multiple users. ## Create a profile 1. Open [Settings → Browser → Profiles](/docs/settings). 1. Click **Add profile**, give it a name. -1. Optionally seed it with cookies, a user-agent, and a viewport size. -1. Default profiles remove Orca and Electron tokens from the browser engine's user agent, preserving the Chrome-shaped identity expected by imported sessions. This focused compatibility measure does not make the embedded browser identical to Chrome. Google sign-in hosts use a scoped Firefox identity. If a site rejects the cleaned identity, including some Cloudflare-protected sites, create a profile that keeps the **native Electron user agent** instead. +1. Optionally seed it with cookies and a viewport size. -You can also create a no-spoof profile from the CLI with `orca tab profile create --no-ua-spoof` when you script browser setup. +## Browser identity + +The browser user agent is an app-wide choice because documents and web workers must present one coherent identity. Open **Settings → Browser → Browser identity** to choose between the cleaned identity and Electron's native identity. The change takes effect after you restart Orca. + +The cleaned identity removes Orca and Electron tokens from the browser engine's user agent, preserving the Chrome-shaped identity expected by imported sessions. This focused compatibility measure does not make the embedded browser identical to Chrome. In **Cleaned** mode, Google sign-in hosts use a scoped Firefox identity. **Native** mode keeps Electron's identity for sites that reject the cleaned identity, including some Cloudflare-protected sites, but Google sign-in is unavailable; switch back to **Cleaned** and restart Orca to sign in. ## Cookie import and Google sign-in diff --git a/src/cli/args.ts b/src/cli/args.ts index f458faa85f8..140190eed50 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -187,42 +187,18 @@ export function effectiveAllowedFlags(spec: CommandSpec): string[] { ] } -export function isCommandGroup(commandPath: string[]): boolean { - return ( - (commandPath.length === 1 && - [ - 'account', - 'artifacts', - 'automations', - 'project', - 'host', - 'repo', - 'worktree', - 'terminal', - 'file', - 'tab', - 'cookie', - 'intercept', - 'capture', - 'mouse', - 'set', - 'clipboard', - 'dialog', - 'storage', - 'orchestration', - 'computer', - 'emulator', - 'agent', - 'environment', - 'diagnostics', - 'linear', - 'skills', - 'vm' - ].includes(commandPath[0])) || - (commandPath.length === 2 && commandPath[0] === 'agent' && commandPath[1] === 'hooks') || - (commandPath.length === 2 && - commandPath[0] === 'storage' && - ['local', 'session'].includes(commandPath[1])) +export function isCommandGroup(specs: CommandSpec[], commandPath: string[]): boolean { + if (commandPath.length === 0) { + return false + } + return specs.some( + (spec) => + spec.hidden !== true && + specPaths(spec).some( + (candidate) => + candidate.length > commandPath.length && + matches(candidate.slice(0, commandPath.length), commandPath) + ) ) } diff --git a/src/cli/browser-format.ts b/src/cli/browser-format.ts index 76fdc18db75..0c4466e9755 100644 --- a/src/cli/browser-format.ts +++ b/src/cli/browser-format.ts @@ -47,8 +47,7 @@ export function formatBrowserProfileList(result: BrowserProfileListResult): stri .map((profile) => { const marker = profile.scope === 'default' ? '* ' : ' ' const source = profile.source?.browserFamily ?? 'none' - const userAgent = profile.userAgentMode === 'native' ? ' ua:native' : '' - return `${marker}${profile.id} ${profile.label} ${profile.scope} source:${source}${userAgent}` + return `${marker}${profile.id} ${profile.label} ${profile.scope} source:${source}` }) .join('\n') } diff --git a/src/cli/browser-handler-groups.ts b/src/cli/browser-handler-groups.ts index 3567e8b0596..87cf8850380 100644 --- a/src/cli/browser-handler-groups.ts +++ b/src/cli/browser-handler-groups.ts @@ -3,6 +3,11 @@ import type { HandlerGroup } from './handler-group-manifest' // Why split out: the browser command surface is a third of the CLI's groups and // changes as a unit, so it keeps handler-group-manifest.ts readable at a glance. export const BROWSER_HANDLER_GROUPS: readonly HandlerGroup[] = [ + { + name: 'browser-identity', + keys: ['browser identity get', 'browser identity set'], + load: async () => (await import('./handlers/browser-identity.js')).BROWSER_IDENTITY_HANDLERS + }, { name: 'browser-nav', keys: [ diff --git a/src/cli/browser.test.ts b/src/cli/browser.test.ts index f1dff8bc302..2bdb8ab0afb 100644 --- a/src/cli/browser.test.ts +++ b/src/cli/browser.test.ts @@ -314,6 +314,76 @@ describe('orca cli browser page targeting', () => { }) }) +describe('orca cli browser identity', () => { + beforeEach(() => { + callMock.mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('gets the host identity only after capability negotiation', async () => { + queueFixtures( + callMock, + okFixture('status', { capabilities: ['browser.identity.v1'] }), + okFixture('identity', { + identity: { + state: 'valid', + appliedMode: 'clean', + configuredMode: 'native', + explicitSelection: true, + migrationNoticePending: false, + restartRequired: true + }, + migrationNotice: null + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['browser', 'identity', 'get', '--json'], '/tmp/not-an-orca-worktree') + + expect(callMock).toHaveBeenNthCalledWith(1, 'status.get') + expect(callMock).toHaveBeenNthCalledWith(2, 'browser.identity.get') + }) + + it('sets the host identity through the runtime writer', async () => { + queueFixtures( + callMock, + okFixture('status', { capabilities: ['browser.identity.v1'] }), + okFixture('identity', { + ok: true, + identity: { + state: 'valid', + appliedMode: 'clean', + configuredMode: 'native', + explicitSelection: true, + migrationNoticePending: false, + restartRequired: true + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['browser', 'identity', 'set', '--mode', 'native', '--json'], + '/tmp/not-an-orca-worktree' + ) + + expect(callMock).toHaveBeenNthCalledWith(2, 'browser.identity.set', { mode: 'native' }) + }) + + it('refuses an older runtime instead of guessing at identity support', async () => { + queueFixtures(callMock, okFixture('status', { capabilities: [] })) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['browser', 'identity', 'get'], '/tmp/not-an-orca-worktree') + + expect(callMock).toHaveBeenCalledTimes(1) + expect(error).toHaveBeenCalledWith(expect.stringContaining('Update or restart Orca')) + }) +}) + describe('orca cli browser profile management', () => { beforeEach(() => { callMock.mockReset() @@ -435,29 +505,6 @@ describe('orca cli browser tab profiles', () => { expect(logSpy).toHaveBeenCalledWith('No browser profiles found.') }) - it('marks native-UA profiles in text output', async () => { - queueFixtures( - callMock, - okFixture('req_profiles_native_ua', { - profiles: [ - { - id: 'google', - scope: 'isolated', - label: 'Google', - partition: 'persist:orca-browser-session-google', - source: null, - userAgentMode: 'native' - } - ] - }) - ) - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - await main(['tab', 'profile', 'list'], '/tmp/not-an-orca-worktree') - - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('ua:native')) - }) - it('creates isolated browser tab profiles by default', async () => { queueFixtures( callMock, @@ -509,33 +556,6 @@ describe('orca cli browser tab profiles', () => { }) }) - it('creates a profile with the native user agent when requested', async () => { - queueFixtures( - callMock, - okFixture('req_profile_native_ua', { - profile: { - id: 'google', - scope: 'isolated', - label: 'Google', - partition: 'persist:orca-browser-session-google', - userAgentMode: 'native' - } - }) - ) - vi.spyOn(console, 'log').mockImplementation(() => {}) - - await main( - ['tab', 'profile', 'create', '--label', 'Google', '--no-ua-spoof', '--json'], - '/tmp/not-an-orca-worktree' - ) - - expect(callMock).toHaveBeenCalledWith('browser.profileCreate', { - label: 'Google', - scope: 'isolated', - userAgentMode: 'native' - }) - }) - it('rejects unknown --scope values instead of silently defaulting to isolated', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/src/cli/flag-help-text.ts b/src/cli/flag-help-text.ts index 3cdee00060d..2aba17080e4 100644 --- a/src/cli/flag-help-text.ts +++ b/src/cli/flag-help-text.ts @@ -82,6 +82,14 @@ export const FLAG_HELP_TEXT: Record = { timezone: '--timezone IANA timezone for the automation', enabled: '--enabled Enable the automation', disabled: '--disabled Disable the automation', + current: '--current Use the current Orca worktree linked Linear issue', + comments: '--comments Include threaded Linear comments', + children: '--children Include recursive child issues', + depth: '--depth Child issue depth for --children/--full', + attachments: '--attachments Include attachment metadata and URLs', + relations: '--relations Include blocking, related, and duplicate links', + activity: '--activity Include issue field-change history', + full: '--full Include all supported V1 issue context within caps', 'reuse-session': '--reuse-session Reuse the previous live session for existing-workspace runs', 'fresh-session': '--fresh-session Disable session reuse for future runs', @@ -101,6 +109,5 @@ export const FLAG_HELP_TEXT: Record = { page: '--page Stable browser page id from `orca tab list --json`', profile: '--profile Browser profile id', 'show-profile': '--show-profile Include tab profile in text output', - 'no-ua-spoof': "--no-ua-spoof Keep Electron's native user agent", format: '--format Screenshot image format' } diff --git a/src/cli/handlers/browser-identity.ts b/src/cli/handlers/browser-identity.ts new file mode 100644 index 00000000000..a13365b9822 --- /dev/null +++ b/src/cli/handlers/browser-identity.ts @@ -0,0 +1,63 @@ +import type { + BrowserIdentityModeSetResult, + BrowserIdentityModeStatus, + BrowserUserAgentMode +} from '../../shared/browser-user-agent-mode' +import { BROWSER_IDENTITY_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import type { RuntimeStatus } from '../../shared/runtime-types' +import type { CommandHandler, HandlerContext } from '../dispatch' +import { getRequiredStringFlag } from '../flags' +import { printResult } from '../format' +import { RuntimeClientError } from '../runtime-client' + +async function assertBrowserIdentitySupported({ client }: HandlerContext): Promise { + const status = await client.call('status.get') + if (!status.result.capabilities?.includes(BROWSER_IDENTITY_RUNTIME_CAPABILITY)) { + throw new RuntimeClientError( + 'incompatible_runtime', + 'The running Orca runtime does not support browser identity management. Update or restart Orca and try again.' + ) + } +} + +function parseMode(flags: Map): BrowserUserAgentMode { + const mode = getRequiredStringFlag(flags, 'mode') + if (mode !== 'clean' && mode !== 'native') { + throw new RuntimeClientError('invalid_argument', '--mode must be "clean" or "native"') + } + return mode +} + +function formatStatus(status: BrowserIdentityModeStatus): string { + const { identity } = status + if (identity.configuredMode === null) { + return `Browser identity: ${identity.state}; Cleaned is applied for this launch. Explicit reset required.` + } + return `Browser identity: ${identity.configuredMode} (applied: ${identity.appliedMode}${identity.restartRequired ? ', restart required' : ''})` +} + +export const BROWSER_IDENTITY_HANDLERS: Record = { + 'browser identity get': async (context) => { + await assertBrowserIdentitySupported(context) + const result = await context.client.call('browser.identity.get') + printResult(result, context.json, formatStatus) + }, + 'browser identity set': async (context) => { + const mode = parseMode(context.flags) + // Opt-in only: without it the host refuses to overwrite corrupt or newer-version data. + const reset = context.flags.get('reset') === true + await assertBrowserIdentitySupported(context) + const result = await context.client.call('browser.identity.set', { + mode, + ...(reset ? { reset: true } : {}) + }) + if (!result.result.ok) { + throw new RuntimeClientError(result.result.error.code, result.result.error.message) + } + printResult(result, context.json, ({ identity }) => + identity.restartRequired + ? `Browser identity set to ${identity.configuredMode}; restart Orca to apply it.` + : `Browser identity set to ${identity.configuredMode}.` + ) + } +} diff --git a/src/cli/handlers/browser-profile.ts b/src/cli/handlers/browser-profile.ts index 769980f2d31..4dc8ad731b3 100644 --- a/src/cli/handlers/browser-profile.ts +++ b/src/cli/handlers/browser-profile.ts @@ -38,8 +38,7 @@ export const BROWSER_PROFILE_HANDLERS: Record = { const scope = parseScopeFlag(flags) const result = await client.call('browser.profileCreate', { label, - scope, - ...(flags.get('no-ua-spoof') === true ? { userAgentMode: 'native' } : {}) + scope }) if (result.result.profile === null) { // Why: registry refuses non-isolated/imported scopes; we already validated diff --git a/src/cli/help.ts b/src/cli/help.ts index f40f2ddfc4f..8fc3b073faf 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -1,5 +1,5 @@ import type { CommandSpec } from './args' -import { findCommandSpec, isCommandGroup, supportsBrowserPageFlag } from './args' +import { findCommandSpec, isCommandGroup, matches, supportsBrowserPageFlag } from './args' import { unknownCommandData } from './command-suggestion' import { formatCommandScopedFlagHelp } from './command-scoped-flag-help' import { FLAG_HELP_TEXT } from './flag-help-text' @@ -15,8 +15,8 @@ export function printHelp(specs: CommandSpec[], commandPath: string[] = []): voi return } - if (isCommandGroup(commandPath)) { - console.log(formatGroupHelp(specs, commandPath[0])) + if (isCommandGroup(specs, commandPath)) { + console.log(formatGroupHelp(specs, commandPath)) return } @@ -62,11 +62,18 @@ export function formatCommandHelp(spec: CommandSpec): string { return lines.join('\n') } -export function formatGroupHelp(specs: CommandSpec[], group: string): string { - const groupSpecs = specs.filter((spec) => spec.path[0] === group && spec.hidden !== true) +export function formatGroupHelp(specs: CommandSpec[], groupPath: string[]): string { + const group = groupPath.join(' ') const lines = [`orca ${group}`, '', `Usage: orca ${group} [options]`, '', 'Commands:'] - for (const spec of groupSpecs) { - lines.push(` ${spec.path.slice(1).join(' ').padEnd(18)} ${spec.summary}`) + for (const spec of specs) { + if ( + spec.hidden === true || + spec.path.length <= groupPath.length || + !matches(spec.path.slice(0, groupPath.length), groupPath) + ) { + continue + } + lines.push(` ${spec.path.slice(groupPath.length).join(' ').padEnd(18)} ${spec.summary}`) } lines.push('', `Run \`orca ${group} --help\` for command-specific usage.`) return lines.join('\n') @@ -183,30 +190,5 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string { } export function formatFlagHelp(flag: string): string { - if (flag === 'current') { - return '--current Use the current Orca worktree linked Linear issue' - } - if (flag === 'comments') { - return '--comments Include threaded Linear comments' - } - if (flag === 'children') { - return '--children Include recursive child issues' - } - if (flag === 'depth') { - return '--depth Child issue depth for --children/--full' - } - if (flag === 'attachments') { - return '--attachments Include attachment metadata and URLs' - } - if (flag === 'relations') { - return '--relations Include blocking, related, and duplicate links' - } - if (flag === 'activity') { - return '--activity Include issue field-change history' - } - if (flag === 'full') { - return '--full Include all supported V1 issue context within caps' - } - return FLAG_HELP_TEXT[flag] ?? `--${flag}` } diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index f63e3832429..2d87ba081bd 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -381,6 +381,38 @@ describe('unknown help command surfaces a suggestion', () => { }) }) +describe('nested command group help', () => { + it.each([ + ['browser', ['browser'], ['identity get', 'identity set']], + ['browser identity', ['browser', 'identity'], ['get', 'set']] + ])( + 'prints successful help for %s without constructing a runtime client', + async (_, path, commands) => { + const previousExitCode = process.exitCode + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + runtimeClientConstructorMock.mockClear() + process.exitCode = 0 + + try { + await main([...path, '--help'], '/tmp/repo') + + expect(process.exitCode).toBe(0) + const output = logSpy.mock.calls.flat().join('\n') + expect(output).toContain(`orca ${path.join(' ')}`) + for (const command of commands) { + expect(output).toContain(command) + } + expect(output).not.toContain('Unknown command') + expect(runtimeClientConstructorMock).not.toHaveBeenCalled() + expect(callMock).not.toHaveBeenCalled() + } finally { + process.exitCode = previousExitCode + logSpy.mockRestore() + } + } + ) +}) + describe('orca root help', () => { it('advertises machine-readable agent discovery', async () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) diff --git a/src/cli/index.ts b/src/cli/index.ts index 77744e13d0d..6553543ccbc 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -93,7 +93,7 @@ export async function main( if ( helpPath.length > 0 && !findCommandSpec(COMMAND_SPECS, helpPath) && - !isCommandGroup(helpPath) + !isCommandGroup(COMMAND_SPECS, helpPath) ) { process.exitCode = 1 } diff --git a/src/cli/root-help-text-secondary.ts b/src/cli/root-help-text-secondary.ts index 78ee5363688..8602e35c49e 100644 --- a/src/cli/root-help-text-secondary.ts +++ b/src/cli/root-help-text-secondary.ts @@ -144,7 +144,6 @@ export const ROOT_HELP_TEXT_SECONDARY = [ ' --page Stable browser page id (preferred for concurrent workflows)', ' --profile Browser profile id', " --show-profile Include the tab's browser profile in text output", - " --no-ua-spoof Keep Electron's native user agent for a new profile", ' --format Screenshot image format', ' --from Drag source element ref', ' --to Drag target element ref', diff --git a/src/cli/specs/browser-basic.ts b/src/cli/specs/browser-basic.ts index 7e3bb4a5770..3125f2f3864 100644 --- a/src/cli/specs/browser-basic.ts +++ b/src/cli/specs/browser-basic.ts @@ -2,6 +2,19 @@ import type { CommandSpec } from '../args' import { GLOBAL_FLAGS } from '../args' export const BROWSER_BASIC_COMMAND_SPECS: CommandSpec[] = [ + { + path: ['browser', 'identity', 'get'], + summary: 'Show the browser identity configured on this Orca host', + usage: 'orca browser identity get [--json]', + aliases: [['browser', 'identity', 'show']], + allowedFlags: [...GLOBAL_FLAGS] + }, + { + path: ['browser', 'identity', 'set'], + summary: 'Choose the browser identity for every page on this Orca host', + usage: 'orca browser identity set --mode [--reset] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'mode', 'reset'] + }, { path: ['open-url'], summary: 'Open a URL on the paired client that hosts this terminal', @@ -196,9 +209,8 @@ export const BROWSER_BASIC_COMMAND_SPECS: CommandSpec[] = [ { path: ['tab', 'profile', 'create'], summary: 'Create a browser session profile for browser tabs', - usage: - 'orca tab profile create --label [--scope ] [--no-ua-spoof] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'label', 'scope', 'no-ua-spoof'] + usage: 'orca tab profile create --label [--scope ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'label', 'scope'] }, { path: ['tab', 'profile', 'delete'], diff --git a/src/main/browser/__mocks__/browser-session-registry-persistence-fixture.ts b/src/main/browser/__mocks__/browser-session-registry-persistence-fixture.ts new file mode 100644 index 00000000000..7213dbbba96 --- /dev/null +++ b/src/main/browser/__mocks__/browser-session-registry-persistence-fixture.ts @@ -0,0 +1,202 @@ +import { vi } from 'vitest' + +type RegistryMock = ReturnType + +/** + * In-memory filesystem and module mocks shared by the BrowserSessionRegistry persistence suites. + * + * `vi.doMock` is not hoisted, which is why it can live here: each test installs the mocks and then + * dynamically imports the registry, so the relative specifiers below resolve against this + * directory exactly as they did when this block lived inside the test file. + */ +export const USER_DATA = '/user-data' +export const META_PATH = `${USER_DATA}/browser-session-meta.json` +export const IDENTITY_RECORD_PATH = `${USER_DATA}/browser-identity-mode.json` +export const CLEAN_USER_AGENT = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.7871.224 Safari/537.36' + +export type FsState = { + files: Map + present: Set +} + +const fsKey = (pathValue: string): string => pathValue.replaceAll('\\', '/') + +export const createFsState = (): FsState => ({ files: new Map(), present: new Set() }) + +export function seedMeta(fsState: FsState, meta: unknown): void { + const raw = JSON.stringify(meta) + fsState.files.set(META_PATH, raw) + fsState.present.add(META_PATH) +} + +/** Annotated rather than inferred: vitest's inferred mock type cannot be named across a module boundary. */ +export type BrowserSessionRegistryMocks = { + sessionFromPartitionMock: RegistryMock + installBrowserSessionUserAgentPolicyMock: RegistryMock + browserManagerHandleGuestWillDownloadMock: RegistryMock + browserManagerNotifyPermissionDeniedMock: RegistryMock + requestSystemMediaAccessMock: RegistryMock +} + +export function installModuleMocks( + fsState: FsState, + copyFailures = new Set(), + failIdentityWrite = false +): BrowserSessionRegistryMocks { + const sessionFromPartitionMock: ReturnType = vi.fn((partition: string) => ({ + partition, + setUserAgent: vi.fn(), + getUserAgent: vi.fn(() => CLEAN_USER_AGENT), + setPermissionRequestHandler: vi.fn(), + setPermissionCheckHandler: vi.fn(), + setDevicePermissionHandler: vi.fn(), + setDisplayMediaRequestHandler: vi.fn(), + on: vi.fn(), + removeListener: vi.fn(), + clearStorageData: vi.fn().mockResolvedValue(undefined), + clearCache: vi.fn().mockResolvedValue(undefined) + })) + const installBrowserSessionUserAgentPolicyMock: RegistryMock = vi.fn(() => vi.fn()) + const browserManagerHandleGuestWillDownloadMock: RegistryMock = vi.fn() + const browserManagerNotifyPermissionDeniedMock: RegistryMock = vi.fn() + const requestSystemMediaAccessMock: RegistryMock = vi.fn().mockResolvedValue(true) + + vi.doMock('electron', () => ({ + app: { getPath: vi.fn(() => USER_DATA) }, + session: { fromPartition: sessionFromPartitionMock }, + systemPreferences: { + askForMediaAccess: vi.fn().mockResolvedValue(true), + getMediaAccessStatus: vi.fn(() => 'granted') + } + })) + + vi.doMock('node:fs', () => ({ + // The identity sidecar goes through writeFileDurableSync, so the in-memory fs has to + // answer its fsync/rename syscalls too or every identity write looks like a disk failure. + closeSync: vi.fn(), + fsyncSync: vi.fn(), + openSync: vi.fn(() => 1), + rmSync: vi.fn((p: string) => { + const key = fsKey(p) + fsState.present.delete(key) + fsState.files.delete(key) + }), + copyFileSync: vi.fn((src: string, dst: string) => { + const sourceKey = fsKey(src) + const destinationKey = fsKey(dst) + if (copyFailures.has(sourceKey)) { + throw new Error(`copy fail for ${src}`) + } + fsState.present.add(destinationKey) + const value = fsState.files.get(sourceKey) + if (value !== undefined) { + fsState.files.set(destinationKey, value) + } + }), + existsSync: vi.fn((p: string) => fsState.present.has(fsKey(p))), + mkdirSync: vi.fn(), + readFileSync: vi.fn((p: string) => { + const v = fsState.files.get(fsKey(p)) + if (v === undefined) { + // Carry the code: absent data reads as "missing", while a codeless throw would + // look "unreadable" and make every caller refuse to write. + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return v + }), + renameSync: vi.fn((from: string, to: string) => { + const sourceKey = fsKey(from) + const destinationKey = fsKey(to) + const v = fsState.files.get(sourceKey) + if (v === undefined) { + throw new Error('ENOENT') + } + fsState.files.set(destinationKey, v) + fsState.present.add(destinationKey) + fsState.files.delete(sourceKey) + fsState.present.delete(sourceKey) + }), + unlinkSync: vi.fn((p: string) => { + const key = fsKey(p) + fsState.present.delete(key) + fsState.files.delete(key) + }), + writeFileSync: vi.fn((p: string, data: string | Uint8Array) => { + if (failIdentityWrite && fsKey(p).includes('browser-identity-mode.json')) { + throw new Error('read-only userData') + } + const value = typeof data === 'string' ? data : Buffer.from(data).toString('utf-8') + const key = fsKey(p) + fsState.files.set(key, value) + fsState.present.add(key) + }) + })) + + vi.doMock('../browser-manager', () => ({ + browserManager: { + notifyPermissionDenied: browserManagerNotifyPermissionDeniedMock, + handleGuestWillDownload: browserManagerHandleGuestWillDownloadMock, + installCertificateRequestGuard: vi.fn(), + removeCertificateRequestGuard: vi.fn() + } + })) + vi.doMock('../browser-media-access', () => ({ + hasSystemMediaAccess: vi.fn(() => true), + requestSystemMediaAccess: requestSystemMediaAccessMock + })) + vi.doMock('../browser-session-ua', () => ({ + installBrowserSessionUserAgentPolicy: installBrowserSessionUserAgentPolicyMock + })) + vi.doMock('../browser-process-user-agent', () => ({ + getBrowserProcessUserAgentIdentity: () => ({ + mode: 'clean', + userAgent: CLEAN_USER_AGENT + }) + })) + vi.doMock('../../persistence', () => ({ + getCanonicalUserDataPath: () => USER_DATA + })) + vi.doMock('../../persistence/loading-store/user-data-path', () => ({ + getCanonicalUserDataPath: () => USER_DATA + })) + // These suites model replay with an in-memory filesystem. The real file-backed SQLite merge has + // dedicated coverage; these fixtures are legacy unmarked images and keep the copy path. + vi.doMock('../browser-cookie-staged-import', () => ({ + SCOPED_COOKIE_IMPORT_FORMAT: 'scoped-v1', + applyScopedStagedCookieImport: vi.fn(() => false), + isScopedStagedCookieImport: vi.fn(() => false), + removeCookieImportScopeMarker: vi.fn() + })) + vi.doMock('../../codex-accounts/fs-utils', () => ({ + renameFileWithWindowsRetry: vi.fn((source: string, target: string) => { + const sourceKey = fsKey(source) + const targetKey = fsKey(target) + if (!fsState.present.has(sourceKey)) { + throw new Error('ENOENT') + } + const value = fsState.files.get(sourceKey) + fsState.present.delete(sourceKey) + fsState.files.delete(sourceKey) + fsState.present.add(targetKey) + if (value !== undefined) { + fsState.files.set(targetKey, value) + } + }), + // Nothing on this path calls writeFileAtomically; it is here only to keep the module shape + // complete. The identity write goes through node:fs above, which is where failure is injected. + writeFileAtomically: vi.fn((pathValue: string, data: string) => { + const key = fsKey(pathValue) + fsState.files.set(key, data) + fsState.present.add(key) + }) + })) + + return { + sessionFromPartitionMock, + installBrowserSessionUserAgentPolicyMock, + browserManagerHandleGuestWillDownloadMock, + browserManagerNotifyPermissionDeniedMock, + requestSystemMediaAccessMock + } +} diff --git a/src/main/browser/browser-google-auth-ua.ts b/src/main/browser/browser-google-auth-ua.ts index e9b802f6d70..15041241545 100644 --- a/src/main/browser/browser-google-auth-ua.ts +++ b/src/main/browser/browser-google-auth-ua.ts @@ -20,6 +20,19 @@ export function isGoogleAuthUrl(rawUrl: string): boolean { } } +export function shouldUseGoogleAuthIdentity( + url: string, + referrer: string, + resourceType: string +): boolean { + if (isGoogleAuthUrl(url)) { + return true + } + // Why: early cross-host subresources can leave before the WebContents Firefox override lands; + // the auth referrer identifies their owning flow. Main-frame exits restore the process identity. + return resourceType !== 'mainFrame' && isGoogleAuthUrl(referrer) +} + // Why: rv:/Gecko/Firefox tokens must line up with a real released build and the // platform token must match the host OS, or the UA is internally inconsistent and // itself a bot tell. diff --git a/src/main/browser/browser-identity-mode-record.test.ts b/src/main/browser/browser-identity-mode-record.test.ts new file mode 100644 index 00000000000..f83932e9bd4 --- /dev/null +++ b/src/main/browser/browser-identity-mode-record.test.ts @@ -0,0 +1,91 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + BROWSER_IDENTITY_MODE_FILE, + BROWSER_IDENTITY_MODE_VERSION, + readBrowserIdentityModeRecord +} from './browser-identity-mode-record' + +function makeUserData(): string { + return mkdtempSync(join(tmpdir(), 'orca-browser-identity-')) +} + +function writeRecord(userDataPath: string, value: unknown): void { + writeFileSync(join(userDataPath, BROWSER_IDENTITY_MODE_FILE), JSON.stringify(value), 'utf8') +} + +describe('readBrowserIdentityModeRecord', () => { + it('distinguishes missing data as implicit clean', () => { + expect(readBrowserIdentityModeRecord(makeUserData())).toEqual({ + state: 'missing', + appliedMode: 'clean', + configuredMode: 'clean', + explicitSelection: false, + migrationNoticePending: false + }) + }) + + it('returns a valid configured identity', () => { + const userDataPath = makeUserData() + writeRecord(userDataPath, { + version: BROWSER_IDENTITY_MODE_VERSION, + mode: 'native', + explicitSelection: true, + migrationNoticePending: true + }) + + expect(readBrowserIdentityModeRecord(userDataPath)).toEqual({ + state: 'valid', + appliedMode: 'native', + configuredMode: 'native', + explicitSelection: true, + migrationNoticePending: true + }) + }) + + it('falls back to clean without inventing a configured mode for corrupt data', () => { + const userDataPath = makeUserData() + writeFileSync(join(userDataPath, BROWSER_IDENTITY_MODE_FILE), '{not json', 'utf8') + + expect(readBrowserIdentityModeRecord(userDataPath)).toEqual({ + state: 'corrupt', + appliedMode: 'clean', + configuredMode: null, + explicitSelection: null, + migrationNoticePending: null + }) + }) + + it('distinguishes a future record from corrupt data', () => { + const userDataPath = makeUserData() + writeRecord(userDataPath, { + version: BROWSER_IDENTITY_MODE_VERSION + 1, + mode: 'native', + explicitSelection: true, + migrationNoticePending: false + }) + + expect(readBrowserIdentityModeRecord(userDataPath)).toEqual({ + state: 'future', + appliedMode: 'clean', + configuredMode: null, + explicitSelection: null, + migrationNoticePending: null + }) + }) + + it('distinguishes an unreadable record from missing data', () => { + const userDataPath = makeUserData() + mkdirSync(join(userDataPath, BROWSER_IDENTITY_MODE_FILE)) + + expect(readBrowserIdentityModeRecord(userDataPath)).toEqual({ + state: 'unreadable', + appliedMode: 'clean', + configuredMode: null, + explicitSelection: null, + migrationNoticePending: null + }) + }) +}) diff --git a/src/main/browser/browser-identity-mode-record.ts b/src/main/browser/browser-identity-mode-record.ts new file mode 100644 index 00000000000..19b3c8fccbc --- /dev/null +++ b/src/main/browser/browser-identity-mode-record.ts @@ -0,0 +1,130 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import type { BrowserUserAgentMode } from '../../shared/browser-user-agent-mode' + +/** + * The browser's identity is one process-wide decision, not a per-profile one. + * + * Electron resolves worker identity from a single process-global default, so two coherent + * identities cannot coexist in one process: a per-profile native mode leaves documents on one + * identity and every worker request on the other, which is a sharper bot signal than either + * alone. The choice therefore lives here, is read before `ready`, and applies to the whole app. + * + * Both identities are load-bearing, which is why this is a choice and not a constant. Measured + * across four origins, five repetitions each: the cleaned identity clears an embedded Turnstile + * widget and WhatsApp's browser check while the native identity is refused by both; the native + * identity clears a full-page Cloudflare interstitial that the cleaned identity never clears. + * + * Read with `readFileSync` rather than through the settings store because the store loads long + * after `ready`, and by then every session and worker has already taken its default. + * + * This module only reads. Every write goes through browser-identity-mode-store.ts, which is the + * single writer — the two-authority bug this replaced came from a second place writing here. + */ +export const BROWSER_IDENTITY_MODE_FILE = 'browser-identity-mode.json' +export const BROWSER_IDENTITY_MODE_VERSION = 1 + +export type BrowserIdentityModeRecord = { + version: typeof BROWSER_IDENTITY_MODE_VERSION + mode: BrowserUserAgentMode + explicitSelection: boolean + migrationNoticePending: boolean +} + +type HealthyBrowserIdentityModeReadResult = { + state: 'missing' | 'valid' + appliedMode: BrowserUserAgentMode + configuredMode: BrowserUserAgentMode + explicitSelection: boolean + migrationNoticePending: boolean +} + +type UnhealthyBrowserIdentityModeReadResult = { + state: 'corrupt' | 'future' | 'unreadable' + appliedMode: 'clean' + configuredMode: null + explicitSelection: null + migrationNoticePending: null +} + +type BrowserIdentityModeFileInput = { + readonly version?: unknown + readonly mode?: unknown + readonly explicitSelection?: unknown + readonly migrationNoticePending?: unknown +} + +/** In-memory health of one read. Never persisted: the file holds a choice, not a state machine. */ +export type BrowserIdentityModeReadResult = + | HealthyBrowserIdentityModeReadResult + | UnhealthyBrowserIdentityModeReadResult + +export function browserIdentityModeRecordPath(userDataPath: string): string { + return join(userDataPath, BROWSER_IDENTITY_MODE_FILE) +} + +function unhealthyResult( + state: UnhealthyBrowserIdentityModeReadResult['state'] +): UnhealthyBrowserIdentityModeReadResult { + return { + state, + appliedMode: 'clean', + configuredMode: null, + explicitSelection: null, + migrationNoticePending: null + } +} + +function parseRecord(raw: string): BrowserIdentityModeReadResult { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return unhealthyResult('corrupt') + } + if (!isBrowserIdentityModeFileInput(parsed)) { + return unhealthyResult('corrupt') + } + const { version, mode, explicitSelection, migrationNoticePending } = parsed + // Why before the shape check: newer data means "update Orca", never "your data is broken". + if (typeof version === 'number' && version > BROWSER_IDENTITY_MODE_VERSION) { + return unhealthyResult('future') + } + if ( + version !== BROWSER_IDENTITY_MODE_VERSION || + (mode !== 'clean' && mode !== 'native') || + typeof explicitSelection !== 'boolean' || + typeof migrationNoticePending !== 'boolean' + ) { + return unhealthyResult('corrupt') + } + return { + state: 'valid', + appliedMode: mode, + configuredMode: mode, + explicitSelection, + migrationNoticePending + } +} + +/** Reads the process identity synchronously before Electron readiness. */ +export function readBrowserIdentityModeRecord(userDataPath: string): BrowserIdentityModeReadResult { + try { + return parseRecord(readFileSync(browserIdentityModeRecordPath(userDataPath), 'utf-8')) + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return { + state: 'missing', + appliedMode: 'clean', + configuredMode: 'clean', + explicitSelection: false, + migrationNoticePending: false + } + } + return unhealthyResult('unreadable') + } +} + +function isBrowserIdentityModeFileInput(value: unknown): value is BrowserIdentityModeFileInput { + return typeof value === 'object' && value !== null +} diff --git a/src/main/browser/browser-identity-mode-store.test.ts b/src/main/browser/browser-identity-mode-store.test.ts new file mode 100644 index 00000000000..0d7d398fb02 --- /dev/null +++ b/src/main/browser/browser-identity-mode-store.test.ts @@ -0,0 +1,203 @@ +import { mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as DurableFileWrite from '../durable-file-write' + +const mocks = vi.hoisted(() => ({ failWrite: false })) + +vi.mock('../durable-file-write', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + writeFileDurableSync: (...args: Parameters) => { + if (mocks.failWrite) { + throw new Error('disk refused identity write') + } + actual.writeFileDurableSync(...args) + } + } +}) + +import { + BROWSER_IDENTITY_MODE_FILE, + BROWSER_IDENTITY_MODE_VERSION +} from './browser-identity-mode-record' +import { + getBrowserIdentityModeSnapshot, + initializeBrowserIdentityModeStore, + resetBrowserIdentityModeStoreForTests, + setBrowserIdentityMode +} from './browser-identity-mode-store' + +function makeUserData(mode: 'clean' | 'native' = 'clean'): string { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-browser-identity-store-')) + writeFileSync( + join(userDataPath, BROWSER_IDENTITY_MODE_FILE), + JSON.stringify({ + version: BROWSER_IDENTITY_MODE_VERSION, + mode, + explicitSelection: false, + migrationNoticePending: true + }), + 'utf8' + ) + return userDataPath +} + +describe('browser identity mode store', () => { + beforeEach(() => { + mocks.failWrite = false + resetBrowserIdentityModeStoreForTests() + }) + + it('durably commits an explicit selection before reporting restart state', async () => { + const userDataPath = makeUserData() + initializeBrowserIdentityModeStore(userDataPath) + + await expect(setBrowserIdentityMode('native')).resolves.toEqual({ + ok: true, + identity: { + state: 'valid', + appliedMode: 'clean', + configuredMode: 'native', + explicitSelection: true, + migrationNoticePending: false, + restartRequired: true + } + }) + expect( + JSON.parse(readFileSync(join(userDataPath, BROWSER_IDENTITY_MODE_FILE), 'utf8')) + ).toEqual({ + version: BROWSER_IDENTITY_MODE_VERSION, + mode: 'native', + explicitSelection: true, + migrationNoticePending: false + }) + }) + + // Not a serialization claim: writeRecord is synchronous, so two calls cannot interleave. This + // pins the observable contract instead -- the later selection is the one that survives. + it('applies the last of two selections issued together', async () => { + const userDataPath = makeUserData() + initializeBrowserIdentityModeStore(userDataPath) + + const first = setBrowserIdentityMode('native') + const second = setBrowserIdentityMode('clean') + + await expect(first).resolves.toMatchObject({ ok: true }) + await expect(second).resolves.toMatchObject({ ok: true }) + expect(getBrowserIdentityModeSnapshot()).toMatchObject({ + appliedMode: 'clean', + configuredMode: 'clean', + explicitSelection: true, + restartRequired: false + }) + }) + + it('returns a structured error and keeps both values unchanged after a failed write', async () => { + initializeBrowserIdentityModeStore(makeUserData()) + mocks.failWrite = true + + await expect(setBrowserIdentityMode('native')).resolves.toEqual({ + ok: false, + error: { + code: 'browser_identity_write_failed', + message: 'disk refused identity write' + }, + identity: { + state: 'valid', + appliedMode: 'clean', + configuredMode: 'clean', + explicitSelection: false, + migrationNoticePending: true, + restartRequired: false + } + }) + expect(getBrowserIdentityModeSnapshot()).toMatchObject({ + appliedMode: 'clean', + configuredMode: 'clean' + }) + }) + + it('refuses ordinary updates while the record is unhealthy', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-browser-identity-store-')) + writeFileSync(join(userDataPath, BROWSER_IDENTITY_MODE_FILE), '{bad json', 'utf8') + initializeBrowserIdentityModeStore(userDataPath) + + await expect(setBrowserIdentityMode('native')).resolves.toMatchObject({ + ok: false, + error: { code: 'browser_identity_reset_required' }, + identity: { state: 'corrupt', configuredMode: null, appliedMode: 'clean' } + }) + expect(readFileSync(join(userDataPath, BROWSER_IDENTITY_MODE_FILE), 'utf8')).toBe('{bad json') + }) + + it.each([ + { label: 'corrupt', bytes: '{bad json' }, + { + label: 'future', + bytes: JSON.stringify({ + version: BROWSER_IDENTITY_MODE_VERSION + 1, + mode: 'native', + explicitSelection: true, + migrationNoticePending: false + }) + } + ])('backs $label bytes up verbatim before publishing a fresh record', async ({ bytes }) => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-browser-identity-store-')) + writeFileSync(join(userDataPath, BROWSER_IDENTITY_MODE_FILE), bytes, 'utf8') + initializeBrowserIdentityModeStore(userDataPath) + + await expect(setBrowserIdentityMode('native', { reset: true })).resolves.toMatchObject({ + ok: true, + identity: { state: 'valid', configuredMode: 'native', explicitSelection: true } + }) + + const backups = readdirSync(userDataPath).filter((name) => name.endsWith('.bak')) + expect(backups).toHaveLength(1) + expect(readFileSync(join(userDataPath, backups[0]), 'utf8')).toBe(bytes) + expect( + JSON.parse(readFileSync(join(userDataPath, BROWSER_IDENTITY_MODE_FILE), 'utf8')) + ).toMatchObject({ version: BROWSER_IDENTITY_MODE_VERSION, mode: 'native' }) + }) + + it('never reuses a backup path across repeated resets', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-browser-identity-store-')) + const recordPath = join(userDataPath, BROWSER_IDENTITY_MODE_FILE) + writeFileSync(recordPath, '{bad json', 'utf8') + initializeBrowserIdentityModeStore(userDataPath) + await expect(setBrowserIdentityMode('native', { reset: true })).resolves.toMatchObject({ + ok: true + }) + + // A later launch finds the record unhealthy again; the first backup must survive untouched. + writeFileSync(recordPath, '{bad json again', 'utf8') + resetBrowserIdentityModeStoreForTests() + initializeBrowserIdentityModeStore(userDataPath) + await expect(setBrowserIdentityMode('clean', { reset: true })).resolves.toMatchObject({ + ok: true + }) + + const backups = readdirSync(userDataPath).filter((name) => name.endsWith('.bak')) + expect(new Set(backups).size).toBe(2) + expect(backups.map((name) => readFileSync(join(userDataPath, name), 'utf8')).sort()).toEqual( + ['{bad json', '{bad json again'].sort() + ) + }) + + it('leaves the unhealthy bytes in place when the backup cannot be written', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-browser-identity-store-')) + writeFileSync(join(userDataPath, BROWSER_IDENTITY_MODE_FILE), '{bad json', 'utf8') + initializeBrowserIdentityModeStore(userDataPath) + mocks.failWrite = true + + await expect(setBrowserIdentityMode('native', { reset: true })).resolves.toMatchObject({ + ok: false, + error: { code: 'browser_identity_backup_failed' } + }) + // Never overwrite what could not be preserved. + expect(readFileSync(join(userDataPath, BROWSER_IDENTITY_MODE_FILE), 'utf8')).toBe('{bad json') + expect(readdirSync(userDataPath)).toEqual([BROWSER_IDENTITY_MODE_FILE]) + }) +}) diff --git a/src/main/browser/browser-identity-mode-store.ts b/src/main/browser/browser-identity-mode-store.ts new file mode 100644 index 00000000000..9f432c6e006 --- /dev/null +++ b/src/main/browser/browser-identity-mode-store.ts @@ -0,0 +1,262 @@ +import { randomUUID } from 'node:crypto' +import { existsSync, readFileSync } from 'node:fs' +import { durableWriteTempPath, writeFileDurableSync } from '../durable-file-write' +import type { + BrowserIdentityModeSetResult, + BrowserIdentityModeSnapshot, + BrowserIdentityModeStatus, + BrowserUserAgentMode +} from '../../shared/browser-user-agent-mode' +import { + BROWSER_IDENTITY_MODE_VERSION, + browserIdentityModeRecordPath, + readBrowserIdentityModeRecord, + type BrowserIdentityModeReadResult, + type BrowserIdentityModeRecord +} from './browser-identity-mode-record' + +/** + * The single writer for the process-wide browser identity. + * + * Preflight reads the root record before `ready` and hands its mode to the engine. The ready + * phase used to mirror the *active Orca profile's* setting back into that record, so switching + * from a native profile to a clean one started the clean profile in native. That second authority + * is gone: the root record is the only one, and this module is its only writer. + * + * `appliedMode` is what this launch is actually presenting and never changes while the process + * lives; `configuredMode` is what the next launch will take. `restartRequired` is derived from the + * two rather than stored, so it cannot drift from them. + */ + +type BrowserIdentityModeStore = { + userDataPath: string + snapshot: BrowserIdentityModeSnapshot +} + +let modeStore: BrowserIdentityModeStore | null = null +const snapshotListeners = new Set<(snapshot: BrowserIdentityModeSnapshot) => void>() +let migrationNoticeDegraded = false +let launchMigrationNoticePending = false + +function snapshotForRead(result: BrowserIdentityModeReadResult): BrowserIdentityModeSnapshot { + return { ...result, restartRequired: false } +} + +function writeRecord(userDataPath: string, record: BrowserIdentityModeRecord): void { + const filePath = browserIdentityModeRecordPath(userDataPath) + writeFileDurableSync( + durableWriteTempPath(filePath), + filePath, + `${JSON.stringify(record, null, 2)}\n` + ) +} + +/** + * Copies unhealthy bytes to a fresh path before anything overwrites them. Byte-for-byte, and + * never onto a name that already exists, so an explicit reset cannot be what loses the data. + */ +function backupUnhealthyRecord(userDataPath: string): string { + const filePath = browserIdentityModeRecordPath(userDataPath) + const bytes = readFileSync(filePath) + const backupPath = `${filePath}.${Date.now()}.${randomUUID().slice(0, 8)}.bak` + if (existsSync(backupPath)) { + throw new Error(`Browser identity backup ${backupPath} already exists`) + } + writeFileDurableSync(durableWriteTempPath(backupPath), backupPath, bytes) + return backupPath +} + +/** Whether this host actually owns a browser identity, which is what the capability advertises. */ +export function isBrowserIdentityModeStoreInitialized(): boolean { + return modeStore !== null +} + +export function initializeBrowserIdentityModeStore( + userDataPath: string +): BrowserIdentityModeSnapshot { + if (modeStore) { + throw new Error('Browser identity mode store was already initialized') + } + const snapshot = snapshotForRead(readBrowserIdentityModeRecord(userDataPath)) + modeStore = { userDataPath, snapshot } + return snapshot +} + +function requireModeStore(): BrowserIdentityModeStore { + if (!modeStore) { + throw new Error('Browser identity mode store is not initialized') + } + return modeStore +} + +export function getBrowserIdentityModeSnapshot(): BrowserIdentityModeSnapshot { + return requireModeStore().snapshot +} + +export function getBrowserIdentityMigrationNotice(): { degraded: boolean } | null { + const snapshot = requireModeStore().snapshot + return launchMigrationNoticePending || snapshot.migrationNoticePending === true + ? { degraded: migrationNoticeDegraded } + : null +} + +export function getBrowserIdentityModeStatus(): BrowserIdentityModeStatus { + return { + identity: getBrowserIdentityModeSnapshot(), + migrationNotice: getBrowserIdentityMigrationNotice() + } +} + +function notifySnapshotListeners(snapshot: BrowserIdentityModeSnapshot): void { + for (const listener of snapshotListeners) { + try { + listener(snapshot) + } catch (error) { + console.error('[browser-identity] Snapshot listener failed:', error) + } + } +} + +export function onBrowserIdentityModeSnapshotChanged( + listener: (snapshot: BrowserIdentityModeSnapshot) => void +): () => void { + snapshotListeners.add(listener) + return () => snapshotListeners.delete(listener) +} + +/** + * Commits an explicit choice. The record lands durably before this resolves. + * + * No queue: writeRecord is synchronous end to end, so two calls cannot interleave and a + * serialization layer here would be machinery no test could falsify. If durable writes ever + * become async, reintroduce serialization with that change, where it is testable. + */ +export async function setBrowserIdentityMode( + mode: BrowserUserAgentMode, + options: { reset?: boolean } = {} +): Promise { + const store = requireModeStore() + const current = store.snapshot + if (current.configuredMode === null) { + // Why never automatic: the data may belong to a newer Orca, and overwriting it silently + // would destroy the only copy. The caller has to ask, and the old bytes survive the ask. + if (!options.reset) { + return { + ok: false, + error: { + code: 'browser_identity_reset_required', + message: + current.state === 'future' + ? 'Browser identity data was written by a newer Orca; update Orca, or reset it explicitly to overwrite it.' + : `Browser identity data is ${current.state}; reset it explicitly to overwrite it.` + }, + identity: current + } + } + try { + backupUnhealthyRecord(store.userDataPath) + } catch (error) { + return { + ok: false, + error: { + code: 'browser_identity_backup_failed', + message: error instanceof Error ? error.message : String(error) + }, + identity: current + } + } + } + const record: BrowserIdentityModeRecord = { + version: BROWSER_IDENTITY_MODE_VERSION, + mode, + explicitSelection: true, + migrationNoticePending: false + } + try { + writeRecord(store.userDataPath, record) + } catch (error) { + // Why unchanged: a rejected write leaves disk on the old value, so reporting the new one + // would make the UI and the next launch disagree. + return { + ok: false, + error: { + code: 'browser_identity_write_failed', + message: error instanceof Error ? error.message : String(error) + }, + identity: current + } + } + const identity: BrowserIdentityModeSnapshot = { + state: 'valid', + appliedMode: current.appliedMode, + configuredMode: mode, + explicitSelection: true, + migrationNoticePending: false, + restartRequired: mode !== current.appliedMode + } + store.snapshot = identity + launchMigrationNoticePending = false + migrationNoticeDegraded = false + notifySnapshotListeners(identity) + return { ok: true, identity } +} + +/** + * Records that a launch found retired per-profile identity data. Best-effort by design: this is + * bookkeeping, so a failure is reported and never allowed to gate session startup. + */ +export async function markBrowserIdentityMigrationNoticePending( + userDataPath: string, + degraded: boolean +): Promise { + if (!modeStore) { + initializeBrowserIdentityModeStore(userDataPath) + } + const store = requireModeStore() + if (store.userDataPath !== userDataPath) { + throw new Error('Browser identity mode store userData path changed') + } + const current = store.snapshot + // The retired per-profile bytes are retained on disk forever by design, so every launch + // rediscovers them. An explicit choice is what retires the notice — without this gate the + // notice re-arms on the launch after the user answers it, and on every launch after that. + if (current.explicitSelection === true) { + return false + } + // Why the in-memory flag regardless: the user still needs the notice even when the record + // cannot be written, and unhealthy data has no mode to write it beside. + launchMigrationNoticePending = true + migrationNoticeDegraded ||= degraded + if (current.configuredMode === null) { + return false + } + const record: BrowserIdentityModeRecord = { + version: BROWSER_IDENTITY_MODE_VERSION, + mode: current.configuredMode, + explicitSelection: current.explicitSelection, + migrationNoticePending: true + } + try { + writeRecord(userDataPath, record) + } catch (error) { + console.error('[browser-identity] Could not persist retired profile notice:', error) + return false + } + store.snapshot = { + state: 'valid', + appliedMode: current.appliedMode, + configuredMode: current.configuredMode, + explicitSelection: current.explicitSelection, + migrationNoticePending: true, + restartRequired: current.restartRequired + } + notifySnapshotListeners(store.snapshot) + return true +} + +export function resetBrowserIdentityModeStoreForTests(): void { + modeStore = null + snapshotListeners.clear() + migrationNoticeDegraded = false + launchMigrationNoticePending = false +} diff --git a/src/main/browser/browser-manager-auth-user-agent.test.ts b/src/main/browser/browser-manager-auth-user-agent.test.ts index 71e128a9c72..d85a785a887 100644 --- a/src/main/browser/browser-manager-auth-user-agent.test.ts +++ b/src/main/browser/browser-manager-auth-user-agent.test.ts @@ -12,7 +12,9 @@ const browserMocks = vi.hoisted(() => ({ guestOpenDevToolsMock: vi.fn(), webContentsFromIdMock: vi.fn(), screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })), - openPopupWithOriginBarMock: vi.fn() + openPopupWithOriginBarMock: vi.fn(), + processUserAgentMode: 'clean', + processUserAgent: 'Mozilla/5.0 (Test) Chrome/140.0.0.0' })) vi.mock('electron', () => ({ @@ -39,9 +41,15 @@ vi.mock('./popup-origin-bar-window', () => ({ openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock })) +vi.mock('./browser-process-user-agent', () => ({ + getBrowserProcessUserAgentIdentity: () => ({ + mode: browserMocks.processUserAgentMode, + userAgent: browserMocks.processUserAgent + }) +})) + import { browserManager } from './browser-manager' import { googleAuthUserAgent } from './browser-google-auth-ua' -import { setBrowserSessionUserAgentMode } from './browser-session-user-agent-mode' import { guestBaseUserAgent, rendererWebContentsId, @@ -68,6 +76,8 @@ describe('browserManager', () => { beforeEach(() => { resetBrowserManagerMocks(browserMocks) resetBrowserManagerState() + browserMocks.processUserAgentMode = 'clean' + browserMocks.processUserAgent = guestBaseUserAgent }) afterEach(() => { @@ -133,7 +143,8 @@ describe('browserManager', () => { expect(setUserAgent).not.toHaveBeenCalled() }) - it('leaves the UA untouched on Google auth hosts for native-UA profiles', () => { + it('leaves the UA untouched on Google auth hosts in native process mode', () => { + browserMocks.processUserAgentMode = 'native' const setUserAgent = vi.fn() const guest = { id: 409, @@ -155,8 +166,7 @@ describe('browserManager', () => { browserManager.registerGuest({ browserPageId: 'browser-native-ua', webContentsId: guest.id, - rendererWebContentsId, - userAgentMode: 'native' + rendererWebContentsId }) const didStartNavigation = guestOnMock.mock.calls.find( ([event]) => event === 'did-start-navigation' @@ -167,93 +177,6 @@ describe('browserManager', () => { expect(setUserAgent).not.toHaveBeenCalled() }) - it('honors native session mode before the guest registration IPC arrives', () => { - const nativeSession = { getUserAgent: vi.fn(() => guestBaseUserAgent) } - setBrowserSessionUserAgentMode(nativeSession as never, 'native') - const setUserAgent = vi.fn() - const guest = { - id: 417, - isDestroyed: vi.fn(() => false), - getType: vi.fn(() => 'webview'), - setBackgroundThrottling: guestSetBackgroundThrottlingMock, - setWindowOpenHandler: guestSetWindowOpenHandlerMock, - on: guestOnMock, - off: guestOffMock, - openDevTools: guestOpenDevToolsMock, - getURL: vi.fn(() => 'https://accounts.google.com/'), - getUserAgent: vi.fn(() => guestBaseUserAgent), - setUserAgent, - session: nativeSession - } - - browserManager.attachGuestPolicies(guest as never) - const didStartNavigation = guestOnMock.mock.calls.find( - ([event]) => event === 'did-start-navigation' - )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void - - didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true) - expect(setUserAgent).not.toHaveBeenCalled() - }) - - // Why: popup child windows get attachGuestPolicies but are never entered into tabIdByWebContentsId, - // so a direct lookup of the UA mode misses the native opt-out. That is worse than doing nothing — - // native sessions skip setupGoogleAuthUserAgentOverride, so the popup would send the raw Electron UA on the - // wire while navigator.userAgent claimed Firefox. Google sign-in popups are a first-class surface. - it('leaves the UA untouched on auth hosts for a popup owned by a native-UA profile', () => { - const ownerGuest = { - id: 415, - isDestroyed: vi.fn(() => false), - getType: vi.fn(() => 'webview'), - setBackgroundThrottling: guestSetBackgroundThrottlingMock, - setWindowOpenHandler: guestSetWindowOpenHandlerMock, - on: guestOnMock, - off: guestOffMock, - openDevTools: guestOpenDevToolsMock, - getURL: vi.fn(() => 'https://accounts.google.com/'), - getUserAgent: vi.fn(() => guestBaseUserAgent), - setUserAgent: vi.fn(), - session: { getUserAgent: vi.fn(() => guestBaseUserAgent) } - } - webContentsFromIdMock.mockReturnValue(ownerGuest) - browserManager.attachGuestPolicies(ownerGuest as never) - browserManager.registerGuest({ - browserPageId: 'browser-native-popup-owner', - webContentsId: ownerGuest.id, - rendererWebContentsId, - userAgentMode: 'native' - }) - - // The popup carries its own listeners so its handler is unambiguous. - const popupOn = vi.fn() - const popupSetUserAgent = vi.fn() - const popupGuest = { - id: 416, - isDestroyed: vi.fn(() => false), - getType: vi.fn(() => 'window'), - setBackgroundThrottling: guestSetBackgroundThrottlingMock, - setWindowOpenHandler: guestSetWindowOpenHandlerMock, - on: popupOn, - off: guestOffMock, - openDevTools: guestOpenDevToolsMock, - getURL: vi.fn(() => 'https://accounts.google.com/'), - getUserAgent: vi.fn(() => guestBaseUserAgent), - setUserAgent: popupSetUserAgent, - session: { getUserAgent: vi.fn(() => guestBaseUserAgent) } - } - browserManager.attachGuestPolicies(popupGuest as never, { - browserTabId: 'browser-native-popup-owner', - rootGuestWebContentsId: ownerGuest.id - }) - - const popupDidStartNavigation = popupOn.mock.calls.find( - ([event]) => event === 'did-start-navigation' - )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void - expect(popupDidStartNavigation).toBeDefined() - - popupDidStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true) - expect(popupSetUserAgent).not.toHaveBeenCalled() - }) - // Why: WebContents.setUserAgent() from will-redirect makes Chromium cancel the in-flight navigation // (ERR_ABORTED) and replay the original request. A "Sign in with Google" button POSTs to the // provider and lands on accounts.google.com only by redirect, so the replay never reproduces it and @@ -503,6 +426,7 @@ describe('browserManager', () => { // host — the wire UA saying Firefox while sec-ch-ua still says Chrome, the exact cross-layer tell // this scope exists to remove. it('keeps a viewport preset on the session identity after an auth-host visit', async () => { + browserMocks.processUserAgent = GUEST_CLEAN_UA const { guest, debuggerSendCommand } = makeViewportGuest(9001) webContentsFromIdMock.mockReturnValue(guest) browserManager.attachGuestPolicies(guest as never) diff --git a/src/main/browser/browser-manager-load-failure-replay.test.ts b/src/main/browser/browser-manager-load-failure-replay.test.ts index 569771f2ffa..33f84476602 100644 --- a/src/main/browser/browser-manager-load-failure-replay.test.ts +++ b/src/main/browser/browser-manager-load-failure-replay.test.ts @@ -39,6 +39,13 @@ vi.mock('./popup-origin-bar-window', () => ({ openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock })) +vi.mock('./browser-process-user-agent', () => ({ + getBrowserProcessUserAgentIdentity: () => ({ + mode: 'clean', + userAgent: 'Mozilla/5.0 (Test) Chrome/140.0.0.0' + }) +})) + import { browserManager } from './browser-manager' import { guestUaMethods, diff --git a/src/main/browser/browser-manager-navigation.ts b/src/main/browser/browser-manager-navigation.ts index c11626fd516..a7dcf5cd4fc 100644 --- a/src/main/browser/browser-manager-navigation.ts +++ b/src/main/browser/browser-manager-navigation.ts @@ -1,8 +1,11 @@ import { openPopupWithOriginBar, type PopupChildWindowOptions } from './popup-origin-bar-window' -import { cleanElectronUserAgent } from './browser-session-ua' -import { getBrowserSessionUserAgentMode } from './browser-session-user-agent-mode' +import { getBrowserProcessUserAgentIdentity } from './browser-process-user-agent' +import type { BrowserSessionRequestUserAgentResolver } from './browser-session-ua' import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua' -import { buildViewportUserAgentOverride } from './browser-viewport-user-agent' +import { + buildViewportUserAgentOverride, + type ViewportUserAgentOverride +} from './browser-viewport-user-agent' import { safeOrigin, type AuthUserAgentOverrideOperation, @@ -11,27 +14,80 @@ import { import { BrowserManagerVisibility } from './browser-manager-visibility' export abstract class BrowserManagerNavigation extends BrowserManagerVisibility { + resolveBrowserGuestRequestUserAgent( + request: Parameters[0] + ): ViewportUserAgentOverride { + const identity = getBrowserProcessUserAgentIdentity() + const firefoxUa = googleAuthUserAgent() + const pendingNavigation = + request.webContentsId === undefined + ? undefined + : this.pendingNavigationByGuestId.get(request.webContentsId) + // Firefox is delivered per-target and cannot reach workers; keep it clean-only to preserve one + // coherent identity per mode instead of pairing a Firefox document with native workers. + const googleAuthEnabled = identity.mode === 'clean' + if ( + googleAuthEnabled && + request.currentUserAgent === firefoxUa && + (!pendingNavigation || isGoogleAuthUrl(pendingNavigation.currentUrl)) + ) { + return { userAgent: firefoxUa } + } + const overrideState = + request.webContentsId === undefined + ? undefined + : this.authUserAgentOverrideStateByGuestId.get(request.webContentsId) + const latestPendingOverride = overrideState?.pending.at(-1) + const currentOverride = + latestPendingOverride && + latestPendingOverride.sequence > (overrideState?.confirmed?.sequence ?? -1) + ? latestPendingOverride + : overrideState?.confirmed + if ( + googleAuthEnabled && + !currentOverride && + request.effectiveUserAgent === firefoxUa && + (!pendingNavigation || isGoogleAuthUrl(pendingNavigation.currentUrl)) + ) { + return { userAgent: firefoxUa } + } + if (googleAuthEnabled && currentOverride?.userAgent === firefoxUa) { + return { userAgent: firefoxUa } + } + const browserPageId = + request.webContentsId === undefined + ? undefined + : this.tabIdByWebContentsId.get(request.webContentsId) + // Shared and service worker requests carry no webContentsId, and resolving a session-wide mobile + // intent for one put the mobile UA on the wire for a context whose own navigator.userAgent is + // desktop-clean — and for every tab sharing the session. One context, one identity: those workers + // stay on the session identity, while emulation reaches documents and the emulated tab's dedicated + // workers, which carry the owning webContentsId and so resolve through browserPageId. + const mobile = browserPageId + ? (this.viewportUaOverrideMobileByTabId.get(browserPageId) ?? false) + : false + return buildViewportUserAgentOverride({ + url: request.url, + mobile, + baseUserAgent: identity.userAgent, + googleAuthEnabled + }) + } + // Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA, - // not the request header, so the header-level Firefox switch in setupGoogleAuthUserAgentOverride + // not the request header, so the Firefox switch in the session request hook // must be matched here per navigation or the two layers disagree — itself a bot tell. - // Restores the session's base identity off the auth hosts. Native-UA profiles opt out - // of the whole clean-UA path, so they keep their untouched identity everywhere. protected applyGoogleAuthUserAgent( guest: Electron.WebContents, url: string, options: { duringRedirect?: boolean } = {} ): void { const browserPageId = this.tabIdByWebContentsId.get(guest.id) - // Why: popup child windows get these policies but are never in tabIdByWebContentsId, so a direct - // lookup misses the native-UA opt-out and would hand a native profile's popup the Firefox UA. - // That is worse than doing nothing: native sessions skip setupGoogleAuthUserAgentOverride, so - // the popup would send the raw Electron UA on the wire while navigator.userAgent claims Firefox. - const ownerTabId = this.resolveBrowserTabIdForGuestWebContentsId(guest.id) - // Session state is authoritative before renderer registration and after a native profile imports a source UA. - const mode = - getBrowserSessionUserAgentMode(guest.session) ?? - (ownerTabId ? this.userAgentModeByPageId.get(ownerTabId) : undefined) - if (mode === 'native') { + const identity = getBrowserProcessUserAgentIdentity() + if (identity.mode === 'native') { + if (browserPageId) { + this.reapplyViewportUserAgentOverride(guest, browserPageId, url) + } return } const firefoxUa = googleAuthUserAgent() @@ -48,7 +104,7 @@ export abstract class BrowserManagerNavigation extends BrowserManagerVisibility : // Only restore when the auth-host override is actually in place, so normal // navigation never touches the session UA. currentUa === firefoxUa - ? guest.session.getUserAgent() + ? identity.userAgent : null let authOverrideIssuedOverCdp = false if (nextUa !== null && nextUa !== currentUa) { @@ -57,7 +113,7 @@ export abstract class BrowserManagerNavigation extends BrowserManagerVisibility // cannot survive — the sign-in lands on a blank tab. CDP retargets navigator.userAgent without // touching the navigation, and it outranks the WebContents UA from then on, so a guest that // switches to it stays on it. The wire UA never depended on this write: - // setupGoogleAuthUserAgentOverride rewrites User-Agent per request for auth-host URLs on its own. + // The session request hook rewrites User-Agent for auth-host URLs on its own. if (options.duringRedirect === true || overrideState !== undefined) { if (this.canOverrideUserAgentOverCdp(guest)) { authOverrideIssuedOverCdp = true @@ -222,7 +278,8 @@ export abstract class BrowserManagerNavigation extends BrowserManagerVisibility // Why: the session UA is the profile's stable base identity. guest.getUserAgent() is not: // applyGoogleAuthUserAgent leaves it pinned to the Firefox auth UA once a guest switches to // the CDP override, so reading it back here would republish that identity on ordinary hosts. - baseUserAgent: cleanElectronUserAgent(baseUserAgent ?? guest.session.getUserAgent()) + baseUserAgent: baseUserAgent ?? getBrowserProcessUserAgentIdentity().userAgent, + googleAuthEnabled: getBrowserProcessUserAgentIdentity().mode === 'clean' }) ) } diff --git a/src/main/browser/browser-manager-registration.ts b/src/main/browser/browser-manager-registration.ts index ba2ac8647eb..4f6abbe67e9 100644 --- a/src/main/browser/browser-manager-registration.ts +++ b/src/main/browser/browser-manager-registration.ts @@ -1,7 +1,6 @@ import { webContents } from 'electron' import { browserDownloadDestinationReservations } from './browser-download-destination' import { isWorkspaceDocPageId } from './doc-preview-guest-policy' -import type { BrowserSessionUserAgentMode } from '../../shared/browser-workspace-types' import type { BrowserGuestRegistration } from './browser-manager-types' import { BrowserManagerGuestPolicy } from './browser-manager-guest-policy' @@ -12,7 +11,6 @@ export abstract class BrowserManagerRegistration extends BrowserManagerGuestPoli workspaceId, worktreeId, sessionProfileId, - userAgentMode, webContentsId, rendererWebContentsId }: BrowserGuestRegistration): boolean { @@ -57,11 +55,6 @@ export abstract class BrowserManagerRegistration extends BrowserManagerGuestPoli this.workspaceIdByPageId.set(browserTabId, workspaceId) } this.sessionProfileIdByPageId.set(browserTabId, sessionProfileId ?? null) - if (userAgentMode) { - this.userAgentModeByPageId.set(browserTabId, userAgentMode) - } else { - this.userAgentModeByPageId.delete(browserTabId) - } this.rendererWebContentsIdByTabId.set(browserTabId, rendererWebContentsId) if (worktreeId) { this.worktreeIdByTabId.set(browserTabId, worktreeId) @@ -129,7 +122,6 @@ export abstract class BrowserManagerRegistration extends BrowserManagerGuestPoli this.rendererWebContentsIdByTabId.delete(browserTabId) this.workspaceIdByPageId.delete(browserTabId) this.sessionProfileIdByPageId.delete(browserTabId) - this.userAgentModeByPageId.delete(browserTabId) this.worktreeIdByTabId.delete(browserTabId) // Why: drop the viewport-op chain so the Map doesn't retain a promise keyed to a destroyed guest. this.viewportOpsByTabId.delete(browserTabId) @@ -147,13 +139,11 @@ export abstract class BrowserManagerRegistration extends BrowserManagerGuestPoli browserPageId, worktreeId, sessionProfileId, - userAgentMode, webContentsId }: { browserPageId: string worktreeId?: string sessionProfileId?: string | null - userAgentMode?: BrowserSessionUserAgentMode webContentsId: number }): boolean { // Why the same check on both registration doors: one id resolving in both halves is the exact @@ -177,11 +167,6 @@ export abstract class BrowserManagerRegistration extends BrowserManagerGuestPoli this.webContentsIdByTabId.set(browserPageId, webContentsId) this.tabIdByWebContentsId.set(webContentsId, browserPageId) this.sessionProfileIdByPageId.set(browserPageId, sessionProfileId ?? null) - if (userAgentMode) { - this.userAgentModeByPageId.set(browserPageId, userAgentMode) - } else { - this.userAgentModeByPageId.delete(browserPageId) - } if (worktreeId) { this.worktreeIdByTabId.set(browserPageId, worktreeId) } @@ -211,7 +196,6 @@ export abstract class BrowserManagerRegistration extends BrowserManagerGuestPoli this.pageInitiatedTabBudgetByRootGuestId.clear() this.worktreeIdByTabId.clear() this.sessionProfileIdByPageId.clear() - this.userAgentModeByPageId.clear() this.viewportUaOverrideMobileByTabId.clear() this.viewportPresetActiveByTabId.clear() this.viewportScrollStateByTabId.clear() diff --git a/src/main/browser/browser-manager-state.ts b/src/main/browser/browser-manager-state.ts index fef6eee3f9a..c30c5ae8f17 100644 --- a/src/main/browser/browser-manager-state.ts +++ b/src/main/browser/browser-manager-state.ts @@ -5,10 +5,7 @@ import { type PageInitiatedTabBudget } from './browser-page-initiated-tab-budget' import type { KeybindingOverrides } from '../../shared/keybindings' -import type { - BrowserLoadError, - BrowserSessionUserAgentMode -} from '../../shared/browser-workspace-types' +import type { BrowserLoadError } from '../../shared/browser-workspace-types' import { resolveBrowserRouteGuestPopupOpener } from './browser-route-guest-popup-ownership' import type { ActiveDownload, @@ -123,7 +120,6 @@ export abstract class BrowserManagerState extends BrowserManagerViewportScrollSt // Why: guests are keyed by page id but renderer visibility by workspace id; bridge the mismatch to activate the right tab before capture. protected readonly workspaceIdByPageId = new Map() protected readonly sessionProfileIdByPageId = new Map() - protected readonly userAgentModeByPageId = new Map() // Why: serialize per-tab setViewportOverride so rapid toggles don't interleave CDP commands and leave emulation in a wrong state. protected readonly viewportOpsByTabId = new Map>() // Why: presence means the preset requires a CDP UA override (installed or in flight), so navigation diff --git a/src/main/browser/browser-manager-types.ts b/src/main/browser/browser-manager-types.ts index b91e2b741fe..99f5280dd11 100644 --- a/src/main/browser/browser-manager-types.ts +++ b/src/main/browser/browser-manager-types.ts @@ -17,7 +17,6 @@ import type { PageInitiatedTabBudget } from './browser-page-initiated-tab-budget import type { BrowserCertificateFailure, BrowserLoadError, - BrowserSessionUserAgentMode, BrowserViewportOverride } from '../../shared/browser-workspace-types' import type { BrowserAnnotationViewportBridgeOptions } from '../../shared/browser-annotation-viewport-bridge' @@ -102,7 +101,6 @@ export type BrowserGuestRegistration = { workspaceId?: string worktreeId?: string sessionProfileId?: string | null - userAgentMode?: BrowserSessionUserAgentMode webContentsId: number rendererWebContentsId: number } @@ -221,7 +219,6 @@ export type { BrowserAnnotationViewportBridgeOptions, BrowserCertificateFailure, BrowserLoadError, - BrowserSessionUserAgentMode, BrowserViewportOverride, BrowserDownloadFinishedEvent, BrowserDownloadProgressEvent, diff --git a/src/main/browser/browser-manager-viewport-override.test.ts b/src/main/browser/browser-manager-viewport-override.test.ts index 0228d8f9ed8..41835a6cd3e 100644 --- a/src/main/browser/browser-manager-viewport-override.test.ts +++ b/src/main/browser/browser-manager-viewport-override.test.ts @@ -12,7 +12,10 @@ const browserMocks = vi.hoisted(() => ({ guestOpenDevToolsMock: vi.fn(), webContentsFromIdMock: vi.fn(), screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })), - openPopupWithOriginBarMock: vi.fn() + openPopupWithOriginBarMock: vi.fn(), + processUserAgentMode: 'clean', + processUserAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36' })) vi.mock('electron', () => ({ @@ -39,6 +42,13 @@ vi.mock('./popup-origin-bar-window', () => ({ openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock })) +vi.mock('./browser-process-user-agent', () => ({ + getBrowserProcessUserAgentIdentity: () => ({ + mode: browserMocks.processUserAgentMode, + userAgent: browserMocks.processUserAgent + }) +})) + import { browserManager } from './browser-manager' import { googleAuthUserAgent } from './browser-google-auth-ua' import { @@ -66,6 +76,8 @@ describe('browserManager', () => { beforeEach(() => { resetBrowserManagerMocks(browserMocks) resetBrowserManagerState() + browserMocks.processUserAgentMode = 'clean' + browserMocks.processUserAgent = GUEST_CLEAN_UA }) afterEach(() => { @@ -117,15 +129,16 @@ describe('browserManager', () => { }) it.each([false, true])( - 'keeps the session UA for native-mode profiles when mobile=%s', + 'keeps native process identity coherent with mobile=%s', async (mobile) => { + browserMocks.processUserAgentMode = 'native' + browserMocks.processUserAgent = GUEST_ELECTRON_UA const { guest, debuggerSendCommand } = makeGuest(mobile ? 4244 : 4243) webContentsFromIdMock.mockReturnValue(guest) browserManager.attachGuestPolicies(guest as never) browserManager.registerGuest({ browserPageId: `tab-native-${mobile}`, sessionProfileId: 'native-profile', - userAgentMode: 'native', webContentsId: guest.id as number, rendererWebContentsId }) @@ -139,10 +152,12 @@ describe('browserManager', () => { }) ).resolves.toBe(true) - expect(debuggerSendCommand).not.toHaveBeenCalledWith( - 'Emulation.setUserAgentOverride', - expect.anything() - ) + const userAgentOverride = lastUserAgentOverride(debuggerSendCommand) + if (mobile) { + expect(userAgentOverride).toMatchObject({ userAgent: expect.stringContaining('iPhone') }) + } else { + expect(userAgentOverride).toEqual({ userAgent: GUEST_ELECTRON_UA }) + } } ) @@ -377,7 +392,7 @@ describe('browserManager', () => { didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/', true) await flushViewportOps() - expect(guest.setUserAgent).toHaveBeenLastCalledWith(GUEST_ELECTRON_UA) + expect(guest.setUserAgent).toHaveBeenLastCalledWith(GUEST_CLEAN_UA) expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA }) // A later preset must also resolve the committed, non-auth URL. @@ -776,14 +791,15 @@ describe('browserManager', () => { ) }) - it('leaves the UA override alone on navigation for native-UA profiles', async () => { + it('reapplies the native process identity instead of the Google exception', async () => { + browserMocks.processUserAgentMode = 'native' + browserMocks.processUserAgent = GUEST_ELECTRON_UA const { guest, debuggerSendCommand } = makeGuest(4250) webContentsFromIdMock.mockReturnValue(guest) browserManager.attachGuestPolicies(guest as never) browserManager.registerGuest({ browserPageId: 'tab-native-nav', sessionProfileId: 'native-profile', - userAgentMode: 'native', webContentsId: guest.id as number, rendererWebContentsId }) @@ -800,10 +816,9 @@ describe('browserManager', () => { debuggerSendCommand.mockClear() didStartNavigation(null, 'https://accounts.google.com/', false, true) await flushViewportOps() - expect(debuggerSendCommand).not.toHaveBeenCalledWith( - 'Emulation.setUserAgentOverride', - expect.anything() - ) + expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', { + userAgent: GUEST_ELECTRON_UA + }) }) it('clears device metrics and disables touch for override=null', async () => { diff --git a/src/main/browser/browser-manager-viewport.ts b/src/main/browser/browser-manager-viewport.ts index ce31dbe37e1..b5599ab4760 100644 --- a/src/main/browser/browser-manager-viewport.ts +++ b/src/main/browser/browser-manager-viewport.ts @@ -7,6 +7,7 @@ import { import type { BrowserViewportOverride } from '../../shared/browser-workspace-types' import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua' import { BrowserManagerDownloadLifecycle } from './browser-manager-download-lifecycle' +import { getBrowserProcessUserAgentIdentity } from './browser-process-user-agent' export abstract class BrowserManagerViewport extends BrowserManagerDownloadLifecycle { // Why: guests are isolated from Orca's preload bridge, so main owns the devtools escape hatch after a tab→guest lookup. @@ -163,13 +164,9 @@ export abstract class BrowserManagerViewport extends BrowserManagerDownloadLifec enabled: override.mobile, maxTouchPoints: override.mobile ? 5 : 0 }) - // Why: viewport sizing must not override a profile's explicit native-UA identity. - if (this.userAgentModeByPageId.get(browserTabId) !== 'native') { - // Navigation must see the preset intent while the final CDP command is in flight. - this.viewportUaOverrideMobileByTabId.set(browserTabId, override.mobile) - // Why: same sender as the navigation path, so both resolve the tab's host identically. - await this.sendViewportUserAgentOverride(guest, override.mobile) - } + // Navigation must see the preset while the final CDP write is in flight. + this.viewportUaOverrideMobileByTabId.set(browserTabId, override.mobile) + await this.sendViewportUserAgentOverride(guest, override.mobile) } else { await dbg.sendCommand('Emulation.clearDeviceMetricsOverride', {}) if (this.webContentsIdByTabId.get(browserTabId) === webContentsId) { @@ -188,11 +185,16 @@ export abstract class BrowserManagerViewport extends BrowserManagerDownloadLifec try { if (this.authUserAgentOverrideStateByGuestId.has(guest.id)) { const url = this.resolveTabNavigationUrl(guest) + const identity = getBrowserProcessUserAgentIdentity() + // Firefox is delivered per-target and cannot reach workers; keep it clean-only to preserve + // one coherent identity per mode instead of pairing a Firefox document with native workers. const restored = await this.applyAuthUserAgentOverrideOverCdp( guest, false, url, - isGoogleAuthUrl(url) ? googleAuthUserAgent() : guest.session.getUserAgent() + identity.mode === 'clean' && isGoogleAuthUrl(url) + ? googleAuthUserAgent() + : identity.userAgent ) if (!restored) { throw new Error('Failed to preserve auth user agent') diff --git a/src/main/browser/browser-manager-worker-request-user-agent.test.ts b/src/main/browser/browser-manager-worker-request-user-agent.test.ts new file mode 100644 index 00000000000..01875217f68 --- /dev/null +++ b/src/main/browser/browser-manager-worker-request-user-agent.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const browserMocks = vi.hoisted(() => ({ + appGetPathMock: vi.fn(() => '/downloads'), + shellOpenExternalMock: vi.fn(), + browserWindowFromWebContentsMock: vi.fn(), + menuBuildFromTemplateMock: vi.fn(), + guestOffMock: vi.fn(), + guestOnMock: vi.fn(), + guestSetBackgroundThrottlingMock: vi.fn(), + guestSetWindowOpenHandlerMock: vi.fn(), + guestOpenDevToolsMock: vi.fn(), + webContentsFromIdMock: vi.fn(), + screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })), + openPopupWithOriginBarMock: vi.fn(), + processUserAgentMode: 'clean', + processUserAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36' +})) + +vi.mock('electron', () => ({ + app: { getPath: browserMocks.appGetPathMock }, + BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock }, + clipboard: { writeText: vi.fn() }, + shell: { openExternal: browserMocks.shellOpenExternalMock }, + Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock }, + screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock }, + webContents: { fromId: browserMocks.webContentsFromIdMock } +})) + +vi.mock('./popup-origin-bar-window', () => ({ + openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock +})) + +vi.mock('./browser-process-user-agent', () => ({ + getBrowserProcessUserAgentIdentity: () => ({ + mode: browserMocks.processUserAgentMode, + userAgent: browserMocks.processUserAgent + }) +})) + +import { browserManager } from './browser-manager' +import { + rendererWebContentsId, + resetBrowserManagerMocks, + resetBrowserManagerState +} from './browser-manager-test-harness' +import { + createViewportGuestFactory, + GUEST_CLEAN_UA +} from './browser-manager-viewport-test-fixtures' + +const { webContentsFromIdMock } = browserMocks +const makeGuest = createViewportGuestFactory(browserMocks) +const MOBILE_VIEWPORT_OVERRIDE = { width: 375, height: 667, deviceScaleFactor: 2, mobile: true } +const MOBILE_UA_PATTERN = /CriOS\// + +/** Registers a guest the way the renderer does, and hands back the session its requests arrive on. */ +function registerGuest(browserPageId: string, webContentsId: number): Electron.Session { + const { guest } = makeGuest(webContentsId) + webContentsFromIdMock.mockReturnValue(guest) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared viewport fixture builds an untyped guest stub; the manager only reads members that stub defines. + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ browserPageId, webContentsId, rendererWebContentsId }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: resolveBrowserGuestRequestUserAgent reads no Session member, so the stub only has to be the object the guest carries. + return guest.session as Electron.Session +} + +function resolve(session: Electron.Session, webContentsId?: number): string { + return browserManager.resolveBrowserGuestRequestUserAgent({ + session, + url: 'https://example.com/asset.js', + webContentsId + }).userAgent +} + +/** + * Viewport emulation is a per-target CDP override. It cannot reach a worker, so the only question + * is what the worker's *request* carries — and it must match what that worker's own JS reports. + */ +describe('worker request identity under viewport emulation', () => { + beforeEach(() => { + resetBrowserManagerMocks(browserMocks) + resetBrowserManagerState() + browserMocks.processUserAgentMode = 'clean' + browserMocks.processUserAgent = GUEST_CLEAN_UA + }) + + it('keeps a worker request desktop-clean while a tab in the same session is emulated mobile', async () => { + const session = registerGuest('tab-mobile', 4242) + expect(await browserManager.setViewportOverride('tab-mobile', MOBILE_VIEWPORT_OVERRIDE)).toBe( + true + ) + + // A worker request carries no webContentsId. Its navigator.userAgent is the session default — + // desktop-clean — so sending the mobile UA on the wire makes one context disagree with itself. + expect(resolve(session)).toBe(GUEST_CLEAN_UA) + }) + + it('still resolves the mobile identity for the emulated tab itself', async () => { + const session = registerGuest('tab-mobile', 4242) + expect(await browserManager.setViewportOverride('tab-mobile', MOBILE_VIEWPORT_OVERRIDE)).toBe( + true + ) + + expect(resolve(session, 4242)).toMatch(MOBILE_UA_PATTERN) + }) + + it('leaves a desktop tab desktop-clean while a peer tab in its session is emulated mobile', async () => { + const session = registerGuest('tab-mobile', 4242) + registerGuest('tab-desktop', 4243) + expect(await browserManager.setViewportOverride('tab-mobile', MOBILE_VIEWPORT_OVERRIDE)).toBe( + true + ) + + expect(resolve(session, 4243)).toBe(GUEST_CLEAN_UA) + }) + + it('keeps worker requests desktop-clean when no tab is emulated at all', () => { + const session = registerGuest('tab-plain', 4244) + + expect(resolve(session)).toBe(GUEST_CLEAN_UA) + }) + + // A popup carries a webContentsId that maps to no registered tab. It resolves through the same + // branch as a worker, so the one rule covers both: no mapped tab means the process identity. + it('keeps an unmapped webContents desktop-clean beside an emulated tab', async () => { + const session = registerGuest('tab-mobile', 4242) + expect(await browserManager.setViewportOverride('tab-mobile', MOBILE_VIEWPORT_OVERRIDE)).toBe( + true + ) + + expect(resolve(session, 9999)).toBe(GUEST_CLEAN_UA) + }) +}) diff --git a/src/main/browser/browser-process-user-agent.test.ts b/src/main/browser/browser-process-user-agent.test.ts new file mode 100644 index 00000000000..b077741a9e5 --- /dev/null +++ b/src/main/browser/browser-process-user-agent.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ app: { isReady: () => false, userAgentFallback: '' } })) + +const { cleanElectronUserAgent } = await import('./browser-process-user-agent') + +const MAC_CLEAN = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36' +const LINUX_CLEAN = + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36' + +describe('cleanElectronUserAgent', () => { + // Why each shape: app.setName decides this token, and dev sets a name containing a space + // ("Orca Dev"). A cleaner that only removes a single whitespace-delimited token leaves the + // app name on the wire in exactly the builds we test with. + it.each([ + [ + 'a one-word app name', + `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Orca/1.4.203 Chrome/150.0.0.0 Electron/43.7.0 Safari/537.36`, + MAC_CLEAN + ], + [ + 'an app name containing a space', + `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Orca Dev/1.4.203 Chrome/150.0.0.0 Electron/43.7.0 Safari/537.36`, + MAC_CLEAN + ], + [ + 'an app name containing two spaces', + `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) My Orca Build/1.0.0 Chrome/150.0.0.0 Electron/43.7.0 Safari/537.36`, + LINUX_CLEAN + ], + [ + 'no app token at all', + `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Electron/43.7.0 Safari/537.36`, + MAC_CLEAN + ], + [ + 'an app name after the engine comment on a platform with a short OS comment', + `Mozilla/5.0 (Test) AppleWebKit/537.36 (KHTML, like Gecko) Package/0.0.0 Chrome/150.0.0.0 Electron/43.7.0 Safari/537.36`, + 'Mozilla/5.0 (Test) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36' + ] + ])('strips the Electron and app tokens for %s', (_label, raw, expected) => { + expect(cleanElectronUserAgent(raw)).toBe(expected) + }) + + it('leaves an already-clean identity byte-identical', () => { + expect(cleanElectronUserAgent(MAC_CLEAN)).toBe(MAC_CLEAN) + }) + + // Why: over-stripping is worse than under-stripping — without the engine comment the app-token + // anchor lands on the OS comment and destroys a real engine token, so these are left alone. + it.each([ + [ + 'an OS comment but no engine comment', + 'Mozilla/5.0 (X11; Linux x86_64) SomeEngine/1.0 MyApp/2.0 Chrome/150.0.0.0 Electron/43.7.0 Safari/537.36' + ], + ['no comment at all', 'SomeOtherAgent/2.0 Chrome/150.0.0.0 Safari/537.36'], + [ + 'a non-Chromium user agent', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0' + ] + ])('leaves a user agent unchanged for %s', (_label, raw) => { + expect(cleanElectronUserAgent(raw)).toBe(raw) + }) +}) diff --git a/src/main/browser/browser-process-user-agent.ts b/src/main/browser/browser-process-user-agent.ts new file mode 100644 index 00000000000..ac9ebc785f0 --- /dev/null +++ b/src/main/browser/browser-process-user-agent.ts @@ -0,0 +1,63 @@ +import { app } from 'electron' +import type { BrowserUserAgentMode } from '../../shared/browser-user-agent-mode' + +export type BrowserProcessUserAgentIdentity = Readonly<{ + mode: BrowserUserAgentMode + /** What every document, frame and worker in this process presents. */ + userAgent: string +}> + +let identity: BrowserProcessUserAgentIdentity | null = null + +const CHROMIUM_ENGINE_COMMENT = '(KHTML, like Gecko)' + +// Why: Electron's default includes its runtime and app tokens, which invalidate Chrome-imported sessions. +// Why gated on the engine comment: the app-token strip anchors on the nearest ")" before Chrome/, so a +// user agent without one would anchor on the OS comment and eat a real engine token. Only +// Chromium-shaped identities are cleaned; anything else is returned byte-identical. +// Why that anchor never crosses another ")": app.setName decides the app token, and dev uses a name +// containing a space ("Orca Dev"), which a single \S+ cannot span — it left the app name on the wire. +// Consuming only non-")" tokens keeps the match inside the gap between the engine comment and Chrome/. +export function cleanElectronUserAgent(userAgent: string): string { + if (!userAgent.includes(CHROMIUM_ENGINE_COMMENT)) { + return userAgent + } + return userAgent + .replace(/\s+Electron\/\S+/, '') + .replace(/(\)\s+)(?:[^)\s]+\s+)*?(Chrome\/)/, '$1$2') +} + +/** + * Fix the whole process's browser identity before anything can read it. + * + * `app.userAgentFallback` is the one default every renderer, frame and worker inherits, so this + * must land before `ready`: a session or WebContents created first keeps the old value, and + * workers would then disagree with documents. `native` deliberately leaves the fallback alone + * rather than assigning the raw string back, so the engine keeps its own untouched default. + */ +export function initializeBrowserProcessUserAgent( + mode: BrowserUserAgentMode +): BrowserProcessUserAgentIdentity { + if (identity) { + throw new Error('Browser process user agent was already initialized') + } + if (app.isReady()) { + throw new Error('Browser process user agent must be initialized before Electron readiness') + } + if (mode === 'clean') { + app.userAgentFallback = cleanElectronUserAgent(app.userAgentFallback) + } + identity = Object.freeze({ mode, userAgent: app.userAgentFallback }) + return identity +} + +export function getBrowserProcessUserAgentIdentity(): BrowserProcessUserAgentIdentity { + if (!identity) { + throw new Error('Browser process user agent is not initialized') + } + return identity +} + +export function resetBrowserProcessUserAgentForTests(): void { + identity = null +} diff --git a/src/main/browser/browser-route-session-policy.ts b/src/main/browser/browser-route-session-policy.ts index 60ef5433766..522ef57ba09 100644 --- a/src/main/browser/browser-route-session-policy.ts +++ b/src/main/browser/browser-route-session-policy.ts @@ -18,7 +18,7 @@ type BrowserRouteSessionPolicyDependencies = { partition: string browserProfileId: string session: BrowserRouteElectronSession - }): void + }): void | Promise clearPolicies(input: { partition: string; session: BrowserRouteElectronSession }): void } @@ -36,7 +36,7 @@ export async function prepareBrowserRouteSessionPolicy(input: { proxyRules: `socks5://${input.proxyEndpoint.host}:${input.proxyEndpoint.port}`, proxyBypassRules: '<-loopback>' }) - input.dependencies.setupPolicies({ + await input.dependencies.setupPolicies({ partition: input.partition, browserProfileId: input.browserProfileId, session diff --git a/src/main/browser/browser-route-session-registry-contract.ts b/src/main/browser/browser-route-session-registry-contract.ts index 0cebbcbb14e..365b04028c2 100644 --- a/src/main/browser/browser-route-session-registry-contract.ts +++ b/src/main/browser/browser-route-session-registry-contract.ts @@ -22,7 +22,7 @@ export type BrowserRouteSessionRegistryDependencies = { partition: string browserProfileId: string session: BrowserRouteElectronSession - }): void + }): void | Promise clearPolicies(input: { partition: string; session: BrowserRouteElectronSession }): void retirePageAuthority(input: BrowserRoutePageAuthorityRetirement): boolean bindingStore: BrowserRoutePartitionBindingStore diff --git a/src/main/browser/browser-route-session-registry.test.ts b/src/main/browser/browser-route-session-registry.test.ts index 8f26c57ff4b..1e4a42ab9a8 100644 --- a/src/main/browser/browser-route-session-registry.test.ts +++ b/src/main/browser/browser-route-session-registry.test.ts @@ -70,7 +70,7 @@ function createHarness( preparingPartition = partition return session }), - setupPolicies: vi.fn(() => { + setupPolicies: vi.fn(async () => { order.push('setup-policies') if (options.setupError) { throw options.setupError @@ -510,7 +510,7 @@ describe('BrowserRouteSessionRegistry', () => { expect(dependencies.clearPolicies).toHaveBeenCalledTimes(1) }) - it('clears partially installed policies when policy setup fails', async () => { + it('clears partially installed policies when async policy setup fails', async () => { const { dependencies, registry, session } = createHarness({ setupError: new Error('policy setup failed') }) diff --git a/src/main/browser/browser-route-session-runtime.ts b/src/main/browser/browser-route-session-runtime.ts index 58fe171bd0e..678a8f24da6 100644 --- a/src/main/browser/browser-route-session-runtime.ts +++ b/src/main/browser/browser-route-session-runtime.ts @@ -42,9 +42,8 @@ export const browserRouteSessionRegistry = new BrowserRouteSessionRegistry({ browserSessionRegistry.requireRouteBrowserProfile(browserProfileId) }, getSession: (partition) => session.fromPartition(partition), - setupPolicies: ({ partition, browserProfileId }) => { - browserSessionRegistry.setupRoutePartitionPolicies(partition, browserProfileId) - }, + setupPolicies: ({ partition, browserProfileId }) => + browserSessionRegistry.setupRoutePartitionPolicies(partition, browserProfileId), clearPolicies: ({ partition }) => { browserSessionRegistry.clearRoutePartitionPolicies(partition) }, diff --git a/src/main/browser/browser-session-meta-store.ts b/src/main/browser/browser-session-meta-store.ts index 8aeb7c36422..2fbc0912560 100644 --- a/src/main/browser/browser-session-meta-store.ts +++ b/src/main/browser/browser-session-meta-store.ts @@ -12,7 +12,13 @@ export type PendingBrowserCookieImport = // Why: no userAgent fields — the session UA is always derived from the running // engine at startup (clean or native), never persisted. Imports before Aug 2026 // stored a synthesized source-browser UA here; persistMeta drops those legacy -// keys on the next write because this loader no longer carries them. +// TOP-LEVEL keys on the next write because this loader no longer carries them. +// +// This does not extend to the retired per-profile `userAgentMode`: it lives inside each +// BrowserSessionProfile in `profiles`, which is carried through untouched, so those bytes +// survive every write. That retention is deliberate — it is what makes rollback and +// data-loss machinery unnecessary — and the startup notice keys on it, so nothing may +// start stripping it. See inspectRetiredBrowserSessionProfileUserAgentModes. export type BrowserSessionMeta = { defaultSource: BrowserSessionProfile['source'] pendingCookieDbPath: string | null diff --git a/src/main/browser/browser-session-partition-policies.test.ts b/src/main/browser/browser-session-partition-policies.test.ts index b092cc8344b..4c86e1067f3 100644 --- a/src/main/browser/browser-session-partition-policies.test.ts +++ b/src/main/browser/browser-session-partition-policies.test.ts @@ -82,11 +82,10 @@ vi.mock('./browser-media-access', () => ({ requestSystemMediaAccess: async () => false })) vi.mock('./browser-session-ua', () => ({ - cleanElectronUserAgent: (userAgent: string) => userAgent, - setupGoogleAuthUserAgentOverride: vi.fn() + installBrowserSessionUserAgentPolicy: vi.fn(() => vi.fn()) })) -vi.mock('./browser-session-user-agent-mode', () => ({ - setBrowserSessionUserAgentMode: vi.fn() +vi.mock('./browser-process-user-agent', () => ({ + getBrowserProcessUserAgentIdentity: () => ({ mode: 'clean', userAgent: 'Mozilla/5.0 Orca' }) })) vi.mock('./browser-webauthn-access', () => ({ allowsBrowserWebAuthnPermission: () => false, @@ -113,8 +112,7 @@ function profileFor(partition: string): BrowserSessionProfile { scope: 'isolated', partition, label: partition, - source: null, - userAgentMode: 'clean' + source: null } } diff --git a/src/main/browser/browser-session-partition-policies.ts b/src/main/browser/browser-session-partition-policies.ts index b7022185174..d55dbc30d78 100644 --- a/src/main/browser/browser-session-partition-policies.ts +++ b/src/main/browser/browser-session-partition-policies.ts @@ -9,8 +9,8 @@ import { } from './browser-session-proxy' import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access' import { isAutoGrantedBrowserSessionPermission } from './browser-session-permission-policy' -import { cleanElectronUserAgent, setupGoogleAuthUserAgentOverride } from './browser-session-ua' -import { setBrowserSessionUserAgentMode } from './browser-session-user-agent-mode' +import { installBrowserSessionUserAgentPolicy } from './browser-session-ua' +import { getBrowserProcessUserAgentIdentity } from './browser-process-user-agent' import { allowsBrowserWebAuthnPermission, clearBrowserWebAuthnAccessHandlers, @@ -20,6 +20,33 @@ import { noticeDocPreviewDownloadBlocked } from './doc-preview-download-block-no // Why: one shared installer keeps every partition's deny-by-default permission/download policies from drifting apart. const configuredPartitions = new Set() +const userAgentPolicyDisposerBySession = new WeakMap void>() + +export function retireBrowserSessionUserAgentPolicy(sess: Session): void { + const dispose = userAgentPolicyDisposerBySession.get(sess) + if (!dispose) { + return + } + userAgentPolicyDisposerBySession.delete(sess) + dispose() +} + +function configureBrowserSessionUserAgentPolicy(sess: Session, installExceptions: boolean): void { + sess.setUserAgent(getBrowserProcessUserAgentIdentity().userAgent) + if (!installExceptions) { + retireBrowserSessionUserAgentPolicy(sess) + return + } + if (userAgentPolicyDisposerBySession.has(sess)) { + return + } + userAgentPolicyDisposerBySession.set( + sess, + installBrowserSessionUserAgentPolicy(sess, (request) => + browserManager.resolveBrowserGuestRequestUserAgent(request) + ) + ) +} /** Drop only the installer memo; retired-session guards remain fail-closed. */ export function forgetBrowserSessionPartitionConfiguration(partition: string): void { @@ -69,17 +96,22 @@ function resolvePermissionNoticeUrl( export type BrowserPartitionDownloadPolicy = 'route' | 'deny' export type BrowserPartitionPermissionPolicy = 'browser' | 'deny' -export function installBrowserSessionPartitionPolicies( +// Why async despite no await: the user agent policy is configured before the first suspension, and +// getBrowserProcessUserAgentIdentity throws when the process identity was never initialized. Callers +// report failure through the promise (`void install(...).catch(...)`), so a synchronous throw would +// escape every one of them and gate browser-session startup on bookkeeping that is allowed to fail. +export async function installBrowserSessionPartitionPolicies( profile: BrowserSessionProfile, options: { downloads?: BrowserPartitionDownloadPolicy permissions?: BrowserPartitionPermissionPolicy applyAppWideProxy?: boolean + userAgentExceptions?: boolean } = {} ): Promise { const { partition } = profile const sess = session.fromPartition(partition) - setBrowserSessionUserAgentMode(sess, profile.userAgentMode ?? 'clean') + configureBrowserSessionUserAgentPolicy(sess, options.userAgentExceptions !== false) // Why: route partitions own a SOCKS transport policy that the app proxy must not overwrite. const proxyReady = ( options.applyAppWideProxy === false ? Promise.resolve() : applyProxyToBrowserSession(sess) @@ -92,11 +124,6 @@ export function installBrowserSessionPartitionPolicies( } browserManager.installCertificateRequestGuard(sess) - if (profile.userAgentMode !== 'native' && typeof sess.getUserAgent === 'function') { - const cleanUA = cleanElectronUserAgent(sess.getUserAgent()) - sess.setUserAgent(cleanUA) - setupGoogleAuthUserAgentOverride(sess) - } if (options?.permissions === 'deny') { sess.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)) sess.setPermissionCheckHandler(() => false) @@ -170,6 +197,7 @@ export function installBrowserSessionPartitionPolicies( export function clearBrowserSessionPartitionPolicies(partition: string, sess: Session): void { // Why: the Electron Session survives partition deletion; clear callbacks/listeners so removed profiles don't retain closures. invalidateBrowserSessionProxyApplication(sess) + retireBrowserSessionUserAgentPolicy(sess) configuredPartitions.delete(partition) browserManager.removeCertificateRequestGuard(sess) sess.removeListener('will-download', handleWillDownload) @@ -179,25 +207,3 @@ export function clearBrowserSessionPartitionPolicies(partition: string, sess: Se sess.setPermissionCheckHandler(null) sess.setDisplayMediaRequestHandler(null) } - -export function applyBrowserSessionUserAgentModes(profiles: BrowserSessionProfile[]): void { - for (const profile of profiles) { - const partition = profile.partition - try { - const sess = session.fromPartition(partition) - const userAgentMode = profile.userAgentMode ?? 'clean' - setBrowserSessionUserAgentMode(sess, userAgentMode) - - if (profile.userAgentMode === 'native') { - continue - } - - // Why: imported sessions need the same Chrome-shaped identity after app restart. - const cleanUA = cleanElectronUserAgent(sess.getUserAgent()) - sess.setUserAgent(cleanUA) - setupGoogleAuthUserAgentOverride(sess) - } catch { - /* session not available yet (e.g. unit tests or pre-ready) */ - } - } -} diff --git a/src/main/browser/browser-session-partition-proxy-install.test.ts b/src/main/browser/browser-session-partition-proxy-install.test.ts index d0eee031ea0..35f7b923732 100644 --- a/src/main/browser/browser-session-partition-proxy-install.test.ts +++ b/src/main/browser/browser-session-partition-proxy-install.test.ts @@ -25,6 +25,8 @@ const { sessionsByPartition, fromPartitionMock } = vi.hoisted(() => { return { sessionsByPartition, fromPartitionMock } }) +const identityState = vi.hoisted(() => ({ unavailable: false })) + vi.mock('electron', () => ({ session: { defaultSession: { resolveProxy: vi.fn(async () => 'DIRECT'), setProxy: vi.fn(async () => {}) }, @@ -44,12 +46,19 @@ vi.mock('./browser-media-access', () => ({ requestSystemMediaAccess: vi.fn(async () => false) })) vi.mock('./browser-session-ua', () => ({ - cleanElectronUserAgent: vi.fn((ua: string) => ua), - setupGoogleAuthUserAgentOverride: vi.fn() + installBrowserSessionUserAgentPolicy: vi.fn(() => vi.fn()) })) -vi.mock('./browser-session-user-agent-mode', () => ({ - setBrowserSessionUserAgentMode: vi.fn(), - clearBrowserSessionUserAgentMode: vi.fn() +vi.mock('./browser-process-user-agent', () => ({ + getBrowserProcessUserAgentIdentity: () => { + // The real one throws when the process identity was never initialized. + if (identityState.unavailable) { + throw new Error('Browser process user agent is not initialized') + } + return { + mode: 'clean', + userAgent: 'Mozilla/5.0 Chrome/150.0.0.0 Safari/537.36' + } + } })) vi.mock('./browser-webauthn-access', () => ({ allowsBrowserWebAuthnPermission: vi.fn(() => false), @@ -99,6 +108,23 @@ describe('installBrowserSessionPartitionPolicies proxy wiring', () => { afterEach(() => { vi.unstubAllEnvs() + identityState.unavailable = false + }) + + // The installer returns Promise, so every caller reports failure through the promise — + // `void install(...).catch(...)` at browser-session-registry.ts:136 and :336, and a bare + // `void install(...)` at browser-session-route-policies.ts:16. The user agent policy is + // configured synchronously before the first await, so a throw from there escapes all of them + // and takes down browser-session startup instead of being reported. + it('reports an unavailable process identity through the promise, not a synchronous throw', async () => { + const profile = nextProfile() + identityState.unavailable = true + + let installation: Promise | undefined + expect(() => { + installation = installBrowserSessionPartitionPolicies(profile) + }).not.toThrow() + await expect(installation).rejects.toThrow('Browser process user agent is not initialized') }) // Why (STA-4779): the installer is the single funnel every browser partition passes through. diff --git a/src/main/browser/browser-session-persisted-profile-validation.ts b/src/main/browser/browser-session-persisted-profile-validation.ts index 31e4f69f9fb..69a778ac4ef 100644 --- a/src/main/browser/browser-session-persisted-profile-validation.ts +++ b/src/main/browser/browser-session-persisted-profile-validation.ts @@ -4,6 +4,10 @@ import type { BrowserSessionProfile } from '../../shared/browser-workspace-types const BROWSER_SESSION_PROFILE_ID_RE = /^[\da-f-]{8}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{12}$/ +type PersistedProfileWithUserAgentMode = Record & { + readonly userAgentMode: unknown +} + // Why: validate on-disk profile shape so a tampered JSON file can't inject an arbitrary partition into the will-attach-webview allowlist. export function isValidPersistedBrowserSessionProfile( profile: unknown, @@ -19,13 +23,47 @@ export function isValidPersistedBrowserSessionProfile( typeof candidate.id === 'string' && typeof candidate.partition === 'string' && typeof candidate.label === 'string' && - (candidate.userAgentMode === undefined || - candidate.userAgentMode === 'clean' || - candidate.userAgentMode === 'native') && isProfileOwnedSessionPartition(candidate.id, candidate.partition, activeOrcaProfileId) ) } +export function inspectRetiredBrowserSessionProfileUserAgentModes( + profiles: readonly unknown[], + activeOrcaProfileId: string +): { noticePending: boolean; degraded: boolean } { + let noticePending = false + let degraded = false + for (const profile of profiles) { + // Refusing to hydrate an entry is not the same as finding a retired choice: hydrateFromPersisted + // already skips it silently, and a notice here would claim an old choice could not be inspected + // for a profile that never carried one. + if (!isRecord(profile) || !hasPersistedProfileUserAgentMode(profile)) { + continue + } + noticePending = true + const mode = profile.userAgentMode + // Degraded covers both ways the choice is uninspectable: an unreadable mode, and a mode sitting + // on an entry we refuse to hydrate, where we cannot say which profile it belonged to. + if ( + (mode !== 'clean' && mode !== 'native') || + !isValidPersistedBrowserSessionProfile(profile, activeOrcaProfileId) + ) { + degraded = true + } + } + return { noticePending, degraded } +} + +function hasPersistedProfileUserAgentMode( + profile: Record +): profile is PersistedProfileWithUserAgentMode { + return Object.hasOwn(profile, 'userAgentMode') +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + function isProfileOwnedSessionPartition( profileId: string, partition: string, diff --git a/src/main/browser/browser-session-profile-retirement.ts b/src/main/browser/browser-session-profile-retirement.ts index dbc59d7c109..ee835806d90 100644 --- a/src/main/browser/browser-session-profile-retirement.ts +++ b/src/main/browser/browser-session-profile-retirement.ts @@ -1,7 +1,6 @@ import type { Session } from 'electron' import { retireProxySessionApplication } from '../network/proxy-settings' import { clearBrowserSessionPartitionPolicies } from './browser-session-partition-policies' -import { clearBrowserSessionUserAgentMode } from './browser-session-user-agent-mode' export async function retireFailedBrowserSessionProfile( partition: string, @@ -9,7 +8,6 @@ export async function retireFailedBrowserSessionProfile( ): Promise { const retirement = retireProxySessionApplication(sess) try { - clearBrowserSessionUserAgentMode(sess) clearBrowserSessionPartitionPolicies(partition, sess) } catch { // Best-effort policy cleanup must not skip retirement. diff --git a/src/main/browser/browser-session-registry-identity.persistence.test.ts b/src/main/browser/browser-session-registry-identity.persistence.test.ts new file mode 100644 index 00000000000..872f1724a4a --- /dev/null +++ b/src/main/browser/browser-session-registry-identity.persistence.test.ts @@ -0,0 +1,217 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + CLEAN_USER_AGENT, + createFsState, + IDENTITY_RECORD_PATH, + installModuleMocks, + META_PATH, + seedMeta +} from './__mocks__/browser-session-registry-persistence-fixture' + +describe('BrowserSessionRegistry retired identity data', () => { + beforeEach(() => { + vi.resetModules() + vi.restoreAllMocks() + }) + + // Why: imports before Aug 2026 persisted a synthesized source-browser UA + // (fork imports as a broken Chrome/1.x, Chrome imports as a valid version). + // Neither may ever be applied again — the engine-derived UA is the only one. + it('ignores legacy persisted UAs, valid or broken, and applies the engine UA', async () => { + const importedPartition = 'persist:orca-browser-session-11111111-1111-4111-8111-111111111111' + const brokenUa = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/1.158.1 Safari/537.36' + const validUa = 'Mozilla/5.0 Chrome/120.0.0.0 Safari/537.36' + const fsState = createFsState() + seedMeta(fsState, { + defaultSource: { browserFamily: 'arc', importedAt: 1 }, + userAgent: brokenUa, + userAgentByPartition: { + 'persist:orca-browser': brokenUa, + [importedPartition]: validUa + }, + pendingCookieDbPath: null, + pendingCookieImports: {}, + profiles: [ + { + id: '11111111-1111-4111-8111-111111111111', + scope: 'imported', + partition: importedPartition, + label: 'Imported', + source: { browserFamily: 'chrome', importedAt: 1 } + } + ] + }) + + const { sessionFromPartitionMock, installBrowserSessionUserAgentPolicyMock } = + installModuleMocks(fsState) + const { browserSessionRegistry } = await import('./browser-session-registry') + + browserSessionRegistry.initializeBrowserSessionsFromPersistedState() + + const appliedUas = sessionFromPartitionMock.mock.results.flatMap((r) => + r.value.setUserAgent.mock.calls.map((c: unknown[]) => c[0]) + ) + expect(appliedUas).not.toContain(brokenUa) + expect(appliedUas).not.toContain(validUa) + // Why: every partition inherits the one process identity rather than an imported value. + expect(appliedUas.length).toBeGreaterThan(0) + expect(appliedUas.every((ua) => ua === CLEAN_USER_AGENT)).toBe(true) + expect(installBrowserSessionUserAgentPolicyMock).toHaveBeenCalled() + }) + + it('flags the retired per-profile choice without rewriting its persisted bytes', async () => { + const importedPartition = 'persist:orca-browser-session-11111111-1111-4111-8111-111111111111' + const fsState = createFsState() + seedMeta(fsState, { + defaultSource: null, + profiles: [ + { + id: '11111111-1111-4111-8111-111111111111', + scope: 'imported', + partition: importedPartition, + label: 'Imported', + source: { browserFamily: 'comet', importedAt: 1 }, + userAgentMode: 'native' + } + ] + }) + + installModuleMocks(fsState) + const { browserSessionRegistry } = await import('./browser-session-registry') + + browserSessionRegistry.initializeBrowserSessionsFromPersistedState() + + await vi.waitFor(() => + expect(JSON.parse(fsState.files.get(IDENTITY_RECORD_PATH) ?? '{}')).toEqual({ + version: 1, + mode: 'clean', + explicitSelection: false, + migrationNoticePending: true + }) + ) + // Retaining the retired key is what makes rollback and data-loss machinery unnecessary. + expect(JSON.parse(fsState.files.get(META_PATH) ?? '{}').profiles[0].userAgentMode).toBe( + 'native' + ) + }) + + // The notice is documented as one-time, but the legacy bytes it keys on are retained forever by + // design, so nothing but the explicit choice can stop a later launch from re-arming it. + it('does not re-arm the retired-choice notice on the launch after an explicit choice', async () => { + const profileId = '11111111-1111-4111-8111-111111111111' + const fsState = createFsState() + seedMeta(fsState, { + defaultSource: null, + profiles: [ + { + id: profileId, + scope: 'isolated', + partition: `persist:orca-browser-session-${profileId}`, + label: 'Existing', + source: null, + userAgentMode: 'native' + } + ] + }) + + installModuleMocks(fsState) + const { browserSessionRegistry } = await import('./browser-session-registry') + const identity = await import('./browser-identity-mode-store') + + browserSessionRegistry.initializeBrowserSessionsFromPersistedState() + expect(identity.getBrowserIdentityMigrationNotice()).toEqual({ degraded: false }) + + await identity.setBrowserIdentityMode('native') + expect(identity.getBrowserIdentityMigrationNotice()).toBeNull() + + // A fresh launch re-reads the record from disk; the same retired bytes are still beside it. + identity.resetBrowserIdentityModeStoreForTests() + browserSessionRegistry.initializeBrowserSessionsFromPersistedState() + + expect(identity.getBrowserIdentityMigrationNotice()).toBeNull() + expect(JSON.parse(fsState.files.get(IDENTITY_RECORD_PATH) ?? '{}')).toMatchObject({ + explicitSelection: true, + migrationNoticePending: false + }) + }) + + it.each([ + { scenario: 'malformed members', malformed: [null, 42, 'broken'], failWrite: false }, + { scenario: 'a read-only notice', malformed: [], failWrite: true } + ])('hydrates the valid profile despite $scenario', async ({ malformed, failWrite }) => { + const profileId = '11111111-1111-4111-8111-111111111111' + const partition = `persist:orca-browser-session-${profileId}` + const fsState = createFsState() + seedMeta(fsState, { + defaultSource: null, + profiles: [ + ...malformed, + { + id: profileId, + scope: 'isolated', + partition, + label: 'Existing', + source: null, + userAgentMode: 'native' + } + ] + }) + installModuleMocks(fsState, new Set(), failWrite) + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { browserSessionRegistry } = await import('./browser-session-registry') + + expect(() => browserSessionRegistry.initializeBrowserSessionsFromPersistedState()).not.toThrow() + expect(browserSessionRegistry.getProfile(profileId)?.partition).toBe(partition) + if (failWrite) { + // Notice bookkeeping may fail; it must report and never gate session startup. + await vi.waitFor(() => expect(errors).toHaveBeenCalled()) + expect(errors.mock.calls[0]?.[1]).toMatchObject({ message: 'read-only userData' }) + } + await vi.waitFor(() => expect(fsState.files.has(IDENTITY_RECORD_PATH)).toBe(!failWrite)) + const written = JSON.parse(fsState.files.get(META_PATH) ?? '{}') + expect(written.profiles).toHaveLength(malformed.length + 1) + expect(written.profiles.at(-1).userAgentMode).toBe('native') + }) + + it('hydrates a retired native profile under the process identity', async () => { + const importedPartition = 'persist:orca-browser-session-12121212-1212-4121-8121-121212121212' + const fsState = createFsState() + seedMeta(fsState, { + defaultSource: null, + userAgent: null, + userAgentByPartition: {}, + pendingCookieDbPath: null, + pendingCookieImports: {}, + profiles: [ + { + id: '12121212-1212-4121-8121-121212121212', + scope: 'isolated', + partition: importedPartition, + label: 'Google', + source: null, + userAgentMode: 'native' + } + ] + }) + + const { sessionFromPartitionMock, installBrowserSessionUserAgentPolicyMock } = + installModuleMocks(fsState) + const { browserSessionRegistry } = await import('./browser-session-registry') + + browserSessionRegistry.initializeBrowserSessionsFromPersistedState() + + const importedSessions = sessionFromPartitionMock.mock.results + .filter((_, index) => sessionFromPartitionMock.mock.calls[index]?.[0] === importedPartition) + .map((result) => result.value) + expect(importedSessions.length).toBeGreaterThan(0) + expect( + importedSessions.every((sess) => sess.setUserAgent.mock.calls[0]?.[0] === CLEAN_USER_AGENT) + ).toBe(true) + expect( + installBrowserSessionUserAgentPolicyMock.mock.calls.some( + ([sess]) => sess.partition === importedPartition + ) + ).toBe(true) + }) +}) diff --git a/src/main/browser/browser-session-registry-import-boundary.test.ts b/src/main/browser/browser-session-registry-import-boundary.test.ts new file mode 100644 index 00000000000..96950797c4a --- /dev/null +++ b/src/main/browser/browser-session-registry-import-boundary.test.ts @@ -0,0 +1,12 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { expect, it } from 'vitest' + +it('keeps the cookie fixture registry outside the persistence barrel graph', () => { + const source = readFileSync(join(__dirname, 'browser-session-registry.ts'), 'utf8') + + expect(source).toMatch( + /import\s*\{\s*getCanonicalUserDataPath\s*\}\s*from\s*['"]\.\.\/persistence\/loading-store\/user-data-path['"]/ + ) + expect(source).not.toMatch(/from\s*['"]\.\.\/persistence['"]/) +}) diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index b6495d3d1e1..a64f341bf52 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -1,167 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' - -const USER_DATA = '/user-data' -const META_PATH = `${USER_DATA}/browser-session-meta.json` -const RAW_ELECTRON_USER_AGENT = - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Orca/1.4.198 Chrome/150.0.7871.224 Electron/43.4.1 Safari/537.36' -const CLEAN_USER_AGENT = - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.7871.224 Safari/537.36' - -type FsState = { - files: Map - present: Set -} - -function fsKey(pathValue: string): string { - return pathValue.replaceAll('\\', '/') -} - -function createFsState(): FsState { - return { files: new Map(), present: new Set() } -} - -function seedMeta(fsState: FsState, meta: unknown): void { - const raw = JSON.stringify(meta) - fsState.files.set(META_PATH, raw) - fsState.present.add(META_PATH) -} - -function installModuleMocks( - fsState: FsState, - copyFailures = new Set() -): { - sessionFromPartitionMock: ReturnType - cleanElectronUserAgentMock: ReturnType - setupGoogleAuthUserAgentOverrideMock: ReturnType - browserManagerHandleGuestWillDownloadMock: ReturnType - browserManagerNotifyPermissionDeniedMock: ReturnType - requestSystemMediaAccessMock: ReturnType -} { - const sessionFromPartitionMock = vi.fn((partition: string) => ({ - partition, - setUserAgent: vi.fn(), - getUserAgent: vi.fn(() => RAW_ELECTRON_USER_AGENT), - setPermissionRequestHandler: vi.fn(), - setPermissionCheckHandler: vi.fn(), - setDevicePermissionHandler: vi.fn(), - setDisplayMediaRequestHandler: vi.fn(), - on: vi.fn(), - removeListener: vi.fn(), - clearStorageData: vi.fn().mockResolvedValue(undefined), - clearCache: vi.fn().mockResolvedValue(undefined) - })) - const cleanElectronUserAgentMock = vi.fn(() => CLEAN_USER_AGENT) - const setupGoogleAuthUserAgentOverrideMock = vi.fn() - const browserManagerHandleGuestWillDownloadMock = vi.fn() - const browserManagerNotifyPermissionDeniedMock = vi.fn() - const requestSystemMediaAccessMock = vi.fn().mockResolvedValue(true) - - vi.doMock('electron', () => ({ - app: { getPath: vi.fn(() => USER_DATA) }, - session: { fromPartition: sessionFromPartitionMock }, - systemPreferences: { - askForMediaAccess: vi.fn().mockResolvedValue(true), - getMediaAccessStatus: vi.fn(() => 'granted') - } - })) - - vi.doMock('node:fs', () => ({ - copyFileSync: vi.fn((src: string, dst: string) => { - const sourceKey = fsKey(src) - const destinationKey = fsKey(dst) - if (copyFailures.has(sourceKey)) { - throw new Error(`copy fail for ${src}`) - } - fsState.present.add(destinationKey) - const value = fsState.files.get(sourceKey) - if (value !== undefined) { - fsState.files.set(destinationKey, value) - } - }), - existsSync: vi.fn((p: string) => fsState.present.has(fsKey(p))), - mkdirSync: vi.fn(), - readFileSync: vi.fn((p: string) => { - const v = fsState.files.get(fsKey(p)) - if (v === undefined) { - throw new Error('ENOENT') - } - return v - }), - renameSync: vi.fn((from: string, to: string) => { - const sourceKey = fsKey(from) - const destinationKey = fsKey(to) - const v = fsState.files.get(sourceKey) - if (v === undefined) { - throw new Error('ENOENT') - } - fsState.files.set(destinationKey, v) - fsState.present.add(destinationKey) - fsState.files.delete(sourceKey) - fsState.present.delete(sourceKey) - }), - unlinkSync: vi.fn((p: string) => { - const key = fsKey(p) - fsState.present.delete(key) - fsState.files.delete(key) - }), - writeFileSync: vi.fn((p: string, data: string | Uint8Array) => { - const value = typeof data === 'string' ? data : Buffer.from(data).toString('utf-8') - const key = fsKey(p) - fsState.files.set(key, value) - fsState.present.add(key) - }) - })) - - vi.doMock('./browser-manager', () => ({ - browserManager: { - notifyPermissionDenied: browserManagerNotifyPermissionDeniedMock, - handleGuestWillDownload: browserManagerHandleGuestWillDownloadMock, - installCertificateRequestGuard: vi.fn(), - removeCertificateRequestGuard: vi.fn() - } - })) - vi.doMock('./browser-media-access', () => ({ - hasSystemMediaAccess: vi.fn(() => true), - requestSystemMediaAccess: requestSystemMediaAccessMock - })) - vi.doMock('./browser-session-ua', () => ({ - cleanElectronUserAgent: cleanElectronUserAgentMock, - setupGoogleAuthUserAgentOverride: setupGoogleAuthUserAgentOverrideMock - })) - // This suite models replay with an in-memory filesystem. The real file-backed SQLite merge has - // dedicated coverage; these fixtures are legacy unmarked images and keep the copy path. - vi.doMock('./browser-cookie-staged-import', () => ({ - SCOPED_COOKIE_IMPORT_FORMAT: 'scoped-v1', - applyScopedStagedCookieImport: vi.fn(() => false), - isScopedStagedCookieImport: vi.fn(() => false), - removeCookieImportScopeMarker: vi.fn() - })) - vi.doMock('../codex-accounts/fs-utils', () => ({ - renameFileWithWindowsRetry: vi.fn((source: string, target: string) => { - const sourceKey = fsKey(source) - const targetKey = fsKey(target) - if (!fsState.present.has(sourceKey)) { - throw new Error('ENOENT') - } - const value = fsState.files.get(sourceKey) - fsState.present.delete(sourceKey) - fsState.files.delete(sourceKey) - fsState.present.add(targetKey) - if (value !== undefined) { - fsState.files.set(targetKey, value) - } - }) - })) - - return { - sessionFromPartitionMock, - cleanElectronUserAgentMock, - setupGoogleAuthUserAgentOverrideMock, - browserManagerHandleGuestWillDownloadMock, - browserManagerNotifyPermissionDeniedMock, - requestSystemMediaAccessMock - } -} +import { + CLEAN_USER_AGENT, + createFsState, + installModuleMocks, + META_PATH, + seedMeta +} from './__mocks__/browser-session-registry-persistence-fixture' describe('BrowserSessionRegistry persistence', () => { beforeEach(() => { @@ -226,9 +70,7 @@ describe('BrowserSessionRegistry persistence', () => { orcaProfileId: 'local-work', profileDirectory: '/user-data/profiles/local-work' }) - const profile = await browserSessionRegistry.createProfile('isolated', 'Work Browser', { - userAgentMode: 'native' - }) + const profile = await browserSessionRegistry.createProfile('isolated', 'Work Browser') expect(profile).not.toBeNull() expect(fsState.files.has(profileMetaPath)).toBe(true) @@ -236,45 +78,24 @@ describe('BrowserSessionRegistry persistence', () => { expect(JSON.parse(fsState.files.get(profileMetaPath) ?? '{}').profiles[0]).toMatchObject({ id: profile!.id, partition: profile!.partition, - label: 'Work Browser', - userAgentMode: 'native' + label: 'Work Browser' }) }) - it('keeps UA cleaning as the fallback for profiles without an override', async () => { + it('applies the process identity and request exceptions to new profiles', async () => { const fsState = createFsState() - const { - sessionFromPartitionMock, - cleanElectronUserAgentMock, - setupGoogleAuthUserAgentOverrideMock - } = installModuleMocks(fsState) + const { sessionFromPartitionMock, installBrowserSessionUserAgentPolicyMock } = + installModuleMocks(fsState) const { browserSessionRegistry } = await import('./browser-session-registry') await browserSessionRegistry.createProfile('isolated', 'Default identity') const profileSession = sessionFromPartitionMock.mock.results.at(-1)?.value - expect(cleanElectronUserAgentMock).toHaveBeenCalledWith(RAW_ELECTRON_USER_AGENT) expect(profileSession.setUserAgent).toHaveBeenCalledWith(CLEAN_USER_AGENT) - expect(setupGoogleAuthUserAgentOverrideMock).toHaveBeenCalledWith(profileSession) - }) - - it('leaves UA and client hints untouched for native-mode profiles', async () => { - const fsState = createFsState() - const { - sessionFromPartitionMock, - cleanElectronUserAgentMock, - setupGoogleAuthUserAgentOverrideMock - } = installModuleMocks(fsState) - const { browserSessionRegistry } = await import('./browser-session-registry') - - await browserSessionRegistry.createProfile('isolated', 'Google', { userAgentMode: 'native' }) - - const profileSession = sessionFromPartitionMock.mock.results.at(-1)?.value - const { getBrowserSessionUserAgentMode } = await import('./browser-session-user-agent-mode') - expect(profileSession.setUserAgent).not.toHaveBeenCalled() - expect(cleanElectronUserAgentMock).not.toHaveBeenCalled() - expect(setupGoogleAuthUserAgentOverrideMock).not.toHaveBeenCalled() - expect(getBrowserSessionUserAgentMode(profileSession as never)).toBe('native') + expect(installBrowserSessionUserAgentPolicyMock).toHaveBeenCalledWith( + profileSession, + expect.any(Function) + ) }) it('merges partition-keyed pending entries without clobbering unrelated entries', async () => { @@ -393,145 +214,6 @@ describe('BrowserSessionRegistry persistence', () => { expect(fsState.present.has('/staged/default')).toBe(true) }) - // Why: imports before Aug 2026 persisted a synthesized source-browser UA - // (fork imports as a broken Chrome/1.x, Chrome imports as a valid version). - // Neither may ever be applied again — the engine-derived UA is the only one. - it('ignores legacy persisted UAs, valid or broken, and applies the engine UA', async () => { - const importedPartition = 'persist:orca-browser-session-11111111-1111-4111-8111-111111111111' - const brokenUa = - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/1.158.1 Safari/537.36' - const validUa = 'Mozilla/5.0 Chrome/120.0.0.0 Safari/537.36' - const fsState = createFsState() - seedMeta(fsState, { - defaultSource: { browserFamily: 'arc', importedAt: 1 }, - userAgent: brokenUa, - userAgentByPartition: { - 'persist:orca-browser': brokenUa, - [importedPartition]: validUa - }, - pendingCookieDbPath: null, - pendingCookieImports: {}, - profiles: [ - { - id: '11111111-1111-4111-8111-111111111111', - scope: 'imported', - partition: importedPartition, - label: 'Imported', - source: { browserFamily: 'chrome', importedAt: 1 } - } - ] - }) - - const { - sessionFromPartitionMock, - cleanElectronUserAgentMock, - setupGoogleAuthUserAgentOverrideMock - } = installModuleMocks(fsState) - const { browserSessionRegistry } = await import('./browser-session-registry') - - browserSessionRegistry.initializeBrowserSessionsFromPersistedState() - - const appliedUas = sessionFromPartitionMock.mock.results.flatMap((r) => - r.value.setUserAgent.mock.calls.map((c: unknown[]) => c[0]) - ) - expect(appliedUas).not.toContain(brokenUa) - expect(appliedUas).not.toContain(validUa) - // Why: every non-native profile falls to Orca's own cleaned engine UA. - expect(appliedUas.length).toBeGreaterThan(0) - expect(appliedUas.every((ua) => ua === CLEAN_USER_AGENT)).toBe(true) - expect(cleanElectronUserAgentMock).toHaveBeenCalled() - expect( - cleanElectronUserAgentMock.mock.calls.every(([ua]) => ua === RAW_ELECTRON_USER_AGENT) - ).toBe(true) - expect(setupGoogleAuthUserAgentOverrideMock).toHaveBeenCalled() - }) - - it('never applies a legacy persisted UA to a native-mode profile', async () => { - const importedPartition = 'persist:orca-browser-session-11111111-1111-4111-8111-111111111111' - const importedUa = 'Mozilla/5.0 Chrome/120.0.0.0 Safari/537.36' - const fsState = createFsState() - seedMeta(fsState, { - defaultSource: null, - userAgent: null, - userAgentByPartition: { [importedPartition]: importedUa }, - pendingCookieDbPath: null, - pendingCookieImports: {}, - profiles: [ - { - id: '11111111-1111-4111-8111-111111111111', - scope: 'imported', - partition: importedPartition, - label: 'Imported', - source: { browserFamily: 'comet', importedAt: 1 }, - userAgentMode: 'native' - } - ] - }) - - const { sessionFromPartitionMock } = installModuleMocks(fsState) - const { browserSessionRegistry } = await import('./browser-session-registry') - - browserSessionRegistry.initializeBrowserSessionsFromPersistedState() - - const importedSessions = sessionFromPartitionMock.mock.results - .filter((_, idx) => sessionFromPartitionMock.mock.calls[idx]?.[0] === importedPartition) - .map((r) => r.value) - expect(importedSessions.length).toBeGreaterThan(0) - // Why: native mode means the engine UA stands untouched — no setUserAgent at all. - expect(importedSessions.every((s) => s.setUserAgent.mock.calls.length === 0)).toBe(true) - const { getBrowserSessionUserAgentMode } = await import('./browser-session-user-agent-mode') - expect( - importedSessions.every( - (session) => getBrowserSessionUserAgentMode(session as never) === 'native' - ) - ).toBe(true) - }) - - it('preserves native mode across hydration when no source UA was imported', async () => { - const importedPartition = 'persist:orca-browser-session-12121212-1212-4121-8121-121212121212' - const fsState = createFsState() - seedMeta(fsState, { - defaultSource: null, - userAgent: null, - userAgentByPartition: {}, - pendingCookieDbPath: null, - pendingCookieImports: {}, - profiles: [ - { - id: '12121212-1212-4121-8121-121212121212', - scope: 'isolated', - partition: importedPartition, - label: 'Google', - source: null, - userAgentMode: 'native' - } - ] - }) - - const { sessionFromPartitionMock, setupGoogleAuthUserAgentOverrideMock } = - installModuleMocks(fsState) - const { browserSessionRegistry } = await import('./browser-session-registry') - - browserSessionRegistry.initializeBrowserSessionsFromPersistedState() - - const importedSessions = sessionFromPartitionMock.mock.results - .filter((_, index) => sessionFromPartitionMock.mock.calls[index]?.[0] === importedPartition) - .map((result) => result.value) - expect(importedSessions.length).toBeGreaterThan(0) - expect(importedSessions.every((sess) => sess.setUserAgent.mock.calls.length === 0)).toBe(true) - expect( - setupGoogleAuthUserAgentOverrideMock.mock.calls.some( - ([sess]) => (sess as { partition?: string }).partition === importedPartition - ) - ).toBe(false) - const { getBrowserSessionUserAgentMode } = await import('./browser-session-user-agent-mode') - expect( - importedSessions.every( - (session) => getBrowserSessionUserAgentMode(session as never) === 'native' - ) - ).toBe(true) - }) - it('sets up default-partition policies on restore', async () => { const fsState = createFsState() seedMeta(fsState, { diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index 5c628723777..27965a56433 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -11,6 +11,10 @@ const { getMediaAccessStatusMock: vi.fn(), removeCertificateRequestGuardMock: vi.fn() })) +const processUserAgentMode = vi.hoisted(() => { + const state: { value: 'clean' | 'native' } = { value: 'clean' } + return state +}) vi.mock('electron', () => ({ session: { @@ -22,6 +26,13 @@ vi.mock('electron', () => ({ } })) +vi.mock('./browser-process-user-agent', () => ({ + getBrowserProcessUserAgentIdentity: () => ({ + mode: processUserAgentMode.value, + userAgent: 'Mozilla/5.0 Chrome/150.0.0.0 Safari/537.36' + }) +})) + vi.mock('./browser-manager', () => ({ browserManager: { notifyPermissionDenied: vi.fn(), @@ -33,7 +44,7 @@ vi.mock('./browser-manager', () => ({ import { browserSessionRegistry } from './browser-session-registry' import { googleAuthUserAgent } from './browser-google-auth-ua' -import { setupGoogleAuthUserAgentOverride } from './browser-session-ua' +import { installBrowserSessionUserAgentPolicy } from './browser-session-ua' import { setBrowserNetworkProxySettingsResolver } from './browser-session-proxy' import { handleElectronProxyLogin } from '../network/electron-proxy-credentials' import { applyProxySettingsToSession } from '../network/proxy-settings' @@ -50,10 +61,13 @@ describe('BrowserSessionRegistry', () => { askForMediaAccessMock.mockReset() getMediaAccessStatusMock.mockReset() removeCertificateRequestGuardMock.mockClear() + processUserAgentMode.value = 'clean' setBrowserNetworkProxySettingsResolver(null) askForMediaAccessMock.mockResolvedValue(true) getMediaAccessStatusMock.mockReturnValue('granted') sessionFromPartitionMock.mockReturnValue({ + setUserAgent: vi.fn(), + webRequest: { onBeforeSendHeaders: vi.fn() }, setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), setDevicePermissionHandler: vi.fn(), @@ -194,13 +208,6 @@ describe('BrowserSessionRegistry', () => { expect(profile).toBeNull() }) - it('rejects invalid user-agent modes at the registry boundary', async () => { - const profile = await browserSessionRegistry.createProfile('isolated', 'Invalid UA', { - userAgentMode: 'rotating' as never - }) - expect(profile).toBeNull() - }) - it('allows created profile partitions', async () => { const profile = await browserSessionRegistry.createProfile('isolated', 'Allowed') expect(profile).not.toBeNull() @@ -293,6 +300,20 @@ describe('BrowserSessionRegistry', () => { expect(removeCertificateRequestGuardMock).not.toHaveBeenCalled() }) + // Why: the Electron Session outlives its partition, so a deleted profile must not keep a header hook. + it('retires the user agent policy when deleting a profile', async () => { + const profile = await browserSessionRegistry.createProfile('isolated', 'UA Delete Test') + const mockSession = sessionFromPartitionMock.mock.results[0]?.value + expect(mockSession.webRequest.onBeforeSendHeaders).toHaveBeenCalledWith( + expect.anything(), + expect.any(Function) + ) + + await expect(browserSessionRegistry.deleteProfile(profile!.id)).resolves.toBe(true) + + expect(mockSession.webRequest.onBeforeSendHeaders).toHaveBeenLastCalledWith(null) + }) + it('keeps the request guard installed while deleted-profile guests remain', async () => { setBrowserNetworkProxySettingsResolver(() => ({ httpProxyUrl: 'http://proxy.example:8080', @@ -344,8 +365,7 @@ describe('BrowserSessionRegistry', () => { scope: 'isolated', partition: claimedPartition, label: 'Conflicting identity', - source: null, - userAgentMode: 'native' + source: null } ]) @@ -528,12 +548,19 @@ describe('BrowserSessionRegistry', () => { }) }) - describe('setupGoogleAuthUserAgentOverride', () => { + describe('installBrowserSessionUserAgentPolicy', () => { function install(): (details: unknown, callback: ReturnType) => void { const onBeforeSendHeaders = vi.fn() - setupGoogleAuthUserAgentOverride({ webRequest: { onBeforeSendHeaders } } as never) + installBrowserSessionUserAgentPolicy( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook reads only the mocked webRequest member exercised here. + { webRequest: { onBeforeSendHeaders } } as never, + (request) => + request.currentUserAgent === googleAuthUserAgent() + ? { userAgent: googleAuthUserAgent() } + : undefined + ) expect(onBeforeSendHeaders).toHaveBeenCalledWith( - { urls: ['https://*/*'] }, + { urls: ['http://*/*', 'https://*/*', 'ws://*/*', 'wss://*/*'] }, expect.any(Function) ) return onBeforeSendHeaders.mock.calls[0][1] @@ -584,6 +611,25 @@ describe('BrowserSessionRegistry', () => { expect(modified.Accept).toBe('text/html') }) + it('keeps native requests untouched on Google auth hosts', () => { + processUserAgentMode.value = 'native' + const callback = vi.fn() + install()( + { + url: 'https://accounts.google.com/v3/signin/identifier', + requestHeaders: { + 'User-Agent': 'NativeElectron/43.0', + 'sec-ch-ua': 'browser-owned' + } + }, + callback + ) + expect(callback.mock.calls[0][0].requestHeaders).toEqual({ + 'User-Agent': 'NativeElectron/43.0', + 'sec-ch-ua': 'browser-owned' + }) + }) + it('strips client hints on a cross-host request that carries the Firefox auth UA', () => { const callback = vi.fn() install()( diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index d8c217bb54a..94135153991 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -9,7 +9,6 @@ import { } from '../../shared/orca-profiles' import type { BrowserSessionProfile, - BrowserSessionProfileCreateOptions, BrowserSessionProfileScope } from '../../shared/browser-workspace-types' import { @@ -24,12 +23,14 @@ import { } from './browser-session-meta-store' import type { BrowserSessionMeta } from './browser-session-meta-store' import { - applyBrowserSessionUserAgentModes, forgetBrowserSessionPartitionConfiguration, - installBrowserSessionPartitionPolicies + installBrowserSessionPartitionPolicies, + retireBrowserSessionUserAgentPolicy } from './browser-session-partition-policies' -import { isValidPersistedBrowserSessionProfile } from './browser-session-persisted-profile-validation' -import { clearBrowserSessionUserAgentMode } from './browser-session-user-agent-mode' +import { + isValidPersistedBrowserSessionProfile, + inspectRetiredBrowserSessionProfileUserAgentModes +} from './browser-session-persisted-profile-validation' import { clearBrowserRoutePartitionPolicies, installBrowserRoutePartitionPolicies @@ -38,6 +39,8 @@ import { retireProxySessionApplication } from '../network/proxy-settings' import { invalidateBrowserSessionProxyApplication } from './browser-session-proxy' import { retireFailedBrowserSessionProfile } from './browser-session-profile-retirement' import { cancelBrowserWebAuthnAccountRequestsForSession } from './browser-webauthn-account-picker' +import { getCanonicalUserDataPath } from '../persistence/loading-store/user-data-path' +import { markBrowserIdentityMigrationNoticePending } from './browser-identity-mode-store' export type BrowserSessionRegistryProfileOptions = { orcaProfileId: string @@ -108,6 +111,17 @@ class BrowserSessionRegistry { // Why re-read defaultSource: the constructor may run before app.isReady() (userData path unavailable), so loadPersistedSource() returned null. initializeBrowserSessionsFromPersistedState(): void { const meta = this.loadPersistedMeta() + const migration = inspectRetiredBrowserSessionProfileUserAgentModes( + meta.profiles, + this.activeOrcaProfileId + ) + if (migration.noticePending) { + // Why scoped: identity persistence must never reject browser-session startup. + void markBrowserIdentityMigrationNoticePending( + getCanonicalUserDataPath(), + migration.degraded + ).catch((error) => console.error('[browser-identity] Migration notice failed:', error)) + } if (meta.defaultSource) { const current = this.profiles.get('default') if (current && current.source === null) { @@ -123,8 +137,6 @@ class BrowserSessionRegistry { void installBrowserSessionPartitionPolicies(defaultProfile).catch(() => { console.warn('[proxy] Failed to apply proxy to browser partition', defaultProfile.partition) }) - - applyBrowserSessionUserAgentModes(this.listProfiles()) } // Why: must run before any session.fromPartition() so CookieMonster reads the staged cookies instead of overwriting them from its in-memory DB. @@ -188,12 +200,12 @@ class BrowserSessionRegistry { return this.profiles.get(profileId)?.partition ?? null } - setupRoutePartitionPolicies(partition: string, browserProfileId: string): void { + setupRoutePartitionPolicies(partition: string, browserProfileId: string): Promise { const profile = this.profiles.get(browserProfileId) if (!profile) { throw new Error('browser_route_partition_profile_unavailable') } - installBrowserRoutePartitionPolicies(profile, partition) + return installBrowserRoutePartitionPolicies(profile, partition) } requireRouteBrowserProfile(browserProfileId: string): void { @@ -208,16 +220,10 @@ class BrowserSessionRegistry { async createProfile( scope: BrowserSessionProfileScope, - label: string, - options: BrowserSessionProfileCreateOptions = {} + label: string ): Promise { // Why: the registry is also an IPC boundary, so runtime types alone cannot keep invalid values out of persisted metadata. - if ( - (scope !== 'isolated' && scope !== 'imported') || - (options.userAgentMode !== undefined && - options.userAgentMode !== 'clean' && - options.userAgentMode !== 'native') - ) { + if (scope !== 'isolated' && scope !== 'imported') { return null } const id = randomUUID() @@ -228,8 +234,7 @@ class BrowserSessionRegistry { scope, partition, label, - source: null, - ...(options.userAgentMode ? { userAgentMode: options.userAgentMode } : {}) + source: null } try { await installBrowserSessionPartitionPolicies(profile) @@ -279,8 +284,8 @@ class BrowserSessionRegistry { // Why: clear the partition's storage so deleting a profile doesn't leave orphaned cookies/cache behind. try { const sess = session.fromPartition(profile.partition) - clearBrowserSessionUserAgentMode(sess) forgetBrowserSessionPartitionConfiguration(profile.partition) + retireBrowserSessionUserAgentPolicy(sess) invalidateBrowserSessionProxyApplication(sess) const release = retireProxySessionApplication(sess) // Why: persistent partitions can retain service workers after every WebContents dies, so a retired session's deny policies must remain permanent. diff --git a/src/main/browser/browser-session-route-policies.ts b/src/main/browser/browser-session-route-policies.ts index 410bc9dedeb..6e550869c4c 100644 --- a/src/main/browser/browser-session-route-policies.ts +++ b/src/main/browser/browser-session-route-policies.ts @@ -5,16 +5,15 @@ import { clearBrowserSessionPartitionPolicies, installBrowserSessionPartitionPolicies } from './browser-session-partition-policies' -import { clearBrowserSessionUserAgentMode } from './browser-session-user-agent-mode' export function installBrowserRoutePartitionPolicies( profile: BrowserSessionProfile, partition: string -): void { +): Promise { if (!isBrowserRoutePartition(partition)) { throw new Error('browser_route_partition_profile_unavailable') } - void installBrowserSessionPartitionPolicies( + return installBrowserSessionPartitionPolicies( { ...profile, partition }, { applyAppWideProxy: false } ) @@ -25,6 +24,5 @@ export function clearBrowserRoutePartitionPolicies(partition: string): void { return } const sess = session.fromPartition(partition) - clearBrowserSessionUserAgentMode(sess) clearBrowserSessionPartitionPolicies(partition, sess) } diff --git a/src/main/browser/browser-session-ua-cdp-collector.ts b/src/main/browser/browser-session-ua-cdp-collector.ts new file mode 100644 index 00000000000..f8bdc1437c8 --- /dev/null +++ b/src/main/browser/browser-session-ua-cdp-collector.ts @@ -0,0 +1,282 @@ +import WebSocket from 'ws' +import { cancelUnreadResponseBody } from '../lib/unread-response-body' + +export type BrowserSessionUaCdpRequest = Readonly<{ + targetType: string + resourceType: string + url: string + userAgent: string | null + clientHints: Readonly> +}> + +type PendingRequest = { + targetType: string + resourceType?: string + url?: string + headers?: Record +} + +// CDP payloads are untyped JSON. Narrow once behind a runtime check instead of asserting a +// shape at each read, so a protocol change surfaces as a missing value rather than a lie. +function readRecord(value: unknown): Record | undefined { + if (typeof value !== 'object' || value === null) { + return undefined + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: guarded by the object/null check above; every member is read back through its own typeof check. + return value as Record +} + +function readString(record: Record | undefined, key: string): string | undefined { + const value = record?.[key] + return typeof value === 'string' ? value : undefined +} + +function readStringRecord(value: unknown): Record | undefined { + const record = readRecord(value) + if (!record) { + return undefined + } + const strings: Record = {} + for (const [key, entry] of Object.entries(record)) { + if (typeof entry === 'string') { + strings[key] = entry + } + } + return strings +} + +type CdpMessage = { + id?: number + method?: string + params?: Record + result?: unknown + error?: { message?: string } + sessionId?: string +} + +export class BrowserSessionUaCdpCollector { + readonly diagnostics: string[] = [] + private readonly pendingCommands = new Map< + number, + { resolve: (value: unknown) => void; reject: (error: Error) => void } + >() + private readonly targetsBySessionId = new Map() + private readonly requests = new Map() + private readonly webSockets = new Map() + private nextCommandId = 1 + + private constructor(private readonly socket: WebSocket) { + socket.on('message', (data) => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: JSON.parse is untyped; CdpMessage is all-optional, so every member is still guarded before use in handleMessage. + this.handleMessage(JSON.parse(data.toString()) as CdpMessage) + ) + } + + static async connect(port: number): Promise { + const version = readRecord( + await fetch(`http://127.0.0.1:${port}/json/version`).then((response) => response.json()) + ) + const webSocketDebuggerUrl = readString(version, 'webSocketDebuggerUrl') + if (!webSocketDebuggerUrl) { + throw new Error('cdp_version_missing_websocket_debugger_url') + } + const socket = new WebSocket(webSocketDebuggerUrl) + await new Promise((resolve, reject) => { + socket.once('open', resolve) + socket.once('error', reject) + }) + return new BrowserSessionUaCdpCollector(socket) + } + + async installAutoAttach(): Promise { + await this.send('Target.setDiscoverTargets', { discover: true }) + await this.send('Target.setAutoAttach', { + autoAttach: true, + waitForDebuggerOnStart: true, + flatten: true + }) + } + + snapshot(): BrowserSessionUaCdpRequest[] { + const result: BrowserSessionUaCdpRequest[] = [] + const requests = [...this.requests.values()].flat() + for (const request of [...requests, ...this.webSockets.values()]) { + if (!request.url || !request.headers) { + continue + } + const normalizedHeaders = Object.fromEntries( + Object.entries(request.headers).map(([key, value]) => [key.toLowerCase(), String(value)]) + ) + result.push({ + targetType: request.targetType, + resourceType: request.resourceType ?? 'Other', + url: request.url, + userAgent: normalizedHeaders['user-agent'] ?? null, + clientHints: Object.fromEntries( + Object.entries(normalizedHeaders).filter(([key]) => key.startsWith('sec-ch-ua')) + ) + }) + } + return result + } + + async close(): Promise { + if (this.socket.readyState === WebSocket.CLOSED) { + return + } + await new Promise((resolve) => { + this.socket.once('close', () => resolve()) + this.socket.close() + }) + } + + private send( + method: string, + params: Record, + sessionId?: string + ): Promise { + const id = this.nextCommandId++ + const promise = new Promise((resolve, reject) => { + this.pendingCommands.set(id, { resolve, reject }) + }) + this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })) + return promise + } + + private handleMessage(message: CdpMessage): void { + if (this.diagnostics.length < 50 && message.method) { + this.diagnostics.push(`event:${message.method}:${message.sessionId ?? 'root'}`) + } + if (message.id !== undefined) { + const pending = this.pendingCommands.get(message.id) + if (!pending) { + return + } + this.pendingCommands.delete(message.id) + if (message.error) { + pending.reject(new Error(message.error.message ?? 'CDP command failed')) + } else { + pending.resolve(message.result) + } + return + } + if (message.method === 'Target.targetCreated') { + const targetInfo = readRecord(message.params)?.targetInfo + const info = readRecord(targetInfo) + const targetType = readString(info, 'type') ?? 'unknown' + const targetId = readString(info, 'targetId') ?? 'unknown' + const targetUrl = readString(info, 'url') ?? '' + this.diagnostics.push(`target-created:${targetType}:${targetId}:${targetUrl}`) + } + if (message.method === 'Target.attachedToTarget') { + const params = readRecord(message.params) + const attachedSessionId = readString(params, 'sessionId') + if (attachedSessionId) { + const targetInfo = readRecord(params?.targetInfo) + const targetType = readString(targetInfo, 'type') ?? 'unknown' + const targetId = readString(targetInfo, 'targetId') ?? 'unknown' + const targetUrl = readString(targetInfo, 'url') ?? '' + this.diagnostics.push( + // wfd records whether the target arrived paused; an unpaused nested target is how a + // capture silently comes back empty. + `attached:${targetType}:${targetId}:${targetUrl}:${attachedSessionId}:wfd=${String(params?.waitingForDebugger)}` + ) + this.targetsBySessionId.set(attachedSessionId, targetType) + void this.prepareTarget(attachedSessionId) + } + return + } + const sessionId = message.sessionId ?? 'browser' + const params = message.params ?? {} + if (message.method === 'Runtime.exceptionThrown') { + this.diagnostics.push(`exception:${JSON.stringify(params)}`) + return + } + const requestId = typeof params.requestId === 'string' ? params.requestId : undefined + if (!requestId) { + return + } + const key = `${sessionId}:${requestId}` + if (message.method === 'Network.requestWillBeSent') { + const request = readRecord(params.request) + const hops = this.requests.get(key) ?? [] + const pending = hops.find((candidate) => candidate.url === undefined) + const hop = pending ?? this.createPending(sessionId) + if (!pending) { + hops.push(hop) + } + hop.url = readString(request, 'url') + hop.resourceType = typeof params.type === 'string' ? params.type : 'Other' + this.requests.set(key, hops) + } else if (message.method === 'Network.requestWillBeSentExtraInfo') { + const hops = this.requests.get(key) ?? [] + const pending = hops.find((candidate) => candidate.headers === undefined) + const hop = pending ?? this.createPending(sessionId) + if (!pending) { + hops.push(hop) + } + hop.headers = readStringRecord(params.headers) ?? {} + this.requests.set(key, hops) + } else if (message.method === 'Network.webSocketCreated') { + const pending = this.webSockets.get(key) ?? this.createPending(sessionId) + pending.url = typeof params.url === 'string' ? params.url : undefined + pending.resourceType = 'WebSocket' + this.webSockets.set(key, pending) + } else if (message.method === 'Network.webSocketWillSendHandshakeRequest') { + const pending = this.webSockets.get(key) ?? this.createPending(sessionId) + pending.headers = readStringRecord(readRecord(params.request)?.headers) ?? {} + this.webSockets.set(key, pending) + } + } + + private createPending(sessionId: string): PendingRequest { + return { targetType: this.targetsBySessionId.get(sessionId) ?? 'unknown' } + } + + private async prepareTarget(sessionId: string): Promise { + // Root auto-attach only reaches browser-level targets; an OOPIF or dedicated worker is auto- + // attached — and held paused — only once its own parent session arms auto-attach. Arm it before + // the resume below so nested targets arrive paused instead of already fetching. + const autoAttach = this.send( + 'Target.setAutoAttach', + { + autoAttach: true, + waitForDebuggerOnStart: true, + flatten: true, + // Only nested targets; browser-level ones already attach once through the root session, and + // re-attaching them here would double-count every request they make. + filter: [{ type: 'iframe' }, { type: 'worker' }] + }, + sessionId + ) + // Paused Electron targets acknowledge queued domain enables only after Runtime resumes them. + const network = this.send('Network.enable', {}, sessionId) + const runtime = this.send('Runtime.enable', {}, sessionId) + await this.send('Runtime.runIfWaitingForDebugger', {}, sessionId).catch((error: unknown) => { + this.diagnostics.push(`resume-error:${sessionId}:${String(error)}`) + }) + const enabled = await Promise.allSettled([autoAttach, network, runtime]) + this.diagnostics.push( + `enabled:${sessionId}:${enabled.map((result) => result.status).join(',')}` + ) + this.diagnostics.push(`resumed:${sessionId}`) + } +} + +export async function waitForBrowserCdpEndpoint(port: number): Promise { + const deadline = Date.now() + 15_000 + while (Date.now() < deadline) { + try { + const targets = await fetch(`http://127.0.0.1:${port}/json/version`) + // The probe only needs the status; an unread body can crash the process (orca#8695). + await cancelUnreadResponseBody(targets) + if (targets.ok) { + return + } + } catch { + // Electron has not opened the debugger endpoint yet. + } + await new Promise((resolve) => setTimeout(resolve, 25)) + } + throw new Error('browser_cdp_endpoint_timeout') +} diff --git a/src/main/browser/browser-session-ua-cloudflare-live.electron.test.ts b/src/main/browser/browser-session-ua-cloudflare-live.electron.test.ts new file mode 100644 index 00000000000..05331ab5c65 --- /dev/null +++ b/src/main/browser/browser-session-ua-cloudflare-live.electron.test.ts @@ -0,0 +1,402 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import { createServer } from 'node:net' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { chromium } from 'playwright' +import { afterAll, describe, expect, it } from 'vitest' +import { build as buildVite } from 'vite' +import { + BrowserSessionUaCdpCollector, + waitForBrowserCdpEndpoint +} from './browser-session-ua-cdp-collector' + +const electronBinary = createRequire(import.meta.url)('electron') as string +const fixtureRoots: string[] = [] +const enabled = process.env.ORCA_UA_CLOUDFLARE_LIVE === '1' +let liveTargetUrl = 'https://dash.cloudflare.com/login' +const repetitions = Number(process.env.ORCA_UA_CLOUDFLARE_REPETITIONS ?? 5) +const failureText = 'There was a problem with verification. Please reload and try again.' + +// Several independent challenge deployments, not one origin. `native` runs on every site as a +// positive control: if it fails too, that site proves nothing and its rows are void. +const LIVE_SITES: { key: string; url: string }[] = [ + { key: 'cf-dash', url: 'https://dash.cloudflare.com/login' }, + { key: 'cf-nopecha', url: 'https://nopecha.com/demo/cloudflare' }, + { key: 'cf-scrapingcourse', url: 'https://www.scrapingcourse.com/cloudflare-challenge' }, + { key: 'ua-sniff-whatsapp', url: 'https://web.whatsapp.com/' } +] + +// Why signal matching instead of one hardcoded failure string: each deployment words its block +// differently, and inventing per-site strings is how a rig silently reports garbage. Capture the +// evidence and compare arms. +const BLOCK_SIGNALS = [ + 'problem with verification', + 'just a moment', + 'verify you are human', + 'verifying you are human', + 'checking your browser', + 'enable javascript and cookies', + 'unsupported browser', + 'update your browser', + 'is not supported' +] + +function blockSignals(bodyText: string): string[] { + const haystack = bodyText.toLowerCase() + return BLOCK_SIGNALS.filter((signal) => haystack.includes(signal)) +} + +type LiveArm = 'origin-main' | 'branch' | 'native' +type LiveSite = string + +type LiveRun = Readonly<{ + arm: LiveArm + site: LiveSite + repetition: number + cleanUserAgent: string + nativeUserAgent: string + firefoxUserAgent: string + navigatorUserAgent: string | null + bodyText: string + requests: ReturnType + diagnostics: readonly string[] +}> + +afterAll(() => { + for (const root of fixtureRoots) { + rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + } +}) + +describe.skipIf(!enabled)('Cloudflare live user-agent compatibility', () => { + it('interleaves origin/main and branch with isolated profiles', async () => { + expect(Number.isInteger(repetitions) && repetitions >= 5).toBe(true) + const results: LiveRun[] = [] + for (const { key, url } of LIVE_SITES) { + liveTargetUrl = url + for (let repetition = 1; repetition <= repetitions; repetition += 1) { + // Rotate so no arm always runs first: IP reputation and challenge state drift within a run. + const rotations: LiveArm[][] = [ + ['origin-main', 'branch', 'native'], + ['branch', 'native', 'origin-main'], + ['native', 'origin-main', 'branch'] + ] + const arms: LiveArm[] = rotations[(repetition - 1) % rotations.length]! + for (const arm of arms) { + results.push(await runLiveProbe(arm, repetition, key)) + } + } + } + const report = results.map(summarizeLiveRun) + console.info(`ORCA_UA_CLOUDFLARE_REPORT=${JSON.stringify(report)}`) + const reportPath = process.env.ORCA_UA_CLOUDFLARE_REPORT_PATH + if (reportPath) { + writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`) + } + + for (const run of results.filter(({ arm }) => arm === 'branch')) { + const userAgents = distinctUserAgents(run.requests) + expect(userAgents, JSON.stringify(summarizeLiveRun(run))).toEqual([run.cleanUserAgent]) + expect( + run.requests.filter(({ userAgent }) => userAgent === run.nativeUserAgent) + ).toHaveLength(0) + } + }, 3_600_000) + + it.skip('compares the Google auth document and cross-host resources', async () => { + const results = await Promise.all([ + runLiveProbe('origin-main', 1, 'google-auth'), + runLiveProbe('branch', 1, 'google-auth') + ]) + const report = results.map(summarizeGoogleAuthRun) + console.info(`ORCA_UA_GOOGLE_AUTH_REPORT=${JSON.stringify(report)}`) + const reportPath = process.env.ORCA_UA_GOOGLE_AUTH_REPORT_PATH + if (reportPath) { + writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`) + } + + const branch = results.find(({ arm }) => arm === 'branch')! + const relevant = googleAuthRequests(branch) + expect(relevant.length).toBeGreaterThan(0) + expect(distinctUserAgents(relevant)).toEqual([branch.firefoxUserAgent]) + expect(relevant.filter(({ userAgent }) => userAgent === branch.cleanUserAgent)).toHaveLength(0) + expect(branch.navigatorUserAgent).toBe(branch.firefoxUserAgent) + }, 90_000) +}) + +async function runLiveProbe(arm: LiveArm, repetition: number, site: LiveSite): Promise { + const root = mkdtempSync(join(tmpdir(), `orca-cloudflare-${arm}-${repetition}-`)) + fixtureRoots.push(root) + const processIdentityModulePath = join(root, 'browser-process-user-agent.cjs') + const exceptionModulePath = join(root, 'browser-session-ua.cjs') + await Promise.all([ + buildModule('src/main/browser/browser-process-user-agent.ts', processIdentityModulePath), + buildModule('src/main/browser/browser-session-ua.ts', exceptionModulePath) + ]) + const barrierPath = join(root, 'continue') + const resultPath = join(root, 'result.json') + const fixturePath = join(root, 'main.cjs') + const cdpPort = await reservePort() + writeFileSync( + fixturePath, + fixtureMain({ + arm, + barrierPath, + exceptionModulePath, + processIdentityModulePath, + resultPath, + site, + targetUrl: liveTargetUrl + }) + ) + let child: ChildProcess | null = null + let collector: BrowserSessionUaCdpCollector | null = null + let browser: Awaited> | null = null + try { + child = launchFixture(fixturePath, root, cdpPort) + await waitForBrowserCdpEndpoint(cdpPort) + browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`) + collector = await BrowserSessionUaCdpCollector.connect(cdpPort) + await collector.installAutoAttach() + writeFileSync(barrierPath, '') + const processResult = await waitForProcess(child) + const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result' + expect(processResult.code, `${fixtureResult}\n${processResult.stderr}`).toBe(0) + await new Promise((resolve) => setTimeout(resolve, 100)) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: JSON.parse is untyped; the fixture writes exactly this shape with JSON.stringify, and the assertions below fail loudly on a missing member. + const parsed = JSON.parse(fixtureResult) as Omit< + LiveRun, + 'arm' | 'site' | 'repetition' | 'requests' | 'diagnostics' + > + return { + arm, + site, + repetition, + ...parsed, + requests: collector.snapshot().filter(({ url, userAgent }) => { + if (!userAgent) { + return false + } + try { + return new URL(url).protocol.startsWith('http') + } catch { + return false + } + }), + diagnostics: [...collector.diagnostics] + } + } finally { + await collector?.close().catch(() => {}) + await browser?.close().catch(() => {}) + if (child && child.exitCode === null) { + child.kill('SIGTERM') + } + } +} + +async function buildModule(entry: string, outputPath: string): Promise { + await buildVite({ + configFile: false, + logLevel: 'silent', + build: { + emptyOutDir: false, + lib: { + entry: join(process.cwd(), entry), + formats: ['cjs'], + fileName: () => basename(outputPath) + }, + outDir: join(outputPath, '..'), + target: 'node20', + rollupOptions: { external: ['electron', /^node:/] } + } + }) +} + +function fixtureMain(options: { + arm: LiveArm + barrierPath: string + exceptionModulePath: string + processIdentityModulePath: string + resultPath: string + site: LiveSite + targetUrl: string +}): string { + return String.raw` +const { app, BrowserWindow, session } = require('electron') +const { existsSync, writeFileSync } = require('node:fs') +const processIdentity = require(${JSON.stringify(options.processIdentityModulePath)}) +const { installBrowserSessionUserAgentPolicy } = require(${JSON.stringify(options.exceptionModulePath)}) +const arm = ${JSON.stringify(options.arm)} +const site = ${JSON.stringify(options.site)} +app.setName('OrcaCloudflareLiveProbe') +const nativeUserAgent = app.userAgentFallback +const clean = userAgent => userAgent.replace(/\s+Electron\/\S+/, '').replace(/(\)\s+)\S+\s+(Chrome\/)/, '$1$2') +let identity +if (arm === 'branch') identity = processIdentity.initializeBrowserProcessUserAgent('clean') +const waitForBarrier = async () => { + const deadline = Date.now() + 15000 + while (!existsSync(${JSON.stringify(options.barrierPath)})) { + if (Date.now() >= deadline) throw new Error('startup barrier timeout') + await new Promise(resolve => setTimeout(resolve, 20)) + } +} +async function run() { + await app.whenReady() + await waitForBarrier() + const sess = session.fromPartition('persist:cloudflare-live-probe') + const cleanUserAgent = identity?.userAgent ?? clean(nativeUserAgent) + const firefoxUserAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0' + if (arm === 'origin-main') sess.setUserAgent(cleanUserAgent) + if (arm === 'branch') { + installBrowserSessionUserAgentPolicy(sess, request => { + if (request.resourceType !== 'mainFrame' && (request.currentUserAgent === firefoxUserAgent || request.effectiveUserAgent === firefoxUserAgent)) { + return { userAgent: firefoxUserAgent } + } + if (request.resourceType === 'mainFrame' && request.currentUserAgent === firefoxUserAgent) { + return { userAgent: cleanUserAgent } + } + return undefined + }) + } else if (arm === 'origin-main') { + sess.webRequest.onBeforeSendHeaders({ urls: ['https://*/*'] }, (details, callback) => { + const headers = details.requestHeaders + const key = Object.keys(headers).find(candidate => candidate.toLowerCase() === 'user-agent') || 'User-Agent' + const auth = (() => { try { const url = new URL(details.url); return url.protocol === 'https:' && (url.hostname === 'accounts.google.com' || url.hostname === 'accounts.youtube.com') } catch { return false } })() + if (auth) headers[key] = firefoxUserAgent + if (auth || headers[key] === firefoxUserAgent) { + for (const candidate of Object.keys(headers)) if (candidate.toLowerCase().startsWith('sec-ch-ua')) delete headers[candidate] + } + callback({ requestHeaders: headers }) + }) + } + const window = new BrowserWindow({ show: false, webPreferences: { partition: 'persist:cloudflare-live-probe', sandbox: true } }) + if (site === 'google-auth') window.webContents.setUserAgent(firefoxUserAgent) + let loadError = null + const targetUrl = ${JSON.stringify(options.targetUrl)} + await window.loadURL(targetUrl).catch(error => { loadError = String(error?.message || error) }) + await new Promise(resolve => setTimeout(resolve, 12000)) + const bodyText = await window.webContents.executeJavaScript('document.body?.innerText || ""').catch(() => '') + const navigatorUserAgent = await window.webContents.executeJavaScript('navigator.userAgent').catch(() => null) + writeFileSync(${JSON.stringify(options.resultPath)}, JSON.stringify({ nativeUserAgent, cleanUserAgent, firefoxUserAgent, navigatorUserAgent, bodyText, loadError })) + window.destroy() + app.exit(0) +} +run().catch(error => { writeFileSync(${JSON.stringify(options.resultPath)}, JSON.stringify({ error: String(error?.stack || error) })); app.exit(1) }) +` +} + +function summarizeLiveRun(run: LiveRun) { + const byResourceType: Record> = {} + for (const request of run.requests) { + const userAgent = request.userAgent ?? '' + byResourceType[request.resourceType] ??= {} + byResourceType[request.resourceType]![userAgent] = + (byResourceType[request.resourceType]![userAgent] ?? 0) + 1 + } + return { + arm: run.arm, + site: run.site, + repetition: run.repetition, + requestCount: run.requests.length, + distinctUserAgents: distinctUserAgents(run.requests), + nativeLeakCount: run.requests.filter(({ userAgent }) => userAgent === run.nativeUserAgent) + .length, + navigatorUserAgent: run.navigatorUserAgent, + verificationFailure: run.bodyText.includes(failureText), + blockSignals: blockSignals(run.bodyText), + bodySnippet: run.bodyText.replace(/\s+/g, ' ').slice(0, 220), + byResourceType, + attachedTargetTypes: run.diagnostics + .filter((message) => message.startsWith('attached:')) + .map((message) => message.split(':')[1]) + } +} + +function summarizeGoogleAuthRun(run: LiveRun) { + const relevant = googleAuthRequests(run) + const byHost: Record = {} + for (const request of relevant) { + const host = new URL(request.url).hostname + byHost[host] = (byHost[host] ?? 0) + 1 + } + return { + arm: run.arm, + requestCount: relevant.length, + distinctUserAgents: distinctUserAgents(relevant), + cleanChromeCount: relevant.filter(({ userAgent }) => userAgent === run.cleanUserAgent).length, + firefoxCount: relevant.filter(({ userAgent }) => userAgent === run.firefoxUserAgent).length, + navigatorUserAgent: run.navigatorUserAgent, + byHost + } +} + +function googleAuthRequests(run: LiveRun) { + const hosts = new Set([ + 'accounts.google.com', + 'accounts.youtube.com', + 'www.gstatic.com', + 'fonts.gstatic.com', + 'play.google.com' + ]) + return run.requests.filter(({ url }) => { + try { + return hosts.has(new URL(url).hostname) + } catch { + return false + } + }) +} + +function distinctUserAgents(records: readonly { userAgent: string | null }[]): (string | null)[] { + return [...new Set(records.map(({ userAgent }) => userAgent))].sort() +} + +function launchFixture(fixturePath: string, root: string, cdpPort: number): ChildProcess { + const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env + return spawn( + process.platform === 'linux' ? 'xvfb-run' : electronBinary, + process.platform === 'linux' + ? [ + '--auto-servernum', + electronBinary, + fixturePath, + `--user-data-dir=${join(root, 'profile')}`, + `--remote-debugging-port=${cdpPort}`, + '--no-sandbox' + ] + : [ + fixturePath, + `--user-data-dir=${join(root, 'profile')}`, + `--remote-debugging-port=${cdpPort}` + ], + { env: { ...env, ORCA_BACKGROUND_LAUNCH: '1' }, stdio: ['ignore', 'pipe', 'pipe'] } + ) +} + +async function reservePort(): Promise { + const server = createServer() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('cdp port unavailable') + } + await new Promise((resolve) => server.close(() => resolve())) + return address.port +} + +function waitForProcess(child: ChildProcess): Promise<{ code: number | null; stderr: string }> { + let stderr = '' + child.stderr?.setEncoding('utf8') + child.stderr?.on('data', (chunk: string) => { + stderr += chunk + }) + return new Promise((resolve, reject) => { + child.once('error', reject) + child.once('exit', (code) => resolve({ code, stderr })) + }) +} diff --git a/src/main/browser/browser-session-ua-wire-identity-cross-context.electron.test.ts b/src/main/browser/browser-session-ua-wire-identity-cross-context.electron.test.ts new file mode 100644 index 00000000000..c1044333b9f --- /dev/null +++ b/src/main/browser/browser-session-ua-wire-identity-cross-context.electron.test.ts @@ -0,0 +1,427 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import { createServer } from 'node:net' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { chromium } from 'playwright' +import { afterAll, describe, expect, it } from 'vitest' +import { build as buildVite } from 'vite' +import { + BrowserSessionUaCdpCollector, + type BrowserSessionUaCdpRequest, + waitForBrowserCdpEndpoint +} from './browser-session-ua-cdp-collector' +import { + startBrowserSessionUaWireProbeServer, + type WireProbeJavaScriptIdentity, + type WireProbeReceipt +} from './browser-session-ua-wire-probe-server' + +// This file is deliberately independent from the broad identity test. Its two arms make the +// pre-ready fallback itself the control variable for the cross-site and dedicated-worker probes. +const electronBinary = createRequire(import.meta.url)('electron') as string +const fixtureRoots: string[] = [] + +afterAll(() => { + for (const root of fixtureRoots) { + rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + } +}) + +type ProbeArm = 'clean' | 'fallback-disabled' + +type ProbeResult = Readonly<{ + arm: ProbeArm + rawUserAgent: string + cleanUserAgent: string + navigatorUserAgent: string + receipts: readonly WireProbeReceipt[] + identities: readonly WireProbeJavaScriptIdentity[] + cdpRequests: readonly BrowserSessionUaCdpRequest[] + cdpDiagnostics: readonly string[] + /** Why carried: a CI-only capture failure is undiagnosable without the fixture's own output. */ + fixtureResult: string + fixtureStderr: string +}> + +describe('browser session wire identity in cross-site frames and dedicated workers', () => { + it('keeps OOPIF, dedicated-worker, and client-hint identities clean', async () => { + const result = await runProbe('clean') + assertCapturedContexts(result) + const checks = identityChecks(result) + expect(checks.crossSiteDocument).toBe(true) + expect(checks.crossSiteFetch).toBe(true) + expect(checks.dedicatedWorkerScript).toBe(true) + expect(checks.dedicatedWorkerFetch).toBe(true) + expect(checks.clientHints, JSON.stringify(receiptForPath(result.receipts, '/'))).toBe(true) + }, 60_000) + + it('turns every new clean-identity check red when the process fallback is removed', async () => { + const result = await runProbe('fallback-disabled') + assertCapturedContexts(result) + // These are explicit ablation controls: each predicate is the assertion used by the clean arm, + // and must be false when app.userAgentFallback is never assigned. + const checks = identityChecks(result) + expect(checks.crossSiteDocument).toBe(false) + expect(checks.crossSiteFetch).toBe(false) + expect(checks.dedicatedWorkerScript).toBe(false) + expect(checks.dedicatedWorkerFetch).toBe(false) + expect(checks.clientHints).toBe(false) + }, 60_000) +}) + +async function runProbe(arm: ProbeArm): Promise { + const root = mkdtempSync(join(tmpdir(), `orca-wire-cross-context-${arm}-`)) + fixtureRoots.push(root) + const processIdentityModulePath = join(root, 'browser-process-user-agent.cjs') + const exceptionModulePath = join(root, 'browser-session-ua.cjs') + await Promise.all([ + buildModule('src/main/browser/browser-process-user-agent.ts', processIdentityModulePath), + buildModule('src/main/browser/browser-session-ua.ts', exceptionModulePath) + ]) + const server = await startBrowserSessionUaWireProbeServer() + const resultPath = join(root, 'result.json') + const barrierPath = join(root, 'continue') + const fixturePath = join(root, 'main.cjs') + const cdpPort = await reservePort() + writeFileSync( + fixturePath, + fixtureMain({ + arm, + barrierPath, + exceptionModulePath, + httpOrigin: server.httpOrigin, + processIdentityModulePath, + resultPath + }) + ) + let process: ChildProcess | null = null + let collector: BrowserSessionUaCdpCollector | null = null + let browser: Awaited> | null = null + try { + process = launchFixture(fixturePath, root, cdpPort) + await waitForBrowserCdpEndpoint(cdpPort) + browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`) + collector = await BrowserSessionUaCdpCollector.connect(cdpPort) + await collector.installAutoAttach() + writeFileSync(barrierPath, '') + const processResult = await waitForProcess(process) + const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result' + expect( + processResult.code, + `${fixtureResult}\n${processResult.stderr}\n${JSON.stringify({ diagnostics: collector.diagnostics, receipts: server.receipts, identities: server.identities })}` + ).toBe(0) + await new Promise((resolve) => setTimeout(resolve, 250)) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: JSON.parse is untyped; the fixture writes this exact shape before exiting. + const parsed = JSON.parse(fixtureResult) as Omit< + ProbeResult, + | 'receipts' + | 'identities' + | 'cdpRequests' + | 'cdpDiagnostics' + | 'fixtureResult' + | 'fixtureStderr' + > + return { + ...parsed, + fixtureResult, + fixtureStderr: processResult.stderr, + receipts: [...server.receipts], + identities: [...server.identities], + cdpDiagnostics: [...collector.diagnostics], + cdpRequests: collector.snapshot().filter(({ url }) => { + return ( + url.startsWith(server.httpOrigin) || + url.startsWith(server.crossSiteOrigin) || + url.startsWith(server.httpsOrigin) + ) + }) + } + } finally { + await collector?.close().catch(() => {}) + await browser?.close().catch(() => {}) + await server.close() + if (process && process.exitCode === null) { + process.kill('SIGTERM') + } + } +} + +async function buildModule(entry: string, outputPath: string): Promise { + await buildVite({ + configFile: false, + logLevel: 'silent', + build: { + emptyOutDir: false, + lib: { + entry: join(process.cwd(), entry), + formats: ['cjs'], + fileName: () => basename(outputPath) + }, + outDir: join(outputPath, '..'), + target: 'node20', + rollupOptions: { external: ['electron', /^node:/] } + } + }) +} + +function launchFixture(fixturePath: string, root: string, cdpPort: number): ChildProcess { + const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env + const args = [ + fixturePath, + `--user-data-dir=${join(root, 'profile')}`, + `--remote-debugging-port=${cdpPort}`, + '--site-per-process' + ] + if (process.platform === 'linux') { + args.push('--no-sandbox') + } + return spawn( + process.platform === 'linux' ? 'xvfb-run' : electronBinary, + process.platform === 'linux' ? ['--auto-servernum', electronBinary, ...args] : args, + { + env: { ...env, ORCA_BACKGROUND_LAUNCH: '1' }, + stdio: ['ignore', 'pipe', 'pipe'] + } + ) +} + +function fixtureMain(options: { + arm: ProbeArm + barrierPath: string + exceptionModulePath: string + httpOrigin: string + processIdentityModulePath: string + resultPath: string +}): string { + return String.raw` +const { app, BrowserWindow, session } = require('electron') +const { existsSync, writeFileSync } = require('node:fs') +const processIdentity = require(${JSON.stringify(options.processIdentityModulePath)}) +const { cleanElectronUserAgent } = require(${JSON.stringify(options.exceptionModulePath)}) +const arm = ${JSON.stringify(options.arm)} +app.setName('OrcaCrossContextFixture') +app.commandLine.appendSwitch('site-per-process') +const rawUserAgent = app.userAgentFallback +if (arm === 'clean') processIdentity.initializeBrowserProcessUserAgent('clean') +const waitForBarrier = async () => { + const deadline = Date.now() + 15000 + while (!existsSync(${JSON.stringify(options.barrierPath)})) { + if (Date.now() >= deadline) throw new Error('startup barrier timeout') + await new Promise(resolve => setTimeout(resolve, 20)) + } +} +async function run() { + const timeout = setTimeout(() => { writeFileSync(${JSON.stringify(options.resultPath)}, JSON.stringify({ error: 'timeout' })); app.exit(2) }, 20000) + await app.whenReady() + await waitForBarrier() + const sess = session.fromPartition('persist:wire-cross-context') + sess.setCertificateVerifyProc((_request, callback) => callback(0)) + const windows = [] + const window = new BrowserWindow({ show: false, webPreferences: { partition: 'persist:wire-cross-context', sandbox: true } }) + windows.push(window) + window.webContents.setWindowOpenHandler(() => ({ + action: 'allow', + createWindow: options => { + const popup = new BrowserWindow({ ...options, show: false }) + windows.push(popup) + return popup.webContents + } + })) + await window.loadURL(${JSON.stringify(options.httpOrigin)} + '/?cross-context=1') + const [navigatorUserAgent] = await Promise.all([ + window.webContents.executeJavaScript('navigator.userAgent'), + window.webContents.executeJavaScript('window.probePromise') + ]) + // Let Target.attachedToTarget and its Network events flush for the isolated frame before the + // fixture exits; the frame's own report/fetch receipts are the request-level proof. + await new Promise(resolve => setTimeout(resolve, 1500)) + clearTimeout(timeout) + writeFileSync(${JSON.stringify(options.resultPath)}, JSON.stringify({ arm, rawUserAgent, cleanUserAgent: cleanElectronUserAgent(rawUserAgent), navigatorUserAgent })) + for (const candidate of windows) if (!candidate.isDestroyed()) candidate.destroy() + app.exit(0) +} +run().catch(error => { writeFileSync(${JSON.stringify(options.resultPath)}, JSON.stringify({ error: String(error?.stack || error) })); app.exit(1) }) +` +} + +function assertCapturedContexts(result: ProbeResult): void { + const paths = new Set(result.receipts.map(({ path }) => path)) + for (const path of [ + '/', + '/cross-site-frame', + '/cross-site-frame-fetch', + '/dedicated-worker.js', + '/dedicated-worker-fetch', + '/report/cross-site-frame', + '/report/dedicated-worker' + ]) { + expect( + paths, + `${result.arm} omitted ${path}\n cdp: ${JSON.stringify(result.cdpDiagnostics)}\n receipts: ${JSON.stringify(result.receipts.map((r) => r.path))}\n fixture: ${result.fixtureResult}\n stderr: ${result.fixtureStderr}` + ).toContain(path) + } + expect( + result.cdpDiagnostics.some((message) => message.startsWith('attached:iframe:')), + JSON.stringify(result.cdpDiagnostics) + ).toBe(true) + expect( + result.cdpDiagnostics.some((message) => message.startsWith('attached:worker:')), + JSON.stringify(result.cdpDiagnostics) + ).toBe(true) + expect( + result.receipts + .filter(({ path }) => path === '/cross-site-frame') + .map(({ protocol }) => protocol) + ).toEqual(['https']) + expect( + result.receipts + .filter(({ path }) => path === '/cross-site-frame-fetch') + .map(({ protocol }) => protocol) + ).toEqual(['https']) + expect( + result.cdpRequests.some(({ url, targetType }) => { + return new URL(url).pathname === '/cross-site-frame-fetch' && targetType === 'iframe' + }), + JSON.stringify(result.cdpRequests.filter(({ url }) => url.includes('cross-site-frame-fetch'))) + ).toBe(true) +} + +function identityChecks(result: ProbeResult): Readonly> { + const frameIdentity = identityForContext(result.identities, 'cross-site-frame') + const workerIdentity = identityForContext(result.identities, 'dedicated-worker') + const frameReceipt = receiptForPath(result.receipts, '/cross-site-frame-fetch') + const workerScriptReceipt = receiptForPath(result.receipts, '/dedicated-worker.js') + const workerFetchReceipt = receiptForPath(result.receipts, '/dedicated-worker-fetch') + // Chromium omits client hints on the initial navigation but sends them on the document's + // subsequent fetch; use that captured wire request to compare sec-ch-ua with the same document's + // navigator.userAgentData. + const rootReceipt = receiptForPath(result.receipts, '/report/document') + return { + crossSiteDocument: frameIdentity.userAgent === result.cleanUserAgent, + crossSiteFetch: frameReceipt.userAgent === result.cleanUserAgent, + dedicatedWorkerScript: workerScriptReceipt.userAgent === result.cleanUserAgent, + dedicatedWorkerFetch: + workerIdentity.userAgent === result.cleanUserAgent && + workerFetchReceipt.userAgent === result.cleanUserAgent, + clientHints: clientHintIdentityIsClean( + rootReceipt, + identityForContext(result.identities, 'document'), + result.cleanUserAgent + ) + } +} + +function clientHintIdentityIsClean( + receipt: WireProbeReceipt, + identity: WireProbeJavaScriptIdentity, + cleanUserAgent: string +): boolean { + // Electron's stock UA-CH remains Chromium-shaped even when the fallback is disabled. Compare + // its brands exactly, but require the same wire request to carry the clean legacy UA too; this is + // the strongest true one-identity invariant and makes the ablation red on the Electron token. + const secChUa = receipt.clientHints['sec-ch-ua'] + const wireBrands = parseSecChUa(secChUa) + const navigatorBrands = readNavigatorBrands(identity.userAgentData) + if (!secChUa || wireBrands.length === 0 || navigatorBrands.length === 0) { + return false + } + const token = /electron|orca/i + return ( + receipt.userAgent === cleanUserAgent && + !token.test(receipt.userAgent ?? '') && + !token.test(secChUa) && + !navigatorBrands.some(({ brand, version }) => token.test(brand) || token.test(version)) && + sameBrands(wireBrands, navigatorBrands) + ) +} + +function parseSecChUa(value: string | undefined): { brand: string; version: string }[] { + if (!value) { + return [] + } + const brands: { brand: string; version: string }[] = [] + const pattern = /"([^"]+)"\s*;\s*v="([^"]*)"/g + for (const match of value.matchAll(pattern)) { + const brand = match[1] + const version = match[2] + if (brand !== undefined && version !== undefined) { + brands.push({ brand, version }) + } + } + return brands +} + +function readNavigatorBrands(value: unknown): { brand: string; version: string }[] { + if (typeof value !== 'object' || value === null) { + return [] + } + const brandsValue = Object.entries(value).find(([key]) => key === 'brands')?.[1] + if (!Array.isArray(brandsValue)) { + return [] + } + const brands: { brand: string; version: string }[] = [] + for (const entry of brandsValue) { + if (typeof entry !== 'object' || entry === null) { + continue + } + const fields = Object.fromEntries(Object.entries(entry)) + const brand = fields.brand + const version = fields.version + if (typeof brand === 'string' && typeof version === 'string') { + brands.push({ brand, version }) + } + } + return brands +} + +function sameBrands( + left: readonly { brand: string; version: string }[], + right: readonly { brand: string; version: string }[] +): boolean { + const normalize = (brands: readonly { brand: string; version: string }[]) => + brands.map(({ brand, version }) => `${brand}\u0000${version}`).sort() + return JSON.stringify(normalize(left)) === JSON.stringify(normalize(right)) +} + +function identityForContext( + identities: readonly WireProbeJavaScriptIdentity[], + context: string +): WireProbeJavaScriptIdentity { + const matches = identities.filter((identity) => identity.context === context) + expect(matches, context).toHaveLength(1) + return matches[0]! +} + +function receiptForPath(receipts: readonly WireProbeReceipt[], path: string): WireProbeReceipt { + const matches = receipts.filter((receipt) => receipt.path === path) + expect(matches, path).not.toHaveLength(0) + return matches[0]! +} + +async function reservePort(): Promise { + const server = createServer() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('cdp port unavailable') + } + await new Promise((resolve) => server.close(() => resolve())) + return address.port +} + +function waitForProcess(process: ChildProcess): Promise<{ code: number | null; stderr: string }> { + let stderr = '' + process.stderr?.setEncoding('utf8') + process.stderr?.on('data', (chunk: string) => { + stderr += chunk + }) + return new Promise((resolve, reject) => { + process.once('error', reject) + process.once('exit', (code) => resolve({ code, stderr })) + }) +} diff --git a/src/main/browser/browser-session-ua-wire-identity.electron.test.ts b/src/main/browser/browser-session-ua-wire-identity.electron.test.ts index 9fc1c760643..f26a113eb0a 100644 --- a/src/main/browser/browser-session-ua-wire-identity.electron.test.ts +++ b/src/main/browser/browser-session-ua-wire-identity.electron.test.ts @@ -1,23 +1,22 @@ -import { spawnSync } from 'node:child_process' +import { spawn, type ChildProcess } from 'node:child_process' +import { createServer } from 'node:net' import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, join } from 'node:path' +import { chromium } from 'playwright' import { afterAll, describe, expect, it } from 'vitest' import { build as buildVite } from 'vite' import { - LOCAL_HTTPS_TEST_CERTIFICATE, - LOCAL_HTTPS_TEST_PRIVATE_KEY -} from './browser-local-https-test-certificate' - -// Why this runs a real Electron: sites that hold a transplanted session re-check the browser -// identity that minted it, and an `Orca/x.y.z … Electron/x.y.z` UA is not one any browser sends — -// LinkedIn and x.com revoked live sessions over it (STA-7147). The header layer is the only place -// that identity can be proven, and the vm-based unit tests cannot see Chromium's header emission -// at all. Every clean-mode partition must therefore strip the Electron and app tokens on the -// wire for ordinary hosts and present the Firefox identity on Google's sign-in hosts only. This -// focused revocation fix does not claim full Chrome fingerprint parity; native mode remains the -// fallback for sites that reject the cleaned identity, including some Turnstile deployments. + BrowserSessionUaCdpCollector, + type BrowserSessionUaCdpRequest, + waitForBrowserCdpEndpoint +} from './browser-session-ua-cdp-collector' +import { + startBrowserSessionUaWireProbeServer, + type WireProbeJavaScriptIdentity, + type WireProbeReceipt +} from './browser-session-ua-wire-probe-server' const electronBinary = createRequire(import.meta.url)('electron') as string const fixtureRoots: string[] = [] @@ -28,241 +27,469 @@ afterAll(() => { } }) -// Retry once when Electron startup times out before `ready`; keep later failures fatal. -const FIXTURE_LAUNCH_ATTEMPTS = 2 +type ProbeArm = 'clean' | 'late-session-setter' | 'mobile' | 'mixed-mobile' | 'native' -type CapturedRequest = { - url: string - userAgent: string | null - clientHints: Record -} - -type UserAgentBrand = { - brand: string - version: string -} - -type NavigatorUserAgentData = { - brands: UserAgentBrand[] - highEntropy: { fullVersionList?: UserAgentBrand[] } -} - -type FixtureResult = { +type ProbeResult = Readonly<{ + arm: ProbeArm rawUserAgent: string + cleanUserAgent: string + mobileUserAgent: string sessionUserAgent: string navigatorUserAgent: string - navigatorUserAgentData: NavigatorUserAgentData | null - requests: CapturedRequest[] -} + fallbackAfterReadyNameChange: string + startupMarks: readonly string[] + receipts: readonly WireProbeReceipt[] + identities: readonly WireProbeJavaScriptIdentity[] + cdpRequests: readonly BrowserSessionUaCdpRequest[] + cdpDiagnostics: readonly string[] +}> -function neverReachedElectronReady(fixtureResult: string): boolean { - try { - return (JSON.parse(fixtureResult) as { step?: string }).step === 'timed out after starting' - } catch { - return false - } -} +const requiredPaths = [ + '/', + '/document-fetch', + '/document-xhr', + '/document-image', + '/frame', + '/blob-fetch', + '/blob-xhr', + '/blob-image', + '/shared-worker-fetch-a', + '/shared-worker-fetch-b', + '/service-worker-fetch', + '/popup', + '/popup-fetch', + '/no-header-fill', + '/default-session-fill', + '/isolated-session-fill', + '/default-window', + '/isolated-window', + '/plain-ws', + '/secure-ws' +] as const -function buildFixtureMain(modulePath: string, resultPath: string): string { - return ` -const { app, BrowserWindow, session } = require('electron') -const { createServer } = require('node:https') -const { writeFileSync } = require('node:fs') -const { cleanElectronUserAgent, setupGoogleAuthUserAgentOverride } = require(${JSON.stringify(modulePath)}) -const resultPath = ${JSON.stringify(resultPath)} -// Why: production's UA carries an app token ("Orca/1.4.198") between the engine comment and -// Chrome/, and an unnamed fixture emits none — which would leave half of cleanElectronUserAgent -// unexercised while the test still passed. -app.setName('OrcaWireIdentityFixture') -let currentStep = 'starting' -const mark = (step) => { - currentStep = step - writeFileSync(resultPath, JSON.stringify({ step })) -} +describe('browser session wire identity under Electron', () => { + it('uses one process-clean identity for documents, blob frames, workers, HTTP, and WebSockets', async () => { + const result = await runProbe('clean') + assertCoverage(result) + expect(result.rawUserAgent).toMatch(/ Electron\/\d/) + expect(result.rawUserAgent).toMatch(/\(KHTML, like Gecko\) \S+ Chrome\//) + expect(result.cleanUserAgent).not.toContain('Electron/') + expect(result.startupMarks).toEqual(['fallback', 'ready', 'session', 'webContents']) + expect(result.fallbackAfterReadyNameChange).toBe(result.cleanUserAgent) + expect(distinctUserAgents(result.receipts)).toEqual([result.cleanUserAgent]) + expect(distinctUserAgents(result.cdpRequests)).toEqual([result.cleanUserAgent]) + expect(distinctJavaScriptUserAgents(result.identities)).toEqual([result.cleanUserAgent]) + }, 40_000) -async function run() { - const timeout = setTimeout(() => { - writeFileSync(resultPath, JSON.stringify({ step: 'timed out after ' + currentStep })) - app.exit(1) - }, 15000) - await app.whenReady() - mark('ready') - const partition = 'persist:wire-identity-test' - const sess = session.fromPartition(partition) - // Mirrors installBrowserSessionPartitionPolicies for a non-native profile. - const rawUserAgent = sess.getUserAgent() - const cleanUa = cleanElectronUserAgent(rawUserAgent) - sess.setUserAgent(cleanUa) - setupGoogleAuthUserAgentOverride(sess) - mark('clean identity installed') + it('goes red without the pre-ready process fallback even when the Session setter is restored', async () => { + const result = await runProbe('late-session-setter') + assertCoverage(result) + expect(distinctUserAgents(result.receipts)).toContain(result.rawUserAgent) + expect(distinctUserAgents(result.receipts)).toContain(result.cleanUserAgent) + expect(identityViolations(result)).not.toEqual([]) + expect(result.receipts.some(({ userAgent }) => /Firefox\//.test(userAgent ?? ''))).toBe(false) + }, 40_000) - sess.setCertificateVerifyProc((_request, callback) => callback(0)) - const requests = [] - sess.webRequest.onSendHeaders({ urls: ['https://*/*'] }, (details) => { - const headers = details.requestHeaders || {} - const uaKey = Object.keys(headers).find((key) => key.toLowerCase() === 'user-agent') - const clientHints = {} - for (const [key, value] of Object.entries(headers)) { - if (key.toLowerCase().startsWith('sec-ch-ua')) { - clientHints[key.toLowerCase()] = value - } + // Viewport emulation is a per-target CDP override. It reaches the emulated target and nothing + // else, so every context must report on the wire the same identity its own JavaScript reports — + // a document that fetches as mobile and a worker that fetches as whatever it says it is. + it('emulates the targeted tab and leaves every other context self-consistent', async () => { + const result = await runProbe('mobile') + assertCoverage(result) + + const targetPaths = [ + '/', + '/document-fetch', + '/document-xhr', + '/document-image', + '/blob-fetch', + '/blob-xhr', + '/blob-image', + '/plain-ws', + '/secure-ws' + ] + expect(distinctUserAgents(receiptsForPaths(result.receipts, targetPaths))).toEqual([ + result.mobileUserAgent + ]) + expect(identityForContext(result.identities, 'document').userAgent).toBe(result.mobileUserAgent) + expect(identityForContext(result.identities, 'blob').userAgent).toBe(result.mobileUserAgent) + + // A per-target override cannot reach a worker, so the worker stays on the session identity in + // JavaScript. Its requests must leave on that same identity rather than borrowing the preset + // of whichever tab happened to start it. + for (const [context, paths] of [ + ['shared-worker', ['/shared-worker-fetch-a', '/shared-worker-fetch-b']], + ['service-worker', ['/service-worker-fetch']] + ] as const) { + expect(identityForContext(result.identities, context).userAgent).toBe(result.cleanUserAgent) + expect(distinctUserAgents(receiptsForPaths(result.receipts, paths))).toEqual([ + result.cleanUserAgent + ]) } - requests.push({ - url: details.url, - userAgent: uaKey ? headers[uaKey] : null, - clientHints - }) - }) - const server = createServer( - { - cert: ${JSON.stringify(LOCAL_HTTPS_TEST_CERTIFICATE)}, - key: ${JSON.stringify(LOCAL_HTTPS_TEST_PRIVATE_KEY)} - }, - (_request, response) => { - response.setHeader('Accept-CH', 'Sec-CH-UA-Full-Version-List') - response.end('identity') - } - ) - await new Promise((resolve, reject) => { - server.once('error', reject) - server.listen(0, '127.0.0.1', resolve) - }) - const origin = 'https://127.0.0.1:' + server.address().port - const window = new BrowserWindow({ show: false, webPreferences: { partition } }) - mark('window created') - let navigatorUserAgent - let navigatorUserAgentData - try { - await window.loadURL(origin + '/') - navigatorUserAgent = await window.webContents.executeJavaScript('navigator.userAgent') - navigatorUserAgentData = await window.webContents.executeJavaScript( - "(async () => { const data = navigator.userAgentData; return data ? { brands: data.brands, highEntropy: await data.getHighEntropyValues(['fullVersionList']) } : null })()" + expect(userAgentForPath(result.receipts, '/popup')).toBe(result.cleanUserAgent) + expect(identityForContext(result.identities, 'popup').userAgent).toBe(result.cleanUserAgent) + }, 40_000) + + // The leak this closes: with one tab emulated mobile and a desktop peer sharing the session, the + // shared worker reported desktop in JavaScript while its fetches left as mobile — and the peer's + // own worker traffic inherited a preset that peer never had. Closing the emulated tab silently + // reverted it. A single context was internally inconsistent, which is worse than two contexts + // that disagree but are each coherent. + it('leaves a desktop peer and the shared worker untouched by another tab emulation', async () => { + const result = await runProbe('mixed-mobile') + assertCoverage(result) + expect(identityForContext(result.identities, 'document').userAgent).toBe(result.mobileUserAgent) + expect(identityForContext(result.identities, 'desktop-peer').userAgent).toBe( + result.cleanUserAgent ) - await window.webContents.executeJavaScript( - 'fetch("/hints").then((response) => response.text())' + expect(userAgentForPath(result.receipts, '/desktop-peer')).toBe(result.cleanUserAgent) + + // Both shared workers report clean in JavaScript, so both must fetch as clean. + expect( + result.identities + .filter(({ context }) => context === 'shared-worker') + .map(({ userAgent }) => userAgent) + ).toEqual([result.cleanUserAgent, result.cleanUserAgent]) + expect( + distinctUserAgents( + receiptsForPaths(result.receipts, ['/shared-worker-fetch-a', '/shared-worker-fetch-b']) + ) + ).toEqual([result.cleanUserAgent]) + }, 40_000) + + it('keeps the process-native identity across documents, frames, and workers', async () => { + const result = await runProbe('native') + assertCoverage(result) + expect(identityForContext(result.identities, 'document').userAgent).toBe(result.rawUserAgent) + expect(identityForContext(result.identities, 'blob').userAgent).toBe(result.rawUserAgent) + expect(identityForContext(result.identities, 'shared-worker').userAgent).toBe( + result.rawUserAgent ) - } finally { - await new Promise((resolve) => server.close(resolve)) - } - - // Dispatch a real auth-host request without allowing it to reach the Internet. - await sess.setProxy({ proxyRules: 'http://127.0.0.1:9', proxyBypassRules: '<-loopback>' }) - await window.loadURL('https://accounts.google.com/v3/signin/identifier').catch(() => {}) - mark('navigations attempted') - clearTimeout(timeout) - writeFileSync(resultPath, JSON.stringify({ - rawUserAgent, - sessionUserAgent: sess.getUserAgent(), - navigatorUserAgent, - navigatorUserAgentData, - requests - })) - window.destroy() - app.exit(0) -} - -run().catch((error) => { - writeFileSync(resultPath, JSON.stringify({ step: currentStep, error: String(error?.stack || error) })) - app.exit(1) + expect(identityForContext(result.identities, 'service-worker').userAgent).toBe( + result.rawUserAgent + ) + expect(userAgentForPath(result.receipts, '/')).toBe(result.rawUserAgent) + expect(userAgentForPath(result.receipts, '/blob-fetch')).toBe(result.rawUserAgent) + expect(userAgentForPath(result.receipts, '/shared-worker-fetch-a')).toBe(result.rawUserAgent) + expect(userAgentForPath(result.receipts, '/service-worker-fetch')).toBe(result.rawUserAgent) + expect(userAgentForPath(result.receipts, '/no-header-fill')).toBe(result.rawUserAgent) + }, 40_000) }) -` + +async function runProbe(arm: ProbeArm): Promise { + const root = mkdtempSync(join(tmpdir(), `orca-wire-identity-${arm}-`)) + fixtureRoots.push(root) + const processIdentityModulePath = join(root, 'browser-process-user-agent.cjs') + const exceptionModulePath = join(root, 'browser-session-ua.cjs') + await Promise.all([ + buildModule('src/main/browser/browser-process-user-agent.ts', processIdentityModulePath), + buildModule('src/main/browser/browser-session-ua.ts', exceptionModulePath) + ]) + const server = await startBrowserSessionUaWireProbeServer() + const resultPath = join(root, 'result.json') + const barrierPath = join(root, 'continue') + const fixturePath = join(root, 'main.cjs') + const cdpPort = await reservePort() + writeFileSync( + fixturePath, + fixtureMain({ + arm, + barrierPath, + exceptionModulePath, + httpOrigin: server.httpOrigin, + processIdentityModulePath, + resultPath + }) + ) + let process: ChildProcess | null = null + let collector: BrowserSessionUaCdpCollector | null = null + let browser: Awaited> | null = null + try { + process = launchFixture(fixturePath, root, cdpPort) + await waitForBrowserCdpEndpoint(cdpPort) + browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`) + collector = await BrowserSessionUaCdpCollector.connect(cdpPort) + await collector.installAutoAttach() + writeFileSync(barrierPath, '') + const processResult = await waitForProcess(process) + const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result' + expect( + processResult.code, + `${fixtureResult}\n${processResult.stderr}\n${JSON.stringify({ diagnostics: collector.diagnostics, receipts: server.receipts, identities: server.identities })}` + ).toBe(0) + await new Promise((resolve) => setTimeout(resolve, 100)) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: JSON.parse is untyped; the fixture writes exactly this shape with JSON.stringify, and the assertions below fail loudly on a missing member. + const parsed = JSON.parse(fixtureResult) as Omit< + ProbeResult, + 'receipts' | 'identities' | 'cdpRequests' | 'cdpDiagnostics' + > + return { + ...parsed, + receipts: [...server.receipts], + identities: [...server.identities], + cdpDiagnostics: [...collector.diagnostics], + cdpRequests: collector + .snapshot() + .filter( + ({ url }) => url.startsWith(server.httpOrigin) || url.startsWith(server.httpsOrigin) + ) + } + } finally { + await collector?.close().catch(() => {}) + await browser?.close().catch(() => {}) + await server.close() + if (process && process.exitCode === null) { + process.kill('SIGTERM') + } + } } -async function runFixture(): Promise { - const root = mkdtempSync(join(tmpdir(), 'orca-wire-identity-')) - fixtureRoots.push(root) - const modulePath = join(root, 'browser-session-ua.cjs') - const resultPath = join(root, 'result.json') - const fixturePath = join(root, 'main.cjs') +async function buildModule(entry: string, outputPath: string): Promise { await buildVite({ configFile: false, logLevel: 'silent', build: { emptyOutDir: false, lib: { - entry: join(process.cwd(), 'src/main/browser/browser-session-ua.ts'), + entry: join(process.cwd(), entry), formats: ['cjs'], - fileName: () => 'browser-session-ua.cjs' + fileName: () => basename(outputPath) }, - outDir: root, + outDir: join(outputPath, '..'), target: 'node20', rollupOptions: { external: ['electron', /^node:/] } } }) - writeFileSync(fixturePath, buildFixtureMain(modulePath, resultPath)) +} + +function launchFixture(fixturePath: string, root: string, cdpPort: number): ChildProcess { const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env - const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary - for (let attempt = 1; ; attempt += 1) { - rmSync(resultPath, { force: true }) - // Why a fresh profile per attempt: a launch that never reached `ready` may have left the - // Chromium profile mid-initialization, and reusing it would bias the retry. - const electronArgs = [fixturePath, `--user-data-dir=${join(root, `profile-${attempt}`)}`] - const run = spawnSync( - executable, - process.platform === 'linux' - ? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox'] - : electronArgs, - { encoding: 'utf8', env, timeout: 60_000 } - ) - const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result' - if (attempt < FIXTURE_LAUNCH_ATTEMPTS && neverReachedElectronReady(fixtureResult)) { - continue + return spawn( + process.platform === 'linux' ? 'xvfb-run' : electronBinary, + process.platform === 'linux' + ? [ + '--auto-servernum', + electronBinary, + fixturePath, + `--user-data-dir=${join(root, 'profile')}`, + `--remote-debugging-port=${cdpPort}`, + '--no-sandbox' + ] + : [ + fixturePath, + `--user-data-dir=${join(root, 'profile')}`, + `--remote-debugging-port=${cdpPort}` + ], + { + env: { ...env, ORCA_BACKGROUND_LAUNCH: '1' }, + stdio: ['ignore', 'pipe', 'pipe'] } - expect(run.error).toBeUndefined() - expect(run.status, `${fixtureResult}\n${run.stdout}\n${run.stderr}`).toBe(0) - return JSON.parse(fixtureResult) as FixtureResult + ) +} + +function fixtureMain(options: { + arm: ProbeArm + barrierPath: string + exceptionModulePath: string + httpOrigin: string + processIdentityModulePath: string + resultPath: string +}): string { + return String.raw` +const { app, BrowserWindow, net, session } = require('electron') +const { existsSync, writeFileSync } = require('node:fs') +const processIdentity = require(${JSON.stringify(options.processIdentityModulePath)}) +const { cleanElectronUserAgent } = require(${JSON.stringify(options.exceptionModulePath)}) +const arm = ${JSON.stringify(options.arm)} +const startupMarks = [] +app.setName('OrcaWireIdentityFixture') +const preReadyNativeUserAgent = app.userAgentFallback +let identity +if (arm !== 'late-session-setter') { + identity = processIdentity.initializeBrowserProcessUserAgent(arm === 'native' ? 'native' : 'clean') + startupMarks.push('fallback') +} +const waitForBarrier = async () => { + const deadline = Date.now() + 15000 + while (!existsSync(${JSON.stringify(options.barrierPath)})) { + if (Date.now() >= deadline) throw new Error('startup barrier timeout') + await new Promise(resolve => setTimeout(resolve, 20)) } } - -function parseClientHintBrands(value: string): UserAgentBrand[] { - return [...value.matchAll(/"([^"]+)";v="([^"]+)"/g)].map((match) => ({ - brand: match[1], - version: match[2] +const requestWithoutUserAgent = (sess, url) => new Promise((resolve, reject) => { + const request = net.request({ session: sess, url }) + request.on('response', response => { response.on('data', () => {}); response.on('end', resolve) }) + request.on('error', reject) + request.end() +}) +async function run() { + const timeout = setTimeout(() => { writeFileSync(${JSON.stringify(options.resultPath)}, JSON.stringify({ error: 'timeout', startupMarks })); app.exit(2) }, 10000) + await app.whenReady() + startupMarks.push('ready') + app.setName('OrcaWireIdentityFixtureAfterReady') + const fallbackAfterReadyNameChange = app.userAgentFallback + await waitForBarrier() + const sess = session.fromPartition('persist:wire-identity-test') + startupMarks.push('session') + const rawUserAgent = arm === 'clean' ? preReadyNativeUserAgent : app.userAgentFallback + const cleanUserAgent = identity?.cleanUserAgent ?? cleanElectronUserAgent(rawUserAgent) + if (arm === 'late-session-setter') sess.setUserAgent(cleanUserAgent) + sess.setCertificateVerifyProc((_request, callback) => callback(0)) + const chromeVersion = cleanUserAgent.match(/Chrome\/([\d.]+)/)?.[1] || process.versions.chrome + const major = chromeVersion.split('.')[0] + const mobileUserAgent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/' + chromeVersion + ' Mobile/15E148 Safari/604.1' + let mainWebContentsId + if (arm === 'clean' || arm === 'mobile' || arm === 'mixed-mobile') { + sess.webRequest.onBeforeSendHeaders( + { urls: ['http://*/*', 'https://*/*', 'ws://*/*', 'wss://*/*'] }, + (details, callback) => { + const userAgentKey = Object.keys(details.requestHeaders).find( + key => key.toLowerCase() === 'user-agent' + ) || 'User-Agent' + if (arm !== 'mobile' && arm !== 'mixed-mobile') { + callback({ requestHeaders: details.requestHeaders }) + return + } + // Models the viewport-emulation rule: only the emulated target's own requests are rewritten. + // A worker request carries no webContentsId, so it keeps the session identity here — which is + // the identity the worker's own JavaScript reports. + if (details.webContentsId !== mainWebContentsId) { + callback({ requestHeaders: details.requestHeaders }) + return + } + details.requestHeaders[userAgentKey] = mobileUserAgent + callback({ requestHeaders: details.requestHeaders }) + } + ) + } + const windows = [] + const window = new BrowserWindow({ show: false, webPreferences: { partition: 'persist:wire-identity-test', sandbox: true } }) + windows.push(window) + startupMarks.push('webContents') + mainWebContentsId = window.webContents.id + const pageIdentity = arm === 'native' ? rawUserAgent : arm === 'mobile' || arm === 'mixed-mobile' ? mobileUserAgent : cleanUserAgent + if (arm === 'native' || arm === 'mobile' || arm === 'mixed-mobile') window.webContents.setUserAgent(pageIdentity) + window.webContents.setWindowOpenHandler(() => ({ + action: 'allow', + createWindow: options => { + const popup = new BrowserWindow({ ...options, show: false }) + popup.webContents.setUserAgent(arm === 'native' ? rawUserAgent : cleanUserAgent) + windows.push(popup) + return popup.webContents + } })) + await window.loadURL(${JSON.stringify(options.httpOrigin)} + '/') + const [navigatorUserAgent] = await Promise.all([ + window.webContents.executeJavaScript('navigator.userAgent'), + window.webContents.executeJavaScript('window.probePromise'), + requestWithoutUserAgent(sess, ${JSON.stringify(options.httpOrigin)} + '/no-header-fill') + ]) + if (arm === 'mixed-mobile') { + const peer = new BrowserWindow({ show: false, webPreferences: { partition: 'persist:wire-identity-test', sandbox: true } }) + windows.push(peer) + await peer.loadURL(${JSON.stringify(options.httpOrigin)} + '/desktop-peer') + await peer.webContents.executeJavaScript('window.peerProbePromise') + } + const defaultWindow = new BrowserWindow({ show: false, webPreferences: { sandbox: true } }) + windows.push(defaultWindow) + await defaultWindow.loadURL(${JSON.stringify(options.httpOrigin)} + '/default-window') + const appIsolatedWindow = new BrowserWindow({ show: false, webPreferences: { partition: 'persist:app-surface', sandbox: true } }) + windows.push(appIsolatedWindow) + await appIsolatedWindow.loadURL(${JSON.stringify(options.httpOrigin)} + '/isolated-window') + await Promise.all([ + requestWithoutUserAgent(session.defaultSession, ${JSON.stringify(options.httpOrigin)} + '/default-session-fill'), + requestWithoutUserAgent(session.fromPartition('persist:app-surface'), ${JSON.stringify(options.httpOrigin)} + '/isolated-session-fill') + ]) + await new Promise(resolve => setTimeout(resolve, 250)) + clearTimeout(timeout) + writeFileSync(${JSON.stringify(options.resultPath)}, JSON.stringify({ arm, rawUserAgent, cleanUserAgent, mobileUserAgent, sessionUserAgent: sess.getUserAgent(), navigatorUserAgent, fallbackAfterReadyNameChange, startupMarks })) + for (const candidate of windows) if (!candidate.isDestroyed()) candidate.destroy() + app.exit(0) +} +run().catch(error => { writeFileSync(${JSON.stringify(options.resultPath)}, JSON.stringify({ error: String(error?.stack || error), startupMarks })); app.exit(1) }) +` } -describe('browser session wire identity under Electron', () => { - it('strips the Electron and app tokens for ordinary hosts and sends Firefox to Google auth hosts', async () => { - const result = await runFixture() +function assertCoverage(result: ProbeResult): void { + const paths = new Set(result.receipts.map(({ path }) => path)) + for (const path of requiredPaths) { + expect( + paths, + `${result.arm} omitted ${path}: ${JSON.stringify(result.cdpDiagnostics)}` + ).toContain(path) + } + const cdpUrls = result.cdpRequests.map(({ url }) => new URL(url).pathname) + expect(cdpUrls).toContain('/blob-fetch') + expect(result.cdpDiagnostics.some((message) => message.includes('attached:shared_worker:'))).toBe( + true + ) + const expectedContexts = ['blob', 'document', 'frame', 'popup', 'service-worker', 'shared-worker'] + if (result.arm === 'mixed-mobile') { + expectedContexts.push('desktop-peer', 'shared-worker') + } + expect(result.identities.map(({ context }) => context).sort()).toEqual(expectedContexts.sort()) +} - // Presence precondition: the raw identity really does carry the tokens, so the absence - // assertions below cannot pass vacuously on an empty or already-clean UA. - expect(result.rawUserAgent).toMatch(/ Electron\/\d/) - expect(result.rawUserAgent).toMatch(/\(KHTML, like Gecko\) \S+ Chrome\//) +function identityViolations(result: ProbeResult): string[] { + return result.receipts + .filter(({ userAgent }) => userAgent !== result.cleanUserAgent) + .map(({ protocol, path }) => `${protocol}:${path}`) +} - // The whole point of STA-7147: nothing between the engine comment and Chrome/, and no - // Electron token anywhere — the shape a real Chrome sends. - expect(result.sessionUserAgent).not.toContain('Electron/') - expect(result.sessionUserAgent).toMatch(/\(KHTML, like Gecko\) Chrome\/[\d.]+ Safari\/537\.36$/) +function distinctUserAgents(records: readonly { userAgent: string | null }[]): (string | null)[] { + return [...new Set(records.map(({ userAgent }) => userAgent))].sort() +} - const ordinary = result.requests.find((request) => request.url.endsWith('/hints')) - expect(ordinary, JSON.stringify(result.requests)).toBeDefined() - expect(ordinary?.userAgent).toBe(result.sessionUserAgent) - expect(result.navigatorUserAgent).toBe(result.sessionUserAgent) - expect(result.navigatorUserAgentData).not.toBeNull() +function distinctJavaScriptUserAgents(records: readonly WireProbeJavaScriptIdentity[]): string[] { + return [...new Set(records.map(({ userAgent }) => userAgent))].sort() +} - // Chromium owns both client-hint surfaces. Rewriting only the request headers would make this - // comparison fail while leaving the legacy UA assertions above green. - const wireBrands = parseClientHintBrands(ordinary?.clientHints['sec-ch-ua'] ?? '') - expect(wireBrands).toEqual(result.navigatorUserAgentData?.brands) - expect(wireBrands.some(({ brand }) => /Electron|Orca/i.test(brand))).toBe(false) - const chromeMajor = result.sessionUserAgent.match(/Chrome\/(\d+)/)?.[1] - expect(wireBrands.find(({ brand }) => brand === 'Chromium')?.version).toBe(chromeMajor) +function receiptsForPaths( + receipts: readonly WireProbeReceipt[], + paths: readonly string[] +): WireProbeReceipt[] { + const selected = new Set(paths) + return receipts.filter(({ path }) => selected.has(path)) +} - const fullVersionList = ordinary?.clientHints['sec-ch-ua-full-version-list'] - if (fullVersionList) { - expect(parseClientHintBrands(fullVersionList)).toEqual( - result.navigatorUserAgentData?.highEntropy.fullVersionList - ) - } +function userAgentForPath(receipts: readonly WireProbeReceipt[], path: string): string | null { + const values = distinctUserAgents(receipts.filter((receipt) => receipt.path === path)) + expect(values, path).toHaveLength(1) + return values[0] ?? null +} - const auth = result.requests.find((request) => - request.url.startsWith('https://accounts.google.com/') - ) - expect(auth, JSON.stringify(result.requests)).toBeDefined() - expect(auth?.userAgent).toMatch(/Firefox\/\d/) - expect(auth?.userAgent).not.toContain('Chrome') - expect(auth?.clientHints).toEqual({}) +function identityForContext( + identities: readonly WireProbeJavaScriptIdentity[], + context: string +): WireProbeJavaScriptIdentity { + const matches = identities.filter((identity) => identity.context === context) + expect(matches, context).toHaveLength(1) + return matches[0]! +} + +async function reservePort(): Promise { + const server = createServer() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) }) -}) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('cdp port unavailable') + } + await new Promise((resolve) => server.close(() => resolve())) + return address.port +} + +function waitForProcess(process: ChildProcess): Promise<{ code: number | null; stderr: string }> { + let stderr = '' + process.stderr?.setEncoding('utf8') + process.stderr?.on('data', (chunk: string) => { + stderr += chunk + }) + return new Promise((resolve, reject) => { + process.once('error', reject) + process.once('exit', (code) => resolve({ code, stderr })) + }) +} diff --git a/src/main/browser/browser-session-ua-wire-probe-server.ts b/src/main/browser/browser-session-ua-wire-probe-server.ts new file mode 100644 index 00000000000..d00117ad86b --- /dev/null +++ b/src/main/browser/browser-session-ua-wire-probe-server.ts @@ -0,0 +1,309 @@ +import { createHash } from 'node:crypto' +import { + createServer as createHttpServer, + type IncomingMessage, + type ServerResponse +} from 'node:http' +import { createServer as createHttpsServer } from 'node:https' +import type { AddressInfo } from 'node:net' +import type { Duplex } from 'node:stream' +import { + LOCAL_HTTPS_TEST_CERTIFICATE, + LOCAL_HTTPS_TEST_PRIVATE_KEY +} from './browser-local-https-test-certificate' + +export type WireProbeReceipt = Readonly<{ + protocol: 'http' | 'https' | 'ws' | 'wss' + path: string + userAgent: string | null + clientHints: Readonly> +}> + +export type WireProbeJavaScriptIdentity = Readonly<{ + context: string + userAgent: string + userAgentData: unknown +}> + +export type BrowserSessionUaWireProbeServer = Readonly<{ + httpOrigin: string + crossSiteOrigin: string + httpsOrigin: string + receipts: WireProbeReceipt[] + identities: WireProbeJavaScriptIdentity[] + close: () => Promise +}> +function boundPort(server: { address: () => AddressInfo | string | null }): number { + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error('wire_probe_server_not_listening_on_tcp') + } + return address.port +} +export async function startBrowserSessionUaWireProbeServer(): Promise { + const receipts: WireProbeReceipt[] = [] + const identities: WireProbeJavaScriptIdentity[] = [] + const upgradedSockets = new Set() + let origins: { http: string; https: string } | null = null + const respond = + (protocol: 'http' | 'https') => + async (request: IncomingMessage, response: ServerResponse): Promise => { + const path = new URL(request.url ?? '/', 'http://probe.invalid').pathname + receipts.push({ + protocol, + path, + userAgent: + typeof request.headers['user-agent'] === 'string' ? request.headers['user-agent'] : null, + clientHints: requestClientHints(request) + }) + if (path.startsWith('/report/')) { + const body = await readBody(request) + identities.push({ context: path.slice('/report/'.length), ...JSON.parse(body) }) + respondText(response, 'ok') + return + } + if (path === '/shared-worker.js') { + respondScript(response, sharedWorkerScript(origins?.http ?? '')) + return + } + if (path === '/service-worker.js') { + response.setHeader('Service-Worker-Allowed', '/') + respondScript(response, serviceWorkerScript(origins?.http ?? '')) + return + } + if (path === '/frame') { + respondHtml(response, childPage('frame', origins?.http ?? '')) + return + } + if (path === '/cross-site-frame') { + respondHtml( + response, + childPage('cross-site-frame', origins?.http ?? '', false, origins?.https ?? '') + ) + return + } + if (path === '/dedicated-worker.js') { + respondScript(response, dedicatedWorkerScript(origins?.http ?? '')) + return + } + if (path === '/popup') { + respondHtml(response, childPage('popup', origins?.http ?? '', true)) + return + } + if (path === '/desktop-peer') { + respondHtml(response, desktopPeerPage(origins?.http ?? '')) + return + } + if (path.endsWith('-image')) { + response.writeHead(200, { 'Cache-Control': 'no-store', 'Content-Type': 'image/gif' }) + response.end(Buffer.from('R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=', 'base64')) + return + } + if (path === '/') { + respondHtml( + response, + probePage( + origins?.http ?? '', + origins?.https ?? '', + origins?.https ?? '', + new URL(request.url ?? '/', 'http://probe.invalid').searchParams.get( + 'cross-context' + ) === '1' + ) + ) + return + } + respondText(response, path) + } + const http = createHttpServer((request, response) => void respond('http')(request, response)) + const https = createHttpsServer( + { cert: LOCAL_HTTPS_TEST_CERTIFICATE, key: LOCAL_HTTPS_TEST_PRIVATE_KEY }, + (request, response) => void respond('https')(request, response) + ) + installWebSocketResponder(http, 'ws', receipts, upgradedSockets) + installWebSocketResponder(https, 'wss', receipts, upgradedSockets) + await Promise.all([listen(http), listen(https)]) + origins = { + http: `http://127.0.0.1:${boundPort(http)}`, + https: `https://127.0.0.1:${boundPort(https)}` + } + return { + httpOrigin: origins.http, + crossSiteOrigin: origins.https, + httpsOrigin: origins.https, + receipts, + identities, + close: async () => { + for (const socket of upgradedSockets) { + socket.destroy() + } + await Promise.all([closeServer(http), closeServer(https)]) + } + } +} +function probePage( + httpOrigin: string, + httpsOrigin: string, + crossSiteOrigin: string, + crossContext: boolean +): string { + const blobScript = contextScript('blob', httpOrigin, ['/blob-fetch', '/blob-xhr', '/blob-image']) + const blobDocument = `` + const serializedBlobDocument = JSON.stringify(blobDocument).replace('', '<\\/script>') + const crossSiteFrameScript = crossContext + ? `const crossSiteFrame = document.createElement('iframe'); crossSiteFrame.src = ${JSON.stringify(crossSiteOrigin)} + '/cross-site-frame'; document.body.append(crossSiteFrame)` + : '' + const dedicatedWorkerScriptText = crossContext + ? `const dedicatedDone = message('dedicated-worker'); const dedicated = new Worker(${JSON.stringify(httpOrigin)} + '/dedicated-worker.js'); dedicated.onmessage = event => postMessage(event.data, '*')` + : '' + return `UA wire probe` +} +function childPage( + context: string, + httpOrigin: string, + popup = false, + fetchOrigin = httpOrigin +): string { + const extra = popup ? `await fetch(${JSON.stringify(httpOrigin)} + '/popup-fetch')` : '' + const crossSiteFetch = + context === 'cross-site-frame' + ? `await fetch(${JSON.stringify(fetchOrigin)} + '/cross-site-frame-fetch')` + : '' + return `` +} + +function desktopPeerPage(httpOrigin: string): string { + return `` +} +function contextScript(context: string, httpOrigin: string, routes: string[]): string { + return `(async () => { const identity = { userAgent: navigator.userAgent, userAgentData: navigator.userAgentData ? { brands: navigator.userAgentData.brands, mobile: navigator.userAgentData.mobile, platform: navigator.userAgentData.platform } : null }; await fetch(${JSON.stringify(httpOrigin)} + '/report/${context}', { method: 'POST', body: JSON.stringify(identity) }); await fetch(${JSON.stringify(httpOrigin + routes[0])}); await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('GET', ${JSON.stringify(httpOrigin + routes[1])}); xhr.onload = resolve; xhr.onerror = reject; xhr.send() }); await new Promise((resolve, reject) => { const image = new Image(); image.onload = resolve; image.onerror = reject; image.src = ${JSON.stringify(httpOrigin + routes[2])} }); parent.postMessage({ context: '${context}' }, '*') })()` +} +function sharedWorkerScript(httpOrigin: string): string { + return `onconnect = event => { const port = event.ports[0]; (async () => { const identity = { userAgent: navigator.userAgent, userAgentData: navigator.userAgentData ? { brands: navigator.userAgentData.brands, mobile: navigator.userAgentData.mobile, platform: navigator.userAgentData.platform } : null }; await fetch(${JSON.stringify(httpOrigin)} + '/report/shared-worker', { method: 'POST', body: JSON.stringify(identity) }); await fetch(${JSON.stringify(httpOrigin)} + '/shared-worker-fetch-a'); await fetch(${JSON.stringify(httpOrigin)} + '/shared-worker-fetch-b'); port.postMessage({ context: 'shared-worker' }) })() }` +} +function dedicatedWorkerScript(httpOrigin: string): string { + return `const identity = { userAgent: navigator.userAgent, userAgentData: navigator.userAgentData ? { brands: navigator.userAgentData.brands, mobile: navigator.userAgentData.mobile, platform: navigator.userAgentData.platform } : null }; (async () => { await fetch(${JSON.stringify(httpOrigin)} + '/report/dedicated-worker', { method: 'POST', body: JSON.stringify(identity) }); await fetch(${JSON.stringify(httpOrigin)} + '/dedicated-worker-fetch'); postMessage({ context: 'dedicated-worker' }); })();` +} + +function serviceWorkerScript(httpOrigin: string): string { + return `addEventListener('install', event => event.waitUntil(skipWaiting())); addEventListener('activate', event => event.waitUntil(clients.claim())); addEventListener('message', event => { if (event.data !== 'probe') return; event.waitUntil((async () => { const identity = { userAgent: navigator.userAgent, userAgentData: navigator.userAgentData ? { brands: navigator.userAgentData.brands, mobile: navigator.userAgentData.mobile, platform: navigator.userAgentData.platform } : null }; await fetch(${JSON.stringify(httpOrigin)} + '/report/service-worker', { method: 'POST', body: JSON.stringify(identity) }); await fetch(${JSON.stringify(httpOrigin)} + '/service-worker-fetch'); event.source.postMessage({ context: 'service-worker' }) })()) })` +} + +function installWebSocketResponder( + server: ReturnType | ReturnType, + protocol: 'ws' | 'wss', + receipts: WireProbeReceipt[], + upgradedSockets: Set +): void { + server.on('upgrade', (request, socket) => { + upgradedSockets.add(socket) + socket.once('close', () => upgradedSockets.delete(socket)) + const key = request.headers['sec-websocket-key'] + receipts.push({ + protocol, + path: new URL(request.url ?? '/', 'http://probe.invalid').pathname, + userAgent: + typeof request.headers['user-agent'] === 'string' ? request.headers['user-agent'] : null, + clientHints: requestClientHints(request) + }) + if (typeof key !== 'string') { + socket.destroy() + return + } + const accept = createHash('sha1') + .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`) + .digest('base64') + socket.end( + `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n` + ) + }) +} + +function listen(server: ReturnType): Promise { + return new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve() + }) + }) +} + +function closeServer(server: ReturnType): Promise { + server.closeAllConnections() + return new Promise((resolve) => server.close(() => resolve())) +} + +function readBody(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + request.on('data', (chunk: Buffer) => chunks.push(chunk)) + request.once('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) + request.once('error', reject) + }) +} + +function respondText(response: ServerResponse, body: string): void { + response.writeHead(200, { 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-store' }) + response.end(body) +} + +function requestClientHints(request: IncomingMessage): Record { + return Object.fromEntries( + Object.entries(request.headers).flatMap(([key, value]) => + key.toLowerCase().startsWith('sec-ch-ua') && typeof value === 'string' + ? [[key.toLowerCase(), value]] + : [] + ) + ) +} + +function respondHtml(response: ServerResponse, body: string): void { + response.writeHead(200, { 'Cache-Control': 'no-store', 'Content-Type': 'text/html' }) + response.end(body) +} + +function respondScript(response: ServerResponse, body: string): void { + response.writeHead(200, { + 'Cache-Control': 'no-store', + 'Content-Type': 'application/javascript' + }) + response.end(body) +} diff --git a/src/main/browser/browser-session-ua.ts b/src/main/browser/browser-session-ua.ts index b7fd26449f5..5b4d4735dcd 100644 --- a/src/main/browser/browser-session-ua.ts +++ b/src/main/browser/browser-session-ua.ts @@ -1,56 +1,126 @@ import type { Session } from 'electron' +import type { ViewportUserAgentOverride } from './browser-viewport-user-agent' +export { cleanElectronUserAgent } from './browser-process-user-agent' +import { getBrowserProcessUserAgentIdentity } from './browser-process-user-agent' import { currentUserAgent, googleAuthUserAgent, - isGoogleAuthUrl, setUserAgentHeader, + shouldUseGoogleAuthIdentity, stripClientHints } from './browser-google-auth-ua' -// Why: Electron's default UA includes "Electron/X.X.X" and the app name -// (e.g. "orca/1.2.3"), an impossible identity for sessions imported from Chrome. -// This focused revocation fix strips only those tokens; it does not attempt full Chrome -// impersonation, and Chromium's client-hint identity remains browser-owned. -export function cleanElectronUserAgent(ua: string): string { - return ( - ua - .replace(/\s+Electron\/\S+/, '') - // Why: \S+ matches any non-whitespace token (e.g. "orca/1.3.8-rc.0") - // including pre-release semver strings that [\d.]+ would miss. - .replace(/(\)\s+)\S+\s+(Chrome\/)/, '$1$2') - ) +export type BrowserSessionRequestUserAgentResolver = (args: { + session: Session + url: string + referrer?: string + resourceType?: string + webContentsId?: number + currentUserAgent?: string + effectiveUserAgent?: string +}) => ViewportUserAgentOverride | undefined + +function quoteClientHint(value: string): string { + return `"${value.replace(/["\\]/g, '\\$&')}"` } -// Why: Chromium already publishes one internally consistent client-hint identity through both -// request headers and navigator.userAgentData. This handler only owns the host-scoped Firefox -// exception; synthesizing Chrome brands here would make those two browser-owned surfaces disagree. -export function setupGoogleAuthUserAgentOverride(sess: Session): void { +function formatClientHintBrands(brands: { brand: string; version: string }[]): string { + return brands + .map(({ brand, version }) => `${quoteClientHint(brand)};v=${quoteClientHint(version)}`) + .join(', ') +} + +function applyUserAgentMetadataHeaders( + headers: Record, + metadata: NonNullable +): void { + const values: Record = { + 'sec-ch-ua': formatClientHintBrands(metadata.brands), + 'sec-ch-ua-full-version-list': formatClientHintBrands(metadata.fullVersionList), + 'sec-ch-ua-full-version': quoteClientHint(metadata.fullVersion), + 'sec-ch-ua-platform': quoteClientHint(metadata.platform), + 'sec-ch-ua-platform-version': quoteClientHint(metadata.platformVersion), + 'sec-ch-ua-arch': quoteClientHint(metadata.architecture), + 'sec-ch-ua-model': quoteClientHint(metadata.model), + 'sec-ch-ua-mobile': metadata.mobile ? '?1' : '?0' + } + for (const key of Object.keys(headers)) { + const lowerKey = key.toLowerCase() + if (!lowerKey.startsWith('sec-ch-ua')) { + continue + } + const value = values[lowerKey] + if (value === undefined) { + delete headers[key] + } else { + headers[key] = value + } + } +} + +// Desktop client hints remain browser-owned. Mobile overrides carry the same metadata CDP used, +// so worker requests replace only hints Chromium already chose to emit without inventing them. +export function installBrowserSessionUserAgentPolicy( + sess: Session, + resolveRequestUserAgent?: BrowserSessionRequestUserAgentResolver +): () => void { const firefoxUa = googleAuthUserAgent() - - sess.webRequest.onBeforeSendHeaders({ urls: ['https://*/*'] }, (details, callback) => { - const headers = details.requestHeaders - if (isGoogleAuthUrl(details.url)) { - // Why: present a Firefox identity on Google's sign-in hosts so the user logs - // in inside the app and Google issues self-refreshing bound cookies. Strip - // sec-ch-ua* because real Firefox sends none. - setUserAgentHeader(headers, firefoxUa) - stripClientHints(headers) + sess.webRequest.onBeforeSendHeaders( + { urls: ['http://*/*', 'https://*/*', 'ws://*/*', 'wss://*/*'] }, + (details, callback) => { + const headers = details.requestHeaders + const requestUserAgent = currentUserAgent(headers) + let effectiveUserAgent: string | undefined + try { + effectiveUserAgent = details.webContents?.getUserAgent() + } catch { + // The request can race guest teardown; the header and manager state still provide a fallback. + } + // Firefox is delivered per-target and cannot reach workers; keep it clean-only to preserve one + // coherent identity per mode instead of pairing a Firefox document with native workers. + if ( + getBrowserProcessUserAgentIdentity().mode === 'clean' && + shouldUseGoogleAuthIdentity(details.url, details.referrer ?? '', details.resourceType ?? '') + ) { + setUserAgentHeader(headers, firefoxUa) + stripClientHints(headers) + callback({ requestHeaders: headers }) + return + } + const identity = resolveRequestUserAgent?.({ + session: sess, + url: details.url, + referrer: details.referrer, + resourceType: details.resourceType, + webContentsId: details.webContentsId, + currentUserAgent: requestUserAgent, + effectiveUserAgent + }) + if (!identity) { + callback({ requestHeaders: headers }) + return + } + if (identity.userAgent) { + setUserAgentHeader(headers, identity.userAgent) + } + if (identity.userAgent === firefoxUa) { + stripClientHints(headers) + callback({ requestHeaders: headers }) + return + } + if (identity.userAgentMetadata) { + applyUserAgentMetadataHeaders(headers, identity.userAgentMetadata) + } callback({ requestHeaders: headers }) + } + ) + let disposed = false + return (): void => { + if (disposed) { return } - if (currentUserAgent(headers) === firefoxUa) { - // Why: while the auth document is on screen the WebContents UA is Firefox, - // so its cross-host subresource/XHR requests (gstatic, play.google.com, the - // sign-in challenge endpoints) reach here carrying the Firefox UA yet still - // bearing Chromium client hints. Rewriting those to Chrome pairs a Firefox - // UA with Chrome hints — a sharper cross-host identity tell than either - // alone, which can stall Google's password-submit challenge. Real Firefox - // sends no client hints, so strip them to keep one identity for the flow. - stripClientHints(headers) - callback({ requestHeaders: headers }) - return - } - callback({ requestHeaders: headers }) - }) + disposed = true + sess.webRequest.onBeforeSendHeaders(null) + } } diff --git a/src/main/browser/browser-session-user-agent-migration-inspection.test.ts b/src/main/browser/browser-session-user-agent-migration-inspection.test.ts new file mode 100644 index 00000000000..73fc76c446e --- /dev/null +++ b/src/main/browser/browser-session-user-agent-migration-inspection.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { getOrcaProfileBrowserSessionPartition } from '../../shared/orca-profiles' +import { inspectRetiredBrowserSessionProfileUserAgentModes } from './browser-session-persisted-profile-validation' + +const ORCA_PROFILE_ID = 'local-default' +const PROFILE_ID = '11111111-1111-4111-8111-111111111111' + +function profileWithMode(mode: unknown): Record { + return { + id: PROFILE_ID, + scope: 'isolated', + partition: getOrcaProfileBrowserSessionPartition(ORCA_PROFILE_ID, PROFILE_ID), + label: 'Existing', + source: null, + userAgentMode: mode + } +} + +/** Fails `isValidPersistedBrowserSessionProfile` on its id, for reasons unrelated to identity. */ +function unhydratableProfile(extra: Record = {}): Record { + return { + id: 'not-a-uuid', + scope: 'isolated', + partition: 'persist:orca-browser-session-not-a-uuid', + label: 'Unhydratable', + source: null, + ...extra + } +} + +describe('retired browser profile identity inspection', () => { + it('detects an inspectable old choice without removing its bytes', () => { + const profile = profileWithMode('native') + + expect(inspectRetiredBrowserSessionProfileUserAgentModes([profile], ORCA_PROFILE_ID)).toEqual({ + noticePending: true, + degraded: false + }) + expect(profile.userAgentMode).toBe('native') + }) + + // "I refuse to hydrate this" is not "a retired identity choice was found". hydrateFromPersisted + // skips these entries silently, and the notice text claims an old choice could not be inspected — + // which would be a lie about a profile that never carried one, repeated on every launch. + it.each([ + { scenario: 'null', entry: null }, + { scenario: 'a number', entry: 42 }, + { scenario: 'a string', entry: 'broken' }, + { + scenario: 'a profile that fails validation for an unrelated reason', + entry: unhydratableProfile() + } + ])('stays silent about $scenario, which carries no identity choice', ({ entry }) => { + expect(inspectRetiredBrowserSessionProfileUserAgentModes([entry], ORCA_PROFILE_ID)).toEqual({ + noticePending: false, + degraded: false + }) + }) + + it.each([ + { scenario: 'an unreadable mode', entry: profileWithMode('unexpected') }, + { + scenario: 'a mode on an entry that cannot be hydrated', + entry: unhydratableProfile({ userAgentMode: 'native' }) + } + ])('turns $scenario into a degraded notice without throwing', ({ entry }) => { + expect(() => + inspectRetiredBrowserSessionProfileUserAgentModes([entry], ORCA_PROFILE_ID) + ).not.toThrow() + expect(inspectRetiredBrowserSessionProfileUserAgentModes([entry], ORCA_PROFILE_ID)).toEqual({ + noticePending: true, + degraded: true + }) + }) +}) diff --git a/src/main/browser/browser-session-user-agent-mode.ts b/src/main/browser/browser-session-user-agent-mode.ts deleted file mode 100644 index 94051d12ff7..00000000000 --- a/src/main/browser/browser-session-user-agent-mode.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Session } from 'electron' - -import type { BrowserSessionUserAgentMode } from '../../shared/browser-workspace-types' - -const userAgentModeBySession = new WeakMap() - -export function setBrowserSessionUserAgentMode( - session: Session, - mode: BrowserSessionUserAgentMode -): void { - userAgentModeBySession.set(session, mode) -} - -export function getBrowserSessionUserAgentMode( - session: Session -): BrowserSessionUserAgentMode | undefined { - return userAgentModeBySession.get(session) -} - -export function clearBrowserSessionUserAgentMode(session: Session): void { - userAgentModeBySession.delete(session) -} diff --git a/src/main/browser/browser-viewport-user-agent.ts b/src/main/browser/browser-viewport-user-agent.ts index 691ed0b88a8..31c06307b46 100644 --- a/src/main/browser/browser-viewport-user-agent.ts +++ b/src/main/browser/browser-viewport-user-agent.ts @@ -37,8 +37,9 @@ export function buildViewportUserAgentOverride(args: { url: string mobile: boolean baseUserAgent: string + googleAuthEnabled?: boolean }): ViewportUserAgentOverride { - if (isGoogleAuthUrl(args.url)) { + if (args.googleAuthEnabled !== false && isGoogleAuthUrl(args.url)) { // Why: match the header-level Firefox switch exactly, and send no userAgentMetadata — real // Firefox emits no client hints, so Chrome brands here would contradict the stripped headers. return { userAgent: googleAuthUserAgent() } diff --git a/src/main/browser/browser-webauthn-profile-delete.test.ts b/src/main/browser/browser-webauthn-profile-delete.test.ts index 3c471fe2dc2..18e2d58de5c 100644 --- a/src/main/browser/browser-webauthn-profile-delete.test.ts +++ b/src/main/browser/browser-webauthn-profile-delete.test.ts @@ -33,6 +33,10 @@ vi.mock('./browser-manager', () => ({ } })) +vi.mock('./browser-process-user-agent', () => ({ + getBrowserProcessUserAgentIdentity: () => ({ mode: 'clean', userAgent: 'Mozilla/5.0 Test' }) +})) + import { browserSessionRegistry } from './browser-session-registry' import { cancelAllBrowserWebAuthnAccountRequests, @@ -42,6 +46,7 @@ import { type MockSession = Electron.Session & EventEmitter function mockSession(): MockSession { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this focused Electron Session double implements every member the exercised policy and WebAuthn paths read. return Object.assign(new EventEmitter(), { clearCache: vi.fn().mockResolvedValue(undefined), clearStorageData: vi.fn().mockResolvedValue(undefined), @@ -49,6 +54,7 @@ function mockSession(): MockSession { setDisplayMediaRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), setPermissionRequestHandler: vi.fn(), + setUserAgent: vi.fn(), webRequest: { onBeforeSendHeaders: vi.fn() } }) as unknown as MockSession } diff --git a/src/main/browser/doc-preview-protocol.test.ts b/src/main/browser/doc-preview-protocol.test.ts index 32700be32fd..bce25768960 100644 --- a/src/main/browser/doc-preview-protocol.test.ts +++ b/src/main/browser/doc-preview-protocol.test.ts @@ -267,7 +267,7 @@ describe('installDocPreviewProtocolHandler', () => { installDocPreviewProtocolHandler() expect(mocks.installBrowserSessionPartitionPolicies).toHaveBeenCalledWith( - expect.objectContaining({ partition: 'orca-doc-preview', userAgentMode: 'clean' }), + expect.objectContaining({ partition: 'orca-doc-preview' }), expect.anything() ) }) diff --git a/src/main/browser/doc-preview-protocol.ts b/src/main/browser/doc-preview-protocol.ts index fd60817ff36..8ea26fe7aa9 100644 --- a/src/main/browser/doc-preview-protocol.ts +++ b/src/main/browser/doc-preview-protocol.ts @@ -134,8 +134,7 @@ export function installDocPreviewProtocolHandler(): void { scope: 'isolated', partition: DOC_PREVIEW_PARTITION, label: 'Document preview', - source: null, - userAgentMode: 'clean' + source: null }, // Why downloads are the one policy that does not carry over: the browser download flow needs a // page to attribute the file to, and a previewed document is not one. Routed here it would diff --git a/src/main/browser/local-ssh-browser-partitions.ts b/src/main/browser/local-ssh-browser-partitions.ts index 85be4aa4105..a314cc12fa7 100644 --- a/src/main/browser/local-ssh-browser-partitions.ts +++ b/src/main/browser/local-ssh-browser-partitions.ts @@ -155,9 +155,8 @@ async function prepareFresh(input: { proxyEndpoint, dependencies: { getSession: (partition) => session.fromPartition(partition), - setupPolicies: ({ partition, browserProfileId }) => { - browserSessionRegistry.setupRoutePartitionPolicies(partition, browserProfileId) - }, + setupPolicies: ({ partition, browserProfileId }) => + browserSessionRegistry.setupRoutePartitionPolicies(partition, browserProfileId), clearPolicies: ({ partition }) => { browserSessionRegistry.clearRoutePartitionPolicies(partition) } diff --git a/src/main/browser/offscreen-browser-backend.ts b/src/main/browser/offscreen-browser-backend.ts index 300a01e0b76..039ed02b4bf 100644 --- a/src/main/browser/offscreen-browser-backend.ts +++ b/src/main/browser/offscreen-browser-backend.ts @@ -76,7 +76,6 @@ export class OffscreenBrowserBackend implements BrowserBackend { browserPageId, worktreeId: params.worktreeId, sessionProfileId: profile?.id ?? null, - userAgentMode: profile?.userAgentMode, webContentsId: win.webContents.id }) if (!registered) { diff --git a/src/main/global-fetch-call-site-audit.test.ts b/src/main/global-fetch-call-site-audit.test.ts index a16171ea10c..4db34a40fda 100644 --- a/src/main/global-fetch-call-site-audit.test.ts +++ b/src/main/global-fetch-call-site-audit.test.ts @@ -42,6 +42,12 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map([ ['main/browser/browser-route-h3-egress-electron-main.ts', 1], ['main/browser/browser-route-persisted-worker-fixture.ts', 3], ['main/browser/browser-route-tcp-egress-fixture.ts', 1], + // Electron-test rig: the CDP poll cancels its unread body and the version probe consumes + // the body through response.json(), so neither leaves an unread undici response. + ['main/browser/browser-session-ua-cdp-collector.ts', 2], + // Every hit is inside an injected page/worker script source string, not a call this + // process makes. + ['main/browser/browser-session-ua-wire-probe-server.ts', 10], ['main/opencode/status-plugin-post-source.ts', 1], ['main/pi/agent-status-extension-source.ts', 1], // local identifiers named `fetch` (git fetch), not HTTP diff --git a/src/main/ipc/browser-preview-tool-authorization.test.ts b/src/main/ipc/browser-preview-tool-authorization.test.ts index 58d72ded647..ad7c569b72b 100644 --- a/src/main/ipc/browser-preview-tool-authorization.test.ts +++ b/src/main/ipc/browser-preview-tool-authorization.test.ts @@ -141,7 +141,10 @@ const BROWSER_PAGE_CHANNELS = [ 'browser:session:clientRouteImportSources', 'browser:session:detectBrowsers', 'browser:session:detectBrowsersForClientHost', - 'browser:session:importFromBrowser' + 'browser:session:importFromBrowser', + // Process-wide identity: reads/writes the host's own user-agent choice, never a viewed guest. + 'browser:identity:get', + 'browser:identity:set' ] type Handler = (event: { sender: Electron.WebContents }, args: unknown) => unknown diff --git a/src/main/ipc/browser-session-profile-ipc.test.ts b/src/main/ipc/browser-session-profile-ipc.test.ts index 181241cdd52..0ae2a9f5181 100644 --- a/src/main/ipc/browser-session-profile-ipc.test.ts +++ b/src/main/ipc/browser-session-profile-ipc.test.ts @@ -1,13 +1,25 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { handleMock, removeHandlerMock, createProfileMock, routeIdentityMock, detectBrowsersMock } = - vi.hoisted(() => ({ - handleMock: vi.fn(), - removeHandlerMock: vi.fn(), - createProfileMock: vi.fn(), - routeIdentityMock: vi.fn(), - detectBrowsersMock: vi.fn(() => []) - })) +const { + handleMock, + removeHandlerMock, + createProfileMock, + routeIdentityMock, + detectBrowsersMock, + setBrowserIdentityModeMock +} = vi.hoisted(() => ({ + handleMock: vi.fn(), + removeHandlerMock: vi.fn(), + createProfileMock: vi.fn(), + routeIdentityMock: vi.fn(), + detectBrowsersMock: vi.fn(() => []), + setBrowserIdentityModeMock: vi.fn(async () => ({ ok: true })) +})) + +vi.mock('../browser/browser-identity-mode-store', () => ({ + setBrowserIdentityMode: setBrowserIdentityModeMock, + getBrowserIdentityModeStatus: vi.fn(() => ({ identity: {}, migrationNotice: null })) +})) vi.mock('electron', () => ({ BrowserWindow: { fromWebContents: vi.fn() }, @@ -51,6 +63,8 @@ describe('browser session profile IPC', () => { routeIdentityMock.mockReset() detectBrowsersMock.mockReset() detectBrowsersMock.mockReturnValue([]) + setBrowserIdentityModeMock.mockReset() + setBrowserIdentityModeMock.mockResolvedValue({ ok: true }) setTrustedBrowserRendererWebContentsId(null) }) @@ -63,6 +77,33 @@ describe('browser session profile IPC', () => { } as Electron.WebContents } + function identitySetHandler(): ( + event: { sender: Electron.WebContents }, + mode: unknown + ) => Promise { + registerBrowserHandlers() + return handleMock.mock.calls.find(([channel]) => channel === 'browser:identity:set')?.[1] + } + + // Why reject rather than coerce: the RPC door validates mode against z.enum(['clean','native']) + // and rejects. Coercing an unrecognized value to 'clean' here made one concept answer an unknown + // value two different ways, and reported success for a mode that was quietly replaced. + it('refuses an unrecognized identity mode instead of silently selecting Cleaned', async () => { + setTrustedBrowserRendererWebContentsId(91) + const handler = identitySetHandler() + + await expect(handler({ sender: trustedSender() }, 'rotating')).rejects.toThrow(/rotating/) + expect(setBrowserIdentityModeMock).not.toHaveBeenCalled() + }) + + it('commits a recognized identity mode unchanged', async () => { + setTrustedBrowserRendererWebContentsId(91) + const handler = identitySetHandler() + + await expect(handler({ sender: trustedSender() }, 'native')).resolves.toEqual({ ok: true }) + expect(setBrowserIdentityModeMock).toHaveBeenCalledWith('native') + }) + function clientHostDetectHandler(): ( event: { sender: Electron.WebContents }, args: { environmentId: string } @@ -111,22 +152,22 @@ describe('browser session profile IPC', () => { expect(detectBrowsersMock).not.toHaveBeenCalled() }) - it('forwards the user-agent mode from a trusted renderer', async () => { + it('creates a profile for a trusted renderer', async () => { const profile = { id: 'profile-google', scope: 'isolated', partition: 'persist:orca-browser-session-profile-google', label: 'Google', - source: null, - userAgentMode: 'native' + source: null } createProfileMock.mockReturnValue(profile) registerBrowserHandlers() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registered test handler is selected by its exact channel and called with its declared boundary shape. const createHandler = handleMock.mock.calls.find( ([channel]) => channel === 'browser:session:createProfile' )?.[1] as ( event: { sender: Electron.WebContents }, - args: { scope: 'isolated'; label: string; userAgentMode: 'native' } + args: { scope: 'isolated'; label: string } ) => unknown const sender = { id: 91, @@ -136,10 +177,8 @@ describe('browser session profile IPC', () => { } as Electron.WebContents await expect( - createHandler({ sender }, { scope: 'isolated', label: 'Google', userAgentMode: 'native' }) + createHandler({ sender }, { scope: 'isolated', label: 'Google' }) ).resolves.toEqual(profile) - expect(createProfileMock).toHaveBeenCalledWith('isolated', 'Google', { - userAgentMode: 'native' - }) + expect(createProfileMock).toHaveBeenCalledWith('isolated', 'Google') }) }) diff --git a/src/main/ipc/browser-session-profile-ipc.ts b/src/main/ipc/browser-session-profile-ipc.ts index c4e0da963ca..0d05a079920 100644 --- a/src/main/ipc/browser-session-profile-ipc.ts +++ b/src/main/ipc/browser-session-profile-ipc.ts @@ -14,14 +14,19 @@ import { import type { BrowserCookieImportResult, BrowserSessionProfile, - BrowserSessionProfileCreateOptions, BrowserSessionProfileScope } from '../../shared/browser-workspace-types' +import { + getBrowserIdentityModeStatus, + setBrowserIdentityMode +} from '../browser/browser-identity-mode-store' export function registerBrowserSessionProfileHandlers(): void { ipcMain.removeHandler('browser:session:listProfiles') ipcMain.removeHandler('browser:session:createProfile') ipcMain.removeHandler('browser:session:deleteProfile') + ipcMain.removeHandler('browser:identity:get') + ipcMain.removeHandler('browser:identity:set') ipcMain.removeHandler('browser:session:importCookies') ipcMain.removeHandler('browser:session:resolvePartition') @@ -36,20 +41,36 @@ export function registerBrowserSessionProfileHandlers(): void { 'browser:session:createProfile', async ( event, - args: { - scope: BrowserSessionProfileScope - label: string - } & BrowserSessionProfileCreateOptions + args: { scope: BrowserSessionProfileScope; label: string } ): Promise => { if (!isTrustedBrowserRenderer(event.sender)) { return null } - return await browserSessionRegistry.createProfile(args.scope, args.label, { - userAgentMode: args.userAgentMode - }) + return await browserSessionRegistry.createProfile(args.scope, args.label) } ) + ipcMain.handle('browser:identity:get', (event) => { + if (!isTrustedBrowserRenderer(event.sender)) { + return null + } + return getBrowserIdentityModeStatus() + }) + + ipcMain.handle('browser:identity:set', async (event, mode: unknown) => { + if (!isTrustedBrowserRenderer(event.sender)) { + return null + } + // Why reject rather than coerce: the RPC door validates against z.enum(['clean', 'native']) + // and rejects. Coercing an unrecognized value to 'clean' made one concept answer an unknown + // value two different ways, and reported success for a mode that was quietly replaced — + // silently downgrading a future mode name the caller believed was honoured. + if (mode !== 'clean' && mode !== 'native') { + throw new Error(`Unsupported browser identity mode: ${String(mode)}`) + } + return setBrowserIdentityMode(mode) + }) + ipcMain.handle( 'browser:session:deleteProfile', async (event, args: { profileId: string }): Promise => { diff --git a/src/main/ipc/browser.ts b/src/main/ipc/browser.ts index d9841cf3bd1..76e502d969e 100644 --- a/src/main/ipc/browser.ts +++ b/src/main/ipc/browser.ts @@ -1,7 +1,6 @@ import { ipcMain, webContents } from 'electron' import { browserCertificateTrustController, browserManager } from '../browser/browser-manager' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' -import { browserSessionRegistry } from '../browser/browser-session-registry' import { isWorkspaceDocPageId } from '../browser/doc-preview-guest-policy' import { isTrustedBrowserRenderer } from './browser-renderer-trust' import { @@ -80,10 +79,8 @@ export function registerBrowserHandlers(): void { // with a new webContentsId. The bridge must destroy the old session's // proxy (its webContents is gone) and let the next command recreate it. const previousWcId = browserManager.getGuestWebContentsId(args.browserPageId) - const profile = browserSessionRegistry.getProfile(args.sessionProfileId ?? 'default') const registered = browserManager.registerGuest({ ...args, - userAgentMode: profile?.userAgentMode, rendererWebContentsId: event.sender.id }) if (!registered) { diff --git a/src/main/runtime/orca-runtime-browser.test.ts b/src/main/runtime/orca-runtime-browser.test.ts index 64b67ac9c95..8996c031f75 100644 --- a/src/main/runtime/orca-runtime-browser.test.ts +++ b/src/main/runtime/orca-runtime-browser.test.ts @@ -138,29 +138,22 @@ describe('RuntimeBrowserCommands browser screencast', () => { browserSessionRegistryMock.createProfile.mockReset() }) - it('creates profiles with the requested user-agent mode', async () => { + it('creates profiles with the requested scope and label', async () => { const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') const profile = { id: 'profile-google', scope: 'isolated', partition: 'persist:orca-browser-session-profile-google', label: 'Google', - source: null, - userAgentMode: 'native' + source: null } browserSessionRegistryMock.createProfile.mockReturnValue(profile) const commands = new RuntimeBrowserCommands(createHost()) await expect( - commands.browserProfileCreate({ - label: 'Google', - scope: 'isolated', - userAgentMode: 'native' - }) + commands.browserProfileCreate({ label: 'Google', scope: 'isolated' }) ).resolves.toEqual({ profile }) - expect(browserSessionRegistryMock.createProfile).toHaveBeenCalledWith('isolated', 'Google', { - userAgentMode: 'native' - }) + expect(browserSessionRegistryMock.createProfile).toHaveBeenCalledWith('isolated', 'Google') }) it('waits for explicit worktree browser registration after requesting a hidden mount', async () => { diff --git a/src/main/runtime/orca-runtime-get-status.ts b/src/main/runtime/orca-runtime-get-status.ts index d177c8fe64d..bf7d3818045 100644 --- a/src/main/runtime/orca-runtime-get-status.ts +++ b/src/main/runtime/orca-runtime-get-status.ts @@ -4,10 +4,12 @@ import { runtimeBrowserCommandsFactoryIsHeadless, runtimeBrowserUnavailableCause } from './runtime-browser-commands-factory' +import { isBrowserIdentityModeStoreInitialized } from '../browser/browser-identity-mode-store' import type { RuntimeCapability } from '../../shared/protocol-version' import { BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY, BROWSER_HEADLESS_RUNTIME_CAPABILITY, + BROWSER_IDENTITY_RUNTIME_CAPABILITY, MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY, RUNTIME_CAPABILITIES, @@ -75,6 +77,12 @@ export class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntimeId { if (hasOffscreen || hasHeadlessCommands) { capabilities.push(BROWSER_HEADLESS_RUNTIME_CAPABILITY) } + // Why not a static capability: the identity is this host's own process-wide choice, fixed + // before ready. A host that never initialized the store has no identity to report or change, + // so advertising it would point clients at a method that can only throw. + if (isBrowserIdentityModeStoreInitialized()) { + capabilities.push(BROWSER_IDENTITY_RUNTIME_CAPABILITY) + } // Why: certificate proceed is owned by the browser-hosting process for both // desktop webviews and offscreen pages. Advertise whenever either backend // can host a page so remote clients can surface Proceed Anyway (Unsafe). diff --git a/src/main/runtime/orca-runtime-tests/browser-capabilities.spec.ts b/src/main/runtime/orca-runtime-tests/browser-capabilities.spec.ts index 6b09d1263b1..ffe98483585 100644 --- a/src/main/runtime/orca-runtime-tests/browser-capabilities.spec.ts +++ b/src/main/runtime/orca-runtime-tests/browser-capabilities.spec.ts @@ -23,8 +23,29 @@ import { attachClientBrowserHost, publishClientHostedPage } from '../orca-runtime-test-scenario-builders.spec' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + initializeBrowserIdentityModeStore, + resetBrowserIdentityModeStoreForTests +} from '../../browser/browser-identity-mode-store' describe('OrcaRuntimeService', () => { + // The mixed-version guarantee: a host that never initialized the identity store must not + // advertise a method that can only throw there. + it('advertises the browser identity capability only where an identity store exists', () => { + resetBrowserIdentityModeStoreForTests() + expect(createRuntime().getStatus().capabilities).not.toContain('browser.identity.v1') + + initializeBrowserIdentityModeStore(mkdtempSync(join(tmpdir(), 'orca-identity-capability-'))) + try { + expect(createRuntime().getStatus().capabilities).toContain('browser.identity.v1') + } finally { + resetBrowserIdentityModeStoreForTests() + } + }) + it('advertises headless browser capability when an offscreen backend backs a windowless host', () => { const runtime = createRuntime() runtime.setOffscreenBrowserBackend({ createTab: vi.fn(), closeTab: vi.fn() }) diff --git a/src/main/runtime/rpc/methods/browser-identity-rpc.test.ts b/src/main/runtime/rpc/methods/browser-identity-rpc.test.ts new file mode 100644 index 00000000000..c10abb06bb4 --- /dev/null +++ b/src/main/runtime/rpc/methods/browser-identity-rpc.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + get: vi.fn(() => ({ identity: { state: 'missing' }, migrationNotice: null })), + set: vi.fn(async () => ({ ok: true })) +})) + +vi.mock('../../../browser/browser-identity-mode-store', () => ({ + getBrowserIdentityModeStatus: mocks.get, + setBrowserIdentityMode: mocks.set +})) + +import type { OrcaRuntimeService } from '../../orca-runtime' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import { BROWSER_IDENTITY_METHODS } from './browser-identity-rpc' + +function request(method: string, params?: unknown): RpcRequest { + return { id: 'identity-1', authToken: 'token', method, params } +} + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the identity handlers read no runtime member; only the reply envelope needs getRuntimeId. +const RUNTIME = { getRuntimeId: () => 'runtime-1' } as unknown as OrcaRuntimeService + +function identityDispatcher(): RpcDispatcher { + return new RpcDispatcher({ runtime: RUNTIME, methods: BROWSER_IDENTITY_METHODS }) +} + +describe('browser identity RPC', () => { + it('serves the host-local identity snapshot', async () => { + const response = await identityDispatcher().dispatch(request('browser.identity.get')) + + expect(response).toMatchObject({ ok: true, result: { migrationNotice: null } }) + expect(mocks.get).toHaveBeenCalledTimes(1) + }) + + it('commits a host-local identity selection', async () => { + await identityDispatcher().dispatch(request('browser.identity.set', { mode: 'native' })) + + expect(mocks.set).toHaveBeenCalledWith('native', { reset: undefined }) + }) + + it('forwards an explicit reset request to the single writer', async () => { + await identityDispatcher().dispatch( + request('browser.identity.set', { mode: 'clean', reset: true }) + ) + + expect(mocks.set).toHaveBeenCalledWith('clean', { reset: true }) + }) +}) diff --git a/src/main/runtime/rpc/methods/browser-identity-rpc.ts b/src/main/runtime/rpc/methods/browser-identity-rpc.ts new file mode 100644 index 00000000000..55fbd8d1e6b --- /dev/null +++ b/src/main/runtime/rpc/methods/browser-identity-rpc.ts @@ -0,0 +1,21 @@ +import { defineMethod } from '../core' +import { BrowserIdentitySet } from './browser-schemas' +import { + getBrowserIdentityModeStatus, + setBrowserIdentityMode +} from '../../../browser/browser-identity-mode-store' + +// Why separate from browser-core: these read and write this host's own process identity rather +// than driving a page, so they take no BrowserTarget and never reach the runtime browser commands. +export const BROWSER_IDENTITY_METHODS = [ + defineMethod({ + name: 'browser.identity.get', + params: null, + handler: () => getBrowserIdentityModeStatus() + }), + defineMethod({ + name: 'browser.identity.set', + params: BrowserIdentitySet, + handler: async ({ mode, reset }) => setBrowserIdentityMode(mode, { reset }) + }) +] as const diff --git a/src/main/runtime/rpc/methods/browser-schemas.ts b/src/main/runtime/rpc/methods/browser-schemas.ts index 034fee898ad..95beade5c5a 100644 --- a/src/main/runtime/rpc/methods/browser-schemas.ts +++ b/src/main/runtime/rpc/methods/browser-schemas.ts @@ -26,7 +26,6 @@ export { MouseButton, MouseWheel, MouseXY, - ProfileCreate, ProfileDelete, ProfileImportFromBrowser, Screencast, @@ -53,3 +52,7 @@ export { Viewport, Wait } from '../../../../shared/rpc-contract/browser-params' +export { + BrowserIdentitySet, + ProfileCreate +} from '../../../../shared/rpc-contract/browser-identity-params' diff --git a/src/main/runtime/rpc/methods/browser.test.ts b/src/main/runtime/rpc/methods/browser.test.ts index cb104565c3d..b5df3a6616b 100644 --- a/src/main/runtime/rpc/methods/browser.test.ts +++ b/src/main/runtime/rpc/methods/browser.test.ts @@ -68,16 +68,36 @@ describe('browser RPC methods', () => { }) }) - it('validates profile user-agent modes', () => { - expect( - ProfileCreate.safeParse({ label: 'Google', scope: 'isolated', userAgentMode: 'native' }) - .success - ).toBe(true) - expect(ProfileCreate.safeParse({ label: 'Work', scope: 'isolated' }).success).toBe(true) - expect( - ProfileCreate.safeParse({ label: 'Bad', scope: 'isolated', userAgentMode: 'rotating' }) - .success - ).toBe(false) + it('rejects the retired profile user-agent field with changed-semantics guidance', () => { + expect(() => + ProfileCreate.parse({ label: 'Google', scope: 'isolated', userAgentMode: 'native' }) + ).toThrow('browser_profile_user_agent_mode_is_now_app_wide') + }) + + // The schema check above proves the shape; this proves an older client actually gets the + // rejection over the wire instead of a success with the field quietly dropped. + it('rejects the retired profile user-agent field through the dispatcher', async () => { + const browserProfileCreate = vi.fn().mockResolvedValue({ id: 'profile-1' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dispatcher reads only getRuntimeId and the single browser method stubbed here. + const runtime = { + getRuntimeId: () => 'test-runtime', + browserProfileCreate + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: BROWSER_CORE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('browser.profileCreate', { + label: 'Google', + scope: 'isolated', + userAgentMode: 'native' + }) + ) + + // Why a working runtime stub: if the field were accepted and stripped again the call would + // succeed, so every assertion below is load-bearing rather than passing on a missing method. + expect(response).toMatchObject({ ok: false }) + expect(JSON.stringify(response)).toContain('browser_profile_user_agent_mode_is_now_app_wide') + expect(browserProfileCreate).not.toHaveBeenCalled() }) it('routes core browser automation commands to the runtime server', async () => { diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 3be913d7ca5..a247c8bbe41 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -6,6 +6,7 @@ import { WORKTREE_METHODS } from './worktree' import { TERMINAL_METHODS } from './terminal' import { TERMINAL_ORPHAN_METHODS } from './terminal-orphan' import { BROWSER_CORE_METHODS } from './browser-core' +import { BROWSER_IDENTITY_METHODS } from './browser-identity-rpc' import { BROWSER_EXTRA_METHODS } from './browser-extras' import { BROWSER_SCREENCAST_METHODS } from './browser-screencast' import { BROWSER_CLIENT_HOST_METHODS } from './browser-client-host' @@ -64,6 +65,7 @@ export const ALL_RPC_METHODS = [ ...TERMINAL_METHODS, ...TERMINAL_ORPHAN_METHODS, ...BROWSER_CORE_METHODS, + ...BROWSER_IDENTITY_METHODS, ...BROWSER_SCREENCAST_METHODS, ...BROWSER_EXTRA_METHODS, ...BROWSER_CLIENT_HOST_METHODS, diff --git a/src/main/runtime/runtime-browser-commands-browser-tab-set-profile.ts b/src/main/runtime/runtime-browser-commands-browser-tab-set-profile.ts index 3f701aea347..482182a0769 100644 --- a/src/main/runtime/runtime-browser-commands-browser-tab-set-profile.ts +++ b/src/main/runtime/runtime-browser-commands-browser-tab-set-profile.ts @@ -16,7 +16,6 @@ import { browserManager } from '../browser/browser-manager' import { randomUUID } from 'node:crypto' import { ipcMain } from 'electron' import { waitForTabRegistration } from '../ipc/browser-tab-registration-wait' -import type { BrowserSessionUserAgentMode } from '../../shared/browser-workspace-types' import { detectInstalledBrowsers } from '../browser/browser-cookie-import' export class RuntimeBrowserCommandsWithBrowserTabSetProfile extends RuntimeBrowserCommandsWithBrowserTabCreate { @@ -158,12 +157,9 @@ export class RuntimeBrowserCommandsWithBrowserTabSetProfile extends RuntimeBrows async browserProfileCreate(params: { label: string scope: 'isolated' | 'imported' - userAgentMode?: BrowserSessionUserAgentMode }): Promise { return { - profile: await browserSessionRegistry.createProfile(params.scope, params.label, { - userAgentMode: params.userAgentMode - }) + profile: await browserSessionRegistry.createProfile(params.scope, params.label) } } diff --git a/src/main/server/serve-stdout-boundary.test.ts b/src/main/server/serve-stdout-boundary.test.ts index b59227e932d..38d59cf8d47 100644 --- a/src/main/server/serve-stdout-boundary.test.ts +++ b/src/main/server/serve-stdout-boundary.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from 'vitest' -import { reserveServeStdoutForReadiness } from './serve-stdout-boundary' +import { + emitServeBrowserIdentityActionLine, + reserveServeStdoutForReadiness +} from './serve-stdout-boundary' describe('reserveServeStdoutForReadiness', () => { it('routes console diagnostics to stderr', () => { @@ -18,3 +21,45 @@ describe('reserveServeStdoutForReadiness', () => { expect(target.error.mock.calls).toEqual([['debug'], ['info'], ['log']]) }) }) + +describe('emitServeBrowserIdentityActionLine', () => { + it.each([ + { + state: 'valid' as const, + migrationNotice: { degraded: false }, + expected: 'choose Cleaned or Native' + }, + { + state: 'valid' as const, + migrationNotice: { degraded: true }, + expected: 'old choice could not be inspected' + }, + { state: 'corrupt' as const, migrationNotice: null, expected: 'reset it explicitly' }, + { state: 'future' as const, migrationNotice: null, expected: 'update Orca' } + ])('writes one stderr action for $state', ({ state, migrationNotice, expected }) => { + const write = vi.fn() + const identity = + state === 'valid' + ? { + state, + appliedMode: 'clean' as const, + configuredMode: 'clean' as const, + explicitSelection: false, + migrationNoticePending: true, + restartRequired: false + } + : { + state, + appliedMode: 'clean' as const, + configuredMode: null, + explicitSelection: null, + migrationNoticePending: null, + restartRequired: false as const + } + + emitServeBrowserIdentityActionLine({ identity, migrationNotice }, { write }) + + expect(write).toHaveBeenCalledTimes(1) + expect(write).toHaveBeenCalledWith(expect.stringContaining(expected)) + }) +}) diff --git a/src/main/server/serve-stdout-boundary.ts b/src/main/server/serve-stdout-boundary.ts index b4a44bb51ef..b5010abc2d7 100644 --- a/src/main/server/serve-stdout-boundary.ts +++ b/src/main/server/serve-stdout-boundary.ts @@ -1,4 +1,7 @@ +import type { BrowserIdentityModeStatus } from '../../shared/browser-user-agent-mode' + type DiagnosticConsole = Pick +type StderrTarget = Pick export function reserveServeStdoutForReadiness(target: DiagnosticConsole = console): void { // Why: stdout is the serve readiness API; route incidental diagnostics to stderr so JSON stays parseable. @@ -7,3 +10,25 @@ export function reserveServeStdoutForReadiness(target: DiagnosticConsole = conso target.info = writeDiagnostic target.log = writeDiagnostic } + +export function emitServeBrowserIdentityActionLine( + status: BrowserIdentityModeStatus, + target: StderrTarget = process.stderr +): void { + let action: string | null = null + if (status.identity.state === 'future') { + action = 'browser identity data is from a newer version; update Orca' + } else if ( + status.identity.state === 'corrupt' || + status.identity.state === 'unreadable' + ) { + action = `browser identity data is ${status.identity.state}; reset it explicitly` + } else if (status.migrationNotice?.degraded) { + action = 'an old choice could not be inspected; choose Cleaned or Native' + } else if (status.migrationNotice) { + action = 'browser identity changed to app-wide; choose Cleaned or Native' + } + if (action) { + target.write(`[browser-identity] action required: ${action}\n`) + } +} diff --git a/src/main/startup/browser-process-user-agent-ordering.test.ts b/src/main/startup/browser-process-user-agent-ordering.test.ts new file mode 100644 index 00000000000..5913890b5cf --- /dev/null +++ b/src/main/startup/browser-process-user-agent-ordering.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => { + const events: string[] = [] + // Why a two-word app token: this file sets the dev app name to "Orca Development", and Electron + // builds the app token from that name. A single-token fixture could not exhibit the multi-word + // leak the cleaner exists to handle, so it disagreed with the scenario it set up. + // Why the engine comment: a real app.userAgentFallback always carries it, and the cleaner only + // touches identities that do — a fixture without it models a string Electron cannot produce. + let userAgent = + 'Mozilla/5.0 (Test) AppleWebKit/537.36 (KHTML, like Gecko) Orca Development/0.0.0 Chrome/150.0.0.0 Electron/43.0.0 Safari/537.36' + const app = { + isPackaged: false, + exit: vi.fn(), + getVersion: vi.fn(() => '1.0.0'), + getPath: vi.fn(() => '/canonical-user-data'), + get userAgentFallback(): string { + events.push('read-user-agent') + return userAgent + }, + set userAgentFallback(value: string) { + events.push('write-user-agent') + userAgent = value + }, + isReady: vi.fn(() => { + events.push('is-ready') + return false + }), + setName: vi.fn((name: string) => { + events.push(`set-name:${name}`) + }) + } + return { app, events, userAgent: () => userAgent } +}) + +vi.mock('electron', () => ({ + app: mocks.app, + ipcMain: {}, + powerMonitor: {}, + session: { defaultSession: {} } +})) +vi.mock('@electron-toolkit/utils', () => ({ is: { dev: true } })) +vi.mock('./cli-launch-redirect', () => ({ + maybeRedirectCliLaunch: () => ({ redirected: false, status: 0 }) +})) +vi.mock('./serve-mode-argv', () => ({ + argvRequestsServeMode: () => false, + normalizeServeModeArgv: (argv: string[]) => argv +})) +vi.mock('./configure-process', () => ({ + configureDevUserDataPath: vi.fn(), + configureElectronNetworkCompatibility: vi.fn(), + configureOrcaUserDataPathEnv: vi.fn(), + disableUnsupportedChromiumFeatures: vi.fn(), + enableMainProcessGpuFeatures: vi.fn(), + installDevParentDisconnectQuit: vi.fn(), + installDevParentSignalQuit: vi.fn(), + installDevParentWatchdog: vi.fn(), + optOutOfHiddenPageWakeUpThrottling: vi.fn(), + patchPackagedProcessPath: vi.fn() +})) +vi.mock('../serve-update-handoff', () => ({ installServeSupervisorDisconnectQuit: vi.fn() })) +vi.mock('./main-process-error-guards', () => ({ + installUncaughtPipeErrorGuard: vi.fn(), + installUnhandledRejectionLogging: vi.fn() +})) +vi.mock('./hydrate-shell-path') +vi.mock('../runtime/remote-server-updater', () => ({ configureRemoteServerUpdater: vi.fn() })) +vi.mock('../updater', () => ({ + getRemoteServerUpdaterSnapshot: vi.fn(), + checkForRemoteServerUpdate: vi.fn(), + downloadRemoteServerUpdate: vi.fn(), + installRemoteServerUpdate: vi.fn(), + isQuittingForUpdate: () => false +})) +vi.mock('./dev-instance-identity', () => ({ + getDevInstanceIdentity: () => ({ + isDev: true, + appName: 'Orca Development', + appUserModelId: 'com.orca.development' + }), + shouldApplyPreReadyAppName: () => true +})) +vi.mock('./renderer-heap-headroom') +vi.mock('./startup-diagnostics', () => ({ + isStartupDiagnosticsEnabled: () => { + mocks.events.push('continued-after-browser-identity') + throw new Error('preflight-test-stop') + }, + logStartupDiagnostic: vi.fn() +})) +vi.mock('./event-loop-stall-probe') +vi.mock('../diagnostics/main-thread-churn-probe') +vi.mock('../git/source-control/git-read-cache-invalidation', () => ({ + settledDiffCache: { stats: vi.fn() } +})) +vi.mock('../server/serve-stdout-boundary') +vi.mock('./serve-desktop-activation', () => ({ + createServeDesktopActivationGate: () => ({}) +})) +vi.mock('./single-instance-lock', () => ({ + shouldBypassSingleInstanceLock: () => false, + shouldSkipSingleInstanceLock: () => true, + acquireSingleInstanceLock: vi.fn(), + logSingleInstanceLockBypass: vi.fn(), + logSingleInstanceLockFailure: vi.fn(), + SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE: 1 +})) +vi.mock('../../shared/app-environment', () => ({ setAppEnvironment: vi.fn() })) +vi.mock('../host/electron-app-environment', () => ({ ElectronAppEnvironment: class {} })) +vi.mock('../own-chromium-tree-kill-guard') +vi.mock('../../shared/secret-store') +vi.mock('../host/electron-secret-store') +vi.mock('../ipc/pty-host-bindings') +vi.mock('../host/electron-runtime-desktop-surface') +vi.mock('../runtime/runtime-desktop-surface') +vi.mock('../host/electron-browser-commands') +vi.mock('../runtime/runtime-browser-commands-factory') +vi.mock('../host/electron-http-client') +vi.mock('../network/http-client') +vi.mock('../host/electron-speech-services') +vi.mock('../speech/speech-runtime-service') +vi.mock('../ipc/worktree-watcher-removal') +vi.mock('../ipc/filesystem-watcher') +vi.mock('../network/proxy-settings') +vi.mock('../persistence', () => ({ + initDataPath: () => mocks.events.push('init-data-path'), + getCanonicalUserDataPath: () => '/canonical-user-data' +})) +vi.mock('../macos-press-and-hold-default') +vi.mock('../ai-vault/session-parse-cache-persistence') +vi.mock('../orca-profiles/profile-index-store') +vi.mock('../stats/collector') +vi.mock('../claude-usage/store') +vi.mock('../codex-usage/store') +vi.mock('../opencode-usage/store') +vi.mock('../browser/doc-preview-protocol') +vi.mock('../crash-reporting/crashpad-capture') +vi.mock('../crash-reporting/crash-report-store') +vi.mock('../crash-reporting/crash-breadcrumb-store') +vi.mock('../crash-reporting/durable-crash-breadcrumb') +vi.mock('../crash-reporting/gpu-crash-diagnostics') +vi.mock('../crash-reporting/main-process-lifecycle-identity') +vi.mock('./ensure-virtual-display', () => ({ + ensureVirtualDisplayForHeadlessServe: vi.fn(), + hasUsableLinuxDisplay: () => true, + MISSING_LINUX_DISPLAY_MESSAGE: 'missing display' +})) +vi.mock('./gpu-lifecycle') +vi.mock('./main-process-state', () => ({ mainProcessState: {} })) +vi.mock('./synthetic-title-runtime') +vi.mock('../browser/browser-identity-mode-store', () => ({ + initializeBrowserIdentityModeStore: (path: string) => { + mocks.events.push(`read-mode:${path}`) + return { + state: 'valid', + appliedMode: 'clean', + configuredMode: 'clean', + explicitSelection: true, + migrationNoticePending: false + } + } +})) + +describe('browser process user-agent startup ordering', () => { + it('executes after the dev app name and before later preflight work', async () => { + const { getBrowserProcessUserAgentIdentity } = + await import('../browser/browser-process-user-agent') + const { runMainProcessPreflight } = await import('./main-process-preflight') + + expect(() => + runMainProcessPreflight({ + focusExistingWindow: vi.fn(), + requestDesktopActivation: vi.fn() + }) + ).toThrow('preflight-test-stop') + + const nameIndex = mocks.events.indexOf('set-name:Orca Development') + const modeIndex = mocks.events.indexOf('read-mode:/canonical-user-data') + const writeIndex = mocks.events.indexOf('write-user-agent') + const continuationIndex = mocks.events.indexOf('continued-after-browser-identity') + expect(mocks.events.indexOf('init-data-path')).toBeLessThan(nameIndex) + expect(nameIndex).toBeLessThan(modeIndex) + expect(modeIndex).toBeLessThan(writeIndex) + expect(writeIndex).toBeLessThan(continuationIndex) + expect(getBrowserProcessUserAgentIdentity()).toEqual({ + mode: 'clean', + userAgent: mocks.userAgent() + }) + // Both app-name words must be gone, not just the last: a single \S+ would have left "Orca". + expect(mocks.userAgent()).not.toMatch(/Electron/) + expect(mocks.userAgent()).not.toMatch(/Orca|Development/) + }) +}) diff --git a/src/main/startup/cli-command-names.ts b/src/main/startup/cli-command-names.ts index c8aa6e2cf2a..0c001b74f44 100644 --- a/src/main/startup/cli-command-names.ts +++ b/src/main/startup/cli-command-names.ts @@ -6,6 +6,7 @@ export const CLI_COMMAND_NAMES = [ 'artifacts', 'automations', 'back', + 'browser', 'capture', 'check', 'claude-teams', diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts index 177a2357441..6fc05172764 100644 --- a/src/main/startup/main-process-preflight.ts +++ b/src/main/startup/main-process-preflight.ts @@ -86,6 +86,8 @@ import { import { maybeApplyGpuFallbackForThisLaunch, registerGpuLifecycleHandlers } from './gpu-lifecycle' import { mainProcessState as state } from './main-process-state' import { initializeSyntheticTitleRuntime } from './synthetic-title-runtime' +import { initializeBrowserProcessUserAgent } from '../browser/browser-process-user-agent' +import { initializeBrowserIdentityModeStore } from '../browser/browser-identity-mode-store' export type MainProcessPreflightOptions = { focusExistingWindow: () => void @@ -178,6 +180,15 @@ export function runMainProcessPreflight(options: MainProcessPreflightOptions): b // Why captured now: after the dev/E2E override above, and before app.setName('Orca') (whenReady) // changes how userData resolves on a case-sensitive filesystem. See persistence.ts:20-28. initDataPath() + // Why: Electron resolves the macOS safeStorage Keychain service name from the app name before + // ready. Dev pins userData above, so applying its name here cannot shift the captured path. + if (state.devInstanceIdentity && shouldApplyPreReadyAppName(state.devInstanceIdentity)) { + app.setName(state.devInstanceIdentity.appName) + } + // Why: renderer and worker defaults are process-global and must be fixed before any session exists. + initializeBrowserProcessUserAgent( + initializeBrowserIdentityModeStore(getCanonicalUserDataPath()).appliedMode + ) state.startupDiagnosticsEnabled = isStartupDiagnosticsEnabled() if (state.startupDiagnosticsEnabled) { logStartupDiagnostic('before-single-instance-lock', { @@ -275,15 +286,6 @@ export function runMainProcessPreflight(options: MainProcessPreflightOptions): b initClaudeUsagePath() initCodexUsagePath() initOpenCodeUsagePath() - // Why: Electron resolves the macOS safeStorage Keychain service name - // (" Safe Storage") before `ready`, so the setName in whenReady is - // too late to move it — dev otherwise lands on the package.json name. Dev-only - // so a packaged build keeps deriving the key from its own CFBundleName. - // Safe here: dev always pins userData via app.setPath (configure-process.ts), - // so setName cannot shift the paths captured just above. - if (state.devInstanceIdentity && shouldApplyPreReadyAppName(state.devInstanceIdentity)) { - app.setName(state.devInstanceIdentity.appName) - } // Why: Electron freezes the privileged scheme table at ready, so the doc-preview // scheme must be declared here or its webview loses fetch/secure-origin privileges. registerDocPreviewSchemePrivileges() diff --git a/src/main/startup/main-process-ready-identity-write.test.ts b/src/main/startup/main-process-ready-identity-write.test.ts new file mode 100644 index 00000000000..13011ccfb6b --- /dev/null +++ b/src/main/startup/main-process-ready-identity-write.test.ts @@ -0,0 +1,345 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as DurableFileWrite from '../durable-file-write' + +const ORCA_PROFILE_ID = 'local-default' +const RETIRED_PROFILE_ID = '11111111-1111-4111-8111-111111111111' + +const mocks = vi.hoisted(() => ({ + // Assigned in beforeAll; the factories below read them lazily, so real directories exist by the + // time ready composition resolves the canonical userData path and the active profile directory. + userDataPath: '', + profileDirectory: '', + state: { + devInstanceIdentity: { appUserModelId: 'app.id', appName: 'Orca' }, + isServeMode: false, + mainProcessI18nReady: Promise.resolve(), + managedWslCliReconciliationStatus: 'settled', + initialProxyApplicationReady: Promise.resolve(), + hangDetection: null, + store: null + }, + openMainWindow: vi.fn(), + runtimeRpcStart: vi.fn(async () => {}), + // The identity record's only writer. Watching this is what makes the pin real: asserting on + // writeFileAtomically watched a function the identity store never calls. + writeFileDurableSync: vi.fn() +})) + +vi.mock('../durable-file-write', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + // Records and then really writes: the registry path below must land on disk so + // readBrowserIdentityModeRecord is reading what ready actually produced. + writeFileDurableSync: (...args: Parameters) => { + mocks.writeFileDurableSync(...args) + actual.writeFileDurableSync(...args) + } + } +}) + +vi.mock('electron', () => ({ + app: { + on: vi.fn(), + setName: vi.fn(), + getPath: vi.fn(() => mocks.userDataPath), + getVersion: vi.fn(() => '1.0.0'), + isPackaged: false + }, + session: { + defaultSession: {}, + fromPartition: vi.fn(() => ({ + setUserAgent: vi.fn(), + getUserAgent: vi.fn(() => 'Mozilla/5.0 Test'), + setPermissionRequestHandler: vi.fn(), + setPermissionCheckHandler: vi.fn(), + setDisplayMediaRequestHandler: vi.fn(), + on: vi.fn(), + removeListener: vi.fn() + })) + } +})) +vi.mock('@electron-toolkit/utils', () => ({ + electronApp: { setAppUserModelId: vi.fn() }, + is: { dev: false } +})) +vi.mock('./main-process-state', () => ({ mainProcessState: mocks.state })) +vi.mock('../persistence', () => ({ + Store: class { + getSettings() { + return {} + } + onSettingsChanged() {} + getClaudeLivePtySessionIds() { + return [] + } + getSshTargets() { + return [] + } + }, + getCanonicalUserDataPath: () => mocks.userDataPath +})) +// The registry reads the canonical path from this module, not from '../persistence'. +vi.mock('../persistence/loading-store/user-data-path', () => ({ + getCanonicalUserDataPath: () => mocks.userDataPath +})) +vi.mock('../window/foreground-activation-policy', () => ({ + applyBackgroundActivationPolicy: vi.fn() +})) +vi.mock('../network/proxy-settings', () => ({ + applyElectronProxySettings: vi.fn(async () => ({ source: 'direct' })), + retireProxySessionApplication: vi.fn() +})) +vi.mock('../network/electron-proxy-request-guard', () => ({ + installElectronProxyRequestGuard: vi.fn() +})) +vi.mock('../network/electron-proxy-credentials', () => ({ handleElectronProxyLogin: vi.fn() })) +vi.mock('../hang-watchdog/main-thread-hang-watchdog', () => ({ + installMainThreadHangWatchdog: vi.fn() +})) +vi.mock('../hang-watchdog/hang-detection-marker', () => ({ + consumeHangDetectionMarker: vi.fn(() => null), + hangDetectionMarkerPath: vi.fn(() => '/test-marker') +})) +vi.mock('../browser/browser-manager', () => ({ + browserCertificateTrustController: {}, + browserManager: { + installCertificateRequestGuard: vi.fn(), + removeCertificateRequestGuard: vi.fn(), + notifyPermissionDenied: vi.fn(), + handleGuestWillDownload: vi.fn() + } +})) +vi.mock('../orca-profiles/profile-index-store', () => ({ + ensureActiveOrcaProfile: () => ({ + profile: { id: ORCA_PROFILE_ID }, + profileDirectory: mocks.profileDirectory, + dataFile: join(mocks.profileDirectory, 'data.json') + }) +})) +vi.mock('../browser/browser-client-host-id', () => ({ initializeBrowserClientHostId: vi.fn() })) +vi.mock('../host/deferred-secret-protection-report', () => ({ + scheduleSecretProtectionGapReport: vi.fn() +})) +vi.mock('../ssh/ssh-host-key-store', () => ({ initSshHostKeyStoreFile: vi.fn() })) +vi.mock('../pty/legacy-terminal-shim-dir', () => ({ neutralizeLegacyTerminalShimDir: vi.fn() })) +vi.mock('./windows-shell-path-hydration', () => ({ + createWindowsShellPathHydration: () => ({ whenReady: Promise.resolve() }) +})) +vi.mock('../git/runner', () => ({ + configureWindowsHostGitEnvironmentReadiness: vi.fn(), + setDefaultWslDistroOverride: vi.fn() +})) +vi.mock('../agent-hooks/wsl-hook-relay-manager', () => ({ + wslHookRelayManager: { setManagedHookSettingsResolver: vi.fn() } +})) +vi.mock('../claude-accounts/live-pty-gate', () => ({ + attachClaudeLivePtyPersistence: vi.fn(), + onLiveClaudePtysDrained: vi.fn(), + seedLiveClaudePtysFromPersistence: vi.fn() +})) +vi.mock('../app-icon', () => ({ applyAppIcon: vi.fn() })) +vi.mock('./dev-education-suppression', () => ({ + shouldSuppressDevEducation: () => false, + suppressDevEducationForStore: vi.fn() +})) +vi.mock('../browser/browser-session-proxy', () => ({ + applyBrowserSessionProxies: vi.fn(async () => {}), + setBrowserNetworkProxySettingsResolver: vi.fn(), + invalidateBrowserSessionProxyApplication: vi.fn() +})) +vi.mock('../browser/doc-preview-protocol', () => ({ installDocPreviewProtocolHandler: vi.fn() })) +vi.mock('../ipc/doc-preview-grant-ipc', () => ({ registerDocPreviewGrantHandlers: vi.fn() })) + +// browser-session-startup and browser-session-registry are deliberately NOT mocked: they are the +// one ready-phase path that can write the identity record, and stubbing them is what made the +// original assertion unable to fail. Only the pieces hanging off that path — partition policies, +// route sessions, cookie staging — are stubbed, so the meta load, the retired-choice inspection +// and the identity write are all real. +vi.mock('../browser/browser-route-session-runtime', () => ({ + configureRouteSessionsForOrcaProfile: vi.fn() +})) +vi.mock('../browser/paired-runtime-browser-client-host-runtime', () => ({ + configurePairedRuntimeBrowserClientHostsForOrcaProfile: vi.fn() +})) +vi.mock('../browser/browser-route-partition-storage-runtime', () => ({ + collectOrphanedBrowserRoutePartitionStorage: vi.fn(async () => {}) +})) +vi.mock('../browser/browser-session-partition-policies', () => ({ + installBrowserSessionPartitionPolicies: vi.fn(async () => {}), + forgetBrowserSessionPartitionConfiguration: vi.fn(), + clearBrowserSessionPartitionPolicies: vi.fn() +})) +vi.mock('../browser/browser-session-cookie-staging', () => ({ + applyPendingBrowserCookieImports: vi.fn(), + clearPendingBrowserCookieImport: vi.fn(), + setPendingBrowserCookieImport: vi.fn() +})) +vi.mock('../browser/browser-session-route-policies', () => ({ + installBrowserRoutePartitionPolicies: vi.fn(), + clearBrowserRoutePartitionPolicies: vi.fn() +})) +vi.mock('../browser/browser-session-profile-retirement', () => ({ + retireFailedBrowserSessionProfile: vi.fn(async () => {}) +})) +vi.mock('../browser/browser-webauthn-account-picker', () => ({ + cancelBrowserWebAuthnAccountRequestsForSession: vi.fn() +})) + +vi.mock('./startup-diagnostics', () => ({ logStartupMilestone: vi.fn() })) +vi.mock('./http1-compatibility-marker', () => ({ writeHttp1CompatibilityMarker: vi.fn() })) +vi.mock('../crash-reporting/durable-crash-breadcrumb', () => ({ + recordDurableCrashBreadcrumb: vi.fn() +})) +vi.mock('./main-window-actions', () => ({ syncMacMenuBarIcon: vi.fn() })) +vi.mock('./gpu-lifecycle', () => ({ updateGpuAccelerationAboutPanel: vi.fn() })) +vi.mock('../cli/wsl-cli-registration-reconciliation', () => ({ + reconcileManagedWslCliRegistrations: vi.fn(async () => []) +})) +vi.mock('./wsl-cli-reconciliation-startup-barrier', () => ({ + createWslCliReconciliationStartupBarrier: () => Promise.resolve() +})) +vi.mock('../agent-hooks/managed-agent-hook-controls', () => ({ + isAgentStatusHooksEnabled: vi.fn() +})) +vi.mock('./main-process-ready-runtime', () => ({ + initializeReadyRuntimeServices: vi.fn(async () => {}) +})) +vi.mock('./main-process-i18n-menu', () => ({ + initializeMainProcessI18nAndMenu: vi.fn(async () => {}) +})) +vi.mock('./main-process-runtime-launch', () => ({ + initializeMainProcessRuntimeLaunch: vi.fn(async (options: { openMainWindow: () => void }) => { + if (mocks.state.isServeMode) { + await mocks.runtimeRpcStart() + } else { + options.openMainWindow() + } + }) +})) + +import { + BROWSER_IDENTITY_MODE_FILE, + BROWSER_IDENTITY_MODE_VERSION, + readBrowserIdentityModeRecord +} from '../browser/browser-identity-mode-record' +import { BROWSER_SESSION_META_FILE_NAME } from '../browser/browser-session-meta-store' +import { getOrcaProfileBrowserSessionPartition } from '../../shared/orca-profiles' + +function seedIdentityRecord(mode: string, explicitSelection: boolean): void { + writeFileSync( + join(mocks.userDataPath, BROWSER_IDENTITY_MODE_FILE), + JSON.stringify({ + version: BROWSER_IDENTITY_MODE_VERSION, + mode, + explicitSelection, + migrationNoticePending: false + }), + 'utf8' + ) +} + +/** A profile carrying the retired per-profile choice, which is what arms the startup notice. */ +function seedRetiredProfile(): void { + writeFileSync( + join(mocks.profileDirectory, BROWSER_SESSION_META_FILE_NAME), + JSON.stringify({ + defaultSource: null, + pendingCookieDbPath: null, + pendingCookieImports: {}, + profiles: [ + { + id: RETIRED_PROFILE_ID, + scope: 'isolated', + partition: getOrcaProfileBrowserSessionPartition(ORCA_PROFILE_ID, RETIRED_PROFILE_ID), + label: 'Existing', + source: null, + userAgentMode: 'native' + } + ] + }), + 'utf8' + ) +} + +/** + * `initializeBrowserSessionsForApp` latches on a module-level flag, so each case needs a fresh + * module graph; that forces the dynamic imports here. + */ +async function runReady(): Promise { + const identity = await import('../browser/browser-identity-mode-store') + // Preflight's read is what fixes the identity for this launch. + identity.initializeBrowserIdentityModeStore(mocks.userDataPath) + const { initializeMainProcessReady } = await import('./main-process-ready') + await initializeMainProcessReady({ + openMainWindow: mocks.openMainWindow, + handleMacAppActivation: vi.fn() + }) +} + +function identityRecordWrites(): unknown[] { + return mocks.writeFileDurableSync.mock.calls.filter(([, target]) => + String(target).endsWith(BROWSER_IDENTITY_MODE_FILE) + ) +} + +describe('ready-phase browser identity authority', () => { + beforeAll(() => { + mocks.userDataPath = mkdtempSync(join(tmpdir(), 'orca-ready-identity-')) + mocks.profileDirectory = mkdtempSync(join(tmpdir(), 'orca-ready-identity-profile-')) + }) + + beforeEach(() => { + vi.resetModules() + mocks.openMainWindow.mockClear() + mocks.runtimeRpcStart.mockClear() + mocks.writeFileDurableSync.mockClear() + mocks.state.isServeMode = false + }) + + // The bug: ready used to mirror a retired per-profile setting into the root record, so switching + // from a native profile to a clean one started the clean profile in native. The root record read + // before ready is the only authority now, and ready must not rewrite it in either direction — + // not even when the real registry finds retired per-profile bytes sitting right beside it. + it.each([{ rootMode: 'native' }, { rootMode: 'clean' }])( + 'leaves root=$rootMode authoritative over a retired profile choice', + async ({ rootMode }) => { + seedIdentityRecord(rootMode, true) + seedRetiredProfile() + + await runReady() + + expect(readBrowserIdentityModeRecord(mocks.userDataPath)).toMatchObject({ + state: 'valid', + appliedMode: rootMode, + configuredMode: rootMode, + explicitSelection: true, + migrationNoticePending: false + }) + // The explicit choice already retired the notice, so the real registry path must not + // re-arm it — and with nothing to write, the record is never touched at all. + expect(identityRecordWrites()).toEqual([]) + } + ) + + // The other half: proof the registry path this test stops mocking is actually live. Without an + // explicit choice the same retired profile must arm the notice, through ready, on disk. + it('arms the retired-choice notice through the real registry path', async () => { + seedIdentityRecord('clean', false) + seedRetiredProfile() + + await runReady() + + expect(identityRecordWrites()).toHaveLength(1) + expect(readBrowserIdentityModeRecord(mocks.userDataPath)).toMatchObject({ + state: 'valid', + appliedMode: 'clean', + configuredMode: 'clean', + explicitSelection: false, + migrationNoticePending: true + }) + }) +}) diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index fdedf310cbb..858c6b75786 100644 --- a/src/main/startup/main-process-runtime-launch.ts +++ b/src/main/startup/main-process-runtime-launch.ts @@ -39,6 +39,8 @@ import { triggerStartupNotificationRegistration } from '../ipc/startup-notificat import { startDesktopPushService } from './main-process-push-startup' import { mainProcessState as state } from './main-process-state' import { logStartupMilestone } from './startup-diagnostics' +import { emitServeBrowserIdentityActionLine } from '../server/serve-stdout-boundary' +import { getBrowserIdentityModeStatus } from '../browser/browser-identity-mode-store' type RuntimeService = NonNullable @@ -207,6 +209,7 @@ async function launchServeMode( // Why: serve deletes worktrees too, and the history GC that normally drains delete tombstones is // armed from the main window — without this, a quit mid-removal leaks the tree until a desktop launch. scheduleAllPendingHistoryTreeRemovals() + emitServeBrowserIdentityActionLine(getBrowserIdentityModeStatus()) await printServeReady(serveOptions) } diff --git a/src/preload/api/browser-api.ts b/src/preload/api/browser-api.ts index d541c32dfd8..5aff6728abf 100644 --- a/src/preload/api/browser-api.ts +++ b/src/preload/api/browser-api.ts @@ -1,4 +1,9 @@ import type { BrowserSetAnnotationViewportBridgeArgs } from '../../shared/browser-annotation-viewport-bridge' +import type { + BrowserIdentityModeSetResult, + BrowserIdentityModeStatus, + BrowserUserAgentMode +} from '../../shared/browser-user-agent-mode' import type { BrowserClientPageMetadataParams, BrowserClientPageMetadataPublishOutcome @@ -33,7 +38,6 @@ import type { BrowserCookieImportResult, BrowserLoadError, BrowserSessionProfile, - BrowserSessionProfileCreateOptions, BrowserSessionProfileScope, BrowserSessionProfileSource, BrowserViewportOverride, @@ -140,12 +144,12 @@ export type BrowserApi = { browserProfileId?: string skipProbe?: boolean }) => Promise<{ partition: string }> - sessionCreateProfile: ( - args: { - scope: BrowserSessionProfileScope - label: string - } & BrowserSessionProfileCreateOptions - ) => Promise + sessionCreateProfile: (args: { + scope: BrowserSessionProfileScope + label: string + }) => Promise + identityGet: () => Promise + identitySet: (mode: BrowserUserAgentMode) => Promise sessionDeleteProfile: (args: { profileId: string }) => Promise sessionImportCookies: (args: { profileId: string }) => Promise sessionResolvePartition: (args: { profileId: string | null }) => Promise diff --git a/src/preload/api/browser-bridge-page-interaction-and-sessions.ts b/src/preload/api/browser-bridge-page-interaction-and-sessions.ts index c2f72ff7cb6..0e07440f245 100644 --- a/src/preload/api/browser-bridge-page-interaction-and-sessions.ts +++ b/src/preload/api/browser-bridge-page-interaction-and-sessions.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron' import type { PreloadApi } from '../api-types' +import type { BrowserUserAgentMode } from '../../shared/browser-user-agent-mode' export const browserPageInteractionAndSessionsApi = { onContextMenuRequested: ( @@ -117,11 +118,10 @@ export const browserPageInteractionAndSessionsApi = { skipProbe?: boolean }): Promise<{ partition: string }> => ipcRenderer.invoke('browser:prepareSshWorkspacePartition', args), - sessionCreateProfile: (args: { - scope: 'default' | 'isolated' | 'imported' - label: string - userAgentMode?: 'clean' | 'native' - }) => ipcRenderer.invoke('browser:session:createProfile', args), + sessionCreateProfile: (args: { scope: 'default' | 'isolated' | 'imported'; label: string }) => + ipcRenderer.invoke('browser:session:createProfile', args), + identityGet: () => ipcRenderer.invoke('browser:identity:get'), + identitySet: (mode: BrowserUserAgentMode) => ipcRenderer.invoke('browser:identity:set', mode), sessionDeleteProfile: (args: { profileId: string }): Promise => ipcRenderer.invoke('browser:session:deleteProfile', args), sessionImportCookies: (args: { profileId: string }) => diff --git a/src/renderer/src/app-shell/use-app-shell-services.ts b/src/renderer/src/app-shell/use-app-shell-services.ts index e969609c268..1eee41663df 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -17,6 +17,7 @@ import { useOsc52ClipboardDefaultOnNotice } from '../components/terminal-pane/os import { useWebSessionTabsSync } from '../runtime/web-session-tabs-sync' import { useLocalStructuredSessionTabsSync } from '../runtime/local-structured-session-tabs-sync' import { useRemoteRuntimeRecoveryTriggers } from '../runtime/use-remote-runtime-recovery-triggers' +import { useBrowserIdentityMigrationNotice } from '../components/browser-pane/browser-user-agent-migration-notice' /** * App-level subscriptions that must outlive any individual surface. Each one is here because @@ -48,4 +49,5 @@ export function useAppShellServices(options: { floatingPanelVisible: boolean }): useLargeTextControlPaste() usePrimarySelectionPaste(primarySelectionMiddleClickPaste) useOsc52ClipboardDefaultOnNotice(persistedUIReady) + useBrowserIdentityMigrationNotice() } diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index 4c6ab94f010..98cf1ffe4d0 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -17,6 +17,8 @@ const ROOT_SURFACES_PATH = 'src/renderer/src/app-shell/AppRootSurfaces.tsx' const LAZY_MODAL_MOUNTS_PATH = 'src/renderer/src/app-shell/use-lazy-modal-mounts.ts' const SESSION_PERSISTENCE_PATH = 'src/renderer/src/app-shell/use-app-session-persistence.ts' const PERSISTED_UI_WRITER_PATH = 'src/renderer/src/app-shell/use-persisted-ui-writer.ts' +const BROWSER_GUEST_SESSION_PATH = + 'src/renderer/src/components/browser-pane/host-guest/browser-page-webview-guest-session.ts' describe('renderer startup runtime routing', () => { it('routes packaged terminal restore through the daemon adoption gate', () => { @@ -585,6 +587,13 @@ describe('renderer startup runtime routing', () => { expect(appSource).toContain(' { + expect(readSource(SHELL_SERVICES_PATH)).toContain('useBrowserIdentityMigrationNotice()') + expect(readSource(BROWSER_GUEST_SESSION_PATH)).not.toContain( + 'showPendingBrowserUserAgentMigrationNotice' + ) + }) + it('checkpoints activeView and all session snapshots through one beforeunload handler (#9002)', () => { const source = readSource(SESSION_PERSISTENCE_PATH) const checkpointStart = source.indexOf( diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserToolbarMenu.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserToolbarMenu.tsx index 42ce7ae52f5..026931d2554 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserToolbarMenu.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserToolbarMenu.tsx @@ -61,7 +61,6 @@ export function BrowserToolbarMenu({ const [newProfileDialogOpen, setNewProfileDialogOpen] = useState(false) const [newProfileName, setNewProfileName] = useState('') - const [useNativeUserAgent, setUseNativeUserAgent] = useState(false) const [isCreatingProfile, setIsCreatingProfile] = useState(false) const [pendingSwitchProfileId, setPendingSwitchProfileId] = useState( undefined @@ -86,7 +85,6 @@ export function BrowserToolbarMenu({ setNewProfileDialogOpen(open) if (!open) { setNewProfileName('') - setUseNativeUserAgent(false) } } @@ -138,11 +136,7 @@ export function BrowserToolbarMenu({ setIsCreatingProfile(true) try { - const profile = await createBrowserSessionProfile( - 'isolated', - trimmed, - useNativeUserAgent ? { userAgentMode: 'native' } : undefined - ) + const profile = await createBrowserSessionProfile('isolated', trimmed) if (!profile) { if (mountedRef.current) { toast.error( @@ -161,7 +155,6 @@ export function BrowserToolbarMenu({ setNewProfileDialogOpen(false) setNewProfileName('') - setUseNativeUserAgent(false) onDestroyWebview() switchBrowserTabProfile(workspaceId, profile.id, profile.partition) @@ -258,14 +251,11 @@ export function BrowserToolbarMenu({ onNewProfileDialogOpenChange={handleNewProfileDialogOpenChange} newProfileName={newProfileName} onNewProfileNameChange={setNewProfileName} - useNativeUserAgent={useNativeUserAgent} - onUseNativeUserAgentChange={setUseNativeUserAgent} isCreatingProfile={isCreatingProfile} onCreateProfile={() => void handleCreateProfile()} onCancelNewProfile={() => { setNewProfileDialogOpen(false) setNewProfileName('') - setUseNativeUserAgent(false) }} /> diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-toolbar-profile-dialogs.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-toolbar-profile-dialogs.tsx index 46b45a5b6a9..216062958d7 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-toolbar-profile-dialogs.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-toolbar-profile-dialogs.tsx @@ -9,7 +9,6 @@ import { DialogTitle } from '@/components/ui/dialog' import { translate } from '@/i18n/i18n' -import { BrowserProfileUserAgentOption } from '../../browser-profile-user-agent-option' type BrowserToolbarProfileDialogsProps = { pendingSwitchProfileId: string | null | undefined @@ -19,8 +18,6 @@ type BrowserToolbarProfileDialogsProps = { onNewProfileDialogOpenChange: (open: boolean) => void newProfileName: string onNewProfileNameChange: (value: string) => void - useNativeUserAgent: boolean - onUseNativeUserAgentChange: (value: boolean) => void isCreatingProfile: boolean onCreateProfile: () => void onCancelNewProfile: () => void @@ -34,8 +31,6 @@ export function BrowserToolbarProfileDialogs({ onNewProfileDialogOpenChange, newProfileName, onNewProfileNameChange, - useNativeUserAgent, - onUseNativeUserAgentChange, isCreatingProfile, onCreateProfile, onCancelNewProfile @@ -103,12 +98,6 @@ export function BrowserToolbarProfileDialogs({ maxLength={50} className="mb-3" /> -
- -