fix(browser): make the browser identity one process-wide choice (#13822) (#20767)

* feat(browser): process-wide browser identity, chosen before ready

Electron resolves worker identity from a single process-global default, so two
coherent identities cannot coexist in one process. This makes clean/native one
app-wide decision read before `ready`, instead of a per-profile one that leaves
documents on one identity and every worker request on the other.

Both identities are load-bearing, measured across four origins at five reps:
the cleaned identity clears an embedded Turnstile widget and WhatsApp's browser
check where native is refused; native clears a full-page Cloudflare interstitial
that the cleaned identity never clears.

Base commit only: removing the per-profile field, its settings surface, and the
migration notice follow.

* test(browser): cover cross-context UA wire identity

* refactor(browser): make user agent identity app-wide

* test(browser): repair process identity wire fixture

* Fix browser identity startup migration failures

* WIP: rescue in-flight reduced-design work from a dead worker

Worker ctx_cb5b1262d7fe stopped ~2h ago mid-implementation (last heartbeat
2026-09-14T22:48:06Z) leaving this uncommitted. Committed unverified to make it
recoverable; not reviewed, not necessarily green.

* fix(browser): repair the rescued identity work so it typechecks

Finishes the interrupted edits in 7db9c54b54:

- browser-user-agent-migration-notice.ts was truncated mid-write; close the
  then() callback so the file parses.
- Register browser.identity.get/set in the generated RPC params catalog so the
  params type-parity gate is satisfied.
- Retire the persistence assertions for the superseded design: a
  migratedNativeProfileIds event map, a notice-acknowledgement clear, and a
  global persistence-failure accessor. Legacy userAgentMode bytes are retained
  now, so these assert retention plus a failed notice write still hydrating.
- The in-memory fs fixture threw a codeless ENOENT, which reads as "unreadable"
  rather than "missing" and made every identity write refuse. Carry the code.
- Use the segmented control's per-option disabled rather than adding a
  control-level prop it does not have.

* refactor(browser): make the identity store the only writer

The rescued work already serialized identity writes, but the writer lived beside the pre-ready reader, so nothing stopped a second caller from writing the record directly -- which is the shape of the bug this change set removes.

browser-identity-mode-record.ts is now read-only: record shape, path, parsing and the pre-ready synchronous read. browser-identity-mode-store.ts owns every mutation behind one queue, holds the snapshot and listeners, and derives restartRequired from appliedMode vs configuredMode rather than storing it. Consumers move to the store.

The two identity RPC methods also move out of browser-core.ts into browser-identity-rpc.ts: they read and write this host's own process identity rather than driving a page, and browser-core.ts was over its line cap. The generated params catalog is byte-identical.

* feat(browser): make resetting unhealthy identity data explicit and lossless

A corrupt or newer-version record left the identity unchangeable with no way out. An explicit reset now copies the old bytes verbatim to a fresh unique path before publishing a replacement, and refuses the whole operation if that backup cannot be written -- so the reset can never be the thing that loses the data. Nothing resets automatically.

Future-version data says update Orca rather than reporting corruption. Reset is opt-in via browser.identity.set and orca browser identity set --reset.

ProfileCreate and BrowserIdentitySet move to browser-identity-params.ts: both carry the per-profile to app-wide identity move, and browser-params.ts was over its line cap.

Also registers browser as a top-level CLI name so the Windows launch redirect covers it -- without it orca browser identity get boots the GUI and exits silently there -- and adds the canonical browser identity show alias the CLI vocabulary policy requires.

* feat(browser): advertise the identity capability only where it exists

browser.identity.v1 was static, so every host claimed it including one that never initialized the identity store, where both methods can only throw. It now follows the browser.headless.v1 precedent and is pushed at status time when the store is actually initialized.

Also covers the retired profileCreate userAgentMode field at the dispatcher rather than only at the schema, so an older client provably gets the changed-semantics rejection over the wire instead of a success with the field quietly dropped.

* refactor(browser): delete the identity write queue and guard backup uniqueness

The queue could not be falsified by any test: writeRecord is synchronous end to end, so two calls cannot interleave and removing serialization entirely left every store test green. Carrying machinery whose guard is unconstructible is what the design review told us to cut, so it is gone. If durable writes ever become async, serialization comes back with the change that makes it testable.

The test that claimed to prove serialization now states what it actually pins -- the later of two selections is the one that survives -- and the module doc no longer claims a queue that is not there.

Adds the guard that was missing on reset: two resets across separate launches must produce two distinct backups, each holding its own original bytes. Verified discriminating -- a fixed backup filename fails it.

* test(browser): guard the identity capability and harden two weak assertions

Pins the mixed-version guarantee that had no test: browser.identity.v1 is advertised when the identity store is initialized and absent when it is not. Verified discriminating -- advertising it unconditionally fails the test.

The profileCreate rejection test asserted ok:false against a runtime with no browserProfileCreate, so that assertion passed even when the retired field was accepted. It now stubs a working runtime method, making ok:false load-bearing, and asserts the runtime is never reached.

Removes the persistence fixture's dead failIdentityWrite branch on writeFileAtomically: nothing on that path calls it, so it implied a second write mechanism that does not exist. Failure is injected through node:fs, which is what the identity write actually uses.

* test(browser): classify the identity channels on the preview seam

The channel split is asserted total, so adding browser:identity:get/set left it
short by two. They manage the host's own process-wide user-agent choice rather
than acting on a guest the reader is looking at, so they sit with the session
and profile channels, not the preview tools.

* test(browser): audit the identity rig's global-fetch call sites

The wire probe server and CDP collector arrived with the cross-context coverage
and were never added to the audit list. The collector's two real call sites are
safe: the poll cancels its unread body and the version probe consumes it through
response.json(). Every hit in the probe server is inside an injected page or
worker script source string, not a call this process makes.

* fix(browser): strip an app name that contains a space

app.setName decides the app token in the user agent, and dev sets "Orca Dev".
The cleaner matched a single whitespace-delimited token, which cannot span that
space, so the replace failed outright and every dev build presented
"Orca Dev/1.4.203" on the wire — the exact token class that gets transplanted
sessions revoked.

Anchoring on the engine comment and consuming lazily up to Chrome/ removes any
number of app tokens. A user agent without that comment is returned unchanged
rather than mangled, because over-stripping is worse than under-stripping.

The function had no unit test at all; it was only exercised through the
real-Electron wire tests, which run with a single-token fixture name. That is
why this survived.

* fix(browser): anchor the cleaner on the gap before Chrome/

My first attempt anchored on the engine comment, which broke a startup fixture
whose platform comment is "(Test)" with no "(KHTML, like Gecko)" at all — the app
token survived and the ordering test went red.

Anchoring on the nearest ")" before Chrome/ and consuming only non-")" tokens
keeps the match inside that gap, so it handles a multi-word app name, a synthetic
platform comment, and an already-clean identity alike. A user agent with no such
gap is still returned unchanged.

The fixture shape is now a test case, since it is what caught the first attempt.

* test(browser): repair the cleaner's case table

A missing comma between two it.each elements was reformatted into an index
expression, collapsing the table so every case ran with undefined input.

* test(browser): make a CI-only capture failure diagnosable

This probe passes locally and fails on CI with an empty receipt set, an empty
CDP diagnostic list, and a fixture that still exits 0 — so the assertion message
carried nothing usable. Thread the fixture's own result and stderr into the
capture assertion so the next run says what the fixture actually did.

* fix(browser): let an explicit choice retire the migration notice for good

The retired per-profile userAgentMode bytes are retained on disk by design, so
every launch rediscovers them and re-arms the notice — including the launch
right after the user answers it, and every launch after that. Documented as
one-time, it was permanent.

The record already carries explicitSelection, which is exactly the fact that
should end the notice. Gate the mark at the single writer rather than deleting
the legacy key, so the retained bytes stay untouched and disk never claims a
notice is pending beside a choice the user already made.

The new test pushed the persistence suite past max-lines, so the in-memory fs
and module mocks move to a named fixture module and the retired-identity tests
move beside them in their own file.

* fix(browser): stop reporting an unhydratable profile as a retired choice

A profile that fails validation for a reason unrelated to identity — a non-UUID
id, a mismatched partition — armed both the notice and its degraded flag. Since
hydrateFromPersisted skips such entries silently and nothing ever repairs them,
the user got "an old browser identity choice could not be inspected" forever,
about a profile that never carried one.

Key the notice on the presence of userAgentMode instead, and use validation only
to decide whether the choice that was found is inspectable. Refusing to hydrate
an entry and finding a retired choice are now separate facts.

The old case table asserted the defect for null, 42 and 'broken', so it is
replaced by two tables stating the new contract rather than adapted to pass.

* fix(browser): stop rewriting worker requests for viewport emulation

A worker request carries no webContentsId, so it always took the session-wide
branch and picked up the mobile UA if any tab in the session had a mobile
preset. That made a single context disagree with itself: a desktop tab's shared
worker reported a desktop navigator.userAgent — the per-target CDP override
cannot reach a worker — while its fetches left as CriOS. It also leaked across
tabs, and closing the emulated tab silently reverted it.

On main the divergence was between contexts, each internally coherent. Making
one context internally inconsistent is worse by this PR's own standard, so
accept that viewport emulation reaches documents only. Workers keep the session
identity on the wire, which is the identity they report in JavaScript.

That left hasSessionMobileViewportIntent with no reader, so the map it fed and
its three accessors go too, rather than leaving a dead latch behind the guard.

The electron fixture models this rule in its own header hook, so its hook and
both mobile arms are rewritten around the invariant that each context's wire
identity equals the identity its own JavaScript reports — not adapted to keep
the old path list passing.

* test(browser): point the identity tests at keys and writers that exist

browserUserAgentMode appears in zero production files and zero commits on main;
`git log -S` finds nothing. The retired key is profile.userAgentMode inside
browser-session-meta.json. Two tests were built on the invented one.

The global-settings test is deleted rather than repointed: no browser identity
key has ever lived in global settings, and stripRetiredGlobalSettings strips
only three unrelated keys, so the test asserted that an arbitrary unknown key
survives an object spread — a fact about the normalizer, not about identity.

The ready-phase test asserted on writeFileAtomically while the identity store
writes through writeFileDurableSync, so it could not go red for the write it
existed to forbid. It now watches the real writer, matched on the record path so
an unrelated durable write cannot fail it for the wrong reason, and the invented
settings key is gone from the Store mock.

Proven by ablation: injecting a byte-identical rewrite of the record into ready
composition leaves every snapshot and record assertion green and is caught only
by the new assertion, while writeFileAtomically is never called.

* fix(browser): let an unavailable process identity reject instead of throwing

installBrowserSessionPartitionPolicies returned Promise<void> without being
async, and configures the user agent policy before any suspension point.
getBrowserProcessUserAgentIdentity throws when the process identity was never
initialized, so that throw escaped synchronously past every caller's handler:
`void install(...).catch(...)` in the registry, and a bare `void install(...)`
in the route policies, which has no handler at all.

Bookkeeping must never gate a user action. Session startup would have died on a
failure its callers were already written to absorb and report.

* docs(browser): scope the meta-store claim about dropped legacy keys

The comment said persistMeta drops legacy keys on the next write because the
loader no longer carries them. That holds for the top-level userAgent keys it
describes, but not for the retired per-profile userAgentMode: it sits inside
each BrowserSessionProfile in `profiles`, which is carried through untouched, so
those bytes survive every write.

Retaining them is deliberate — it is what makes rollback and data-loss machinery
unnecessary, and the startup notice keys on their presence — so the comment read
as broader cover than it provided, in the one place someone would look before
deciding it was safe to strip them.

* test(browser): pin the unmapped-webContents path beside an emulated tab

A popup carries a webContentsId that maps to no registered tab, so it resolves
through the same branch as a worker request that carries none at all. The branch
already handled both, but only the absent-id case was covered.

* test(browser): make the ordering fixture exhibit a multi-word app name

This file sets the dev app name to "Orca Development" and then used a
single-token user agent fixture, so it set up the multi-word scenario and used a
fixture that could not exhibit it — which is how the multi-word app-name leak
got through. The fixture now carries a two-word app token, matching what
app.setName produces in dev, and the assertion names both words: a single \S+
match would leave "Orca" on the wire and still pass a one-token check.

* test(settings): cover the local branch of the browser identity setting

The only existing test covered the remote-host branch. The local branch — load,
select, refused write, and reset-required — had none, and that is the path the
retired-identity notice sends users down to make the choice that retires it.

Covers the selected-mode render, the commit that reports restartRequired, a
refused write surfacing its message without showing the mode as changed, and the
reset-required state offering no control.

* test(browser): run the real registry path in the ready identity pin

The test stubbed browser-session-startup and browser-session-registry, which are
the one ready-phase path that can write the identity record, so the record
content assertion could not fail for the write it existed to forbid.

Both are now real. Only the pieces hanging off the identity path are stubbed —
partition policies, route sessions, cookie staging, webauthn — so the meta load,
the retired-choice inspection, the identity store and the durable write all run
for real against temp directories. The canonical path mock moves to
persistence/loading-store/user-data-path, which is where the registry reads it;
mocking persistence alone left the registry pointed elsewhere. The active
profile directory is now a real temp dir, so the seeded browser-session-meta.json
is actually found — against the old /test-profile literal the meta load found
nothing and the whole exercise would have been vacuous.

A third case proves the path is live: with no explicit choice, the same retired
profile arms the notice through ready and lands migrationNoticePending on disk.
The two authority cases assert the opposite, that an explicit choice leaves the
record untouched.

initializeBrowserSessionsForApp latches on module state, so each case resets
modules and imports ready dynamically.

Ablated: disabling the explicitSelection gate turns both authority cases red on
the record content assertion while the arming case stays green.

* fix(browser): reject an unrecognized identity mode at the IPC door

normalizeBrowserUserAgentMode turned any unrecognized value into 'clean', so the
IPC door reported success for a mode it had quietly replaced, while the RPC door
validates against z.enum(['clean', 'native']) and rejects. One concept answered
an unknown value two different ways, and a future mode name was silently
downgraded rather than refused.

The handler now rejects, which is what the RPC door does and what the renderer
already handles — its catch puts the message in the error slot. Returning a
result instead would have meant inventing a fourth error code for a case no
legitimate caller can reach.

normalizeBrowserUserAgentMode had no other consumer, so it goes with the change:
leaving a coercion helper called "normalize" in shared/ invites the behaviour
straight back in.

* fix(settings): name the reset command where identity data is unusable

When configuredMode is null the setting says identity data must be reset
explicitly and then offers no control, because the reset overwrites data that
may belong to a newer Orca. The only escape is the CLI, which the message never
named — so it told the user to do something and gave them no way to do it.

Copy only: one line naming the command, no control and no destructive action in
the UI. The command goes in a new key beside the existing sentence rather than
expanding its default, which keeps the already-translated string valid.

No en.json entry: this component has no catalog entries for any of its keys, so
English resolves from the call-site defaults and adding one only for the new key
would be inconsistent with its siblings.

* fix(i18n): add the browser identity keys to the localization catalog

* fix(i18n): regenerate the runtime-required English catalog

* fix(browser): attach nested CDP targets paused before enabling Network

An OOPIF or dedicated worker was reached only through Target.targetCreated plus
an explicit attachToTarget, which never pauses the target. The frame could issue
its subresource fetch before Network.enable took effect, so the capture came back
empty and the cross-context assertion failed under CI load.

Re-arm auto-attach on each attached session, filtered to nested target types, so
an OOPIF or worker arrives waiting for the debugger and its enables are ordered
ahead of the resume. Drop the explicit attach, which is now both redundant and
the racy path.

* fix(settings): localize the browser identity search keywords

* fix(browser): await route policy setup

* fix(browser): satisfy strict static analysis

* test(browser): update live identity fixture API

* test(browser): preserve native UA in live probe

* fix(browser): close the open review findings on the identity revert

- drop a stray JSDoc left over from the removed per-profile setting
- leave user agents without a Chromium engine comment byte-identical
  instead of anchoring the app-token strip on the OS comment and
  destroying a real engine token
- localize the browser identity unavailable error
- correct the worker comment: only shared and service worker requests
  carry no webContentsId, so emulation still reaches dedicated workers
- retire the session user agent policy when a profile is deleted

* test(browser): model a real Electron fallback in the startup UA fixture

The ordering fixture carried no "(KHTML, like Gecko)" engine comment, a
shape app.userAgentFallback cannot actually produce. That unfaithfulness
was what made the old over-stripping look correct, and it broke once the
cleaner started leaving non-Chromium identities alone.

Add the engine comment, keeping the two-word "Orca Development" app token
so the multi-word leak this test exists to catch is still caught. Both
assertions are unchanged.
This commit is contained in:
Brennan Benson
2026-09-16 10:31:01 -07:00
committed by GitHub
parent bdb18003e0
commit aee98ccaa0
111 changed files with 5441 additions and 1247 deletions
+1 -1
View File
@@ -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.
+7 -4
View File
@@ -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
+12 -36
View File
@@ -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)
)
)
}
+1 -2
View File
@@ -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')
}
+5
View File
@@ -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: [
+70 -50
View File
@@ -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(() => {})
+8 -1
View File
@@ -82,6 +82,14 @@ export const FLAG_HELP_TEXT: Record<string, string> = {
timezone: '--timezone <tz> 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 <n> 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<string, string> = {
page: '--page <id> Stable browser page id from `orca tab list --json`',
profile: '--profile <id> 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 <png|jpeg> Screenshot image format'
}
+63
View File
@@ -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<void> {
const status = await client.call<RuntimeStatus>('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<string, string | boolean>): 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<string, CommandHandler> = {
'browser identity get': async (context) => {
await assertBrowserIdentitySupported(context)
const result = await context.client.call<BrowserIdentityModeStatus>('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<BrowserIdentityModeSetResult>('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}.`
)
}
}
+1 -2
View File
@@ -38,8 +38,7 @@ export const BROWSER_PROFILE_HANDLERS: Record<string, CommandHandler> = {
const scope = parseScopeFlag(flags)
const result = await client.call<BrowserProfileCreateResult>('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
+14 -32
View File
@@ -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} <command> [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} <command> --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 <n> 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}`
}
+32
View File
@@ -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(() => {})
+1 -1
View File
@@ -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
}
-1
View File
@@ -144,7 +144,6 @@ export const ROOT_HELP_TEXT_SECONDARY = [
' --page <id> Stable browser page id (preferred for concurrent workflows)',
' --profile <id> 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 <png|jpeg> Screenshot image format',
' --from <ref> Drag source element ref',
' --to <ref> Drag target element ref',
+15 -3
View File
@@ -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 <clean|native> [--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 <name> [--scope <isolated|imported>] [--no-ua-spoof] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'label', 'scope', 'no-ua-spoof']
usage: 'orca tab profile create --label <name> [--scope <isolated|imported>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'label', 'scope']
},
{
path: ['tab', 'profile', 'delete'],
@@ -0,0 +1,202 @@
import { vi } from 'vitest'
type RegistryMock = ReturnType<typeof vi.fn>
/**
* 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<string, string>
present: Set<string>
}
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<string>(),
failIdentityWrite = false
): BrowserSessionRegistryMocks {
const sessionFromPartitionMock: ReturnType<typeof vi.fn> = 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
}
}
@@ -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.
@@ -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
})
})
})
@@ -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
}
@@ -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<typeof DurableFileWrite>()
return {
...actual,
writeFileDurableSync: (...args: Parameters<typeof actual.writeFileDurableSync>) => {
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])
})
})
@@ -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<BrowserIdentityModeSetResult> {
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<boolean> {
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
}
@@ -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)
@@ -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,
+76 -19
View File
@@ -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<BrowserSessionRequestUserAgentResolver>[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'
})
)
}
@@ -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()
+1 -5
View File
@@ -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<string, string>()
protected readonly sessionProfileIdByPageId = new Map<string, string | null>()
protected readonly userAgentModeByPageId = new Map<string, BrowserSessionUserAgentMode>()
// 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<string, Promise<unknown>>()
// Why: presence means the preset requires a CDP UA override (installed or in flight), so navigation
@@ -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,
@@ -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 () => {
+10 -8
View File
@@ -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')
@@ -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)
})
})
@@ -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)
})
})
@@ -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
}
@@ -18,7 +18,7 @@ type BrowserRouteSessionPolicyDependencies = {
partition: string
browserProfileId: string
session: BrowserRouteElectronSession
}): void
}): void | Promise<void>
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
@@ -22,7 +22,7 @@ export type BrowserRouteSessionRegistryDependencies = {
partition: string
browserProfileId: string
session: BrowserRouteElectronSession
}): void
}): void | Promise<void>
clearPolicies(input: { partition: string; session: BrowserRouteElectronSession }): void
retirePageAuthority(input: BrowserRoutePageAuthorityRetirement): boolean
bindingStore: BrowserRoutePartitionBindingStore
@@ -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')
})
@@ -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)
},
@@ -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
@@ -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
}
}
@@ -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<string>()
const userAgentPolicyDisposerBySession = new WeakMap<Session, () => 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<void> {
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) */
}
}
}
@@ -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<void>, 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<void> | 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.
@@ -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<string, unknown> & {
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<string, unknown>
): profile is PersistedProfileWithUserAgentMode {
return Object.hasOwn(profile, 'userAgentMode')
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function isProfileOwnedSessionPartition(
profileId: string,
partition: string,
@@ -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<void> {
const retirement = retireProxySessionApplication(sess)
try {
clearBrowserSessionUserAgentMode(sess)
clearBrowserSessionPartitionPolicies(partition, sess)
} catch {
// Best-effort policy cleanup must not skip retirement.
@@ -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)
})
})
@@ -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['"]/)
})
@@ -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<string, string>
present: Set<string>
}
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<string>()
): {
sessionFromPartitionMock: ReturnType<typeof vi.fn>
cleanElectronUserAgentMock: ReturnType<typeof vi.fn>
setupGoogleAuthUserAgentOverrideMock: ReturnType<typeof vi.fn>
browserManagerHandleGuestWillDownloadMock: ReturnType<typeof vi.fn>
browserManagerNotifyPermissionDeniedMock: ReturnType<typeof vi.fn>
requestSystemMediaAccessMock: ReturnType<typeof vi.fn>
} {
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, {
@@ -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<typeof vi.fn>) => 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()(
+25 -20
View File
@@ -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<void> {
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<BrowserSessionProfile | null> {
// 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.
@@ -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<void> {
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)
}
@@ -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<Record<string, string>>
}>
type PendingRequest = {
targetType: string
resourceType?: string
url?: string
headers?: Record<string, string>
}
// 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<string, unknown> | 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<string, unknown>
}
function readString(record: Record<string, unknown> | undefined, key: string): string | undefined {
const value = record?.[key]
return typeof value === 'string' ? value : undefined
}
function readStringRecord(value: unknown): Record<string, string> | undefined {
const record = readRecord(value)
if (!record) {
return undefined
}
const strings: Record<string, string> = {}
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<string, unknown>
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<string, string>()
private readonly requests = new Map<string, PendingRequest[]>()
private readonly webSockets = new Map<string, PendingRequest>()
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<BrowserSessionUaCdpCollector> {
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<void>((resolve, reject) => {
socket.once('open', resolve)
socket.once('error', reject)
})
return new BrowserSessionUaCdpCollector(socket)
}
async installAutoAttach(): Promise<void> {
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<void> {
if (this.socket.readyState === WebSocket.CLOSED) {
return
}
await new Promise<void>((resolve) => {
this.socket.once('close', () => resolve())
this.socket.close()
})
}
private send(
method: string,
params: Record<string, unknown>,
sessionId?: string
): Promise<unknown> {
const id = this.nextCommandId++
const promise = new Promise<unknown>((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<void> {
// 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<void> {
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')
}
@@ -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<BrowserSessionUaCdpCollector['snapshot']>
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<LiveRun> {
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<ReturnType<typeof chromium.connectOverCDP>> | 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<void> {
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<string, Record<string, number>> = {}
for (const request of run.requests) {
const userAgent = request.userAgent ?? '<missing>'
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<string, number> = {}
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<number> {
const server = createServer()
await new Promise<void>((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<void>((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 }))
})
}
@@ -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<ProbeResult> {
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<ReturnType<typeof chromium.connectOverCDP>> | 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<void> {
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<Record<string, boolean>> {
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<number> {
const server = createServer()
await new Promise<void>((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<void>((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 }))
})
}
@@ -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<string, string>
}
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('<!doctype html><title>identity</title>')
}
)
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<ProbeResult> {
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<ReturnType<typeof chromium.connectOverCDP>> | 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<FixtureResult> {
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<void> {
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<number> {
const server = createServer()
await new Promise<void>((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<void>((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 }))
})
}
@@ -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<Record<string, string>>
}>
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<void>
}>
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<BrowserSessionUaWireProbeServer> {
const receipts: WireProbeReceipt[] = []
const identities: WireProbeJavaScriptIdentity[] = []
const upgradedSockets = new Set<Duplex>()
let origins: { http: string; https: string } | null = null
const respond =
(protocol: 'http' | 'https') =>
async (request: IncomingMessage, response: ServerResponse): Promise<void> => {
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 = `<!doctype html><script>${blobScript}</script>`
const serializedBlobDocument = JSON.stringify(blobDocument).replace('</script>', '<\\/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 `<!doctype html><title>UA wire probe</title><script>
const identity = () => ({ userAgent: navigator.userAgent, userAgentData: navigator.userAgentData ? { brands: navigator.userAgentData.brands, mobile: navigator.userAgentData.mobile, platform: navigator.userAgentData.platform } : null })
const report = context => fetch(${JSON.stringify(httpOrigin)} + '/report/' + context, { method: 'POST', body: JSON.stringify(identity()) })
const fetchRoute = path => fetch(${JSON.stringify(httpOrigin)} + path).then(response => response.text())
const xhrRoute = path => new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('GET', ${JSON.stringify(httpOrigin)} + path); xhr.onload = resolve; xhr.onerror = reject; xhr.send() })
const imageRoute = path => new Promise((resolve, reject) => { const image = new Image(); image.onload = resolve; image.onerror = reject; image.src = ${JSON.stringify(httpOrigin)} + path })
const message = context => new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error(context + ' timeout')), 10000); const listener = event => { if (event.data?.context !== context) return; clearTimeout(timeout); removeEventListener('message', listener); resolve(event.data) }; addEventListener('message', listener) })
const socket = url => new Promise((resolve, reject) => { const ws = new WebSocket(url); ws.onopen = () => { ws.close(); resolve() }; ws.onerror = reject })
window.probePromise = (async () => {
await report('document')
const frameDone = message('frame'); const frame = document.createElement('iframe'); frame.src = ${JSON.stringify(httpOrigin)} + '/frame'; document.body.append(frame)
${crossSiteFrameScript}
const blobDone = message('blob'); const blob = document.createElement('iframe'); blob.src = URL.createObjectURL(new Blob([${serializedBlobDocument}], { type: 'text/html' })); document.body.append(blob)
const sharedDone = message('shared-worker'); const shared = new SharedWorker(${JSON.stringify(httpOrigin)} + '/shared-worker.js'); shared.port.start(); shared.port.onmessage = event => postMessage(event.data, '*')
${dedicatedWorkerScriptText}
const serviceDone = message('service-worker'); const registration = await navigator.serviceWorker.register('/service-worker.js'); await navigator.serviceWorker.ready; navigator.serviceWorker.addEventListener('message', event => postMessage(event.data, '*')); (navigator.serviceWorker.controller || registration.active).postMessage('probe')
const popupDone = message('popup'); window.open(${JSON.stringify(httpOrigin)} + '/popup', '_blank')
await Promise.all([
fetchRoute('/document-fetch'), xhrRoute('/document-xhr'), imageRoute('/document-image'),
socket('ws://' + new URL(${JSON.stringify(httpOrigin)}).host + '/plain-ws'),
socket('wss://' + new URL(${JSON.stringify(httpsOrigin)}).host + '/secure-ws'),
frameDone, blobDone, sharedDone${crossContext ? ', dedicatedDone' : ''}, serviceDone, popupDone
])
return true
})()
</script>`
}
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 `<!doctype html><script>(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) }); ${extra} ${crossSiteFetch}; (opener || parent).postMessage({ context: '${context}' }, '*') })()</script>`
}
function desktopPeerPage(httpOrigin: string): string {
return `<!doctype html><script>
const identity = { userAgent: navigator.userAgent, userAgentData: navigator.userAgentData ? { brands: navigator.userAgentData.brands, mobile: navigator.userAgentData.mobile, platform: navigator.userAgentData.platform } : null }
window.peerProbePromise = (async () => {
await fetch(${JSON.stringify(httpOrigin)} + '/report/desktop-peer', { method: 'POST', body: JSON.stringify(identity) })
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('desktop peer shared-worker timeout')), 10000)
const shared = new SharedWorker(${JSON.stringify(httpOrigin)} + '/shared-worker.js')
shared.port.start()
shared.port.onmessage = () => { clearTimeout(timeout); resolve() }
})
return true
})()
</script>`
}
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<typeof createHttpServer> | ReturnType<typeof createHttpsServer>,
protocol: 'ws' | 'wss',
receipts: WireProbeReceipt[],
upgradedSockets: Set<Duplex>
): 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<typeof createHttpServer>): Promise<void> {
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<typeof createHttpServer>): Promise<void> {
server.closeAllConnections()
return new Promise((resolve) => server.close(() => resolve()))
}
function readBody(request: IncomingMessage): Promise<string> {
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<string, string> {
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)
}
+110 -40
View File
@@ -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<string, string>,
metadata: NonNullable<ViewportUserAgentOverride['userAgentMetadata']>
): void {
const values: Record<string, string> = {
'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)
}
}
@@ -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<string, unknown> {
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<string, unknown> = {}): Record<string, unknown> {
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
})
})
})
@@ -1,22 +0,0 @@
import type { Session } from 'electron'
import type { BrowserSessionUserAgentMode } from '../../shared/browser-workspace-types'
const userAgentModeBySession = new WeakMap<Session, BrowserSessionUserAgentMode>()
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)
}
@@ -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() }
@@ -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
}
@@ -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()
)
})
+1 -2
View File
@@ -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
@@ -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)
}
@@ -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) {
@@ -42,6 +42,12 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map<string, number>([
['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
@@ -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
@@ -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<unknown> {
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')
})
})
+29 -8
View File
@@ -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<BrowserSessionProfile | null> => {
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<boolean> => {
-3
View File
@@ -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) {
+4 -11
View File
@@ -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 () => {
@@ -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).
@@ -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() })
@@ -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 })
})
})
@@ -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
@@ -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'
+30 -10
View File
@@ -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 () => {
+2
View File
@@ -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,
@@ -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<BrowserProfileCreateResult> {
return {
profile: await browserSessionRegistry.createProfile(params.scope, params.label, {
userAgentMode: params.userAgentMode
})
profile: await browserSessionRegistry.createProfile(params.scope, params.label)
}
}
+46 -1
View File
@@ -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))
})
})
+25
View File
@@ -1,4 +1,7 @@
import type { BrowserIdentityModeStatus } from '../../shared/browser-user-agent-mode'
type DiagnosticConsole = Pick<Console, 'debug' | 'error' | 'info' | 'log'>
type StderrTarget = Pick<NodeJS.WriteStream, 'write'>
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`)
}
}
@@ -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/)
})
})
+1
View File
@@ -6,6 +6,7 @@ export const CLI_COMMAND_NAMES = [
'artifacts',
'automations',
'back',
'browser',
'capture',
'check',
'claude-teams',
+11 -9
View File
@@ -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
// ("<app 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()
@@ -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<typeof DurableFileWrite>()
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<typeof actual.writeFileDurableSync>) => {
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<void> {
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
})
})
})
@@ -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<typeof state.runtime>
@@ -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)
}
+11 -7
View File
@@ -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<BrowserSessionProfile | null>
sessionCreateProfile: (args: {
scope: BrowserSessionProfileScope
label: string
}) => Promise<BrowserSessionProfile | null>
identityGet: () => Promise<BrowserIdentityModeStatus | null>
identitySet: (mode: BrowserUserAgentMode) => Promise<BrowserIdentityModeSetResult | null>
sessionDeleteProfile: (args: { profileId: string }) => Promise<boolean>
sessionImportCookies: (args: { profileId: string }) => Promise<BrowserCookieImportResult>
sessionResolvePartition: (args: { profileId: string | null }) => Promise<string | null>
@@ -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<boolean> =>
ipcRenderer.invoke('browser:session:deleteProfile', args),
sessionImportCookies: (args: { profileId: string }) =>
@@ -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()
}
@@ -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('<Toaster closeButton')
})
it('mounts the browser identity migration notice from the app shell, not guest registration', () => {
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(
@@ -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<string | null | undefined>(
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)
}}
/>
</>
@@ -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"
/>
<div className="mb-4">
<BrowserProfileUserAgentOption
checked={useNativeUserAgent}
onCheckedChange={onUseNativeUserAgentChange}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" size="sm" onClick={onCancelNewProfile}>
{translate('auto.components.browser.pane.BrowserToolbarMenu.429ef481f9', 'Cancel')}
@@ -0,0 +1,46 @@
import { useEffect } from 'react'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { BROWSER_USER_AGENT_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types'
export function useBrowserIdentityMigrationNotice(): void {
useEffect(() => {
void window.api.browser
.identityGet()
.then((status) => {
if (!status?.migrationNotice) {
return
}
const description = status.migrationNotice.degraded
? translate(
'browser.userAgentMigration.degradedDescription',
'An old browser identity choice could not be inspected. Choose Cleaned or Native in Browser settings; changes take effect after a restart.'
)
: translate(
'browser.userAgentMigration.description',
'Per-profile user-agent settings were removed. Choose Cleaned or Native in Browser settings; changes take effect after a restart.'
)
toast.warning(
translate('browser.userAgentMigration.title', 'Browser identity is now app-wide'),
{
description,
duration: Infinity,
action: {
label: translate('browser.userAgentMigration.openSettings', 'Open Settings'),
onClick: () => {
const store = useAppStore.getState()
store.openSettingsPage()
store.openSettingsTarget({
pane: 'browser',
repoId: null,
sectionId: BROWSER_USER_AGENT_SETTINGS_TARGET_ID
})
}
}
}
)
})
.catch(() => {})
}, [])
}
@@ -1,43 +0,0 @@
// @vitest-environment happy-dom
import { act, useState } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it } from 'vitest'
import { BrowserProfileUserAgentOption } from './browser-profile-user-agent-option'
let root: Root | null = null
let container: HTMLDivElement | null = null
afterEach(() => {
act(() => root?.unmount())
container?.remove()
root = null
container = null
})
function renderOption(): HTMLDivElement {
function Harness(): React.JSX.Element {
const [checked, setChecked] = useState(false)
return <BrowserProfileUserAgentOption checked={checked} onCheckedChange={setChecked} />
}
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => root?.render(<Harness />))
return container
}
describe('BrowserProfileUserAgentOption', () => {
it('explains the compatibility tradeoff and toggles the native mode', () => {
const rendered = renderOption()
const checkbox = rendered.querySelector<HTMLButtonElement>('[role="checkbox"]')
expect(rendered.textContent).toContain('May improve Google sign-in')
expect(checkbox?.getAttribute('aria-checked')).toBe('false')
act(() => checkbox?.click())
expect(checkbox?.getAttribute('aria-checked')).toBe('true')
})
})
@@ -1,43 +0,0 @@
import { useId } from 'react'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { translate } from '@/i18n/i18n'
type BrowserProfileUserAgentOptionProps = {
checked: boolean
onCheckedChange: (checked: boolean) => void
}
export function BrowserProfileUserAgentOption({
checked,
onCheckedChange
}: BrowserProfileUserAgentOptionProps): React.JSX.Element {
const id = useId()
const descriptionId = `${id}-description`
return (
<div className="flex items-start gap-3 rounded-md border border-border/70 p-3">
<Checkbox
id={id}
checked={checked}
onCheckedChange={(nextChecked) => onCheckedChange(nextChecked === true)}
aria-describedby={descriptionId}
className="mt-0.5"
/>
<div className="space-y-1">
<Label htmlFor={id} className="text-sm">
{translate(
'auto.components.browser.profile.user.agent.option.04af3dc12b',
'Use unmodified user agent'
)}
</Label>
<p id={descriptionId} className="text-xs text-muted-foreground">
{translate(
'auto.components.browser.profile.user.agent.option.5bf47a3c91',
'May improve Google sign-in, but can reduce compatibility with bot-protected sites.'
)}
</p>
</div>
</div>
)
}
@@ -6,7 +6,6 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '
import { useAppStore } from '../../store'
import { useMountedRef } from '@/hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
import { BrowserProfileUserAgentOption } from '../browser-profile-user-agent-option'
type BrowserNewProfileDialogProps = {
open: boolean
@@ -19,13 +18,11 @@ export function BrowserNewProfileDialog({
}: BrowserNewProfileDialogProps): React.JSX.Element {
const mountedRef = useMountedRef()
const [newProfileName, setNewProfileName] = useState('')
const [useNativeUserAgent, setUseNativeUserAgent] = useState(false)
const [isCreatingProfile, setIsCreatingProfile] = useState(false)
const handleClose = (): void => {
onOpenChange(false)
setNewProfileName('')
setUseNativeUserAgent(false)
}
return (
@@ -54,11 +51,7 @@ export function BrowserNewProfileDialog({
try {
const profile = await useAppStore
.getState()
.createBrowserSessionProfile(
'isolated',
trimmed,
useNativeUserAgent ? { userAgentMode: 'native' } : undefined
)
.createBrowserSessionProfile('isolated', trimmed)
if (!mountedRef.current) {
return
}
@@ -97,12 +90,6 @@ export function BrowserNewProfileDialog({
maxLength={50}
className="mb-3"
/>
<div className="mb-4">
<BrowserProfileUserAgentOption
checked={useNativeUserAgent}
onCheckedChange={setUseNativeUserAgent}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" size="sm" onClick={handleClose}>
{translate('auto.components.settings.BrowserPane.81ff774667', 'Cancel')}
@@ -16,6 +16,7 @@ import { BrowserTerminalLinkActionsSetting } from './BrowserTerminalLinkActionsS
import { BrowserLocalhostWorktreeLabelsSetting } from './BrowserLocalhostWorktreeLabelsSetting'
import { BrowserClientHostedRemoteSetting } from './BrowserClientHostedRemoteSetting'
import { BrowserSshWorkspaceRoutingSetting } from './BrowserSshWorkspaceRoutingSetting'
import { BrowserUserAgentSetting } from './BrowserUserAgentSetting'
import { SettingsSubsectionHeader } from './SettingsFormControls'
import { BrowserSessionCookiesSection } from './BrowserSessionCookiesSection'
import { BrowserNewProfileDialog } from './BrowserNewProfileDialog'
@@ -119,6 +120,7 @@ export function BrowserPane({
const showSshWorkspaceRouting = matchesSettingsSearch(searchQuery, [
getBrowserPaneSearchEntries()[9]
])
const showUserAgent = matchesSettingsSearch(searchQuery, [getBrowserPaneSearchEntries()[10]])
const showBrowserUse = matchesSettingsSearch(searchQuery, getBrowserUsePaneSearchEntries())
const isMac = isMacUserAgent()
const linkRoutingDescription = getBrowserLinkRoutingDescription(
@@ -250,6 +252,10 @@ export function BrowserPane({
/>
) : null}
{showUserAgent ? (
<BrowserUserAgentSetting hostId={settingsFocusedHostId} />
) : null}
{showLinkRouting ? (
<BrowserLinkRoutingSetting
settings={settings}
@@ -113,10 +113,6 @@ export function BrowserProfileRow({
const sourceLabel = profile.source
? `${BROWSER_FAMILY_LABELS[profile.source.browserFamily] ?? profile.source.browserFamily}${profile.source.profileName ? ` (${profile.source.profileName})` : ''}`
: translate('auto.components.settings.BrowserProfileRow.796d846483', 'No cookies imported')
const userAgentLabel =
profile.userAgentMode === 'native'
? translate('auto.components.settings.BrowserProfileRow.b5c0479e21', 'Unmodified user agent')
: null
// Why: uses div[role=button] instead of <button> to avoid nested <button>
// elements — the dropdown trigger and trash actions inside also render as
@@ -147,10 +143,7 @@ export function BrowserProfileRow({
</span>
) : null}
</div>
<p className="truncate text-[11px] text-muted-foreground">
{sourceLabel}
{userAgentLabel ? ` · ${userAgentLabel}` : ''}
</p>
<p className="truncate text-[11px] text-muted-foreground">{sourceLabel}</p>
</div>
<div className="flex shrink-0 items-center gap-1" onClick={(e) => e.stopPropagation()}>
<DropdownMenu
@@ -0,0 +1,131 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host'
import { TooltipProvider } from '../ui/tooltip'
import { BrowserUserAgentSetting } from './BrowserUserAgentSetting'
const identityGet = vi.fn()
const identitySet = vi.fn()
function renderFor(hostId: ExecutionHostId): void {
render(
<TooltipProvider>
<BrowserUserAgentSetting hostId={hostId} />
</TooltipProvider>
)
}
function statusFor(
mode: 'clean' | 'native',
overrides: Record<string, unknown> = {}
): Record<string, unknown> {
return {
identity: {
state: 'valid',
appliedMode: mode,
configuredMode: mode,
explicitSelection: true,
migrationNoticePending: false,
restartRequired: false,
...overrides
},
migrationNotice: null
}
}
describe('BrowserUserAgentSetting', () => {
beforeEach(() => {
identityGet.mockReset()
identitySet.mockReset()
Object.defineProperty(window, 'api', {
configurable: true,
value: { browser: { identityGet, identitySet } }
})
})
afterEach(cleanup)
it('never substitutes the local identity while Remote Settings is focused', () => {
identityGet.mockResolvedValue(statusFor('native'))
renderFor('runtime:remote-host')
expect(identityGet).not.toHaveBeenCalled()
expect(screen.getByText(/manage browser identity on the remote host/i)).toBeTruthy()
expect(screen.queryByRole('radiogroup')).toBeNull()
})
it('shows the configured mode as the selected option on the local host', async () => {
identityGet.mockResolvedValue(statusFor('native'))
renderFor(LOCAL_EXECUTION_HOST_ID)
const native = await screen.findByRole('radio', { name: 'Native' })
expect(native.getAttribute('aria-checked')).toBe('true')
expect(screen.getByRole('radio', { name: 'Cleaned' }).getAttribute('aria-checked')).toBe(
'false'
)
})
// This is the path the retired-identity notice sends the user down: it asks them to choose, and
// choosing is what retires the notice for good. It had no coverage.
it('commits the chosen mode and reports that a restart is required', async () => {
identityGet.mockResolvedValue(statusFor('clean'))
identitySet.mockResolvedValue({
ok: true,
identity: {
state: 'valid',
appliedMode: 'clean',
configuredMode: 'native',
explicitSelection: true,
migrationNoticePending: false,
restartRequired: true
}
})
renderFor(LOCAL_EXECUTION_HOST_ID)
fireEvent.click(await screen.findByRole('radio', { name: 'Native' }))
await waitFor(() => expect(screen.getByText(/restart required/i)).toBeTruthy())
expect(identitySet).toHaveBeenCalledWith('native')
expect(screen.getByRole('radio', { name: 'Native' }).getAttribute('aria-checked')).toBe('true')
})
it('surfaces a refused write instead of showing the mode as changed', async () => {
identityGet.mockResolvedValue(statusFor('clean'))
identitySet.mockResolvedValue({
ok: false,
error: { code: 'browser_identity_reset_required', message: 'Identity data is corrupt' },
identity: statusFor('clean').identity
})
renderFor(LOCAL_EXECUTION_HOST_ID)
fireEvent.click(await screen.findByRole('radio', { name: 'Native' }))
await waitFor(() => expect(screen.getByText('Identity data is corrupt')).toBeTruthy())
expect(screen.getByRole('radio', { name: 'Cleaned' }).getAttribute('aria-checked')).toBe('true')
})
it('offers no control when identity data must be reset first', async () => {
identityGet.mockResolvedValue({
identity: {
state: 'corrupt',
appliedMode: 'clean',
configuredMode: null,
explicitSelection: null,
migrationNoticePending: null,
restartRequired: false
},
migrationNotice: null
})
renderFor(LOCAL_EXECUTION_HOST_ID)
expect(await screen.findByText(/must be reset explicitly/i)).toBeTruthy()
expect(screen.queryByRole('radiogroup')).toBeNull()
// Naming the escape is the whole point: the UI exposes no reset control, so without the
// command this state tells the user to do something with no way to do it.
expect(screen.getByText(/orca browser identity set --mode <mode> --reset/i)).toBeTruthy()
})
})
@@ -0,0 +1,158 @@
import { useEffect, useState } from 'react'
import type {
BrowserIdentityModeStatus,
BrowserUserAgentMode
} from '../../../../shared/browser-user-agent-mode'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host'
import { BROWSER_USER_AGENT_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types'
import { translate } from '@/i18n/i18n'
import { SearchableSetting } from './SearchableSetting'
import { SettingsRow, SettingsSegmentedControl } from './SettingsFormControls'
type BrowserUserAgentSettingProps = {
hostId: ExecutionHostId
}
export function BrowserUserAgentSetting({
hostId
}: BrowserUserAgentSettingProps): React.JSX.Element {
const [status, setStatus] = useState<BrowserIdentityModeStatus | null>(null)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const title = translate('settings.browser.userAgent.title', 'Browser identity')
const description = translate(
'settings.browser.userAgent.description',
'Choose the user agent for every browser profile and page. Native mode disables Google sign-in. Changes take effect after a restart.'
)
const isLocal = hostId === LOCAL_EXECUTION_HOST_ID
useEffect(() => {
setStatus(null)
setError(null)
if (!isLocal) {
return
}
let disposed = false
void window.api.browser
.identityGet()
.then((nextStatus) => {
if (!disposed) {
setStatus(nextStatus)
}
})
.catch((reason) => {
if (!disposed) {
setError(reason instanceof Error ? reason.message : String(reason))
}
})
return () => {
disposed = true
}
}, [isLocal])
const setMode = (mode: BrowserUserAgentMode): void => {
setSaving(true)
setError(null)
void window.api.browser
.identitySet(mode)
.then((result) => {
if (!result) {
setError(
translate('settings.browser.userAgent.unavailable', 'Browser identity is unavailable.')
)
} else if (!result.ok) {
setError(result.error.message)
} else {
setStatus({ identity: result.identity, migrationNotice: null })
}
})
.catch((reason) => setError(reason instanceof Error ? reason.message : String(reason)))
.finally(() => setSaving(false))
}
let control: React.JSX.Element
if (!isLocal) {
control = (
<span className="text-xs text-muted-foreground">
{translate(
'settings.browser.userAgent.remoteUnsupported',
'Manage browser identity on the remote host with the Orca CLI.'
)}
</span>
)
} else if (!status) {
control = (
<span className="text-xs text-muted-foreground">
{error ?? translate('settings.browser.userAgent.loading', 'Loading…')}
</span>
)
} else if (status.identity.configuredMode === null) {
// Why the command is named here: this state deliberately exposes no reset control, because the
// reset overwrites data that may belong to a newer Orca. Without naming the escape the message
// tells the user their data must be reset and then offers no way to do it.
control = (
<div className="space-y-1 text-right">
<div className="text-xs text-destructive">
{translate(
'settings.browser.userAgent.resetRequired',
'Identity data must be reset explicitly before it can be changed.'
)}
</div>
<div className="text-[11px] text-muted-foreground">
{translate(
'settings.browser.userAgent.resetRequiredCommand',
'Reset it from the command line: orca browser identity set --mode <mode> --reset'
)}
</div>
</div>
)
} else {
control = (
<div className="space-y-1 text-right">
<SettingsSegmentedControl<BrowserUserAgentMode>
size="sm"
ariaLabel={title}
value={status.identity.configuredMode}
onChange={setMode}
options={[
{
value: 'clean',
disabled: saving,
label: translate('settings.browser.userAgent.optionClean', 'Cleaned'),
tooltip: translate(
'settings.browser.userAgent.optionCleanTooltip',
'Removes Orca and Electron tokens to match imported Chrome sessions.'
)
},
{
value: 'native',
disabled: saving,
label: translate('settings.browser.userAgent.optionNative', 'Native'),
tooltip: translate(
'settings.browser.userAgent.optionNativeTooltip',
"Keeps Electron's built-in identity for sites that reject the cleaned identity. Google sign-in is unavailable in Native mode."
)
}
]}
/>
{status.identity.restartRequired ? (
<div className="text-[11px] text-muted-foreground">
{translate('settings.browser.userAgent.restartRequired', 'Restart required')}
</div>
) : null}
{error ? <div className="text-[11px] text-destructive">{error}</div> : null}
</div>
)
}
return (
<SearchableSetting
id={BROWSER_USER_AGENT_SETTINGS_TARGET_ID}
title={title}
description={description}
keywords={['browser', 'identity', 'user agent', 'native', 'cleaned', 'restart']}
>
<SettingsRow label={title} description={description} control={control} />
</SearchableSetting>
)
}
@@ -106,7 +106,8 @@ describe('browser link routing modifier copy', () => {
'Localhost Worktree Labels',
'Session & Cookies',
'Remote server workspaces',
'SSH workspaces'
'SSH workspaces',
'Browser identity'
])
})
@@ -1,6 +1,7 @@
import type { SettingsSearchEntry } from './settings-search'
import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from './settings-search-keywords'
import { getBrowserUserAgentSearchEntry } from './browser-user-agent-search'
import {
getBrowserLinkRoutingDescription,
getTerminalLinkActionsDescription,
@@ -287,6 +288,7 @@ export function getBrowserPaneSearchEntries(
'network'
)
]
}
},
getBrowserUserAgentSearchEntry()
]
}
@@ -0,0 +1,32 @@
import type { SettingsSearchEntry } from './settings-search'
import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from './settings-search-keywords'
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
export const getBrowserUserAgentSearchEntry = createLocalizedCatalog((): SettingsSearchEntry => ({
title: translate('settings.browser.userAgent.title', 'Browser identity'),
description: translate(
'settings.browser.userAgent.description',
'Choose the user agent for every browser profile and page. Native mode disables Google sign-in. Changes take effect after a restart.'
),
keywords: [
...translateSearchKeyword('auto.components.settings.browser.search.2d2d995c58', 'browser'),
...translateSearchKeyword(
'auto.components.settings.browser.search.userAgent.identity',
'identity'
),
...translateSearchKeyword(
'auto.components.settings.browser.search.userAgent.userAgent',
'user agent'
),
...translateSearchKeyword('auto.components.settings.browser.search.userAgent.native', 'native'),
...translateSearchKeyword(
'auto.components.settings.browser.search.userAgent.cleaned',
'cleaned'
),
...translateSearchKeyword(
'auto.components.settings.browser.search.userAgent.restart',
'restart'
)
]
}))
+11
View File
@@ -324,6 +324,16 @@
"BrowserToolbarMenu": {
"6aa42813e4": "Imported {{value0}} cookies from {{value1}}{{value2}}."
}
},
"profile": {
"user": {
"agent": {
"option": {
"04af3dc12b": "Use unmodified user agent",
"5bf47a3c91": "May improve Google sign-in, but can reduce compatibility with bot-protected sites."
}
}
}
}
},
"contextual": {
@@ -1129,6 +1139,7 @@
},
"BrowserProfileRow": {
"7df818977e": "From",
"b5c0479e21": "Unmodified user agent",
"d420c43729": "Imported {{value0}} cookies from {{value1}}{{value2}} into {{value3}}."
},
"BrowserUsePane": {

Some files were not shown because too many files have changed in this diff Show More