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