From 437ccb655ca493b18999838b497a3be5d1087d06 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 04:05:12 +0000 Subject: [PATCH] fix(browser): let Google sign-in pass in the built-in browser accounts.google.com rejected the Tools panel browser with "This browser or app may not be secure" even with Electron tokens stripped. Reduce the guest Chrome version to its major, as shipping Chrome reports it, and send a current Firefox user agent with no Client Hints only to accounts.google.com, as other Electron browsers (Min) do. Every other host keeps the Chromium identity; profiles, partitions and permissions are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015BrmXrPTREwTbMKNkmHDok --- docs/environment-browser.md | 2 +- main/services/browser/core.test.ts | 44 ++++++++++++++++++++++++++-- main/services/browser/core.ts | 46 ++++++++++++++++++++++++++++-- 3 files changed, 85 insertions(+), 7 deletions(-) diff --git a/docs/environment-browser.md b/docs/environment-browser.md index a8dc94f3a..9284174c0 100644 --- a/docs/environment-browser.md +++ b/docs/environment-browser.md @@ -9,7 +9,7 @@ The browser is adapted from the local T3 Code reference. Its selector runtime is | Surface | Behavior | | --- | --- | | Navigation | Tabs, address field, back/forward, reload/stop, hard reload, loading/failure state, favicon, mute, ten recent URLs per workspace, and detected terminal dev-server URLs. Recents and open tabs are kept in memory for the app session. | -| Viewport | Fill, editable dimensions, the 17 reference device presets, rotation, ratio lock, drag handles, zoom, and system/light/dark page appearance. Presets change layout, not the browser user agent. Guest pages use a Chromium user agent (Electron and Aiden product tokens are stripped) so ordinary site sign-in, including Google, is not rejected as an insecure embedded app. Electron sends no UA Client Hint headers itself, so requests to secure and loopback origins carry only the default `sec-ch-ua`, `sec-ch-ua-mobile`, and `sec-ch-ua-platform` hints, derived from the same Chromium brand list the page sees in `navigator.userAgentData`; no "Google Chrome" brand or high-entropy hint is forged. | +| Viewport | Fill, editable dimensions, the 17 reference device presets, rotation, ratio lock, drag handles, zoom, and system/light/dark page appearance. Presets change layout, not the browser user agent. Guest pages use a Chromium user agent with Electron and Aiden product tokens stripped and the Chrome version reduced to its major (`Chrome/150.0.0.0`), as shipping Chrome reports it. Google's sign-in host rejects embedded Chromium even so, so requests to `https://accounts.google.com` alone carry a current Firefox user agent and no Client Hints, the approach other Electron browsers use; every other host, including other Google hosts, keeps the Chromium identity. Google's check runs server-side and is confirmed only by signing in on a real Mac, not by CI. Electron sends no UA Client Hint headers itself, so requests to secure and loopback origins carry only the default `sec-ch-ua`, `sec-ch-ua-mobile`, and `sec-ch-ua-platform` hints, derived from the same Chromium brand list the page sees in `navigator.userAgentData`; no "Google Chrome" brand or high-entropy hint is forged. | | Capture | Screenshot copy/save, recording start/stop with a local WebM artifact, a draggable/resizable browser over chat, and a separate picture-in-picture window. Native page views stay on screen when chat popovers, hover cards, or agent-step details do not cover the browser slot; full-window dialog overlays still hide the guest so it cannot intercept clicks, including when the modal's accessibility isolation marks its own overlay `aria-hidden`. | | Profiles | Default, memory-only Incognito, and named persistent profiles. Profile changes apply to new tabs. Cookies/cache can be cleared separately. | | Cookie import | Explicit one-time import from supported locally discovered browsers. Quit the source browser first. Import copies cookies only, skips unsupported partitioned/encrypted records, and reports partial results. Safari requires the OS file permission. | diff --git a/main/services/browser/core.test.ts b/main/services/browser/core.test.ts index 12d1fe632..ac5dbb4df 100644 --- a/main/services/browser/core.test.ts +++ b/main/services/browser/core.test.ts @@ -4,6 +4,7 @@ import { applyBrowserGuestIdentityHeaders, browserGuestBrands, browserGuestUserAgent, + browserGoogleSignInUserAgent, browserUrl, browserDisplayUrl, browserRedactPreviewUrls, @@ -39,12 +40,13 @@ test("navigation normalizes public and loopback hosts but refuses privileged sch assert.throws(() => browserUrl(url)); } }); -test("guest user-agent drops Electron and Aiden product tokens Google rejects", () => { +test("guest user-agent drops Electron and Aiden product tokens and reduces Chrome to its major", () => { const electron = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Aiden Agent/0.43.0 Chrome/142.0.7444.175 Electron/43.1.1 Safari/537.36"; const guest = browserGuestUserAgent(electron); + // Shipping Chrome's reduced User-Agent: the build number is never exposed. assert.equal( guest, - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.7444.175 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36", ); assert.doesNotMatch(guest, /Electron|Aiden/u); const linux = browserGuestUserAgent( @@ -67,7 +69,7 @@ test("guest identity headers rewrite Client Hints without dropping preview grant "Sec-CH-UA-Full-Version-List": '"Electron";v="43.1.1"', }, ua, - "https://accounts.google.com/", + "https://mail.google.com/", "darwin", ); assert.equal(headers["User-Agent"], ua); @@ -90,6 +92,42 @@ test("guest identity headers rewrite Client Hints without dropping preview grant assert.deepEqual(insecure, { "User-Agent": ua }); }); +test("Google's sign-in host alone gets a current Firefox identity with no Client Hints", () => { + const ua = browserGuestUserAgent( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.7871.114 Electron/43.1.1 Safari/537.36", + ); + const now = Date.UTC(2026, 9, 20); + const signIn = applyBrowserGuestIdentityHeaders( + { Cookie: "SID=1", "sec-ch-ua": '"Chromium";v="150"', "User-Agent": ua }, + ua, + "https://accounts.google.com/v3/signin/identifier?flowName=GlifWebSignIn", + "darwin", + now, + ); + assert.deepEqual(signIn, { + Cookie: "SID=1", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:157.0) Gecko/20100101 Firefox/157.0", + }); + // Every other host, including other Google hosts and look-alikes, stays Chromium. + for (const url of [ + "https://www.google.com/", + "https://accounts.google.com.example.com/", + "http://accounts.google.com/", + ]) { + assert.equal(applyBrowserGuestIdentityHeaders({}, ua, url, "darwin", now)["User-Agent"], ua); + } +}); + +test("Google sign-in Firefox version follows the four-week release train without running ahead", () => { + // Firefox 144 shipped 2025-10-14 and 157 is due 2026-10-13 on the four-week train. + assert.match(browserGoogleSignInUserAgent("linux", Date.UTC(2025, 9, 20)), /^Mozilla\/5\.0 \(X11; Linux x86_64; rv:(\d+)\.0\) Gecko\/20100101 Firefox\/\1\.0$/u); + const version = (when: number) => Number(browserGoogleSignInUserAgent("win32", when).match(/Firefox\/(\d+)/u)?.[1]); + assert.ok(version(Date.UTC(2025, 9, 20)) <= 144); + assert.ok(version(Date.UTC(2025, 9, 20)) >= 142); + assert.ok(version(Date.UTC(2026, 9, 20)) > version(Date.UTC(2025, 9, 20))); + assert.match(browserGoogleSignInUserAgent("win32", Date.UTC(2026, 9, 20)), /Windows NT 10\.0; Win64; x64/u); +}); + test("guest brands follow Chromium's GREASE spelling and order for each major", () => { const brands = (major: number) => browserGuestBrands(`Mozilla/5.0 (X11) Chrome/${major}.0.0.0 Safari/537.36`); // Verified against Electron 43.1.1 (Chromium 150) navigator.userAgentData.brands. diff --git a/main/services/browser/core.ts b/main/services/browser/core.ts index 0837b563f..f08c0a82d 100644 --- a/main/services/browser/core.ts +++ b/main/services/browser/core.ts @@ -119,12 +119,46 @@ export function browserPartition(profileId: string, incognito: boolean): string return `${incognito ? "" : "persist:"}aiden-browser-${incognito ? "private-" : "profile-"}${digest}`; } -/** Guest pages must look like Chromium, not Electron or Aiden, or Google rejects sign-in. */ +/** + * Guest pages must look like Chromium, not Electron or Aiden. Shipping Chrome + * reports only its major version (`Chrome/150.0.0.0`, User-Agent reduction), so + * a full build number is itself an embedded-browser tell. + */ export function browserGuestUserAgent(raw: string): string { const mozilla = raw.match(/^Mozilla\/[\d.]+/u)?.[0] ?? "Mozilla/5.0"; const platform = raw.match(/\([^)]*\)/u)?.[0] ?? "(Macintosh; Intel Mac OS X 10_15_7)"; - const chrome = raw.match(/Chrome\/[\d.]+/u)?.[0] ?? "Chrome/142.0.0.0"; - return `${mozilla} ${platform} AppleWebKit/537.36 (KHTML, like Gecko) ${chrome} Safari/537.36`; + const major = raw.match(/Chrome\/(\d+)/u)?.[1] ?? "142"; + return `${mozilla} ${platform} AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36`; +} + +const FIREFOX_91_RELEASED_AT = Date.UTC(2021, 7, 10); +// Firefox ships every four weeks; a slightly longer step never claims an unreleased version. +const FIREFOX_RELEASE_STEP_MS = 4.1 * 7 * 24 * 60 * 60 * 1000; + +/** + * accounts.google.com rejects embedded Chromium ("This browser or app may not + * be secure") even with a clean Chrome User-Agent, but accepts a current + * Firefox one. Other Electron browsers (Min) send this header only to that + * host; every other request keeps the Chromium identity. + */ +export function browserGoogleSignInUserAgent( + platform: NodeJS.Platform = process.platform, + now = Date.now(), +): string { + const version = 91 + Math.max(0, Math.floor((now - FIREFOX_91_RELEASED_AT) / FIREFOX_RELEASE_STEP_MS)); + const os = platform === "darwin" + ? "Macintosh; Intel Mac OS X 10.15" + : platform === "win32" ? "Windows NT 10.0; Win64; x64" : "X11; Linux x86_64"; + return `Mozilla/5.0 (${os}; rv:${version}.0) Gecko/20100101 Firefox/${version}.0`; +} + +function browserGoogleSignInRequest(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "https:" && url.hostname === "accounts.google.com"; + } catch { + return false; + } } const BROWSER_GREASE_CHARS = [" ", "(", ":", "-", ".", "/", ")", ";", "=", "?", "_"]; @@ -174,12 +208,18 @@ export function applyBrowserGuestIdentityHeaders( userAgent: string, requestUrl: string, platform: NodeJS.Platform = process.platform, + now = Date.now(), ): Record { const next: Record = {}; for (const [key, value] of Object.entries(headers)) { const name = key.toLowerCase(); if (name !== "user-agent" && !name.startsWith("sec-ch-ua")) next[key] = value; } + // Firefox sends no UA Client Hints, so the Google sign-in host gets none. + if (browserGoogleSignInRequest(requestUrl)) { + next["User-Agent"] = browserGoogleSignInUserAgent(platform, now); + return next; + } next["User-Agent"] = userAgent; if (!browserTrustworthyRequestUrl(requestUrl)) return next; next["sec-ch-ua"] = browserGuestBrands(userAgent)