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)