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
21 changes: 19 additions & 2 deletions packages/desktop-electron/src/main/browser/controller.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand All @@ -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)
Expand Down
80 changes: 80 additions & 0 deletions packages/desktop-electron/src/main/browser/user-agent.test.ts
Original file line number Diff line number Diff line change
@@ -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/<ver>" by default and "opencode/<ver>" 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()
})
})
47 changes: 47 additions & 0 deletions packages/desktop-electron/src/main/browser/user-agent.ts
Original file line number Diff line number Diff line change
@@ -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 `<major>.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))
}