Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/environment-browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
44 changes: 41 additions & 3 deletions main/services/browser/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
applyBrowserGuestIdentityHeaders,
browserGuestBrands,
browserGuestUserAgent,
browserGoogleSignInUserAgent,
browserUrl,
browserDisplayUrl,
browserRedactPreviewUrls,
Expand Down Expand Up @@ -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(
Expand All @@ -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);
Expand All @@ -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.
Expand Down
46 changes: 43 additions & 3 deletions main/services/browser/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [" ", "(", ":", "-", ".", "/", ")", ";", "=", "?", "_"];
Expand Down Expand Up @@ -174,12 +208,18 @@ export function applyBrowserGuestIdentityHeaders(
userAgent: string,
requestUrl: string,
platform: NodeJS.Platform = process.platform,
now = Date.now(),
): Record<string, string> {
const next: Record<string, string> = {};
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)
Expand Down
Loading