From 4aa6c7b9fe62f00a2f9b8ecee41af232dcae54a7 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 17 Jun 2026 12:03:33 +0800 Subject: [PATCH 1/2] feat(browser): give the embedded browser a faithful Chrome user-agent Strip Electron's `Electron/` and the app product token from the embedded browser's UA and pin Chrome to the reduced `.0.0.0` form, deriving the major from process.versions.chrome. Set on the persist:pawwork-browser partition so it covers both manual browsing and CDP automation. Client Hints are left at Chromium's correct defaults so manual and automation present one consistent, faithful Chromium identity (app renderer UA untouched). --- .../src/main/browser/controller.ts | 29 ++++++++- .../src/main/browser/user-agent.test.ts | 63 +++++++++++++++++++ .../src/main/browser/user-agent.ts | 37 +++++++++++ 3 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 packages/desktop-electron/src/main/browser/user-agent.test.ts create mode 100644 packages/desktop-electron/src/main/browser/user-agent.ts diff --git a/packages/desktop-electron/src/main/browser/controller.ts b/packages/desktop-electron/src/main/browser/controller.ts index 4c87be9f6..009c71dc5 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 { toChromeUserAgent } from "./user-agent" import { clearDataReloadAction, computeViewBounds, @@ -23,6 +24,27 @@ export const BROWSER_DISPLAY_TAKEN_CHANNEL = "browser:display-taken" */ const DEFAULT_VIEW_BOUNDS = { x: 0, y: 0, width: 1280, height: 720 } +let partitionUserAgentConfigured = false +/** + * Give the embedded browser's shared partition a faithful Chrome user-agent, + * once. Electron's default UA carries an `Electron/` token and the app's own + * product token where a real Chrome UA has nothing — both are obvious "not a + * normal browser" tells that anti-automation risk control keys on. The view IS + * real Chromium, so we present it as the Chrome/Chromium it actually is rather + * than impersonating a specific brand: the Chrome major is derived from + * `process.versions.chrome` so it tracks the engine, and the partition session UA + * applies to BOTH manual browsing and CDP-driven automation. Client Hints are + * deliberately left at Chromium's correct defaults — manual and automation then + * present one consistent identity (see PR notes), which is the whole point. + * Scoped to the browser partition; the app renderer keeps its own UA. + */ +function ensureBrowserPartitionUserAgent() { + if (partitionUserAgentConfigured) return + partitionUserAgentConfigured = true + const sess = session.fromPartition(BROWSER_PARTITION) + sess.setUserAgent(toChromeUserAgent(sess.getUserAgent(), 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 +63,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..831a75e7a --- /dev/null +++ b/packages/desktop-electron/src/main/browser/user-agent.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test" +import { chromeMajorVersion, 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("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..5b25380c7 --- /dev/null +++ b/packages/desktop-electron/src/main/browser/user-agent.ts @@ -0,0 +1,37 @@ +/** + * The embedded browser's user-agent string. The view is real Chromium (Electron), + * so the honest, low-false-positive identity is a faithful Chrome UA. Electron's + * default string carries an `Electron/` token plus the app's own product + * token (`opencode/` after index.ts's rewrite, or `PawWork .../` before + * it) right where a real Chrome UA has nothing — both are obvious "not a normal + * browser" tells that anti-automation risk control keys on. We strip them and pin + * the Chrome token to the reduced `.0.0.0` form modern Chrome reports, + * deriving the major from the real embedded Chromium (`process.versions.chrome`) + * so it can never drift from the engine actually running. + * + * Pure (no electron import) so it is unit-pinned; the wiring onto the browser + * partition lives in controller.ts. + */ + +/** 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() +} From 5ccb30f970e58bc9ab020d53229ad6fa6f00c364 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 17 Jun 2026 14:07:40 +0800 Subject: [PATCH 2/2] refactor(browser): add a testable UA-wiring seam; trim rationale comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups (UA rewrite behavior unchanged): - Add configurePartitionUserAgent(session, chromeVersion) in the pure user-agent.ts (no electron import) as the seam the controller calls on the real partition session before the first view is created. This makes the wiring testable without an Electron runtime — added a test asserting the partition session receives the cleaned Chrome UA (no Electron/app token). - Trim the long stealth rationale from the production comments in user-agent.ts and controller.ts down to the invariant (faithful Chrome UA; configured before the first view/request); the full rationale lives in the PR description. --- .../src/main/browser/controller.ts | 20 ++++------- .../src/main/browser/user-agent.test.ts | 19 ++++++++++- .../src/main/browser/user-agent.ts | 34 ++++++++++++------- 3 files changed, 46 insertions(+), 27 deletions(-) diff --git a/packages/desktop-electron/src/main/browser/controller.ts b/packages/desktop-electron/src/main/browser/controller.ts index 009c71dc5..d42de142a 100644 --- a/packages/desktop-electron/src/main/browser/controller.ts +++ b/packages/desktop-electron/src/main/browser/controller.ts @@ -1,7 +1,7 @@ import { BrowserWindow, WebContentsView, session, shell } from "electron" import type { BrowserState, BrowserViewLayout } from "@opencode-ai/app/desktop-api" import { BROWSER_PARTITION, browserViewWebPreferences } from "./options" -import { toChromeUserAgent } from "./user-agent" +import { configurePartitionUserAgent } from "./user-agent" import { clearDataReloadAction, computeViewBounds, @@ -26,23 +26,15 @@ const DEFAULT_VIEW_BOUNDS = { x: 0, y: 0, width: 1280, height: 720 } let partitionUserAgentConfigured = false /** - * Give the embedded browser's shared partition a faithful Chrome user-agent, - * once. Electron's default UA carries an `Electron/` token and the app's own - * product token where a real Chrome UA has nothing — both are obvious "not a - * normal browser" tells that anti-automation risk control keys on. The view IS - * real Chromium, so we present it as the Chrome/Chromium it actually is rather - * than impersonating a specific brand: the Chrome major is derived from - * `process.versions.chrome` so it tracks the engine, and the partition session UA - * applies to BOTH manual browsing and CDP-driven automation. Client Hints are - * deliberately left at Chromium's correct defaults — manual and automation then - * present one consistent identity (see PR notes), which is the whole point. - * Scoped to the browser partition; the app renderer keeps its own UA. + * 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 - const sess = session.fromPartition(BROWSER_PARTITION) - sess.setUserAgent(toChromeUserAgent(sess.getUserAgent(), process.versions.chrome ?? "")) + configurePartitionUserAgent(session.fromPartition(BROWSER_PARTITION), process.versions.chrome ?? "") } /** diff --git a/packages/desktop-electron/src/main/browser/user-agent.test.ts b/packages/desktop-electron/src/main/browser/user-agent.test.ts index 831a75e7a..b25edbdc8 100644 --- a/packages/desktop-electron/src/main/browser/user-agent.test.ts +++ b/packages/desktop-electron/src/main/browser/user-agent.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { chromeMajorVersion, toChromeUserAgent } from "./user-agent" +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 — @@ -50,6 +50,23 @@ describe("toChromeUserAgent", () => { }) }) +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") diff --git a/packages/desktop-electron/src/main/browser/user-agent.ts b/packages/desktop-electron/src/main/browser/user-agent.ts index 5b25380c7..77d90c00e 100644 --- a/packages/desktop-electron/src/main/browser/user-agent.ts +++ b/packages/desktop-electron/src/main/browser/user-agent.ts @@ -1,16 +1,11 @@ /** - * The embedded browser's user-agent string. The view is real Chromium (Electron), - * so the honest, low-false-positive identity is a faithful Chrome UA. Electron's - * default string carries an `Electron/` token plus the app's own product - * token (`opencode/` after index.ts's rewrite, or `PawWork .../` before - * it) right where a real Chrome UA has nothing — both are obvious "not a normal - * browser" tells that anti-automation risk control keys on. We strip them and pin - * the Chrome token to the reduced `.0.0.0` form modern Chrome reports, - * deriving the major from the real embedded Chromium (`process.versions.chrome`) - * so it can never drift from the engine actually running. - * - * Pure (no electron import) so it is unit-pinned; the wiring onto the browser - * partition lives in controller.ts. + * 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. */ @@ -35,3 +30,18 @@ export function toChromeUserAgent(rawUserAgent: string, chromeVersion: string): 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)) +}