Skip to content

Commit 84fa02e

Browse files
author
Brit
committed
feat(browser): migrate from puppeteer-core to patchright (undetected Playwright)
Replace puppeteer-core with patchright — a patched, undetected Playwright drop-in that provides built-in stealth (Runtime.enable avoidance, Console.enable disable, command-flag leak fixes, closed shadow root support). Core changes: - launch.ts: chromium.launchServer() + connect(wsEndpoint) for headless mode, connectOverCDP(cdpUrl) for Electron/external attach. CDP-based Emulation.setDeviceMetricsOverride for deviceScaleFactor (DPR) support. Removed all 14 custom stealth init scripts, patchSourceUrl CDP monkey-patch, UA override logic, and @puppeteer/browsers dependency. - registry.ts: BrowserServer|Browser union handle, browserAlive() helper, connectOverCDP() replaces puppeteer.connect(), browser.isConnected() replaces browser.connected. - attach.ts: pickElectronTarget uses browser.contexts().flatMap(ctx => ctx.pages()) instead of browser.targets()/target.type()/target.page(). - tab-worker.ts: page.ariaSnapshot({mode:'ai',boxes:true}) replaces page.accessibility.snapshot(). New parseAriaSnapshot()/parseAriaLine() functions parse the YAML-like output into ObservationEntry[] with string aria-ref IDs (e.g. 'e2'). tab.id(ref) returns page.locator('aria-ref='+ref). CDP Accessibility.getFullAXTree enriches entries with description and keyshortcuts (stealth-safe, does not trigger Runtime.enable). All locator methods updated (.click({timeout}) instead of .setTimeout().click()), setInputFiles replaces uploadFile, mouse.wheel(dx,dy) takes numbers. normalizeSelector translates Puppeteer selectors (aria/X, text/X, xpath/X, pierce/X, p-aria/X) to Playwright equivalents (text=X, xpath=, bare CSS). WebP screenshot saves use resizeImage encoding since Playwright can't capture webp natively. - tab-supervisor.ts: buildInitPayload passes WS endpoint (headless) or CDP URL (attach). pageTargetId() from launch.ts replaces internal _targetId access. closeOrphanTarget uses context-based page discovery. - tab-protocol.ts: WorkerInitPayload uses 'endpoint' instead of 'browserWSEndpoint'. ObservationEntry.id changed from number to string. Observation.viewport removed deviceScaleFactor (DPR set via CDP instead). - cmux/cmux-tab.ts: ref construction uses @${element.id} (not @e${id}) since ids already have the e prefix. selectorSpec parses eN refs correctly. - bundle-dist.ts: RUNTIME_EXTERNAL updated from puppeteer-core to patchright/patchright-core. Removed: - puppeteer-core, @puppeteer/browsers dependencies - packages/coding-agent/src/tools/puppeteer/ (14 stealth scripts) - browser-stealth-targets.test.ts (stealth functionality removed) Added: patchright 1.60.2 dependency
1 parent 62756ef commit 84fa02e

34 files changed

Lines changed: 542 additions & 2402 deletions

bun.lock

Lines changed: 14 additions & 58 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@
4242
"@opentelemetry/resources": "^2.7.1",
4343
"@opentelemetry/sdk-trace-base": "^2.7.1",
4444
"@opentelemetry/sdk-trace-node": "^2.7.1",
45-
"@puppeteer/browsers": "^3.0.4",
4645
"@tailwindcss/node": "^4.3.0",
4746
"@tailwindcss/vite": "^4.3.0",
4847
"@types/babel__generator": "^7.27.0",
@@ -74,7 +73,7 @@
7473
"partial-json": "^0.1.7",
7574
"postcss": "^8.5.15",
7675
"prettier": "^3.8.4",
77-
"puppeteer-core": "^25.1.0",
76+
"patchright": "1.60.2",
7877
"react": "19.2.7",
7978
"react-chartjs-2": "^5.3.1",
8079
"react-dom": "19.2.7",

packages/coding-agent/package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@
6565
"@opentelemetry/resources": "catalog:",
6666
"@opentelemetry/sdk-trace-base": "catalog:",
6767
"@opentelemetry/sdk-trace-node": "catalog:",
68-
"@puppeteer/browsers": "catalog:",
6968
"@types/turndown": "catalog:",
7069
"@xterm/headless": "catalog:",
7170
"arktype": "catalog:",
@@ -77,7 +76,7 @@
7776
"lru-cache": "catalog:",
7877
"mammoth": "catalog:",
7978
"mupdf": "catalog:",
80-
"puppeteer-core": "catalog:",
79+
"patchright": "catalog:",
8180
"turndown": "catalog:",
8281
"turndown-plugin-gfm": "catalog:",
8382
"zod": "catalog:"

packages/coding-agent/scripts/build-binary.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ async function main(): Promise<void> {
6565
"fastembed",
6666
"--external",
6767
"onnxruntime-node",
68+
"--external",
69+
"chromium-bidi",
6870
"--root",
6971
".",
7072
"./packages/coding-agent/src/cli.ts",

packages/coding-agent/scripts/bundle-dist.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@ const ALWAYS_EXTERNAL = ["mupdf", "@oh-my-pi/pi-natives", "@huggingface/transfor
2222
// @ark/schema is patched, so it — and arktype, which pulls @ark/schema — stay
2323
// bundled).
2424
const RUNTIME_EXTERNAL = [
25-
"puppeteer-core",
26-
"@puppeteer/browsers",
25+
"patchright",
26+
"patchright-core",
27+
"chromium-bidi",
2728
"@babel/parser",
2829
"@xterm/headless",
2930
"turndown",

packages/coding-agent/src/config/settings-schema.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3307,7 +3307,7 @@ export const SETTINGS_SCHEMA = {
33073307
tab: "tools",
33083308
group: "Available Tools",
33093309
label: "Browser",
3310-
description: "Enable the browser tool for scripted Chromium automation (puppeteer)",
3310+
description: "Enable the browser tool for scripted Chromium automation (patchright)",
33113311
},
33123312
},
33133313

packages/coding-agent/src/prompts/tools/browser.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ Drives real Chromium tab; full puppeteer access via JS.
2727
- `tab.evaluate(fn, …args)``page.evaluate` for ad-hoc DOM reads.
2828
- `tab.screenshot({ selector?, fullPage?, save?, silent? })` — capture + attach for viewing (`silent: true` skips). Pass `save` only when a later step needs the file.
2929
- `tab.extract(format = "markdown")` — readable page content (`"markdown"` | `"text"`); throws when nothing readable.
30-
- Selectors: CSS + puppeteer handlers `aria/Sign in`, `text/Continue`, `xpath/`, `pierce/`; also Playwright-style `p-aria/`, `p-text/`.
30+
- Selectors: CSS + Playwright engines `text=Sign in`, `xpath=//a`, `role=button`; legacy `aria/X`, `text/X`, `xpath/X`, `pierce/X`, `p-aria/X`, `p-text/X` auto-translated.
3131
</instruction>
3232

3333
<critical>

packages/coding-agent/src/tools/browser/attach.ts

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as net from "node:net";
22
import { Process, ProcessStatus } from "@oh-my-pi/pi-natives";
3-
import type { Browser, Page } from "puppeteer-core";
3+
import type { Browser, Page } from "patchright";
44
import { ToolError, throwIfAborted } from "../tool-errors";
55

66
const ATTACH_TARGET_SKIP_PATTERN =
@@ -119,27 +119,16 @@ export async function findReusableCdp(
119119
}
120120

121121
/**
122-
* Pick the best page target on an attached browser. Prefer discoverable page
123-
* targets first so Chromium/Edge attach flows that hide pages from
124-
* `browser.pages()` can still return a usable tab.
122+
* Pick the best page on an attached browser. In Playwright, pages are accessed
123+
* via browser contexts rather than target objects. We gather all pages across
124+
* all contexts and pick the best match.
125125
*/
126126
export async function pickElectronTarget(browser: Browser, matcher?: string): Promise<Page> {
127-
const discoveredPages = await Promise.all(
128-
browser.targets().map(async target => {
129-
if (String(target.type()) !== "page") return null;
130-
return await target.page().catch(() => null);
131-
}),
132-
);
133-
const usablePages = discoveredPages.filter((page): page is Page => page !== null);
134-
if (usablePages.length > 0) {
135-
return pickPageFromList(usablePages, matcher);
136-
}
137-
138-
const fallbackPages = await browser.pages();
139-
if (!fallbackPages.length) {
127+
const pages = browser.contexts().flatMap(ctx => ctx.pages());
128+
if (!pages.length) {
140129
throw new ToolError("No page targets available on the attached browser");
141130
}
142-
return pickPageFromList(fallbackPages, matcher);
131+
return pickPageFromList(pages, matcher);
143132
}
144133

145134
async function enrichPages(pages: Page[]): Promise<Array<{ page: Page; url: string; title: string }>> {

packages/coding-agent/src/tools/browser/cmux/cmux-tab.ts

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ export class CmuxTab {
246246
#lastViewport: ReadyInfo["viewport"] = DEFAULT_VIEWPORT;
247247
#runContext: RunContext | undefined;
248248
#runtime: JsRuntime | undefined;
249-
readonly #elementRefs = new Map<number, CachedElementRef>();
249+
readonly #elementRefs = new Map<string, CachedElementRef>();
250250
#pageFacade: CmuxPageFacade | undefined;
251251
#browserFacade: CmuxBrowserFacade | undefined;
252252
constructor(opts: { client: CmuxSocketClient; surfaceId: string; url?: string; title?: string }) {
@@ -278,7 +278,6 @@ export class CmuxTab {
278278
this.#lastViewport = {
279279
width: viewport.width,
280280
height: viewport.height,
281-
deviceScaleFactor: viewport.deviceScaleFactor,
282281
};
283282
}
284283

@@ -298,9 +297,7 @@ export class CmuxTab {
298297
this.#lastUrl = urlResult.url;
299298
}
300299
const geometry = await this.#readGeometry().catch(() => undefined);
301-
this.#lastViewport = geometry
302-
? { width: geometry.innerWidth, height: geometry.innerHeight, deviceScaleFactor: geometry.dpr }
303-
: viewport;
300+
this.#lastViewport = geometry ? { width: geometry.innerWidth, height: geometry.innerHeight } : viewport;
304301
await this.title().catch(() => "");
305302
return {
306303
url: this.#lastUrl,
@@ -342,7 +339,6 @@ export class CmuxTab {
342339
const viewport = {
343340
width: geometry.innerWidth,
344341
height: geometry.innerHeight,
345-
deviceScaleFactor: geometry.dpr,
346342
};
347343
this.#lastViewport = viewport;
348344
const observation = cmuxSnapshotToObservation(snapshot as CmuxSnapshotResult, viewport, geometry);
@@ -575,8 +571,8 @@ export class CmuxTab {
575571
throw new ToolError(`tab.waitForResponse() timed out after ${timeoutMs}ms`);
576572
}
577573

578-
async id(id: number): Promise<CmuxElementHandle> {
579-
const ref = this.#elementRefs.get(id)?.ref ?? `@e${id}`;
574+
async id(id: string): Promise<CmuxElementHandle> {
575+
const ref = this.#elementRefs.get(id)?.ref ?? `@${id}`;
580576
await this.#waitForSelector(ref, this.#runContext?.timeoutMs ?? 30_000);
581577
return new CmuxElementHandle(this, ref);
582578
}
@@ -808,7 +804,7 @@ export class CmuxTab {
808804
}
809805

810806
async #selectorExists(spec: SelectorSpec): Promise<boolean> {
811-
if (spec.kind === "ref") return this.#elementRefs.has(Number(spec.value));
807+
if (spec.kind === "ref") return this.#elementRefs.has(spec.value);
812808
const script = `(() => {
813809
const spec = ${JSON.stringify(spec)};
814810
${PAGE_SELECTOR_HELPERS}
@@ -893,8 +889,8 @@ export class CmuxTab {
893889
else if (normalized.startsWith("p-aria/")) normalized = `aria/${normalized.slice("p-aria/".length)}`;
894890
else if (normalized.startsWith("p-xpath/")) normalized = `xpath/${normalized.slice("p-xpath/".length)}`;
895891
else if (normalized.startsWith("p-pierce/")) normalized = `pierce/${normalized.slice("p-pierce/".length)}`;
896-
const ref = /^@?e(\d+)$/.exec(normalized);
897-
if (ref) return { kind: "ref", value: ref[1]!, raw, ref: `@e${ref[1]}` };
892+
const ref = /^@?(e\d+)$/.exec(normalized);
893+
if (ref) return { kind: "ref", value: ref[1]!, raw, ref: `@${ref[1]}` };
898894
const slash = normalized.indexOf("/");
899895
if (slash > 0) {
900896
const prefix = normalized.slice(0, slash);
@@ -916,7 +912,7 @@ export class CmuxTab {
916912
this.#elementRefs.clear();
917913
for (const element of observation.elements) {
918914
this.#elementRefs.set(element.id, {
919-
ref: `@e${element.id}`,
915+
ref: `@${element.id}`,
920916
name: element.name,
921917
role: element.role,
922918
});

packages/coding-agent/src/tools/browser/cmux/rpc.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,13 +82,18 @@ export function cmuxSnapshotToObservation(
8282
for (const ref in refs) {
8383
const value = refs[ref];
8484
if (!value) continue;
85-
const id = Number.parseInt(ref.replace(/^@?e/, ""), 10);
86-
if (Number.isNaN(id)) continue;
85+
// Keep the full ref string (e.g. "e2") as the element ID for aria-ref selectors.
86+
const normalizedRef = ref.startsWith("@") ? ref.slice(1) : ref;
87+
if (!normalizedRef.startsWith("e")) continue;
8788
const role = typeof value.role === "string" && value.role.length > 0 ? value.role : "generic";
8889
const name = typeof value.name === "string" && value.name.length > 0 ? value.name : undefined;
89-
elements.push({ id, role, name, states: [] });
90+
elements.push({ id: normalizedRef, role, name, states: [] });
9091
}
91-
elements.sort((a, b) => a.id - b.id);
92+
elements.sort((a, b) => {
93+
const aNum = Number.parseInt(a.id.replace(/^e/, ""), 10) || 0;
94+
const bNum = Number.parseInt(b.id.replace(/^e/, ""), 10) || 0;
95+
return aNum - bNum;
96+
});
9297

9398
const url =
9499
(typeof result.url === "string" && result.url.length > 0 ? result.url : undefined) ??

0 commit comments

Comments
 (0)