diff --git a/packages/desktop-electron/src/main/browser/controller.ts b/packages/desktop-electron/src/main/browser/controller.ts index 4c87be9f6..d42de142a 100644 --- a/packages/desktop-electron/src/main/browser/controller.ts +++ b/packages/desktop-electron/src/main/browser/controller.ts @@ -1,6 +1,7 @@ -import { BrowserWindow, WebContentsView, shell } from "electron" +import { BrowserWindow, WebContentsView, session, shell } from "electron" import type { BrowserState, BrowserViewLayout } from "@opencode-ai/app/desktop-api" -import { browserViewWebPreferences } from "./options" +import { BROWSER_PARTITION, browserViewWebPreferences } from "./options" +import { configurePartitionUserAgent } from "./user-agent" import { clearDataReloadAction, computeViewBounds, @@ -23,6 +24,19 @@ export const BROWSER_DISPLAY_TAKEN_CHANNEL = "browser:display-taken" */ const DEFAULT_VIEW_BOUNDS = { x: 0, y: 0, width: 1280, height: 720 } +let partitionUserAgentConfigured = false +/** + * Configure the browser partition's UA before the first view/request, exactly + * once: present the partition as the faithful Chrome it is (rewrite + rationale + * in user-agent.ts and the PR). Scoped to the browser partition; the app + * renderer keeps its own UA. + */ +function ensureBrowserPartitionUserAgent() { + if (partitionUserAgentConfigured) return + partitionUserAgentConfigured = true + configurePartitionUserAgent(session.fromPartition(BROWSER_PARTITION), process.versions.chrome ?? "") +} + /** * Owns one embedded browser per CONVERSATION (root session) — or a per-window * draft on Home. The view lives unattached to any window; a window is just a @@ -41,6 +55,9 @@ export class BrowserViewController { private throttlingBefore: boolean | null = null constructor(private target: string) { + // Configure the partition UA before the view exists, so its very first + // request already carries the faithful Chrome UA. + ensureBrowserPartitionUserAgent() this.view = new WebContentsView({ webPreferences: browserViewWebPreferences() }) this.view.setVisible(false) this.view.setBounds(DEFAULT_VIEW_BOUNDS) diff --git a/packages/desktop-electron/src/main/browser/user-agent.test.ts b/packages/desktop-electron/src/main/browser/user-agent.test.ts new file mode 100644 index 000000000..b25edbdc8 --- /dev/null +++ b/packages/desktop-electron/src/main/browser/user-agent.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "bun:test" +import { chromeMajorVersion, configurePartitionUserAgent, toChromeUserAgent } from "./user-agent" + +// Real Electron 40.8.0 UA shapes (Chromium 144). The app product token is +// "PawWork Dev/" by default and "opencode/" after index.ts's rewrite — +// both must be stripped. +const MAC_ELECTRON = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) PawWork Dev/2026.6.7 Chrome/144.0.7559.236 Electron/40.8.0 Safari/537.36" +const MAC_ELECTRON_REWRITTEN = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) opencode/2026.6.7 Chrome/144.0.7559.236 Electron/40.8.0 Safari/537.36" +const WIN_ELECTRON = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) opencode/2026.6.7 Chrome/144.0.7559.236 Electron/40.8.0 Safari/537.36" + +const MAC_CHROME = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36" +const WIN_CHROME = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36" + +describe("toChromeUserAgent", () => { + test("strips Electron + spaced app token and pins the reduced Chrome version (macOS)", () => { + expect(toChromeUserAgent(MAC_ELECTRON, "144.0.7559.236")).toBe(MAC_CHROME) + }) + + test("handles the index.ts-rewritten opencode token", () => { + expect(toChromeUserAgent(MAC_ELECTRON_REWRITTEN, "144.0.7559.236")).toBe(MAC_CHROME) + }) + + test("preserves the Windows platform token", () => { + expect(toChromeUserAgent(WIN_ELECTRON, "144.0.7559.236")).toBe(WIN_CHROME) + }) + + test("leaves no Electron / opencode / PawWork token or double space behind", () => { + const ua = toChromeUserAgent(MAC_ELECTRON, "144.0.7559.236") + expect(ua).not.toContain("Electron") + expect(ua).not.toContain("opencode") + expect(ua).not.toContain("PawWork") + expect(ua).not.toMatch(/ {2,}/) + }) + + test("is idempotent on an already-clean Chrome UA", () => { + expect(toChromeUserAgent(MAC_CHROME, "144.0.7559.236")).toBe(MAC_CHROME) + }) + + test("accepts a major-only version", () => { + expect(toChromeUserAgent(MAC_ELECTRON, "144")).toContain("Chrome/144.0.0.0") + }) + + test("leaves the Chrome token alone when the version is unparseable", () => { + expect(toChromeUserAgent(MAC_ELECTRON, "unknown")).toContain("Chrome/144.0.7559.236") + }) +}) + +describe("configurePartitionUserAgent", () => { + test("sets the cleaned Chrome UA on the partition session (no Electron/app token)", () => { + let applied: string | undefined + const sess = { + getUserAgent: () => MAC_ELECTRON, + setUserAgent: (ua: string) => { + applied = ua + }, + } + configurePartitionUserAgent(sess, "144.0.7559.236") + // The seam must hand the partition the cleaned UA — this is what is in place + // before the controller creates the first view. + expect(applied).toBe(MAC_CHROME) + expect(applied).not.toContain("Electron") + }) +}) + +describe("chromeMajorVersion", () => { + test("extracts the major", () => { + expect(chromeMajorVersion("144.0.7559.236")).toBe("144") + expect(chromeMajorVersion("144")).toBe("144") + }) + + test("returns null for a non-numeric version", () => { + expect(chromeMajorVersion("unknown")).toBeNull() + expect(chromeMajorVersion("")).toBeNull() + }) +}) diff --git a/packages/desktop-electron/src/main/browser/user-agent.ts b/packages/desktop-electron/src/main/browser/user-agent.ts new file mode 100644 index 000000000..77d90c00e --- /dev/null +++ b/packages/desktop-electron/src/main/browser/user-agent.ts @@ -0,0 +1,47 @@ +/** + * The embedded browser's user-agent. The view is real Chromium, so the faithful + * identity is a plain Chrome UA: strip Electron's `Electron/` and app product + * tokens (a real Chrome UA has nothing where Electron injects them) and pin the + * Chrome token to the `.0.0.0` form, deriving the major from the running + * engine (`process.versions.chrome`). Pure (no electron import) so it stays + * unit-tested; the partition wiring is configurePartitionUserAgent, called from + * controller.ts. Full rationale in the PR description. + */ + +/** Major version of a Chromium version string, or null if it has no leading number. */ +export function chromeMajorVersion(chromeVersion: string): string | null { + const major = chromeVersion.trim().split(".")[0] + return /^\d+$/.test(major) ? major : null +} + +/** + * Rewrite an Electron user-agent into the faithful Chrome UA the embedded view + * should present. Idempotent: an already-clean Chrome UA passes through unchanged. + */ +export function toChromeUserAgent(rawUserAgent: string, chromeVersion: string): string { + const major = chromeMajorVersion(chromeVersion) + let ua = rawUserAgent + // Drop the Electron product token — the loudest non-Chrome tell. + .replace(/ Electron\/\S+/g, "") + // Drop the app/product token Electron injects between the engine comment and + // the Chrome token (a real Chrome UA has nothing there). + .replace(/(\(KHTML, like Gecko\) ).*?(Chrome\/)/, "$1$2") + // Pin the Chrome token to the reduced major.0.0.0 form real Chrome reports. + if (major) ua = ua.replace(/Chrome\/\S+/, `Chrome/${major}.0.0.0`) + return ua.replace(/ {2,}/g, " ").trim() +} + +/** Minimal Session surface the partition wiring needs — narrowed so the rewrite is testable without an Electron runtime. */ +export interface UserAgentSession { + getUserAgent(): string + setUserAgent(userAgent: string): void +} + +/** + * Apply the faithful Chrome UA to the embedded browser's partition session. The + * seam controller.ts calls (on the real partition session) before the first view + * is created, so the cleaned UA is in place before any embedded load. + */ +export function configurePartitionUserAgent(sess: UserAgentSession, chromeVersion: string): void { + sess.setUserAgent(toChromeUserAgent(sess.getUserAgent(), chromeVersion)) +}