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
6 changes: 6 additions & 0 deletions .memory/upgrade-18-browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,9 @@ Rejected candidate: capturing only starts emitted synchronously inside loadURL i
New regressions hold the command before its start, then launch real renderer navigation, same-tab popup navigation, or another user command. Different-URL and same-URL replacements must remain loading, receive zero old-command Stops, and recover their held target after an actual renderer crash. Direct, redirected, and beforeunload native command controls must still Stop once and recover the original document after a later real crash. New tests release the real command deadline only after the native HTTP request arrives, avoiding a 500ms scheduling assumption.

Final validation: all four supported-entry regressions fail exact c402 service on unexpected Stop (expected0/received1); baseline built with an esbuild source override without changing the reviewed source files. All seven admission-gated new cases pass. The final complete lifecycle/initial native run passes **28/28 in3.3min**, zero retries/flaky/skipped; includes a denied renderer-navigation control that preserves the owned timeout. Browser140/140, both TypeScript suites, lint, production build and diff checks pass. Evidence `/tmp/aiden18-supported-c402-red.log`, `/tmp/aiden18-intent-gated-green.log`, `/tmp/aiden18-final-native28.log`; durable copies in campaign `pr164-command-ownership-evidence/`. Current-head hosted checks and independent review are separate publication gates.

## PR #249 Pullfrog follow-up: guest identity and modal overlay occlusion

Probe on Electron 43.1.1 (Chromium 150): the guest renderer's `navigator.userAgentData` reports only `Not;A=Brand` v8 + `Chromium` v150 (no Electron brand), and Electron sends no `sec-ch-ua*` headers for guest partitions. The earlier header rewrite forged `Google Chrome` + a wrong GREASE brand + high-entropy hints on every request, contradicting the page. `applyBrowserGuestIdentityHeaders(headers, ua, url)` now strips any incoming `sec-ch-ua*`, and only for potentially trustworthy URLs (https/wss/loopback) emits the default low-entropy trio built by `browserGuestBrands` (Chromium's `GenerateBrandVersionList` GREASE spelling/order for an unbranded build). The Electron E2E `guest identity headers match the renderer's navigator.userAgentData` compares both surfaces and fails on the old rewrite; `/favicon.ico` is fetched by Aiden's own tab strip with the host UA, so the test ignores it.

Radix `hideOthers(content)` sets `aria-hidden="true"` plus `data-aria-hidden="true"` on the modal overlay's portal and the app root. `visibleBrowserNativeOccluders` now ignores only aria-hidden ancestors without that isolation marker, so an open full-window `dialog-overlay` keeps the native view hidden even when the centered content misses the browser slot.
4 changes: 2 additions & 2 deletions docs/environment-browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ 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. |
| 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. |
| 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. |
| 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. |
| Links/files | Chat and terminal web links follow Browser settings; Cmd/Ctrl-click opens the system browser. Files offers Open in Browser for HTML/PDF. A scoped loopback server serves approved workspace documents and web assets without enabling unrestricted `file://` navigation. |
Expand Down
64 changes: 64 additions & 0 deletions main/services/browser/core.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
applyBrowserGuestIdentityHeaders,
browserGuestBrands,
browserGuestUserAgent,
browserUrl,
browserDisplayUrl,
browserRedactPreviewUrls,
Expand Down Expand Up @@ -29,6 +32,67 @@ 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", () => {
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);
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",
);
assert.doesNotMatch(guest, /Electron|Aiden/u);
const linux = browserGuestUserAgent(
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) aiden-agent/0.43.0 Chrome/142.0.0.0 Electron/43.1.1 Safari/537.36",
);
assert.match(linux, /Linux x86_64/u);
assert.doesNotMatch(linux, /Electron|aiden/iu);
});

test("guest identity headers rewrite Client Hints without dropping preview grants", () => {
const ua = browserGuestUserAgent(
"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 headers = applyBrowserGuestIdentityHeaders(
{
"User-Agent": "Electron",
Cookie: "session=1",
"X-Aiden-Preview-Authorization": "grant",
"Sec-CH-UA": '"Electron";v="43"',
"Sec-CH-UA-Full-Version-List": '"Electron";v="43.1.1"',
},
ua,
"https://accounts.google.com/",
"darwin",
);
assert.equal(headers["User-Agent"], ua);
assert.equal(headers["X-Aiden-Preview-Authorization"], "grant");
assert.equal(headers.Cookie, "session=1");
// Exactly the brand list Chromium 142 exposes via navigator.userAgentData in
// an unbranded (Electron) build: no invented "Google Chrome" brand.
assert.equal(headers["sec-ch-ua"], '"Not_A Brand";v="99", "Chromium";v="142"');
assert.equal(headers["sec-ch-ua-platform"], '"macOS"');
assert.equal(headers["sec-ch-ua-mobile"], "?0");
// Stale or high-entropy hints are dropped, never forged or passed through.
assert.deepEqual(
Object.keys(headers).filter((name) => /^sec-ch-ua/iu.test(name)).sort(),
["sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform"],
);
const linux = applyBrowserGuestIdentityHeaders({}, ua, "http://localhost:3000/", "linux");
assert.equal(linux["sec-ch-ua-platform"], '"Linux"');
// Chromium never sends UA-CH to insecure origins.
const insecure = applyBrowserGuestIdentityHeaders({ "sec-ch-ua": "x" }, ua, "http://example.com/", "darwin");
assert.deepEqual(insecure, { "User-Agent": ua });
});

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.
assert.deepEqual(brands(150), [{ brand: "Not;A=Brand", version: "8" }, { brand: "Chromium", version: "150" }]);
// Known Chrome releases: the GREASE brand of 120 and 124.
assert.equal(brands(120)[0].brand, "Not_A Brand");
assert.deepEqual(brands(124)[0], { brand: "Not-A.Brand", version: "99" });
assert.deepEqual(brands(151).map(({ brand }) => brand), ["Chromium", "Not=A?Brand"]);
});

test("profile partitions isolate persistent, ephemeral, and unusual identity bytes", () => {
assert.match(browserPartition("one", false), /^persist:aiden-browser-profile-/);
assert.doesNotMatch(browserPartition("one", true), /^persist:/);
Expand Down
72 changes: 72 additions & 0 deletions main/services/browser/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,78 @@ 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. */
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 BROWSER_GREASE_CHARS = [" ", "(", ":", "-", ".", "/", ")", ";", "=", "?", "_"];
const BROWSER_GREASE_VERSIONS = ["8", "99", "24"];

/**
* The brand list Chromium itself exposes through `navigator.userAgentData` in
* an unbranded build such as Electron: one GREASE brand plus "Chromium",
* spelled and ordered from the major version as in Chromium's
* `GenerateBrandVersionList`. The guest renderer never claims "Google Chrome",
* so the wire headers must not either.
*/
export function browserGuestBrands(userAgent: string): Array<{ brand: string; version: string }> {
const major = Number(userAgent.match(/Chrome\/(\d+)/u)?.[1] ?? "0");
const chars = BROWSER_GREASE_CHARS;
const grease = {
brand: `Not${chars[major % chars.length]}A${chars[(major + 1) % chars.length]}Brand`,
version: BROWSER_GREASE_VERSIONS[major % BROWSER_GREASE_VERSIONS.length],
};
const chromium = { brand: "Chromium", version: String(major) };
return major % 2 === 0 ? [grease, chromium] : [chromium, grease];
}

/** Chromium sends UA Client Hints only to potentially trustworthy origins. */
function browserTrustworthyRequestUrl(value: string): boolean {
try {
const url = new URL(value);
if (url.protocol === "https:" || url.protocol === "wss:") return true;
if (url.protocol !== "http:" && url.protocol !== "ws:") return false;
const host = url.hostname.toLowerCase();
return host === "localhost" || host.endsWith(".localhost") ||
/^127(?:\.\d+){3}$/u.test(host) || host === "[::1]";
} catch {
return false;
}
}

/**
* Electron emits no UA Client Hint headers, while the guest renderer still
* exposes Chromium's `navigator.userAgentData`. Mirror only the default
* low-entropy hints from that same metadata so `User-Agent`, `sec-ch-ua*`, and
* page JavaScript describe one guest. High-entropy hints are never forged;
* pages read them from `navigator.userAgentData.getHighEntropyValues()`.
*/
export function applyBrowserGuestIdentityHeaders(
headers: Record<string, string>,
userAgent: string,
requestUrl: string,
platform: NodeJS.Platform = process.platform,
): 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;
}
next["User-Agent"] = userAgent;
if (!browserTrustworthyRequestUrl(requestUrl)) return next;
next["sec-ch-ua"] = browserGuestBrands(userAgent)
.map(({ brand, version }) => `"${brand}";v="${version}"`)
.join(", ");
next["sec-ch-ua-mobile"] = "?0";
next["sec-ch-ua-platform"] =
platform === "darwin" ? '"macOS"' : platform === "win32" ? '"Windows"' : '"Linux"';
return next;
}

export function browserBoundedNumber(
value: unknown,
min: number,
Expand Down
22 changes: 14 additions & 8 deletions main/services/browser/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import {
browserDisplayUrl,
browserRedactPreviewUrls,
browserPageCaptureBounds,
applyBrowserGuestIdentityHeaders,
browserGuestUserAgent,
browserLocalServers,
BrowserActionQueue,
BROWSER_MAX_TABS,
Expand Down Expand Up @@ -383,12 +385,8 @@ export class BrowserService {
let browserSession = this.sessions.get(partition);
if (!browserSession) {
browserSession = session.fromPartition(partition);
browserSession.setUserAgent(
browserSession
.getUserAgent()
.replace(/\s*Electron\/[\d.]+/g, "")
.replace(/\s*aiden[^\s]*\/[\d.]+/gi, ""),
);
const guestUserAgent = browserGuestUserAgent(browserSession.getUserAgent());
browserSession.setUserAgent(guestUserAgent);
configureBrowserPermissionHandlers(browserSession, (contents) => {
if (!contents || contents.isDestroyed()) return undefined;
const owned = [...this.tabs.values()].some(
Expand Down Expand Up @@ -449,7 +447,13 @@ export class BrowserService {
} catch {
// Navigation/disposal can invalidate frame handles synchronously.
}
callback({ requestHeaders: browserPreviewRequestHeaders(details.requestHeaders, authorization) });
callback({
requestHeaders: applyBrowserGuestIdentityHeaders(
browserPreviewRequestHeaders(details.requestHeaders, authorization),
guestUserAgent,
details.url,
),
});
});
browserSession.on("will-download", (_event, item, wc) => {
if (![...this.tabs.values()].some((t) => t.view.webContents === wc)) {
Expand Down Expand Up @@ -796,9 +800,10 @@ export class BrowserService {
if (workspace.state.tabs.length >= BROWSER_MAX_TABS)
throw new Error(`Close a browser tab before opening more than ${BROWSER_MAX_TABS}.`);
const normalized = browserUrl(url);
const browserSession = this.browserSession(profileId);
const view = new WebContentsView({
webPreferences: {
session: this.browserSession(profileId),
session: browserSession,
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
Expand All @@ -807,6 +812,7 @@ export class BrowserService {
backgroundThrottling: false,
},
});
view.webContents.setUserAgent(browserSession.getUserAgent());
const state: BrowserTab = {
id: `browser-${randomUUID()}`,
workspaceId,
Expand Down
7 changes: 6 additions & 1 deletion renderer/components/browser-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,16 @@ test("native browser hides behind app overlays and never closes tabs on panel hi
const source = readFileSync(new URL("./browser-panel.tsx", import.meta.url), "utf8");
assert.ok(source.includes('action: "present", tabId, visible: false'));
assert.ok(source.includes('enqueueBrowserPresentation(presentationKey'));
assert.ok(source.includes('[role="dialog"], [role="alertdialog"], [data-slot="popover-content"]'));
assert.ok(source.includes("browserNativeViewObstructed"));
assert.ok(source.includes("visibleBrowserNativeOccluders"));
assert.ok(source.includes('action: "annotate", tabId: tab.id, enabled: false'));
assert.ok(source.includes('aria-keyshortcuts="Meta+."'));
assert.ok(source.includes('aria-label="Browser tabs"'));
assert.equal(DEFAULT_BROWSER_SETTINGS.agentAccess, "allow");
const main = readFileSync(new URL("../../main/services/browser/service.ts", import.meta.url), "utf8");
assert.ok(main.includes("browserGuestUserAgent"));
assert.ok(main.includes("applyBrowserGuestIdentityHeaders"));
assert.ok(main.includes("view.webContents.setUserAgent"));
});

test("HTML and PDF files expose a saved workspace file preview through the browser service", () => {
Expand Down
9 changes: 5 additions & 4 deletions renderer/components/browser-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createPortal } from "react-dom";
import { ArrowLeft, ArrowRight, Camera, Check, ExternalLink, Globe, History, Link2, Loader2, MessageCirclePlus, Minus, MoreVertical, PictureInPicture2, Plus, RadioTower, RotateCw, Settings2, Square, Unlink2, Volume2, VolumeX, X } from "lucide-react";
import type { BrowserAnnotation, BrowserCommand, BrowserCommandResult, BrowserImage, BrowserSnapshot, BrowserState, BrowserStylePreview, BrowserTab, BrowserViewport } from "../shared/browser";
import { browserApi } from "../lib/ipc";
import { acceptBrowserState, BROWSER_DEVICE_PRESETS, browserBoundsFromRect, enqueueBrowserPresentation, resizeBrowserViewport, savedBrowserScreenshotPath, validBrowserViewport } from "../lib/browser-ui-state";
import { acceptBrowserState, BROWSER_DEVICE_PRESETS, browserBoundsFromRect, enqueueBrowserPresentation, resizeBrowserViewport, savedBrowserScreenshotPath, validBrowserViewport, visibleBrowserNativeOccluders, browserNativeViewObstructed } from "../lib/browser-ui-state";
import { Button, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, Input, Text, toast } from "./ui";
import { BrowserSettings } from "./browser-settings";
import { BrowserFloatingFrame } from "./browser-floating-frame";
Expand Down Expand Up @@ -131,8 +131,9 @@ export function BrowserPanel({ workspaceId, active, onDock }: { workspaceId: str
frame = 0;
if (disposed) return;
const bounds = browserBoundsFromRect(host.getBoundingClientRect());
const overlay = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"], [role="alertdialog"], [data-slot="popover-content"]'))
.some((element) => element.getBoundingClientRect().width > 0 && element.getAttribute("data-state") !== "closed");
const overlay = bounds
? browserNativeViewObstructed(bounds, visibleBrowserNativeOccluders().map((element) => element.getBoundingClientRect()))
: false;
const visible = surfaceActive && ready && !annotation && !settingsOpen && !menuOpen && !profileMenuOpen && !overlay && Boolean(bounds);
const command: BrowserCommand = { action: "present", tabId, visible, ...(bounds ? { bounds } : {}) };
const key = JSON.stringify(command);
Expand All @@ -145,7 +146,7 @@ export function BrowserPanel({ workspaceId, active, onDock }: { workspaceId: str
const resize = new ResizeObserver(schedule);
resize.observe(host);
const overlays = new MutationObserver(schedule);
overlays.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-state", "aria-hidden", "inert"] });
overlays.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-state", "aria-hidden", "inert", "data-presence", "popover", "style"] });
window.addEventListener("resize", schedule);
window.addEventListener("scroll", schedule, true);
document.addEventListener("transitionend", schedule, true);
Expand Down
1 change: 1 addition & 0 deletions renderer/components/composer-slash-palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ export function ComposerSlashPalette({
<div
className="composer-slash-palette absolute inset-x-3 bottom-full z-40 mb-2 origin-bottom overflow-hidden rounded-dialog border border-separator bg-popover/98 shadow-popover backdrop-blur-xl"
data-composer-slash-palette
data-browser-occluder
data-presence={presenceState}
aria-hidden={presenceState === "exiting" ? "true" : undefined}
>
Expand Down
Loading
Loading