@trusty-squire/mcp 1.1.13 → 1.1.14-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -11
- package/dist/bot/browser-process-owner.d.ts +75 -0
- package/dist/bot/browser-process-owner.d.ts.map +1 -0
- package/dist/bot/browser-process-owner.js +931 -0
- package/dist/bot/browser-process-owner.js.map +1 -0
- package/dist/bot/browser-process-runtime.d.ts +159 -0
- package/dist/bot/browser-process-runtime.d.ts.map +1 -0
- package/dist/bot/browser-process-runtime.js +905 -0
- package/dist/bot/browser-process-runtime.js.map +1 -0
- package/dist/bot/browser.d.ts +45 -194
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +666 -2358
- package/dist/bot/browser.js.map +1 -1
- package/dist/bot/compact-observation-v2.d.ts +9 -0
- package/dist/bot/compact-observation-v2.d.ts.map +1 -1
- package/dist/bot/compact-observation-v2.js +141 -82
- package/dist/bot/compact-observation-v2.js.map +1 -1
- package/dist/bot/identity-runtime.d.ts +56 -0
- package/dist/bot/identity-runtime.d.ts.map +1 -0
- package/dist/bot/identity-runtime.js +141 -0
- package/dist/bot/identity-runtime.js.map +1 -0
- package/dist/bot/owned-pages.d.ts +16 -0
- package/dist/bot/owned-pages.d.ts.map +1 -0
- package/dist/bot/owned-pages.js +58 -0
- package/dist/bot/owned-pages.js.map +1 -0
- package/dist/bot/page-driver.d.ts +35 -0
- package/dist/bot/page-driver.d.ts.map +1 -0
- package/dist/bot/page-driver.js +302 -0
- package/dist/bot/page-driver.js.map +1 -0
- package/dist/bot/provision-session.d.ts +4 -0
- package/dist/bot/provision-session.d.ts.map +1 -1
- package/dist/bot/provision-session.js +122 -29
- package/dist/bot/provision-session.js.map +1 -1
- package/dist/bot/session/lifecycle.d.ts.map +1 -1
- package/dist/bot/session/lifecycle.js +218 -14
- package/dist/bot/session/lifecycle.js.map +1 -1
- package/dist/bot/session/multisession-flag.d.ts +2 -0
- package/dist/bot/session/multisession-flag.d.ts.map +1 -0
- package/dist/bot/session/multisession-flag.js +23 -0
- package/dist/bot/session/multisession-flag.js.map +1 -0
- package/dist/tools/provision-drive.d.ts +12 -12
- package/package.json +1 -1
package/dist/bot/browser.js
CHANGED
|
@@ -21,84 +21,15 @@
|
|
|
21
21
|
// Turnstile/reCAPTCHA-v3 scoring on most SaaS signups. Visible-mode
|
|
22
22
|
// captchas still need the click-and-wait pattern (the Tier 2 captcha
|
|
23
23
|
// gate).
|
|
24
|
-
import {
|
|
25
|
-
import { createRequire } from "node:module";
|
|
26
|
-
import { Socket } from "node:net";
|
|
27
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
28
|
-
import { readFile } from "node:fs/promises";
|
|
29
|
-
import { join } from "node:path";
|
|
30
|
-
import { spawn } from "node:child_process";
|
|
24
|
+
import { childProcessIsRunning, closeLocalBrowserLaunch, markLocalBrowserLaunchTerminal, profileCollisionFromStderr, registerLocalBrowserLaunch, registerSelfManagedChrome, resolveAttachedProfileChildIdentity, selfManagedChromes, signalOwnedChromeProcessTree, spawnLocalBrowser, trackOwnedChromeProcessTree, withChromeStartupLock, } from "./browser-process-runtime.js";
|
|
31
25
|
import { isSameRecipeDomain } from "@trusty-squire/recipe-schema";
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const require = createRequire(import.meta.url);
|
|
40
|
-
// Operator signup runs are deliberately headed. Google, Stytch, and Cloudflare
|
|
41
|
-
// routinely reject a headless Chrome even when it is otherwise self-launched.
|
|
42
|
-
const OPERATOR_BROWSER_HEADLESS = false;
|
|
43
|
-
export function registerLocalBrowserLaunch(profileDir, baseEnv = process.env, marker = createOperatorBrowserMarker()) {
|
|
44
|
-
trackOwnerBrowserLaunch(marker, profileDir);
|
|
45
|
-
return {
|
|
46
|
-
marker,
|
|
47
|
-
env: { ...baseEnv, [OPERATOR_BROWSER_MARKER_ENV]: marker },
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
export async function closeBrowserContextWithin(context, timeoutMs = 2_000) {
|
|
51
|
-
let timer;
|
|
52
|
-
const outcome = await Promise.race([
|
|
53
|
-
Promise.resolve()
|
|
54
|
-
.then(() => context.close())
|
|
55
|
-
.then(() => true, () => false),
|
|
56
|
-
new Promise((resolveTimeout) => {
|
|
57
|
-
timer = setTimeout(() => resolveTimeout(false), timeoutMs);
|
|
58
|
-
}),
|
|
59
|
-
]);
|
|
60
|
-
if (timer !== undefined)
|
|
61
|
-
clearTimeout(timer);
|
|
62
|
-
return outcome;
|
|
63
|
-
}
|
|
64
|
-
function spawnLocalBrowser(binary, args, profileDir, options) {
|
|
65
|
-
const ownership = registerLocalBrowserLaunch(profileDir, options.env, options.marker);
|
|
66
|
-
try {
|
|
67
|
-
const child = spawn(binary, [...args], {
|
|
68
|
-
env: ownership.env,
|
|
69
|
-
stdio: options.stdio,
|
|
70
|
-
detached: options.detached,
|
|
71
|
-
});
|
|
72
|
-
localBrowserLaunchMarkers.set(child, ownership.marker);
|
|
73
|
-
child.once("exit", () => {
|
|
74
|
-
setTimeout(() => {
|
|
75
|
-
reconcileOwnerBrowserLaunchAfterLeaderExit(ownership.marker, profileDir);
|
|
76
|
-
}, 0).unref();
|
|
77
|
-
});
|
|
78
|
-
return child;
|
|
79
|
-
}
|
|
80
|
-
catch (error) {
|
|
81
|
-
untrackOwnerBrowserLaunch(ownership.marker);
|
|
82
|
-
throw error;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
const localBrowserLaunchMarkers = new WeakMap();
|
|
86
|
-
function markLocalBrowserLaunchTerminal(child) {
|
|
87
|
-
if (child === null)
|
|
88
|
-
return;
|
|
89
|
-
const marker = localBrowserLaunchMarkers.get(child);
|
|
90
|
-
if (marker !== undefined)
|
|
91
|
-
markOwnerBrowserLaunchTerminal(marker);
|
|
92
|
-
}
|
|
93
|
-
export async function closeLocalBrowserLaunch(marker, profileDir, runtime = {}) {
|
|
94
|
-
if (marker === undefined)
|
|
95
|
-
return;
|
|
96
|
-
(runtime.markTerminal ?? markOwnerBrowserLaunchTerminal)(marker);
|
|
97
|
-
if (!(await (runtime.terminate ?? terminateOwnerBrowserLaunch)(marker, profileDir))) {
|
|
98
|
-
throw new Error("local login browser closure unproven");
|
|
99
|
-
}
|
|
100
|
-
(runtime.untrack ?? untrackOwnerBrowserLaunch)(marker);
|
|
101
|
-
}
|
|
26
|
+
import {} from "node:child_process";
|
|
27
|
+
import { existsSync, statSync } from "node:fs";
|
|
28
|
+
import { experimentalMultiSessionEnabled } from "./session/multisession-flag.js";
|
|
29
|
+
import { BrowserProcessOwner } from "./browser-process-owner.js";
|
|
30
|
+
import { bindOwnerBrowserLaunch, untrackOwnerBrowserLaunch } from "./owner-process-reaper.js";
|
|
31
|
+
import { PageDriver } from "./page-driver.js";
|
|
32
|
+
import { clearStaleSingletonLock, profileProcessIdentity, reapProfileHolderIfOwned, signalProfileProcess, } from "./profile.js";
|
|
102
33
|
export function contextInitScriptsFor(options) {
|
|
103
34
|
if (options.hardened)
|
|
104
35
|
return [];
|
|
@@ -108,81 +39,6 @@ export function contextInitScriptsFor(options) {
|
|
|
108
39
|
...(options.remoteMode ? [] : ["webgl-spoof"]),
|
|
109
40
|
];
|
|
110
41
|
}
|
|
111
|
-
// Whether to use the CDP-hardened launcher (patchright, which runs
|
|
112
|
-
// evaluations in an isolated world and removes the automation tells —
|
|
113
|
-
// mainWorldExecution, navigator.webdriver, viewport — that Turnstile /
|
|
114
|
-
// reCAPTCHA-v3 / Google's consent SPA score on). See
|
|
115
|
-
// docs/ARCHITECTURE.md.
|
|
116
|
-
//
|
|
117
|
-
// 2026-06-08 — DEFAULT FLIPPED ON. The baseline (playwright-extra +
|
|
118
|
-
// stealth) self-inflicts a detectable navigator.webdriver via its manual
|
|
119
|
-
// defineProperty patch, so it is strictly WORSE on the fingerprint. The
|
|
120
|
-
// hardened launcher is all-green on the rebrowser bot-detector and was
|
|
121
|
-
// live-A/B'd: meilisearch's Google consent-SPA block became a (handleable)
|
|
122
|
-
// FedCM path, and render still signed up + extracted a key cleanly — no
|
|
123
|
-
// crash on either (the old crash was the retired rebrowser fork, not
|
|
124
|
-
// patchright). Default to hardened; opt out with BOT_CDP_HARDENED=0 for
|
|
125
|
-
// the baseline. If patchright isn't installed, getChromium() falls back to
|
|
126
|
-
// baseline gracefully.
|
|
127
|
-
function cdpHardeningRequested() {
|
|
128
|
-
const v = process.env.BOT_CDP_HARDENED;
|
|
129
|
-
if (v === "0" || v === "false" || v === "off")
|
|
130
|
-
return false;
|
|
131
|
-
return true;
|
|
132
|
-
}
|
|
133
|
-
let cachedChromium = null;
|
|
134
|
-
// The stealth profile the cached launcher actually represents. Set the
|
|
135
|
-
// first time getChromium() resolves a launcher and read back via
|
|
136
|
-
// BrowserController.stealthProfile for the CaptchaEvent A/B tag. A
|
|
137
|
-
// patchright load failure degrades it to "baseline" truthfully rather
|
|
138
|
-
// than over-claiming "cdp_hardened" on a run that never got the patch.
|
|
139
|
-
let activeStealthProfile = "baseline";
|
|
140
|
-
function activeStealthProfileValue() {
|
|
141
|
-
return activeStealthProfile;
|
|
142
|
-
}
|
|
143
|
-
function getChromium() {
|
|
144
|
-
if (cachedChromium !== null)
|
|
145
|
-
return cachedChromium;
|
|
146
|
-
const hardened = cdpHardeningRequested();
|
|
147
|
-
try {
|
|
148
|
-
if (hardened) {
|
|
149
|
-
// patchright — a maintained Playwright fork that runs every
|
|
150
|
-
// evaluation in an ISOLATED world (so the bot's DOM probing is
|
|
151
|
-
// invisible to a page that traps DOM methods → closes the
|
|
152
|
-
// `mainWorldExecution` tell) and handles `navigator.webdriver`
|
|
153
|
-
// natively + correctly. Verified ALL-GREEN against the maintained
|
|
154
|
-
// rebrowser bot-detector (mainWorldExecution, navigatorWebdriver,
|
|
155
|
-
// viewport, runtimeEnableLeak all clean). It drives real Chrome
|
|
156
|
-
// (channel) directly — the earlier rebrowser fork couldn't, which is
|
|
157
|
-
// why the old hardened arm was forced onto bundled chromium and then
|
|
158
|
-
// crashed the OAuth flow. NO playwright-extra/stealth wrap here: the
|
|
159
|
-
// stealth plugin's manual `navigator.webdriver` defineProperty
|
|
160
|
-
// RE-ADDS a detectable property (proven counterproductive) — patchright
|
|
161
|
-
// does it right. See docs/ARCHITECTURE.md.
|
|
162
|
-
const patchright = require("patchright");
|
|
163
|
-
cachedChromium = patchright.chromium;
|
|
164
|
-
activeStealthProfile = "cdp_hardened";
|
|
165
|
-
return cachedChromium;
|
|
166
|
-
}
|
|
167
|
-
// Baseline: playwright-extra + stealth (unchanged). addExtra(baseChromium)
|
|
168
|
-
// is exactly what playwright-extra's default `chromium` export already is.
|
|
169
|
-
const { addExtra } = require("playwright-extra");
|
|
170
|
-
const stealth = require("puppeteer-extra-plugin-stealth");
|
|
171
|
-
activeStealthProfile = "baseline";
|
|
172
|
-
const extra = addExtra(baseChromium);
|
|
173
|
-
extra.use(stealth());
|
|
174
|
-
cachedChromium = extra;
|
|
175
|
-
}
|
|
176
|
-
catch (err) {
|
|
177
|
-
// Fall back to vanilla playwright if stealth (or the rebrowser fork)
|
|
178
|
-
// isn't installed. The bot still works, it's just easier to
|
|
179
|
-
// fingerprint as a bot — and the A/B tag stays truthfully "baseline".
|
|
180
|
-
console.warn(`[operator] hardened launcher unavailable, falling back to vanilla chromium: ${err instanceof Error ? err.message : String(err)}`);
|
|
181
|
-
cachedChromium = baseChromium;
|
|
182
|
-
activeStealthProfile = "baseline";
|
|
183
|
-
}
|
|
184
|
-
return cachedChromium;
|
|
185
|
-
}
|
|
186
42
|
export class BrowserClickDispatchError extends Error {
|
|
187
43
|
dispatchStatus;
|
|
188
44
|
constructor(dispatchStatus, error) {
|
|
@@ -286,6 +142,82 @@ export class PaymentSubmitOutcomeUnknownError extends Error {
|
|
|
286
142
|
this.name = "PaymentSubmitOutcomeUnknownError";
|
|
287
143
|
}
|
|
288
144
|
}
|
|
145
|
+
export class OAuthAwaitingHumanError extends Error {
|
|
146
|
+
phase;
|
|
147
|
+
constructor(message, phase = "pending") {
|
|
148
|
+
super(message);
|
|
149
|
+
this.name = "OAuthAwaitingHumanError";
|
|
150
|
+
this.phase = phase;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
export class OAuthFailedError extends Error {
|
|
154
|
+
constructor(message) {
|
|
155
|
+
super(message);
|
|
156
|
+
this.name = "OAuthFailedError";
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// Pure decision at the heart of Fix C's honest outcome classification,
|
|
160
|
+
// applied the instant an OAuth completion wait's deadline elapses. Exported
|
|
161
|
+
// so the exact race it recovers — the deadline firing in the same instant the
|
|
162
|
+
// provider actually hands control back — can be unit-tested without racing
|
|
163
|
+
// real timers: a 2026-09 dogfood run hit precisely this (Google's redirect
|
|
164
|
+
// landed, but the strict wait's confirmation loop had already timed out) and
|
|
165
|
+
// the tool reported a fabricated "session may have expired" failure for an
|
|
166
|
+
// action that had, in fact, just succeeded.
|
|
167
|
+
//
|
|
168
|
+
// - `transientClosed` at the deadline → the provider page is gone, which is
|
|
169
|
+
// this codebase's ordinary popup completion signal (waitForOAuthLifecycle
|
|
170
|
+
// reports the same state as "closed"); settle it the same way.
|
|
171
|
+
// - `returnedToProductOrigin` at the deadline (same-tab, the page actually
|
|
172
|
+
// left the product origin and is back on it — the same departure
|
|
173
|
+
// condition waitForOAuthLifecycle requires) → the provider DID return
|
|
174
|
+
// control; report completion, not failure.
|
|
175
|
+
// - otherwise we simply don't know yet → `awaiting_human` (a consent
|
|
176
|
+
// screen or 2FA challenge is commonly still showing); never asserted as
|
|
177
|
+
// a cause, just the honest "not done".
|
|
178
|
+
//
|
|
179
|
+
// A real failure is never inferred here: it comes only from an observed OAuth
|
|
180
|
+
// error on the return URL (`oauthErrorFromReturnUrl`).
|
|
181
|
+
export function classifyOAuthTimeout(transientClosed, returnedToProductOrigin) {
|
|
182
|
+
if (transientClosed)
|
|
183
|
+
return "closed";
|
|
184
|
+
if (returnedToProductOrigin)
|
|
185
|
+
return "returned";
|
|
186
|
+
return "awaiting_human";
|
|
187
|
+
}
|
|
188
|
+
// The one observed denial/error signal OAuth defines: the provider redirects
|
|
189
|
+
// back to the relying party carrying `error=<code>` (RFC 6749 §4.1.2.1 in the
|
|
190
|
+
// query; §4.2.2.1 in the fragment for implicit flows). The code is reported
|
|
191
|
+
// verbatim — it is a fact the provider stated, not a guess.
|
|
192
|
+
const OAUTH_ERROR_CODE_RE = /^[A-Za-z0-9_.:-]{1,64}$/;
|
|
193
|
+
export function oauthErrorFromReturnUrl(url) {
|
|
194
|
+
let parsed;
|
|
195
|
+
try {
|
|
196
|
+
parsed = new URL(url);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
for (const params of [
|
|
202
|
+
parsed.searchParams,
|
|
203
|
+
new URLSearchParams(parsed.hash.startsWith("#") ? parsed.hash.slice(1) : ""),
|
|
204
|
+
]) {
|
|
205
|
+
const error = params.get("error");
|
|
206
|
+
if (error === null || !OAUTH_ERROR_CODE_RE.test(error))
|
|
207
|
+
continue;
|
|
208
|
+
const description = params.get("error_description");
|
|
209
|
+
return {
|
|
210
|
+
error,
|
|
211
|
+
description: description === null || description.length === 0 ? null : description,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
export function oauthAwaitingHumanMessage(productOrigin, budgetMs) {
|
|
217
|
+
return (`OAuth has not returned to ${productOrigin} within ${Math.ceil(budgetMs / 1000)} seconds. ` +
|
|
218
|
+
"A consent screen or a 2FA/verification challenge may still be showing on the provider; " +
|
|
219
|
+
"call operate_observe to check whether it has resolved, rather than treating this as a failure.");
|
|
220
|
+
}
|
|
289
221
|
export async function runCaptureConfirmedPaymentSubmit(options) {
|
|
290
222
|
let clickError;
|
|
291
223
|
let inputDispatchPossible = false;
|
|
@@ -1920,612 +1852,6 @@ function pngDimensions(buf) {
|
|
|
1920
1852
|
}
|
|
1921
1853
|
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
1922
1854
|
}
|
|
1923
|
-
// Real-Chromium-family browser channels we'll prefer over the bundled
|
|
1924
|
-
// Chromium binary when available. Chromium ships without Widevine,
|
|
1925
|
-
// without proprietary codecs, with an empty navigator.plugins array,
|
|
1926
|
-
// and with a chrome.runtime API surface that bot-detection scripts
|
|
1927
|
-
// know to look for. Using a *real* installation papers over ~6 of
|
|
1928
|
-
// those fingerprint bits at zero engineering cost.
|
|
1929
|
-
//
|
|
1930
|
-
// Order matters: pick the channel most likely to be present *and*
|
|
1931
|
-
// hardest to fingerprint as automation. Stable Chrome > Edge >
|
|
1932
|
-
// Beta/Canary > Brave. Brave isn't a Playwright channel but its
|
|
1933
|
-
// binary path is well-known; we resolve it explicitly below.
|
|
1934
|
-
const PREFERRED_CHANNELS = ["chrome", "msedge", "chrome-beta", "chrome-canary"];
|
|
1935
|
-
// Per-channel binary search paths. Playwright's `executablePath()` is
|
|
1936
|
-
// argumentless (returns the bundled Chromium path), so we can't ask it
|
|
1937
|
-
// "is Chrome installed?" — we have to look ourselves. These are the
|
|
1938
|
-
// canonical install locations on each platform; the first hit wins.
|
|
1939
|
-
//
|
|
1940
|
-
// Limitation: this misses sideloaded installs (Chrome installed via
|
|
1941
|
-
// the user's package manager to a non-default path, dev-builds in
|
|
1942
|
-
// home directories, etc.). For those, the user can set
|
|
1943
|
-
// UNIVERSAL_BOT_CHANNEL=chrome to force Playwright to find it
|
|
1944
|
-
// through its own resolution. We accept the false-negative because
|
|
1945
|
-
// the alternative (asking Playwright to launch and seeing if it
|
|
1946
|
-
// succeeds) costs ~1s of process startup per probe.
|
|
1947
|
-
const CHANNEL_PATHS = {
|
|
1948
|
-
chrome: [
|
|
1949
|
-
// macOS
|
|
1950
|
-
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
1951
|
-
// Linux
|
|
1952
|
-
"/usr/bin/google-chrome",
|
|
1953
|
-
"/usr/bin/google-chrome-stable",
|
|
1954
|
-
"/opt/google/chrome/chrome",
|
|
1955
|
-
// Windows — Playwright resolves these via channel anyway, but list
|
|
1956
|
-
// for completeness on cross-platform Node runs.
|
|
1957
|
-
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
1958
|
-
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
|
1959
|
-
],
|
|
1960
|
-
msedge: [
|
|
1961
|
-
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
1962
|
-
"/usr/bin/microsoft-edge",
|
|
1963
|
-
"/usr/bin/microsoft-edge-stable",
|
|
1964
|
-
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
|
|
1965
|
-
],
|
|
1966
|
-
"chrome-beta": [
|
|
1967
|
-
"/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
|
|
1968
|
-
"/usr/bin/google-chrome-beta",
|
|
1969
|
-
],
|
|
1970
|
-
"chrome-canary": [
|
|
1971
|
-
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
|
1972
|
-
"/usr/bin/google-chrome-unstable",
|
|
1973
|
-
],
|
|
1974
|
-
};
|
|
1975
|
-
// Detect a real-Chromium-family browser channel without launching it.
|
|
1976
|
-
// Returns the channel name (passable as `channel:` to .launch) or null
|
|
1977
|
-
// to mean "use bundled Chromium." Logs the selection to stderr so the
|
|
1978
|
-
// telemetry path can see which browser the run ended up on without
|
|
1979
|
-
// having to thread it through the agent state machine.
|
|
1980
|
-
async function detectChromiumChannel() {
|
|
1981
|
-
// Skip detection in tests / when explicitly opting out. The unit tests
|
|
1982
|
-
// launch hundreds of browsers and shouldn't probe the filesystem each
|
|
1983
|
-
// time; they also can't rely on real Chrome being present.
|
|
1984
|
-
if (process.env.UNIVERSAL_BOT_CHANNEL === "bundled")
|
|
1985
|
-
return null;
|
|
1986
|
-
if (process.env.UNIVERSAL_BOT_CHANNEL !== undefined) {
|
|
1987
|
-
// Explicit override — caller knows what they want.
|
|
1988
|
-
return process.env.UNIVERSAL_BOT_CHANNEL;
|
|
1989
|
-
}
|
|
1990
|
-
const fsMod = await import("node:fs");
|
|
1991
|
-
for (const channel of PREFERRED_CHANNELS) {
|
|
1992
|
-
const candidatePaths = CHANNEL_PATHS[channel] ?? [];
|
|
1993
|
-
for (const candidate of candidatePaths) {
|
|
1994
|
-
try {
|
|
1995
|
-
if (fsMod.existsSync(candidate))
|
|
1996
|
-
return channel;
|
|
1997
|
-
}
|
|
1998
|
-
catch {
|
|
1999
|
-
// permission errors etc. — skip this candidate, try the next
|
|
2000
|
-
}
|
|
2001
|
-
}
|
|
2002
|
-
}
|
|
2003
|
-
return null;
|
|
2004
|
-
}
|
|
2005
|
-
// Resolve the on-disk Chrome binary for a detected channel, for the
|
|
2006
|
-
// self-launch path (see launchSelfManagedContext). Playwright launches a
|
|
2007
|
-
// channel by name; we have to spawn the binary ourselves, so we need the
|
|
2008
|
-
// path. Returns null when the channel is unknown / not found on disk
|
|
2009
|
-
// (caller falls back to launchPersistentContext).
|
|
2010
|
-
export function resolveChannelBinary(channel) {
|
|
2011
|
-
if (channel === null)
|
|
2012
|
-
return null; // bundled Chromium — no self-launch
|
|
2013
|
-
const explicit = process.env.UNIVERSAL_BOT_CHROME_BINARY;
|
|
2014
|
-
if (explicit !== undefined && explicit.length > 0) {
|
|
2015
|
-
return existsSync(explicit) ? explicit : null;
|
|
2016
|
-
}
|
|
2017
|
-
const candidates = CHANNEL_PATHS[channel] ?? [];
|
|
2018
|
-
for (const c of candidates) {
|
|
2019
|
-
try {
|
|
2020
|
-
if (existsSync(c))
|
|
2021
|
-
return c;
|
|
2022
|
-
}
|
|
2023
|
-
catch {
|
|
2024
|
-
// skip unreadable candidate
|
|
2025
|
-
}
|
|
2026
|
-
}
|
|
2027
|
-
return null;
|
|
2028
|
-
}
|
|
2029
|
-
// Whether to launch Chrome ourselves and attach over CDP, instead of
|
|
2030
|
-
// Playwright's launchPersistentContext.
|
|
2031
|
-
//
|
|
2032
|
-
// WHY THIS EXISTS — the single decisive finding (2026-06-12, fully
|
|
2033
|
-
// reproduced + falsifiable; see STATE.md "Cloudflare-Turnstile wall").
|
|
2034
|
-
// Cloudflare Turnstile's interactive challenge FAILS a Playwright/patchright
|
|
2035
|
-
// launchPersistentContext-driven Chrome and PASSES a Chrome the operator
|
|
2036
|
-
// launches itself and then attaches to over CDP — every other variable held
|
|
2037
|
-
// constant (same box, same datacenter IP, same headed display, same Chrome 148
|
|
2038
|
-
// binary, same software-WebGL, same humanized click). The discriminator
|
|
2039
|
-
// matrix:
|
|
2040
|
-
// launchPersistentContext + CDP click → "Verification failed"
|
|
2041
|
-
// launchPersistentContext + OS click → "Verification failed"
|
|
2042
|
-
// plain google-chrome + OS click → "Success!"
|
|
2043
|
-
// plain google-chrome + connectOverCDP + page.mouse → token issued (len816)
|
|
2044
|
-
// So the tell is NEITHER the live CDP attachment NOR the click mechanism —
|
|
2045
|
-
// it is specifically the launch flags/instrumentation Playwright injects at
|
|
2046
|
-
// launchPersistentContext time. Self-launching the binary (no
|
|
2047
|
-
// --enable-automation et al.) and attaching with connectOverCDP avoids it.
|
|
2048
|
-
// Default-ON; opt out with BOT_SELF_LAUNCH=0 for the persistent-context path. Exported for tests.
|
|
2049
|
-
export function selfLaunchEnabled() {
|
|
2050
|
-
const v = process.env.BOT_SELF_LAUNCH;
|
|
2051
|
-
return v !== "0" && v !== "false" && v !== "off";
|
|
2052
|
-
}
|
|
2053
|
-
const PERSISTENT_CONTEXT_LAUNCH_TIMEOUT_MS = 30_000;
|
|
2054
|
-
const PERSISTENT_CONTEXT_CANCELLATION_SETTLE_MS = 2_000;
|
|
2055
|
-
const PERSISTENT_CONTEXT_CANCELLATION_POLL_MS = 25;
|
|
2056
|
-
const PROFILE_IDENTITY_PROOF_TIMEOUT_MS = 2_000;
|
|
2057
|
-
const PROFILE_IDENTITY_POLL_MS = 25;
|
|
2058
|
-
const PROFILE_HOLDER_ABSENCE_GRACE_MS = 100;
|
|
2059
|
-
export async function resolvePersistentFallbackIdentity(opts) {
|
|
2060
|
-
if ((opts.platform ?? process.platform) !== "linux")
|
|
2061
|
-
return { state: "unknown" };
|
|
2062
|
-
const timeoutMs = opts.timeoutMs ?? PROFILE_IDENTITY_PROOF_TIMEOUT_MS;
|
|
2063
|
-
const pollMs = opts.pollMs ?? PROFILE_IDENTITY_POLL_MS;
|
|
2064
|
-
const absenceGraceMs = opts.absenceGraceMs ?? PROFILE_HOLDER_ABSENCE_GRACE_MS;
|
|
2065
|
-
const readHolder = opts.currentHolderPid ?? currentProfileHolderPid;
|
|
2066
|
-
const readIdentity = opts.readIdentity ?? profileProcessIdentity;
|
|
2067
|
-
const clearStaleLock = opts.clearStaleLock ?? clearStaleSingletonLock;
|
|
2068
|
-
const deadline = Date.now() + timeoutMs;
|
|
2069
|
-
let absentSince = null;
|
|
2070
|
-
for (;;) {
|
|
2071
|
-
const holderPid = readHolder(opts.profileDir);
|
|
2072
|
-
if (holderPid === null) {
|
|
2073
|
-
absentSince ??= Date.now();
|
|
2074
|
-
if (Date.now() - absentSince >= absenceGraceMs)
|
|
2075
|
-
return { state: "absent" };
|
|
2076
|
-
}
|
|
2077
|
-
else {
|
|
2078
|
-
absentSince = null;
|
|
2079
|
-
const identity = readIdentity(holderPid, opts.profileDir);
|
|
2080
|
-
if (identity !== null)
|
|
2081
|
-
return { state: "owned", identity };
|
|
2082
|
-
if (clearStaleLock(opts.profileDir))
|
|
2083
|
-
return { state: "absent" };
|
|
2084
|
-
}
|
|
2085
|
-
if (Date.now() >= deadline)
|
|
2086
|
-
return { state: "unknown" };
|
|
2087
|
-
await new Promise((resolveWait) => {
|
|
2088
|
-
const timer = setTimeout(resolveWait, Math.min(pollMs, Math.max(1, deadline - Date.now())));
|
|
2089
|
-
timer.unref();
|
|
2090
|
-
});
|
|
2091
|
-
}
|
|
2092
|
-
}
|
|
2093
|
-
export async function launchCancellablePersistentContext(opts) {
|
|
2094
|
-
const launchTimeoutMs = opts.launchTimeoutMs ?? PERSISTENT_CONTEXT_LAUNCH_TIMEOUT_MS;
|
|
2095
|
-
const launchDeadline = Date.now() + launchTimeoutMs;
|
|
2096
|
-
const launch = Promise.resolve().then(() => opts.launch({ ...opts.options, timeout: launchTimeoutMs }));
|
|
2097
|
-
const outcome = await Promise.race([
|
|
2098
|
-
launch.then((value) => ({ status: "launched", value })),
|
|
2099
|
-
opts.cancellation.then(() => ({ status: "cancelled" })),
|
|
2100
|
-
]);
|
|
2101
|
-
if (outcome.status === "launched")
|
|
2102
|
-
return outcome;
|
|
2103
|
-
let rejectedCleanup = null;
|
|
2104
|
-
const cleanupRejected = () => {
|
|
2105
|
-
if (rejectedCleanup !== null)
|
|
2106
|
-
return rejectedCleanup;
|
|
2107
|
-
const cleanup = Promise.resolve()
|
|
2108
|
-
.then(opts.cleanupRejected)
|
|
2109
|
-
.catch(() => "unknown")
|
|
2110
|
-
.finally(() => {
|
|
2111
|
-
if (rejectedCleanup === cleanup)
|
|
2112
|
-
rejectedCleanup = null;
|
|
2113
|
-
});
|
|
2114
|
-
rejectedCleanup = cleanup;
|
|
2115
|
-
return cleanup;
|
|
2116
|
-
};
|
|
2117
|
-
const lateCleanup = launch
|
|
2118
|
-
.then(opts.cleanupCancelled, cleanupRejected)
|
|
2119
|
-
.catch(() => "unknown");
|
|
2120
|
-
const settleMs = opts.cancellationSettleMs ?? PERSISTENT_CONTEXT_CANCELLATION_SETTLE_MS;
|
|
2121
|
-
const pollMs = opts.cancellationPollMs ?? PERSISTENT_CONTEXT_CANCELLATION_POLL_MS;
|
|
2122
|
-
const cancellationDeadline = Math.max(Date.now(), launchDeadline) + settleMs;
|
|
2123
|
-
let settledCloseState = null;
|
|
2124
|
-
void lateCleanup.then((closeState) => {
|
|
2125
|
-
settledCloseState = closeState;
|
|
2126
|
-
});
|
|
2127
|
-
while (settledCloseState === null && Date.now() < cancellationDeadline) {
|
|
2128
|
-
await cleanupRejected();
|
|
2129
|
-
if (settledCloseState !== null)
|
|
2130
|
-
break;
|
|
2131
|
-
const remaining = cancellationDeadline - Date.now();
|
|
2132
|
-
if (remaining <= 0)
|
|
2133
|
-
break;
|
|
2134
|
-
await Promise.race([
|
|
2135
|
-
lateCleanup,
|
|
2136
|
-
new Promise((resolveWait) => {
|
|
2137
|
-
const timer = setTimeout(resolveWait, Math.min(pollMs, remaining));
|
|
2138
|
-
timer.unref();
|
|
2139
|
-
}),
|
|
2140
|
-
]);
|
|
2141
|
-
}
|
|
2142
|
-
if (settledCloseState !== null) {
|
|
2143
|
-
return { status: "cancelled", closeState: settledCloseState };
|
|
2144
|
-
}
|
|
2145
|
-
await cleanupRejected();
|
|
2146
|
-
void lateCleanup;
|
|
2147
|
-
return { status: "cancelled", closeState: "unknown" };
|
|
2148
|
-
}
|
|
2149
|
-
const DEVTOOLS_ACTIVE_PORT_FILE = "DevToolsActivePort";
|
|
2150
|
-
export async function waitForOwnedDevtoolsEndpoint(profileDir, deadlineMs, child) {
|
|
2151
|
-
const activePortPath = join(profileDir, DEVTOOLS_ACTIVE_PORT_FILE);
|
|
2152
|
-
const deadline = Date.now() + deadlineMs;
|
|
2153
|
-
let lastErr = "";
|
|
2154
|
-
while (Date.now() < deadline) {
|
|
2155
|
-
if (!childProcessIsRunning(child)) {
|
|
2156
|
-
throw new Error("Chrome exited before its owned DevTools endpoint became available");
|
|
2157
|
-
}
|
|
2158
|
-
try {
|
|
2159
|
-
const [portText, browserPath] = (await readFile(activePortPath, "utf8")).split(/\r?\n/);
|
|
2160
|
-
const port = Number(portText);
|
|
2161
|
-
if (!Number.isInteger(port) ||
|
|
2162
|
-
port < 1 ||
|
|
2163
|
-
port > 65_535 ||
|
|
2164
|
-
browserPath === undefined ||
|
|
2165
|
-
!/^\/devtools\/browser\/[A-Za-z0-9-]+$/.test(browserPath)) {
|
|
2166
|
-
throw new Error("invalid DevToolsActivePort contents");
|
|
2167
|
-
}
|
|
2168
|
-
return `ws://127.0.0.1:${port}${browserPath}`;
|
|
2169
|
-
}
|
|
2170
|
-
catch (error) {
|
|
2171
|
-
lastErr = error instanceof Error ? error.message : String(error);
|
|
2172
|
-
}
|
|
2173
|
-
await new Promise((resolveWait) => {
|
|
2174
|
-
const timer = setTimeout(resolveWait, 200);
|
|
2175
|
-
timer.unref();
|
|
2176
|
-
});
|
|
2177
|
-
}
|
|
2178
|
-
throw new Error(`Owned Chrome DevTools endpoint was not published (${lastErr})`);
|
|
2179
|
-
}
|
|
2180
|
-
export async function withChromeStartupLock(fn, opts = {}) {
|
|
2181
|
-
const lockDir = opts.lockDir ?? "/tmp/trusty-squire-chrome-start.lock";
|
|
2182
|
-
const deadlineMs = opts.deadlineMs ?? 60_000;
|
|
2183
|
-
const deadline = Date.now() + deadlineMs;
|
|
2184
|
-
for (;;) {
|
|
2185
|
-
try {
|
|
2186
|
-
mkdirSync(lockDir);
|
|
2187
|
-
break;
|
|
2188
|
-
}
|
|
2189
|
-
catch (err) {
|
|
2190
|
-
try {
|
|
2191
|
-
const ageMs = Date.now() - statSync(lockDir).mtimeMs;
|
|
2192
|
-
if (ageMs > 120_000) {
|
|
2193
|
-
rmSync(lockDir, { recursive: true, force: true });
|
|
2194
|
-
continue;
|
|
2195
|
-
}
|
|
2196
|
-
}
|
|
2197
|
-
catch {
|
|
2198
|
-
rmSync(lockDir, { recursive: true, force: true });
|
|
2199
|
-
continue;
|
|
2200
|
-
}
|
|
2201
|
-
if (Date.now() >= deadline) {
|
|
2202
|
-
if (deadlineMs === 0)
|
|
2203
|
-
throw new ProfileBusyError(PROFILE_BUSY_MESSAGE);
|
|
2204
|
-
throw new Error(`Timed out waiting for Chrome startup lock at ${lockDir}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2205
|
-
}
|
|
2206
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
2207
|
-
}
|
|
2208
|
-
}
|
|
2209
|
-
try {
|
|
2210
|
-
return await fn();
|
|
2211
|
-
}
|
|
2212
|
-
finally {
|
|
2213
|
-
rmSync(lockDir, { recursive: true, force: true });
|
|
2214
|
-
}
|
|
2215
|
-
}
|
|
2216
|
-
const selfManagedChromes = new Map();
|
|
2217
|
-
const ownedChromeProcessTrees = new Set();
|
|
2218
|
-
let selfManagedCleanupInstalled = false;
|
|
2219
|
-
let selfManagedTerminationSignalExitEnabled = true;
|
|
2220
|
-
function cleanupSelfManagedChromes() {
|
|
2221
|
-
for (const proof of ownedChromeProcessTrees) {
|
|
2222
|
-
signalOwnedChromeProcessTree(proof.identity, proof.processGroup, "SIGKILL", { proof });
|
|
2223
|
-
untrackOwnerProcess(proof.identity);
|
|
2224
|
-
}
|
|
2225
|
-
selfManagedChromes.clear();
|
|
2226
|
-
}
|
|
2227
|
-
const exitForSelfManagedSignal = (code) => {
|
|
2228
|
-
cleanupSelfManagedChromes();
|
|
2229
|
-
process.exit(128 + code);
|
|
2230
|
-
};
|
|
2231
|
-
const onSelfManagedSigint = () => exitForSelfManagedSignal(2);
|
|
2232
|
-
const onSelfManagedSigterm = () => exitForSelfManagedSignal(15);
|
|
2233
|
-
const onSelfManagedSighup = () => exitForSelfManagedSignal(1);
|
|
2234
|
-
const selfManagedTerminationSignalHandlers = [
|
|
2235
|
-
["SIGHUP", onSelfManagedSighup],
|
|
2236
|
-
["SIGINT", onSelfManagedSigint],
|
|
2237
|
-
["SIGTERM", onSelfManagedSigterm],
|
|
2238
|
-
];
|
|
2239
|
-
export function synchronizeSelfManagedChromeTerminationSignalHandlers(enabled, runtime = process) {
|
|
2240
|
-
for (const [signal, handler] of selfManagedTerminationSignalHandlers) {
|
|
2241
|
-
if (enabled)
|
|
2242
|
-
runtime.once(signal, handler);
|
|
2243
|
-
else
|
|
2244
|
-
runtime.removeListener(signal, handler);
|
|
2245
|
-
}
|
|
2246
|
-
}
|
|
2247
|
-
// Whether the self-managed termination-signal handlers may exit the process.
|
|
2248
|
-
// False means another shutdown owner (the MCP server's disconnect coordinator,
|
|
2249
|
-
// or an in-flight interactive login) holds process-exit responsibility.
|
|
2250
|
-
export function isSelfManagedChromeTerminationSignalExitEnabled() {
|
|
2251
|
-
return selfManagedTerminationSignalExitEnabled;
|
|
2252
|
-
}
|
|
2253
|
-
export function setSelfManagedChromeTerminationSignalExitEnabled(enabled) {
|
|
2254
|
-
if (selfManagedTerminationSignalExitEnabled === enabled)
|
|
2255
|
-
return;
|
|
2256
|
-
selfManagedTerminationSignalExitEnabled = enabled;
|
|
2257
|
-
if (!selfManagedCleanupInstalled)
|
|
2258
|
-
return;
|
|
2259
|
-
synchronizeSelfManagedChromeTerminationSignalHandlers(enabled);
|
|
2260
|
-
}
|
|
2261
|
-
function installSelfManagedChromeCleanup() {
|
|
2262
|
-
if (selfManagedCleanupInstalled)
|
|
2263
|
-
return;
|
|
2264
|
-
selfManagedCleanupInstalled = true;
|
|
2265
|
-
process.once("exit", cleanupSelfManagedChromes);
|
|
2266
|
-
if (selfManagedTerminationSignalExitEnabled) {
|
|
2267
|
-
synchronizeSelfManagedChromeTerminationSignalHandlers(true);
|
|
2268
|
-
}
|
|
2269
|
-
}
|
|
2270
|
-
function registerSelfManagedChrome(child, profileDir, processGroup = false) {
|
|
2271
|
-
installSelfManagedChromeCleanup();
|
|
2272
|
-
const identity = child.pid === undefined ? null : profileProcessIdentity(child.pid, profileDir);
|
|
2273
|
-
if (identity !== null) {
|
|
2274
|
-
const proof = trackOwnedChromeProcessTree(identity, processGroup);
|
|
2275
|
-
if (proof !== null) {
|
|
2276
|
-
const marker = proof.identity.process_marker;
|
|
2277
|
-
if (marker !== undefined && !bindOwnerBrowserLaunch(marker, proof.identity)) {
|
|
2278
|
-
releaseOwnedChromeProcessTree(proof);
|
|
2279
|
-
throw new Error("local browser launch identity could not be bound to owner custody");
|
|
2280
|
-
}
|
|
2281
|
-
selfManagedChromes.set(identity.pid, { identity, processGroup, proof });
|
|
2282
|
-
}
|
|
2283
|
-
}
|
|
2284
|
-
child.once("exit", () => {
|
|
2285
|
-
if (child.pid === undefined)
|
|
2286
|
-
return;
|
|
2287
|
-
const tracked = selfManagedChromes.get(child.pid);
|
|
2288
|
-
if (tracked === undefined)
|
|
2289
|
-
return;
|
|
2290
|
-
if (ownedChromeProcessTreeState(tracked.proof) === "stale") {
|
|
2291
|
-
releaseOwnedChromeProcessTree(tracked.proof);
|
|
2292
|
-
selfManagedChromes.delete(child.pid);
|
|
2293
|
-
}
|
|
2294
|
-
});
|
|
2295
|
-
return identity;
|
|
2296
|
-
}
|
|
2297
|
-
async function waitForTrackedProfileChildIdentity(child, profileDir, readIdentity, timeoutMs, pollMs, processGroup = false) {
|
|
2298
|
-
const deadline = Date.now() + timeoutMs;
|
|
2299
|
-
while (childProcessIsRunning(child)) {
|
|
2300
|
-
const identity = child.pid === undefined ? null : readIdentity(child.pid, profileDir);
|
|
2301
|
-
if (identity !== null) {
|
|
2302
|
-
const existing = selfManagedChromes.get(identity.pid);
|
|
2303
|
-
const proof = existing?.identity.start_time === identity.start_time
|
|
2304
|
-
? existing.proof
|
|
2305
|
-
: trackOwnedChromeProcessTree(identity, processGroup);
|
|
2306
|
-
if (proof !== null)
|
|
2307
|
-
selfManagedChromes.set(identity.pid, { identity, processGroup, proof });
|
|
2308
|
-
return identity;
|
|
2309
|
-
}
|
|
2310
|
-
if (Date.now() >= deadline)
|
|
2311
|
-
return null;
|
|
2312
|
-
await new Promise((resolveWait) => {
|
|
2313
|
-
const timer = setTimeout(resolveWait, Math.min(pollMs, Math.max(1, deadline - Date.now())));
|
|
2314
|
-
timer.unref();
|
|
2315
|
-
});
|
|
2316
|
-
}
|
|
2317
|
-
return null;
|
|
2318
|
-
}
|
|
2319
|
-
export async function resolveAttachedProfileChildIdentity(child, profileDir, identity, options = {}) {
|
|
2320
|
-
if (identity !== null || (options.platform ?? process.platform) !== "linux")
|
|
2321
|
-
return identity;
|
|
2322
|
-
return await waitForTrackedProfileChildIdentity(child, profileDir, options.readIdentity ?? profileProcessIdentity, options.identityTimeoutMs ?? PROFILE_IDENTITY_PROOF_TIMEOUT_MS, options.identityPollMs ?? PROFILE_IDENTITY_POLL_MS, options.processGroup ?? false);
|
|
2323
|
-
}
|
|
2324
|
-
// Call this ONLY for a Chrome child spawned with detached:true. The identity
|
|
2325
|
-
// check protects against PID reuse, then POSIX negative-PID signalling reaches
|
|
2326
|
-
// Chrome's renderer/GPU/helper tree in one operation. A normal profile-root
|
|
2327
|
-
// signal remains the portable fallback for launchPersistentContext and Windows.
|
|
2328
|
-
export function signalOwnedChromeProcessTree(identity, processGroup, signal, options = {}) {
|
|
2329
|
-
const profileMatches = options.profileMatches ?? profileProcessMatches;
|
|
2330
|
-
const kill = options.kill ?? process.kill;
|
|
2331
|
-
const proof = options.proof ??
|
|
2332
|
-
captureOwnedChromeProcessTreeProof(identity, processGroup, {
|
|
2333
|
-
profileMatches,
|
|
2334
|
-
...(options.platform === undefined ? {} : { platform: options.platform }),
|
|
2335
|
-
...(options.processTreePids === undefined
|
|
2336
|
-
? {}
|
|
2337
|
-
: { processTreePids: options.processTreePids }),
|
|
2338
|
-
...(options.readBirthIdentity === undefined
|
|
2339
|
-
? {}
|
|
2340
|
-
: { readBirthIdentity: options.readBirthIdentity }),
|
|
2341
|
-
});
|
|
2342
|
-
if (proof === null)
|
|
2343
|
-
return false;
|
|
2344
|
-
const platform = options.platform ?? process.platform;
|
|
2345
|
-
const memberState = options.memberState ?? processBirthIdentityState;
|
|
2346
|
-
const matchingMembers = proof.members.filter((member) => memberState(member) === "matching");
|
|
2347
|
-
const matchingGroupMember = proof.processGroup && platform !== "win32"
|
|
2348
|
-
? matchingMembers.some((member) => platform !== "linux" ||
|
|
2349
|
-
(options.processGroupId ?? linuxProcessGroupId)(member.pid) === proof.identity.pid)
|
|
2350
|
-
: false;
|
|
2351
|
-
if (matchingGroupMember) {
|
|
2352
|
-
try {
|
|
2353
|
-
kill(-proof.identity.pid, signal);
|
|
2354
|
-
return true;
|
|
2355
|
-
}
|
|
2356
|
-
catch {
|
|
2357
|
-
// A process may exit between the proof and the signal. Fall through to
|
|
2358
|
-
// the root PID only while it is still identity-proven.
|
|
2359
|
-
}
|
|
2360
|
-
}
|
|
2361
|
-
let signalled = false;
|
|
2362
|
-
// Signal leaves first. This covers the Playwright persistent-context fallback
|
|
2363
|
-
// (including chrome-headless-shell), whose child is not a detached process
|
|
2364
|
-
// group leader but whose renderer tree is still rooted at the identity-proven
|
|
2365
|
-
// browser PID.
|
|
2366
|
-
for (const member of [...proof.members].reverse()) {
|
|
2367
|
-
if (memberState(member) !== "matching")
|
|
2368
|
-
continue;
|
|
2369
|
-
try {
|
|
2370
|
-
kill(member.pid, signal);
|
|
2371
|
-
signalled = true;
|
|
2372
|
-
}
|
|
2373
|
-
catch {
|
|
2374
|
-
// A child can naturally exit while the tree is being walked.
|
|
2375
|
-
}
|
|
2376
|
-
}
|
|
2377
|
-
return signalled;
|
|
2378
|
-
}
|
|
2379
|
-
export function captureOwnedChromeProcessTreeProof(identity, processGroup, options = {}) {
|
|
2380
|
-
const profileMatches = options.profileMatches ?? profileProcessMatches;
|
|
2381
|
-
if (!profileMatches(identity, identity.user_data_dir))
|
|
2382
|
-
return null;
|
|
2383
|
-
const platform = options.platform ?? process.platform;
|
|
2384
|
-
const pids = platform === "linux"
|
|
2385
|
-
? (options.processTreePids ?? linuxProcessTreePids)(identity.pid)
|
|
2386
|
-
: [identity.pid];
|
|
2387
|
-
const readBirthIdentity = options.readBirthIdentity ?? processBirthIdentity;
|
|
2388
|
-
const members = pids.flatMap((pid) => {
|
|
2389
|
-
if (pid === identity.pid)
|
|
2390
|
-
return [{ pid, start_time: identity.start_time }];
|
|
2391
|
-
const member = readBirthIdentity(pid);
|
|
2392
|
-
return member === null ? [] : [member];
|
|
2393
|
-
});
|
|
2394
|
-
if (!members.some((member) => member.pid === identity.pid)) {
|
|
2395
|
-
members.unshift({ pid: identity.pid, start_time: identity.start_time });
|
|
2396
|
-
}
|
|
2397
|
-
return { identity, processGroup, members };
|
|
2398
|
-
}
|
|
2399
|
-
function trackOwnedChromeProcessTree(identity, processGroup) {
|
|
2400
|
-
installSelfManagedChromeCleanup();
|
|
2401
|
-
const marker = operatorBrowserProcessMarker(identity.pid);
|
|
2402
|
-
const trackedIdentity = marker === null ? identity : { ...identity, process_marker: marker };
|
|
2403
|
-
const proof = captureOwnedChromeProcessTreeProof(trackedIdentity, processGroup);
|
|
2404
|
-
if (proof === null)
|
|
2405
|
-
return null;
|
|
2406
|
-
ownedChromeProcessTrees.add(proof);
|
|
2407
|
-
trackOwnerProcess(proof.identity);
|
|
2408
|
-
return proof;
|
|
2409
|
-
}
|
|
2410
|
-
function releaseOwnedChromeProcessTree(proof) {
|
|
2411
|
-
if (proof === null)
|
|
2412
|
-
return;
|
|
2413
|
-
ownedChromeProcessTrees.delete(proof);
|
|
2414
|
-
untrackOwnerProcess(proof.identity);
|
|
2415
|
-
}
|
|
2416
|
-
export function ownedChromeProcessTreeState(proof, options = {}) {
|
|
2417
|
-
const memberState = options.memberState ?? processBirthIdentityState;
|
|
2418
|
-
let sawUnknown = false;
|
|
2419
|
-
for (const member of proof.members) {
|
|
2420
|
-
const state = memberState(member);
|
|
2421
|
-
if (state === "matching")
|
|
2422
|
-
return "matching";
|
|
2423
|
-
if (state === "unknown")
|
|
2424
|
-
sawUnknown = true;
|
|
2425
|
-
}
|
|
2426
|
-
return sawUnknown ? "unknown" : "stale";
|
|
2427
|
-
}
|
|
2428
|
-
function linuxProcessGroupId(pid) {
|
|
2429
|
-
try {
|
|
2430
|
-
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
2431
|
-
const closeParen = stat.lastIndexOf(")");
|
|
2432
|
-
if (closeParen < 0)
|
|
2433
|
-
return null;
|
|
2434
|
-
const processGroupId = Number(stat
|
|
2435
|
-
.slice(closeParen + 2)
|
|
2436
|
-
.trim()
|
|
2437
|
-
.split(/\s+/)[2]);
|
|
2438
|
-
return Number.isSafeInteger(processGroupId) ? processGroupId : null;
|
|
2439
|
-
}
|
|
2440
|
-
catch {
|
|
2441
|
-
return null;
|
|
2442
|
-
}
|
|
2443
|
-
}
|
|
2444
|
-
function linuxProcessTreePids(rootPid) {
|
|
2445
|
-
try {
|
|
2446
|
-
const childrenByParent = new Map();
|
|
2447
|
-
for (const entry of readdirSync("/proc")) {
|
|
2448
|
-
if (!/^\d+$/.test(entry))
|
|
2449
|
-
continue;
|
|
2450
|
-
const pid = Number(entry);
|
|
2451
|
-
try {
|
|
2452
|
-
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
2453
|
-
const closeParen = stat.lastIndexOf(")");
|
|
2454
|
-
if (closeParen < 0)
|
|
2455
|
-
continue;
|
|
2456
|
-
const parentPid = Number(stat
|
|
2457
|
-
.slice(closeParen + 2)
|
|
2458
|
-
.trim()
|
|
2459
|
-
.split(/\s+/)[1]);
|
|
2460
|
-
if (!Number.isSafeInteger(parentPid))
|
|
2461
|
-
continue;
|
|
2462
|
-
const children = childrenByParent.get(parentPid) ?? [];
|
|
2463
|
-
children.push(pid);
|
|
2464
|
-
childrenByParent.set(parentPid, children);
|
|
2465
|
-
}
|
|
2466
|
-
catch {
|
|
2467
|
-
// Processes leave /proc constantly; a partial tree is still safer than
|
|
2468
|
-
// abandoning the profile-root browser after a failed close.
|
|
2469
|
-
}
|
|
2470
|
-
}
|
|
2471
|
-
const pids = [];
|
|
2472
|
-
const pending = [rootPid];
|
|
2473
|
-
const seen = new Set();
|
|
2474
|
-
while (pending.length > 0) {
|
|
2475
|
-
const pid = pending.pop();
|
|
2476
|
-
if (seen.has(pid))
|
|
2477
|
-
continue;
|
|
2478
|
-
seen.add(pid);
|
|
2479
|
-
pids.push(pid);
|
|
2480
|
-
for (const child of childrenByParent.get(pid) ?? [])
|
|
2481
|
-
pending.push(child);
|
|
2482
|
-
}
|
|
2483
|
-
return pids;
|
|
2484
|
-
}
|
|
2485
|
-
catch {
|
|
2486
|
-
return [rootPid];
|
|
2487
|
-
}
|
|
2488
|
-
}
|
|
2489
|
-
export async function terminateTrackedProfileChild(child, profileDir, options = {}) {
|
|
2490
|
-
const readIdentity = options.readIdentity ?? profileProcessIdentity;
|
|
2491
|
-
const terminate = options.terminate ??
|
|
2492
|
-
((ownedIdentity, ownedProfileDir) => {
|
|
2493
|
-
const signalled = signalProfileProcess(ownedIdentity, ownedProfileDir, "SIGKILL");
|
|
2494
|
-
reapProfileHolderIfOwned(ownedProfileDir, ownedIdentity);
|
|
2495
|
-
return signalled;
|
|
2496
|
-
});
|
|
2497
|
-
let identity = options.identity ?? null;
|
|
2498
|
-
if (identity === null && (options.platform ?? process.platform) !== "linux")
|
|
2499
|
-
return null;
|
|
2500
|
-
while (childProcessIsRunning(child)) {
|
|
2501
|
-
identity ??= await waitForTrackedProfileChildIdentity(child, profileDir, readIdentity, options.identityTimeoutMs ?? PROFILE_IDENTITY_PROOF_TIMEOUT_MS, options.identityPollMs ?? PROFILE_IDENTITY_POLL_MS, options.processGroup ?? false);
|
|
2502
|
-
if (identity === null)
|
|
2503
|
-
break;
|
|
2504
|
-
const existing = selfManagedChromes.get(identity.pid);
|
|
2505
|
-
const proof = existing?.identity.start_time === identity.start_time
|
|
2506
|
-
? existing.proof
|
|
2507
|
-
: trackOwnedChromeProcessTree(identity, options.processGroup ?? false);
|
|
2508
|
-
if (proof !== null) {
|
|
2509
|
-
selfManagedChromes.set(identity.pid, {
|
|
2510
|
-
identity,
|
|
2511
|
-
processGroup: options.processGroup ?? false,
|
|
2512
|
-
proof,
|
|
2513
|
-
});
|
|
2514
|
-
}
|
|
2515
|
-
const terminated = terminate(identity, profileDir);
|
|
2516
|
-
if (!terminated) {
|
|
2517
|
-
identity = null;
|
|
2518
|
-
continue;
|
|
2519
|
-
}
|
|
2520
|
-
while (childProcessIsRunning(child)) {
|
|
2521
|
-
await new Promise((resolveWait) => {
|
|
2522
|
-
const timer = setTimeout(resolveWait, 25);
|
|
2523
|
-
timer.unref();
|
|
2524
|
-
});
|
|
2525
|
-
}
|
|
2526
|
-
}
|
|
2527
|
-
return identity;
|
|
2528
|
-
}
|
|
2529
1855
|
// Classify an anti-bot interstitial page from its (title + body) text.
|
|
2530
1856
|
// `onInterstitial` matches the static Cloudflare/Turnstile challenge copy.
|
|
2531
1857
|
// `verificationPassed` is the signal the challenge SUCCEEDED — but
|
|
@@ -2569,14 +1895,6 @@ export function stripCloudflareChallengeParams(rawUrl) {
|
|
|
2569
1895
|
}
|
|
2570
1896
|
return changed ? u.toString() : null;
|
|
2571
1897
|
}
|
|
2572
|
-
export function childProcessIsRunning(child) {
|
|
2573
|
-
return child !== null && child.exitCode === null && child.signalCode === null;
|
|
2574
|
-
}
|
|
2575
|
-
function profileCollisionFromStderr(stderr) {
|
|
2576
|
-
return /ProcessSingleton|SingletonLock|profile.*in use/i.test(stderr)
|
|
2577
|
-
? new ProfileBusyError(PROFILE_BUSY_MESSAGE)
|
|
2578
|
-
: null;
|
|
2579
|
-
}
|
|
2580
1898
|
// The signal that quits the plain login browser.
|
|
2581
1899
|
//
|
|
2582
1900
|
// It MUST NOT be SIGTERM. Chrome routes SIGTERM to its "session ending" path,
|
|
@@ -2761,116 +2079,204 @@ export async function launchPlainLoginBrowser(params) {
|
|
|
2761
2079
|
marker: launchMarker,
|
|
2762
2080
|
};
|
|
2763
2081
|
}
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
persistentFallbackLaunchInFlight = false;
|
|
2809
|
-
persistentFallbackOwnershipMonitor = null;
|
|
2810
|
-
persistentFallbackCancellationState = null;
|
|
2811
|
-
resolveStartCancellation = null;
|
|
2812
|
-
startCancellation = new Promise((resolveCancellation) => {
|
|
2813
|
-
this.resolveStartCancellation = resolveCancellation;
|
|
2814
|
-
});
|
|
2815
|
-
cancelledStartReaper = null;
|
|
2816
|
-
humanize;
|
|
2817
|
-
// Tracks the simulated mouse position so successive clicks can move
|
|
2818
|
-
// along a continuous path (humans don't teleport between clicks).
|
|
2819
|
-
mouseX = 100;
|
|
2820
|
-
mouseY = 100;
|
|
2821
|
-
// Records the browser channel that .start() actually launched. Set
|
|
2822
|
-
// post-launch so telemetry can surface "this run
|
|
2823
|
-
// used real Chrome" vs "this run used bundled Chromium." Useful for
|
|
2824
|
-
// separating fingerprint regressions from network regressions when
|
|
2825
|
-
// a service starts failing.
|
|
2826
|
-
launchedChannel = null;
|
|
2827
|
-
// The proxy server this run egressed through, or null for a direct
|
|
2828
|
-
// connection. Set by .start(); surfaced via the `proxied` getter —
|
|
2829
|
-
// a captcha failure behind a residential proxy is materially
|
|
2830
|
-
// different signal from the same failure on a raw datacenter IP.
|
|
2831
|
-
proxyServer = null;
|
|
2832
|
-
// Optional live provider of the session's current allowed hosts, used by the
|
|
2833
|
-
// fail-fast request-scope guard. Set by provision-session once a session
|
|
2834
|
-
// exists so the guard auto-scopes same-registrable-domain merchant API
|
|
2835
|
-
// siblings and fails-fast on genuinely out-of-scope in-page API calls. When
|
|
2836
|
-
// null/never set (harness replay, non-session use) the guard is inert.
|
|
2837
|
-
hostScopeAllowedHostsProvider = null;
|
|
2838
|
-
operationScopedAllowedHosts = new Map();
|
|
2839
|
-
hostScopeGuardInstallation = null;
|
|
2840
|
-
// Feed the current session's allowed hosts to the request-scope guard. Read
|
|
2841
|
-
// lazily per request, so allow_host / auto-widen updates take effect without
|
|
2842
|
-
// re-registering the route. Registers the single fail-fast route handler on
|
|
2843
|
-
// the first call — so the guard is only ever active for real operator
|
|
2844
|
-
// sessions, never for harness/replay or non-session browsers.
|
|
2845
|
-
async setHostScopeAllowedHosts(provider, siblingDomainProvider = provider) {
|
|
2846
|
-
this.hostScopeAllowedHostsProvider = () => ({
|
|
2847
|
-
allowedHosts: [...provider(), ...this.operationScopedAllowedHosts.keys()],
|
|
2848
|
-
siblingDomainHosts: siblingDomainProvider(),
|
|
2849
|
-
});
|
|
2850
|
-
this.hostScopeGuardInstallation ??= this.installHostScopeGuard().catch((error) => {
|
|
2851
|
-
this.hostScopeGuardInstallation = null;
|
|
2852
|
-
throw error;
|
|
2082
|
+
// Dev-runtime guard: when the bot is run through `tsx`, esbuild may inject
|
|
2083
|
+
// calls to its `__name(fn, "name")` helper into functions passed to
|
|
2084
|
+
// page.evaluate/addInitScript. Those functions execute in the browser page,
|
|
2085
|
+
// where Node's helper does not exist, causing an immediate
|
|
2086
|
+
// `ReferenceError: __name is not defined` before the real signup even
|
|
2087
|
+
// starts. Define the same no-op helper in every document. Built `dist`
|
|
2088
|
+
// should not emit these calls, but the helper is harmless there too.
|
|
2089
|
+
const EVALUATE_NAME_SHIM_SCRIPT = 'Object.defineProperty(globalThis, "__name", { value: (fn) => fn, configurable: true });';
|
|
2090
|
+
// rc.33 / 2026-06-04 — spoof the WebGL UNMASKED vendor+renderer toward a
|
|
2091
|
+
// stock Intel GPU, so the software Mesa/llvmpipe string (--enable-unsafe-
|
|
2092
|
+
// swiftshader gives us a context, but llvmpipe is itself a VM/headless
|
|
2093
|
+
// tell) doesn't read through. Applied TWO ways because patchright
|
|
2094
|
+
// (hardened) isolates document-start scripts from the page's main world:
|
|
2095
|
+
// • addInitScript — document-start; the effective path in the stealth
|
|
2096
|
+
// BASELINE (non-patchright).
|
|
2097
|
+
// • re-applied via page.evaluate on every navigation — the ONLY path that
|
|
2098
|
+
// reaches the MAIN world under patchright. MEASURED 2026-06-04:
|
|
2099
|
+
// addInitScript AND raw CDP Page.addScriptToEvaluateOnNewDocument both
|
|
2100
|
+
// land in patchright's isolated world (renderer stayed llvmpipe);
|
|
2101
|
+
// page.evaluate does not (renderer became Intel), and the v3 score held
|
|
2102
|
+
// at 1.0. Idempotent via a marker so the per-nav re-apply is cheap, and
|
|
2103
|
+
// getParameter.toString() is masked to the original native source so
|
|
2104
|
+
// the patch itself isn't a tell. Only strings change, not rendering.
|
|
2105
|
+
const INSTALL_WEBGL_SPOOF_SCRIPT = String.raw `(() => {
|
|
2106
|
+
const VENDOR_WEBGL = 0x9245; // UNMASKED_VENDOR_WEBGL
|
|
2107
|
+
const RENDERER_WEBGL = 0x9246; // UNMASKED_RENDERER_WEBGL
|
|
2108
|
+
const spoof = (proto) => {
|
|
2109
|
+
// The marker lives on the prototype so re-application is a no-op; the
|
|
2110
|
+
// cast is the one typed-alternative-exhausted spot (adding an ad-hoc
|
|
2111
|
+
// brand to a DOM prototype).
|
|
2112
|
+
if (proto.__tsWebglPatched === true) return;
|
|
2113
|
+
const orig = proto.getParameter;
|
|
2114
|
+
const native = orig.toString();
|
|
2115
|
+
proto.getParameter = function (p) {
|
|
2116
|
+
if (p === VENDOR_WEBGL) return "Google Inc. (Intel)";
|
|
2117
|
+
if (p === RENDERER_WEBGL) {
|
|
2118
|
+
return "ANGLE (Intel, Mesa Intel(R) UHD Graphics 620 (KBL GT2), OpenGL 4.6)";
|
|
2119
|
+
}
|
|
2120
|
+
return orig.call(this, p);
|
|
2121
|
+
};
|
|
2122
|
+
Object.defineProperty(proto.getParameter, "toString", {
|
|
2123
|
+
value: () => native,
|
|
2124
|
+
configurable: true,
|
|
2125
|
+
writable: true,
|
|
2853
2126
|
});
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2127
|
+
proto.__tsWebglPatched = true;
|
|
2128
|
+
};
|
|
2129
|
+
if (typeof WebGLRenderingContext !== "undefined") {
|
|
2130
|
+
spoof(WebGLRenderingContext.prototype);
|
|
2131
|
+
}
|
|
2132
|
+
if (typeof WebGL2RenderingContext !== "undefined") {
|
|
2133
|
+
spoof(WebGL2RenderingContext.prototype);
|
|
2134
|
+
}
|
|
2135
|
+
// Device-tell normalization. The headless harvester box reports 20
|
|
2136
|
+
// logical cores (navigator.hardwareConcurrency) — a consumer residential
|
|
2137
|
+
// device is 4-16. A 20-core Linux machine behind a "residential" IP is
|
|
2138
|
+
// an internal inconsistency Cloudflare Turnstile scores against
|
|
2139
|
+
// (MEASURED 2026-06-11: exa/cartesia Turnstile won't issue a token on a
|
|
2140
|
+
// clean-fingerprint click; hwConcurrency=20 + Linux is the standout
|
|
2141
|
+
// anomaly). Normalize to a common consumer profile. Same per-nav main-
|
|
2142
|
+
// world application as the WebGL spoof — patchright denies init-world
|
|
2143
|
+
// reach, and Turnstile reads these after the challenge script loads
|
|
2144
|
+
// (seconds in), so the framenavigated re-apply wins the race. Defined on
|
|
2145
|
+
// Navigator.prototype (where the native getters live) so there's no own-
|
|
2146
|
+
// property tell on the instance.
|
|
2147
|
+
const navProto = Navigator.prototype;
|
|
2148
|
+
if (navProto.__tsDevicePatched !== true) {
|
|
2149
|
+
try {
|
|
2150
|
+
Object.defineProperty(Navigator.prototype, "hardwareConcurrency", {
|
|
2151
|
+
get: () => 8,
|
|
2152
|
+
configurable: true,
|
|
2153
|
+
});
|
|
2154
|
+
Object.defineProperty(Navigator.prototype, "deviceMemory", {
|
|
2155
|
+
get: () => 8,
|
|
2156
|
+
configurable: true,
|
|
2157
|
+
});
|
|
2158
|
+
// Screen availHeight tell: a virtual screen reports
|
|
2159
|
+
// availHeight == height (no OS taskbar), whereas a real Windows
|
|
2160
|
+
// desktop reserves ~40px for the taskbar (availHeight = height-40,
|
|
2161
|
+
// availWidth = width). Reinstate that gap so the screen reads like
|
|
2162
|
+
// an ordinary desktop, not a bare framebuffer. Guarded so it only
|
|
2163
|
+
// applies when the two are currently equal (i.e. headless).
|
|
2164
|
+
try {
|
|
2165
|
+
if (screen.availHeight === screen.height) {
|
|
2166
|
+
Object.defineProperty(Screen.prototype, "availHeight", {
|
|
2167
|
+
get: () => screen.height - 40,
|
|
2168
|
+
configurable: true,
|
|
2169
|
+
});
|
|
2170
|
+
}
|
|
2171
|
+
} catch {
|
|
2172
|
+
// leave it
|
|
2173
|
+
}
|
|
2174
|
+
navProto.__tsDevicePatched = true;
|
|
2175
|
+
} catch {
|
|
2176
|
+
// descriptor already locked by something else — leave it.
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
})();`;
|
|
2180
|
+
export class BrowserController {
|
|
2181
|
+
get context() {
|
|
2182
|
+
return this.processOwner.context;
|
|
2183
|
+
}
|
|
2184
|
+
set context(value) {
|
|
2185
|
+
this.processOwner.context = value;
|
|
2186
|
+
}
|
|
2187
|
+
get page() {
|
|
2188
|
+
return this.pageDriver.page;
|
|
2189
|
+
}
|
|
2190
|
+
set page(value) {
|
|
2191
|
+
this.pageDriver.page = value;
|
|
2192
|
+
}
|
|
2193
|
+
get primaryPage() {
|
|
2194
|
+
return this.pageDriver.primaryPage;
|
|
2195
|
+
}
|
|
2196
|
+
set primaryPage(value) {
|
|
2197
|
+
this.pageDriver.primaryPage = value;
|
|
2198
|
+
}
|
|
2199
|
+
get oauthProductPage() {
|
|
2200
|
+
return this.pageDriver.oauthProductPage;
|
|
2201
|
+
}
|
|
2202
|
+
set oauthProductPage(value) {
|
|
2203
|
+
this.pageDriver.oauthProductPage = value;
|
|
2204
|
+
}
|
|
2205
|
+
get oauthProviderPage() {
|
|
2206
|
+
return this.pageDriver.oauthProviderPage;
|
|
2207
|
+
}
|
|
2208
|
+
set oauthProviderPage(value) {
|
|
2209
|
+
this.pageDriver.oauthProviderPage = value;
|
|
2210
|
+
}
|
|
2211
|
+
get oauthProviderPageClosed() {
|
|
2212
|
+
return this.pageDriver.oauthProviderPageClosed;
|
|
2213
|
+
}
|
|
2214
|
+
set oauthProviderPageClosed(value) {
|
|
2215
|
+
this.pageDriver.oauthProviderPageClosed = value;
|
|
2216
|
+
}
|
|
2217
|
+
get harnessAttachedPage() {
|
|
2218
|
+
return this.pageDriver.harnessAttachedPage;
|
|
2219
|
+
}
|
|
2220
|
+
set harnessAttachedPage(value) {
|
|
2221
|
+
this.pageDriver.harnessAttachedPage = value;
|
|
2222
|
+
}
|
|
2223
|
+
get ownedPages() {
|
|
2224
|
+
return this.pageDriver.ownedPages;
|
|
2225
|
+
}
|
|
2226
|
+
checkoutCardGroupScope;
|
|
2227
|
+
checkoutOutcomeBaseline;
|
|
2228
|
+
paymentInstrumentExpectation;
|
|
2229
|
+
observedPaymentInstrumentMismatch;
|
|
2230
|
+
checkoutSubmitSequence = 0;
|
|
2231
|
+
clickDispatchSequence = 0;
|
|
2232
|
+
humanize;
|
|
2233
|
+
// Tracks the simulated mouse position so successive clicks can move
|
|
2234
|
+
// along a continuous path (humans don't teleport between clicks).
|
|
2235
|
+
mouseX = 100;
|
|
2236
|
+
mouseY = 100;
|
|
2237
|
+
// Optional live provider of the session's current allowed hosts, used by the
|
|
2238
|
+
// fail-fast request-scope guard. Set by provision-session once a session
|
|
2239
|
+
// exists so the guard auto-scopes same-registrable-domain merchant API
|
|
2240
|
+
// siblings and fails-fast on genuinely out-of-scope in-page API calls. When
|
|
2241
|
+
// null/never set (harness replay, non-session use) the guard is inert.
|
|
2242
|
+
hostScopeAllowedHostsProvider = null;
|
|
2243
|
+
operationScopedAllowedHosts = new Map();
|
|
2244
|
+
hostScopeGuardInstallation = null;
|
|
2245
|
+
hostScopeGuardHandler = null;
|
|
2246
|
+
// Feed the current session's allowed hosts to the request-scope guard. Read
|
|
2247
|
+
// lazily per request, so allow_host / auto-widen updates take effect without
|
|
2248
|
+
// re-registering the route. Registers the single fail-fast route handler on
|
|
2249
|
+
// the first call — so the guard is only ever active for real operator
|
|
2250
|
+
// sessions, never for harness/replay or non-session browsers.
|
|
2251
|
+
async setHostScopeAllowedHosts(provider, siblingDomainProvider = provider) {
|
|
2252
|
+
this.hostScopeAllowedHostsProvider = () => ({
|
|
2253
|
+
allowedHosts: [...provider(), ...this.operationScopedAllowedHosts.keys()],
|
|
2254
|
+
siblingDomainHosts: siblingDomainProvider(),
|
|
2255
|
+
});
|
|
2256
|
+
this.hostScopeGuardInstallation ??= this.installHostScopeGuard().catch((error) => {
|
|
2257
|
+
this.hostScopeGuardInstallation = null;
|
|
2258
|
+
throw error;
|
|
2259
|
+
});
|
|
2260
|
+
await this.hostScopeGuardInstallation;
|
|
2261
|
+
}
|
|
2262
|
+
async withTemporaryHostScopeAllowedHosts(hosts, operation) {
|
|
2263
|
+
const normalized = [...new Set(hosts.map((host) => host.trim().toLowerCase()).filter(Boolean))];
|
|
2264
|
+
for (const host of normalized) {
|
|
2265
|
+
this.operationScopedAllowedHosts.set(host, (this.operationScopedAllowedHosts.get(host) ?? 0) + 1);
|
|
2266
|
+
}
|
|
2267
|
+
try {
|
|
2268
|
+
return await operation();
|
|
2269
|
+
}
|
|
2270
|
+
finally {
|
|
2271
|
+
for (const host of normalized) {
|
|
2272
|
+
const remaining = (this.operationScopedAllowedHosts.get(host) ?? 1) - 1;
|
|
2273
|
+
if (remaining <= 0)
|
|
2274
|
+
this.operationScopedAllowedHosts.delete(host);
|
|
2275
|
+
else
|
|
2276
|
+
this.operationScopedAllowedHosts.set(host, remaining);
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2874
2280
|
// Defect-A fail-fast request-scope guard. Only XHR/fetch subresource API
|
|
2875
2281
|
// calls are scope-guarded; page-load resources (scripts/styles/images/frames)
|
|
2876
2282
|
// always continue, so a legitimate render is never broken by the guard. A
|
|
@@ -2881,12 +2287,28 @@ export class BrowserController {
|
|
|
2881
2287
|
// request that never resolved — wedging the page with an infinite spinner.
|
|
2882
2288
|
// Here it is aborted with a real net error so the page's fetch/XHR rejects
|
|
2883
2289
|
// promptly and the site's own error handling runs.
|
|
2290
|
+
//
|
|
2291
|
+
// The route is CONTEXT-scoped. With TRUSTY_SQUIRE_EXPERIMENTAL_MULTISESSION
|
|
2292
|
+
// off (the shipped default) every request is judged here unconditionally,
|
|
2293
|
+
// exactly as it always was. Under the flag every session sharing the
|
|
2294
|
+
// context has its own guard here and Playwright runs them all for every
|
|
2295
|
+
// request, so a request from a page another session's OwnedPages has
|
|
2296
|
+
// positively claimed is handed on untouched for that session's guard to
|
|
2297
|
+
// judge. A page nobody has claimed (every popup, between its first
|
|
2298
|
+
// navigation commit and the opener's "popup" event), or one whose page
|
|
2299
|
+
// can't be resolved (service-worker requests have no frame), is still
|
|
2300
|
+
// judged here fail-closed.
|
|
2884
2301
|
async installHostScopeGuard() {
|
|
2885
2302
|
const ctx = this.context;
|
|
2886
2303
|
if (ctx === null)
|
|
2887
2304
|
throw new Error("Browser not started");
|
|
2888
|
-
|
|
2305
|
+
const pageAware = experimentalMultiSessionEnabled();
|
|
2306
|
+
const handler = async (route) => {
|
|
2889
2307
|
try {
|
|
2308
|
+
if (pageAware && this.requestPageClaimedByAnotherSession(route)) {
|
|
2309
|
+
await route.fallback();
|
|
2310
|
+
return;
|
|
2311
|
+
}
|
|
2890
2312
|
const url = route.request().url();
|
|
2891
2313
|
const type = route.request().resourceType();
|
|
2892
2314
|
const scope = this.hostScopeAllowedHostsProvider?.() ?? null;
|
|
@@ -2899,689 +2321,112 @@ export class BrowserController {
|
|
|
2899
2321
|
catch {
|
|
2900
2322
|
await route.fallback().catch(() => undefined);
|
|
2901
2323
|
}
|
|
2902
|
-
}
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
// The replay harness owns this context so it can route the storefront from a
|
|
2906
|
-
// HAR, then remove that route before checkout becomes live.
|
|
2907
|
-
harnessAttachedPage = false;
|
|
2908
|
-
// T6/T7 — OAuth handshake bookkeeping. Legacy startOAuth() adopts a
|
|
2909
|
-
// popup window as the active page, so keep the product tab parked here
|
|
2910
|
-
// until settleAfterOAuth() restores it. The operator's oauth_login action
|
|
2911
|
-
// keeps the observed product page active for the click and opens a recovery
|
|
2912
|
-
// tab before the provider can redirect or close either OAuth transport.
|
|
2913
|
-
oauthProductPage = null;
|
|
2914
|
-
oauthProviderPage = null;
|
|
2915
|
-
oauthProviderPageClosed = false;
|
|
2916
|
-
// Surfaced in the run trail so operators can distinguish local headed,
|
|
2917
|
-
// remote, and headless launches.
|
|
2918
|
-
launchedMode = "unknown";
|
|
2919
|
-
get launchMode() {
|
|
2920
|
-
return this.launchedMode;
|
|
2921
|
-
}
|
|
2922
|
-
constructor(opts = {}) {
|
|
2923
|
-
this.humanize = opts.humanize ?? true;
|
|
2924
|
-
this.profileDir = opts.profileDir ?? "";
|
|
2925
|
-
this.proxyOverride =
|
|
2926
|
-
opts.proxyUrl !== undefined && opts.proxyUrl.trim().length > 0 ? opts.proxyUrl.trim() : null;
|
|
2927
|
-
}
|
|
2928
|
-
trackMainDocument(page) {
|
|
2929
|
-
if (this.trackedMainDocumentPages.has(page))
|
|
2930
|
-
return;
|
|
2931
|
-
this.trackedMainDocumentPages.add(page);
|
|
2932
|
-
this.mainDocumentIdentities.set(page, ++this.mainDocumentSequence);
|
|
2933
|
-
// A REPLACED main document advances the identity; a same-document History
|
|
2934
|
-
// API navigation does not. Playwright emits `framenavigated` for both, so
|
|
2935
|
-
// keying on it made every `history.replaceState` inside an SPA checkout
|
|
2936
|
-
// retire every operator ref mid-form — the identity churned faster than a
|
|
2937
|
-
// multi-field address block could be filled. `domcontentloaded` fires once
|
|
2938
|
-
// per real main-frame document (playwright's client `Frame` gates it on
|
|
2939
|
-
// `!this._parentFrame`), which is exactly the document-replacement signal.
|
|
2940
|
-
// A same-document route change to a genuinely different logical page is
|
|
2941
|
-
// still caught by the observation epoch's normalized origin+pathname fold
|
|
2942
|
-
// (compactV2EpochDoc), which is the backstop this narrowing relies on.
|
|
2943
|
-
page.on("domcontentloaded", () => {
|
|
2944
|
-
this.mainDocumentIdentities.set(page, ++this.mainDocumentSequence);
|
|
2945
|
-
});
|
|
2946
|
-
}
|
|
2947
|
-
mainDocumentIdentity() {
|
|
2948
|
-
const page = this.page;
|
|
2949
|
-
if (page === null)
|
|
2950
|
-
return "none";
|
|
2951
|
-
this.trackMainDocument(page);
|
|
2952
|
-
return String(this.mainDocumentIdentities.get(page));
|
|
2953
|
-
}
|
|
2954
|
-
/** Attach normal controller behavior to a harness-owned Playwright page. */
|
|
2955
|
-
static fromHarnessPage(page) {
|
|
2956
|
-
const controller = new BrowserController({ humanize: false });
|
|
2957
|
-
controller.context = page.context();
|
|
2958
|
-
controller.page = page;
|
|
2959
|
-
controller.primaryPage = page;
|
|
2960
|
-
controller.trackMainDocument(page);
|
|
2961
|
-
controller.trackOpenedTabs(page.context());
|
|
2962
|
-
controller.harnessAttachedPage = true;
|
|
2963
|
-
controller.launchedMode = "headless";
|
|
2964
|
-
return controller;
|
|
2965
|
-
}
|
|
2966
|
-
// Record every page the CONTEXT opens. Covers window.open popups and
|
|
2967
|
-
// target=_blank tabs alike; Playwright emits the same context-level "page"
|
|
2968
|
-
// event for both.
|
|
2969
|
-
trackOpenedTabs(context) {
|
|
2970
|
-
context.on("page", (page) => {
|
|
2971
|
-
this.trackMainDocument(page);
|
|
2972
|
-
this.openedTabs.push(page);
|
|
2973
|
-
// Bounded: adoption only ever reads the newest followable entry, and a
|
|
2974
|
-
// controller driven by something other than the operator's click path
|
|
2975
|
-
// never arms (and so never drains) this queue.
|
|
2976
|
-
if (this.openedTabs.length > 8)
|
|
2977
|
-
this.openedTabs.splice(0, this.openedTabs.length - 8);
|
|
2978
|
-
});
|
|
2979
|
-
}
|
|
2980
|
-
// Per-launch egress override. null means direct egress. Explicit overrides
|
|
2981
|
-
// are never subject to host-network classification.
|
|
2982
|
-
proxyOverride;
|
|
2983
|
-
operatorBrowserMarker() {
|
|
2984
|
-
this.operatorProcessMarker ??= createOperatorBrowserMarker();
|
|
2985
|
-
return this.operatorProcessMarker;
|
|
2986
|
-
}
|
|
2987
|
-
async ownedHeadedBrowserEnvironment() {
|
|
2988
|
-
if (this.ownedDisplayRig === null) {
|
|
2989
|
-
const { createXvfbDisplayRig, startRemoteLoginDisplay } = await import("./remote-login-display.js");
|
|
2990
|
-
const rig = createXvfbDisplayRig();
|
|
2991
|
-
this.ownedDisplayRig = rig;
|
|
2992
|
-
await startRemoteLoginDisplay(rig);
|
|
2993
|
-
}
|
|
2994
|
-
const { remoteLoginEnvironment } = await import("./remote-login-display.js");
|
|
2995
|
-
const rig = this.ownedDisplayRig;
|
|
2996
|
-
if (rig === null)
|
|
2997
|
-
throw new Error("headed operator display did not start");
|
|
2998
|
-
return remoteLoginEnvironment(rig, process.env);
|
|
2999
|
-
}
|
|
3000
|
-
async teardownOwnedDisplay() {
|
|
3001
|
-
const rig = this.ownedDisplayRig;
|
|
3002
|
-
this.ownedDisplayRig = null;
|
|
3003
|
-
if (rig === null)
|
|
3004
|
-
return;
|
|
3005
|
-
const { teardownRemoteLoginRig } = await import("./remote-login-display.js");
|
|
3006
|
-
await teardownRemoteLoginRig(rig);
|
|
3007
|
-
}
|
|
3008
|
-
adoptOwnedChromeProcessTree(identity, processGroup) {
|
|
3009
|
-
if (this.ownedChromeProcessTreeProof?.identity.pid === identity.pid &&
|
|
3010
|
-
this.ownedChromeProcessTreeProof.identity.start_time === identity.start_time) {
|
|
3011
|
-
return this.ownedChromeProcessTreeProof;
|
|
3012
|
-
}
|
|
3013
|
-
const tracked = selfManagedChromes.get(identity.pid);
|
|
3014
|
-
const proof = tracked?.identity.start_time === identity.start_time
|
|
3015
|
-
? tracked.proof
|
|
3016
|
-
: trackOwnedChromeProcessTree(identity, processGroup);
|
|
3017
|
-
if (proof !== null && this.ownerLaunchTracked) {
|
|
3018
|
-
if (!bindOwnerBrowserLaunch(this.operatorBrowserMarker(), proof.identity)) {
|
|
3019
|
-
releaseOwnedChromeProcessTree(proof);
|
|
3020
|
-
throw new Error("local browser launch identity could not be bound to owner custody");
|
|
3021
|
-
}
|
|
3022
|
-
}
|
|
3023
|
-
if (proof !== null)
|
|
3024
|
-
this.ownedChromeProcessTreeProof = proof;
|
|
3025
|
-
return proof;
|
|
3026
|
-
}
|
|
3027
|
-
signalCurrentSelfManagedChrome(identity, signal) {
|
|
3028
|
-
return signalOwnedChromeProcessTree(identity, this.childChromeProcessGroup, signal, {
|
|
3029
|
-
...(this.ownedChromeProcessTreeProof === null
|
|
3030
|
-
? {}
|
|
3031
|
-
: { proof: this.ownedChromeProcessTreeProof }),
|
|
3032
|
-
});
|
|
3033
|
-
}
|
|
3034
|
-
// Required health gate for a live session browser. BrowserContext alone is not a
|
|
3035
|
-
// sufficient signal: a dead CDP transport can leave stale JS objects behind.
|
|
3036
|
-
isConnected() {
|
|
3037
|
-
const browser = this.cdpBrowser ?? this.context?.browser() ?? null;
|
|
3038
|
-
return browser?.isConnected() === true;
|
|
2324
|
+
};
|
|
2325
|
+
await ctx.route("**/*", handler);
|
|
2326
|
+
this.hostScopeGuardHandler = handler;
|
|
3039
2327
|
}
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
// been called yet — there's no sensible default to return.
|
|
3044
|
-
get channel() {
|
|
3045
|
-
if (this.context === null) {
|
|
3046
|
-
throw new Error("BrowserController.channel read before .start()");
|
|
2328
|
+
requestPageClaimedByAnotherSession(route) {
|
|
2329
|
+
try {
|
|
2330
|
+
return this.ownedPages.claimedByAnother(route.request().frame().page());
|
|
3047
2331
|
}
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
// The proxy server the most recent .start() routed egress through,
|
|
3051
|
-
// or null for a direct connection. Useful telemetry alongside
|
|
3052
|
-
// `channel`. Throws if .start() hasn't run — same reason as channel.
|
|
3053
|
-
get proxied() {
|
|
3054
|
-
if (this.context === null) {
|
|
3055
|
-
throw new Error("BrowserController.proxied read before .start()");
|
|
2332
|
+
catch {
|
|
2333
|
+
return false;
|
|
3056
2334
|
}
|
|
3057
|
-
return this.proxyServer;
|
|
3058
|
-
}
|
|
3059
|
-
// The stealth profile the most recent .start() launched under:
|
|
3060
|
-
// "cdp_hardened" when the patchright launcher actually loaded
|
|
3061
|
-
// (BOT_CDP_HARDENED set + patchright present), else "baseline". Surfaced
|
|
3062
|
-
// for the CaptchaEvent A/B tag. Throws before .start() — same reason
|
|
3063
|
-
// as channel/proxied.
|
|
3064
|
-
get stealthProfile() {
|
|
3065
|
-
if (this.context === null) {
|
|
3066
|
-
throw new Error("BrowserController.stealthProfile read before .start()");
|
|
3067
|
-
}
|
|
3068
|
-
return activeStealthProfileValue();
|
|
3069
|
-
}
|
|
3070
|
-
// Launch Chrome ourselves and attach over CDP — the Turnstile-safe launch
|
|
3071
|
-
// (see selfLaunchEnabled for the proof). The profile dir is the SAME shared
|
|
3072
|
-
// profile launchPersistentContext would use, so the OAuth session carries
|
|
3073
|
-
// over. Options that a default connectOverCDP context can't take at creation
|
|
3074
|
-
// are applied differently:
|
|
3075
|
-
// • timezone → TZ env on the child (more authentic than a CDP override)
|
|
3076
|
-
// • proxy → --proxy-server flag, with credentials applied post-connect
|
|
3077
|
-
// • viewport → --window-size (with viewport:null-equivalent: we never set
|
|
3078
|
-
// an emulated viewport on the connected context)
|
|
3079
|
-
// • locale/geo/permissions → applied post-connect by start()
|
|
3080
|
-
async launchSelfManagedContext(params) {
|
|
3081
|
-
this.throwIfStartCancelled();
|
|
3082
|
-
// Remote-CDP attach: BOT_CDP_ENDPOINT points at a Chrome already running on
|
|
3083
|
-
// another host (e.g. a real-GPU Mac), reachable over Tailscale. We do NOT
|
|
3084
|
-
// spawn or own the binary — the remote host launched it with its own
|
|
3085
|
-
// profile, real GPU, and (residential) egress. Just attach over CDP. This
|
|
3086
|
-
// is the real-GPU path: software-WebGL output (llvmpipe) is what
|
|
3087
|
-
// hCaptcha-Enterprise-class anti-bot scores, and only real hardware fixes
|
|
3088
|
-
// the rendered-pixel fingerprint that JS spoofing can't.
|
|
3089
|
-
const remoteEndpoint = (process.env.BOT_CDP_ENDPOINT ?? "").trim();
|
|
3090
|
-
if (remoteEndpoint.length > 0) {
|
|
3091
|
-
const launcher = getChromium();
|
|
3092
|
-
const browser = await launcher.connectOverCDP(remoteEndpoint);
|
|
3093
|
-
this.cdpBrowser = browser;
|
|
3094
|
-
this.launchedMode = "remote";
|
|
3095
|
-
const ctx = browser.contexts()[0];
|
|
3096
|
-
if (ctx === undefined) {
|
|
3097
|
-
throw new Error(`remote Chrome (BOT_CDP_ENDPOINT=${remoteEndpoint}) exposed no default browser context`);
|
|
3098
|
-
}
|
|
3099
|
-
return ctx;
|
|
3100
|
-
}
|
|
3101
|
-
const endpoint = await (async () => {
|
|
3102
|
-
this.throwIfStartCancelled();
|
|
3103
|
-
clearStaleSingletonLock(this.profileDir);
|
|
3104
|
-
rmSync(join(this.profileDir, DEVTOOLS_ACTIVE_PORT_FILE), { force: true });
|
|
3105
|
-
const argv = [
|
|
3106
|
-
"--remote-debugging-port=0",
|
|
3107
|
-
"--remote-debugging-address=127.0.0.1",
|
|
3108
|
-
`--user-data-dir=${this.profileDir}`,
|
|
3109
|
-
"--no-first-run",
|
|
3110
|
-
"--no-default-browser-check",
|
|
3111
|
-
"--password-store=basic",
|
|
3112
|
-
"--window-position=0,0",
|
|
3113
|
-
`--window-size=${params.window.width},${params.window.height}`,
|
|
3114
|
-
"--lang=en-US",
|
|
3115
|
-
...params.args,
|
|
3116
|
-
...(params.proxy !== null ? [`--proxy-server=${params.proxy.server}`] : []),
|
|
3117
|
-
"about:blank",
|
|
3118
|
-
];
|
|
3119
|
-
this.commitProfileLaunch();
|
|
3120
|
-
const child = spawnLocalBrowser(params.binary, argv, this.profileDir, {
|
|
3121
|
-
env: params.env,
|
|
3122
|
-
stdio: ["ignore", "ignore", "pipe"],
|
|
3123
|
-
// A dedicated process group gives the session a single, identity-
|
|
3124
|
-
// proven teardown target for Chrome plus every renderer/GPU helper.
|
|
3125
|
-
detached: process.platform !== "win32",
|
|
3126
|
-
marker: this.operatorBrowserMarker(),
|
|
3127
|
-
});
|
|
3128
|
-
this.childChrome = child;
|
|
3129
|
-
this.childChromeProcessGroup = process.platform !== "win32";
|
|
3130
|
-
this.childChromeIdentity = registerSelfManagedChrome(child, this.profileDir, this.childChromeProcessGroup);
|
|
3131
|
-
if (this.childChromeIdentity !== null) {
|
|
3132
|
-
this.adoptOwnedChromeProcessTree(this.childChromeIdentity, this.childChromeProcessGroup);
|
|
3133
|
-
}
|
|
3134
|
-
let chromeStderr = "";
|
|
3135
|
-
let chromeExit = "";
|
|
3136
|
-
child.stderr?.on("data", (chunk) => {
|
|
3137
|
-
chromeStderr = (chromeStderr + chunk.toString("utf8")).slice(-4_000);
|
|
3138
|
-
});
|
|
3139
|
-
child.on("exit", (code, signal) => {
|
|
3140
|
-
chromeExit = ` exit=${code ?? "null"} signal=${signal ?? "none"}`;
|
|
3141
|
-
});
|
|
3142
|
-
if (this.startCancellationRequested) {
|
|
3143
|
-
await this.cancelSpawnedSelfManagedChrome(child);
|
|
3144
|
-
throw new Error("BrowserController start cancelled");
|
|
3145
|
-
}
|
|
3146
|
-
try {
|
|
3147
|
-
const endpoint = await waitForOwnedDevtoolsEndpoint(this.profileDir, 30_000, child);
|
|
3148
|
-
this.childChromeIdentity = await resolveAttachedProfileChildIdentity(child, this.profileDir, this.childChromeIdentity, { processGroup: this.childChromeProcessGroup });
|
|
3149
|
-
if (process.platform === "linux" && this.childChromeIdentity === null) {
|
|
3150
|
-
throw new Error("self-launched Chrome exited before identity was proven");
|
|
3151
|
-
}
|
|
3152
|
-
if (this.childChromeIdentity !== null) {
|
|
3153
|
-
this.adoptOwnedChromeProcessTree(this.childChromeIdentity, this.childChromeProcessGroup);
|
|
3154
|
-
}
|
|
3155
|
-
return endpoint;
|
|
3156
|
-
}
|
|
3157
|
-
catch (err) {
|
|
3158
|
-
const alive = this.childChromeIdentity !== null &&
|
|
3159
|
-
profileProcessMatches(this.childChromeIdentity, this.profileDir);
|
|
3160
|
-
this.childChromeIdentity = await terminateTrackedProfileChild(child, this.profileDir, {
|
|
3161
|
-
identity: this.childChromeIdentity,
|
|
3162
|
-
terminate: (identity, profileDir) => {
|
|
3163
|
-
const signalled = signalOwnedChromeProcessTree(identity, this.childChromeProcessGroup, "SIGKILL", {
|
|
3164
|
-
...(this.ownedChromeProcessTreeProof === null
|
|
3165
|
-
? {}
|
|
3166
|
-
: { proof: this.ownedChromeProcessTreeProof }),
|
|
3167
|
-
});
|
|
3168
|
-
reapProfileHolderIfOwned(profileDir, identity);
|
|
3169
|
-
return signalled;
|
|
3170
|
-
},
|
|
3171
|
-
processGroup: this.childChromeProcessGroup,
|
|
3172
|
-
});
|
|
3173
|
-
this.childChrome = null;
|
|
3174
|
-
this.childChromeIdentity = null;
|
|
3175
|
-
this.childChromeProcessGroup = false;
|
|
3176
|
-
const detail = chromeStderr.trim();
|
|
3177
|
-
throw new Error(`${err instanceof Error ? err.message : String(err)}; Chrome pid=${child.pid ?? "unknown"} alive=${alive ? 1 : 0}` +
|
|
3178
|
-
`${chromeExit}${detail.length > 0 ? `; Chrome stderr: ${detail}` : ""}`);
|
|
3179
|
-
}
|
|
3180
|
-
})();
|
|
3181
|
-
// Use the patchright launcher's connectOverCDP — it's the exact path the
|
|
3182
|
-
// falsification experiment validated (its connect avoids Runtime.enable,
|
|
3183
|
-
// which a plain attach would emit). The anti-detection that matters here
|
|
3184
|
-
// is the LAUNCH (which we now own), not the connect.
|
|
3185
|
-
const launcher = getChromium();
|
|
3186
|
-
const browser = await launcher.connectOverCDP(endpoint);
|
|
3187
|
-
this.cdpBrowser = browser;
|
|
3188
|
-
const ctx = browser.contexts()[0];
|
|
3189
|
-
if (ctx === undefined) {
|
|
3190
|
-
throw new Error("self-launched Chrome exposed no default browser context");
|
|
3191
|
-
}
|
|
3192
|
-
return ctx;
|
|
3193
|
-
}
|
|
3194
|
-
async cancelSpawnedSelfManagedChrome(child) {
|
|
3195
|
-
this.childChromeIdentity = await terminateTrackedProfileChild(child, this.profileDir, {
|
|
3196
|
-
identity: this.childChromeIdentity,
|
|
3197
|
-
terminate: (identity, profileDir) => {
|
|
3198
|
-
const signalled = signalOwnedChromeProcessTree(identity, this.childChromeProcessGroup, "SIGKILL", {
|
|
3199
|
-
...(this.ownedChromeProcessTreeProof === null
|
|
3200
|
-
? {}
|
|
3201
|
-
: { proof: this.ownedChromeProcessTreeProof }),
|
|
3202
|
-
});
|
|
3203
|
-
reapProfileHolderIfOwned(profileDir, identity);
|
|
3204
|
-
return signalled;
|
|
3205
|
-
},
|
|
3206
|
-
processGroup: this.childChromeProcessGroup,
|
|
3207
|
-
});
|
|
3208
|
-
if (this.childChrome === child)
|
|
3209
|
-
this.childChrome = null;
|
|
3210
|
-
this.childChromeIdentity = null;
|
|
3211
|
-
this.childChromeProcessGroup = false;
|
|
3212
2335
|
}
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
// wall-clock (3-5x on byte-heavy pages; also stops trackers from holding the
|
|
3216
|
-
// network "busy"). HARD ALLOW-GUARD first for captcha/challenge + payment
|
|
3217
|
-
// scripts (blocking those breaks the Turnstile/hCaptcha token poll and the
|
|
3218
|
-
// signup form). CSS + first-party JS are never blocked (not in BLOCK_TYPES) —
|
|
3219
|
-
// the SPA form renders from them and the vision planner reads the styled
|
|
3220
|
-
// render. DUAL RISK, hence default-OFF + an OF#2 A/B before flipping on:
|
|
3221
|
-
// (1) a browser that loads ZERO images is itself an anti-bot fingerprint;
|
|
3222
|
-
// (2) the screenshot the vision planner reads loses detail — mitigated
|
|
3223
|
-
// because the DOM inventory is the authoritative action space, but
|
|
3224
|
-
// still a regression risk on image-only affordances.
|
|
3225
|
-
// Registered on the CONTEXT so it covers OAuth popups + iframes.
|
|
3226
|
-
async installResourceBlocking() {
|
|
2336
|
+
async uninstallHostScopeGuard() {
|
|
2337
|
+
const handler = this.hostScopeGuardHandler;
|
|
3227
2338
|
const ctx = this.context;
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
if (
|
|
2339
|
+
this.hostScopeGuardHandler = null;
|
|
2340
|
+
this.hostScopeGuardInstallation = null;
|
|
2341
|
+
if (handler === null || ctx === null)
|
|
3231
2342
|
return;
|
|
3232
|
-
|
|
3233
|
-
const BLOCK_HOSTS = [
|
|
3234
|
-
"google-analytics.com",
|
|
3235
|
-
"googletagmanager.com",
|
|
3236
|
-
"analytics.google.com",
|
|
3237
|
-
"doubleclick.net",
|
|
3238
|
-
"static.hotjar.com",
|
|
3239
|
-
"script.hotjar.com",
|
|
3240
|
-
"segment.com",
|
|
3241
|
-
"segment.io",
|
|
3242
|
-
"cdn.segment.com",
|
|
3243
|
-
"fullstory.com",
|
|
3244
|
-
"mixpanel.com",
|
|
3245
|
-
"bugsnag.com",
|
|
3246
|
-
"intercom.io",
|
|
3247
|
-
"intercomcdn.com",
|
|
3248
|
-
"widget.intercom.io",
|
|
3249
|
-
"connect.facebook.net",
|
|
3250
|
-
"analytics.tiktok.com",
|
|
3251
|
-
"clarity.ms",
|
|
3252
|
-
"cdn.heapanalytics.com",
|
|
3253
|
-
"wistia.com",
|
|
3254
|
-
];
|
|
3255
|
-
// NEVER block — these break signup (captcha/challenge widgets + payment SDK).
|
|
3256
|
-
const ALWAYS_ALLOW = [
|
|
3257
|
-
"challenges.cloudflare.com",
|
|
3258
|
-
"turnstile",
|
|
3259
|
-
"hcaptcha.com",
|
|
3260
|
-
"newassets.hcaptcha.com",
|
|
3261
|
-
"recaptcha",
|
|
3262
|
-
"gstatic.com/recaptcha",
|
|
3263
|
-
"js.stripe.com",
|
|
3264
|
-
];
|
|
3265
|
-
await ctx.route("**/*", async (route) => {
|
|
3266
|
-
try {
|
|
3267
|
-
const url = route.request().url();
|
|
3268
|
-
if (ALWAYS_ALLOW.some((h) => url.includes(h))) {
|
|
3269
|
-
await route.continue();
|
|
3270
|
-
return;
|
|
3271
|
-
}
|
|
3272
|
-
const type = route.request().resourceType();
|
|
3273
|
-
if (BLOCK_TYPES.has(type) || BLOCK_HOSTS.some((h) => url.includes(h))) {
|
|
3274
|
-
await route.abort();
|
|
3275
|
-
return;
|
|
3276
|
-
}
|
|
3277
|
-
await route.continue();
|
|
3278
|
-
}
|
|
3279
|
-
catch {
|
|
3280
|
-
// Routing race / already-handled — never let a decision crash nav.
|
|
3281
|
-
}
|
|
3282
|
-
});
|
|
3283
|
-
console.error("[operator] resource blocking ON (image/media/font + analytics aborted; captcha/CSS/JS allowed)");
|
|
2343
|
+
await ctx.unroute("**/*", handler).catch(() => undefined);
|
|
3284
2344
|
}
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
//
|
|
3344
|
-
//
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
this.
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
}
|
|
3369
|
-
// Keep the operator browser headed: the browser runs on the operator's
|
|
3370
|
-
// Xvfb display, preserving the normal Chrome surface OAuth providers see.
|
|
3371
|
-
this.launchedMode = "headed";
|
|
3372
|
-
// T3: a PERSISTENT context backed by this operator session's unique
|
|
3373
|
-
// profile. launchPersistentContext takes launch + context options in one
|
|
3374
|
-
// call.
|
|
3375
|
-
// Resolve the launcher first so activeStealthProfile is set before we
|
|
3376
|
-
// decide on executablePath below.
|
|
3377
|
-
const launcher = getChromium();
|
|
3378
|
-
const hardened = activeStealthProfileValue() === "cdp_hardened";
|
|
3379
|
-
// Both launchers drive real Chrome via `channel`: baseline through
|
|
3380
|
-
// playwright+stealth, hardened through patchright. patchright closes
|
|
3381
|
-
// the automation tells at the protocol layer and drives real Chrome
|
|
3382
|
-
// directly — so it no longer needs the bundled-chromium pin the old
|
|
3383
|
-
// rebrowser fork required (the pin is what crashed the OAuth flow and
|
|
3384
|
-
// confounded the A/B). One binary for both arms.
|
|
3385
|
-
this.launchedChannel = channel;
|
|
3386
|
-
// Launch args shared by BOTH paths (launchPersistentContext and the
|
|
3387
|
-
// self-launch). See the per-flag rationale: swiftshader gives a real
|
|
3388
|
-
// (software) WebGL context on GPU-less hosts; the others are the
|
|
3389
|
-
// standard headless/sandbox flags. The three background-throttling disables
|
|
3390
|
-
// are payment correctness controls: a backgrounded CardinalCommerce ACS
|
|
3391
|
-
// frame must keep running its timers long enough to finish the issuer's OOB
|
|
3392
|
-
// post-approval handshake with Stripe. Keep them paired with bringToFront()
|
|
3393
|
-
// before payment submission and in waitForThreeDsResolution(). NOTE we
|
|
3394
|
-
// deliberately do NOT include Playwright's automation flags
|
|
3395
|
-
// (--enable-automation et al.) — on the self-launch path their ABSENCE is
|
|
3396
|
-
// the whole fix.
|
|
3397
|
-
const launchArgs = [
|
|
3398
|
-
"--disable-blink-features=AutomationControlled",
|
|
3399
|
-
"--disable-background-timer-throttling",
|
|
3400
|
-
"--disable-backgrounding-occluded-windows",
|
|
3401
|
-
"--disable-renderer-backgrounding",
|
|
3402
|
-
"--no-sandbox",
|
|
3403
|
-
"--disable-dev-shm-usage",
|
|
3404
|
-
"--enable-unsafe-swiftshader",
|
|
3405
|
-
"--ignore-gpu-blocklist",
|
|
3406
|
-
];
|
|
3407
|
-
// F10 clipboard + egress-matched geolocation permission, built once for
|
|
3408
|
-
// either path. Typed as string[] (Playwright's grantPermissions /
|
|
3409
|
-
// permissions option both accept it).
|
|
3410
|
-
const grantedPermissions = [
|
|
3411
|
-
...(geo?.geolocation !== undefined ? ["geolocation"] : []),
|
|
3412
|
-
"clipboard-read",
|
|
3413
|
-
"clipboard-write",
|
|
3414
|
-
];
|
|
3415
|
-
const selfLaunchBinary = selfLaunchEnabled()
|
|
3416
|
-
? (resolveChannelBinary(channel) ?? (channel === null ? launcher.executablePath() : null))
|
|
3417
|
-
: null;
|
|
3418
|
-
const useSelfLaunch = selfLaunchBinary !== null && existsSync(selfLaunchBinary) && canSelfLaunchWithProxy(proxy);
|
|
3419
|
-
let context;
|
|
3420
|
-
this.throwIfStartCancelled();
|
|
3421
|
-
if (useSelfLaunch && selfLaunchBinary !== null) {
|
|
3422
|
-
console.error(`[operator] self-launch + connectOverCDP (Turnstile-safe launch) binary=${selfLaunchBinary}`);
|
|
3423
|
-
const window = { width: 1280, height: 1024 };
|
|
3424
|
-
const selfEnv = {
|
|
3425
|
-
...browserEnv,
|
|
3426
|
-
TZ: geo?.timezoneId ?? "America/New_York",
|
|
3427
|
-
[OPERATOR_BROWSER_MARKER_ENV]: this.operatorBrowserMarker(),
|
|
3428
|
-
};
|
|
3429
|
-
const launch = () => {
|
|
3430
|
-
this.throwIfStartCancelled();
|
|
3431
|
-
return this.launchSelfManagedContext({
|
|
3432
|
-
binary: selfLaunchBinary,
|
|
3433
|
-
args: launchArgs,
|
|
3434
|
-
proxy,
|
|
3435
|
-
env: selfEnv,
|
|
3436
|
-
window,
|
|
3437
|
-
});
|
|
3438
|
-
};
|
|
3439
|
-
context = await launch();
|
|
3440
|
-
try {
|
|
3441
|
-
await context.grantPermissions(grantedPermissions);
|
|
3442
|
-
if (geo?.geolocation !== undefined) {
|
|
3443
|
-
await context.setGeolocation(geo.geolocation);
|
|
3444
|
-
}
|
|
3445
|
-
}
|
|
3446
|
-
catch (err) {
|
|
3447
|
-
console.error(`[operator] post-connect context setup partial: ${err instanceof Error ? err.message : String(err)}`);
|
|
3448
|
-
}
|
|
3449
|
-
}
|
|
3450
|
-
else {
|
|
3451
|
-
this.persistentFallbackLaunchInFlight = true;
|
|
3452
|
-
this.startPersistentFallbackOwnershipMonitor();
|
|
3453
|
-
const cleanupProfileHolder = async () => {
|
|
3454
|
-
const proof = await this.waitForPersistentFallbackIdentity();
|
|
3455
|
-
if (proof.state === "absent")
|
|
3456
|
-
return "closed";
|
|
3457
|
-
if (proof.state === "unknown")
|
|
3458
|
-
return "unknown";
|
|
3459
|
-
const { identity } = proof;
|
|
3460
|
-
const treeProof = this.adoptOwnedChromeProcessTree(identity, false);
|
|
3461
|
-
signalOwnedChromeProcessTree(identity, false, "SIGKILL", {
|
|
3462
|
-
...(treeProof === null ? {} : { proof: treeProof }),
|
|
3463
|
-
});
|
|
3464
|
-
return (await this.waitForOwnedProfileExit(identity, treeProof)) ? "closed" : "unknown";
|
|
3465
|
-
};
|
|
3466
|
-
const cleanupCancelled = async (lateContext) => {
|
|
3467
|
-
const proof = await this.waitForPersistentFallbackIdentity().catch(() => ({ state: "unknown" }));
|
|
3468
|
-
if (proof.state !== "owned") {
|
|
3469
|
-
await lateContext.close().catch(() => undefined);
|
|
3470
|
-
return proof.state === "absent" ? "closed" : "unknown";
|
|
3471
|
-
}
|
|
3472
|
-
const { identity } = proof;
|
|
3473
|
-
const treeProof = this.adoptOwnedChromeProcessTree(identity, false);
|
|
3474
|
-
const closeState = await closeProfileWithProof({
|
|
3475
|
-
profileDir: this.profileDir,
|
|
3476
|
-
identity,
|
|
3477
|
-
close: () => lateContext.close(),
|
|
3478
|
-
forceClose: () => {
|
|
3479
|
-
signalOwnedChromeProcessTree(identity, false, "SIGKILL", {
|
|
3480
|
-
...(treeProof === null ? {} : { proof: treeProof }),
|
|
3481
|
-
});
|
|
3482
|
-
reapProfileHolderIfOwned(this.profileDir, identity);
|
|
3483
|
-
},
|
|
3484
|
-
...(treeProof === null
|
|
3485
|
-
? {}
|
|
3486
|
-
: { identityState: () => ownedChromeProcessTreeState(treeProof) }),
|
|
3487
|
-
});
|
|
3488
|
-
if (closeState === "closed")
|
|
3489
|
-
return closeState;
|
|
3490
|
-
return (await this.waitForOwnedProfileExit(identity, treeProof)) ? "closed" : "unknown";
|
|
3491
|
-
};
|
|
3492
|
-
const outcome = await (async () => {
|
|
3493
|
-
try {
|
|
3494
|
-
return await launchCancellablePersistentContext({
|
|
3495
|
-
launch: (options) => launcher.launchPersistentContext(this.profileDir, options),
|
|
3496
|
-
options: {
|
|
3497
|
-
headless: OPERATOR_BROWSER_HEADLESS,
|
|
3498
|
-
env: {
|
|
3499
|
-
...browserEnv,
|
|
3500
|
-
[OPERATOR_BROWSER_MARKER_ENV]: this.operatorBrowserMarker(),
|
|
3501
|
-
},
|
|
3502
|
-
...(channel !== null ? { channel } : {}),
|
|
3503
|
-
...persistentProxyOptions(proxy),
|
|
3504
|
-
args: [...launchArgs],
|
|
3505
|
-
viewport: null,
|
|
3506
|
-
locale: "en-US",
|
|
3507
|
-
timezoneId: geo?.timezoneId ?? "America/New_York",
|
|
3508
|
-
permissions: grantedPermissions,
|
|
3509
|
-
...(geo?.geolocation !== undefined ? { geolocation: geo.geolocation } : {}),
|
|
3510
|
-
},
|
|
3511
|
-
cancellation: this.startCancellation,
|
|
3512
|
-
cleanupCancelled,
|
|
3513
|
-
cleanupRejected: cleanupProfileHolder,
|
|
3514
|
-
});
|
|
3515
|
-
}
|
|
3516
|
-
catch (error) {
|
|
3517
|
-
if (!this.startCancellationRequested) {
|
|
3518
|
-
this.persistentFallbackLaunchInFlight = false;
|
|
3519
|
-
throw error;
|
|
3520
|
-
}
|
|
3521
|
-
this.persistentFallbackCancellationState = await cleanupProfileHolder().catch(() => "unknown");
|
|
3522
|
-
this.persistentFallbackLaunchInFlight = false;
|
|
3523
|
-
throw new Error("BrowserController start cancelled");
|
|
3524
|
-
}
|
|
3525
|
-
})();
|
|
3526
|
-
if (outcome.status === "cancelled") {
|
|
3527
|
-
this.persistentFallbackCancellationState = outcome.closeState;
|
|
3528
|
-
this.persistentFallbackLaunchInFlight = false;
|
|
3529
|
-
throw new Error("BrowserController start cancelled");
|
|
3530
|
-
}
|
|
3531
|
-
context = outcome.value;
|
|
3532
|
-
if (this.startCancellationRequested) {
|
|
3533
|
-
this.persistentFallbackCancellationState = await cleanupCancelled(context).catch(() => "unknown");
|
|
3534
|
-
this.persistentFallbackLaunchInFlight = false;
|
|
3535
|
-
throw new Error("BrowserController start cancelled");
|
|
3536
|
-
}
|
|
3537
|
-
this.context = context;
|
|
3538
|
-
this.launchedContext = true;
|
|
3539
|
-
this.launchedProfileHolderIdentity = await this.requirePersistentFallbackOwnership(async () => {
|
|
3540
|
-
markOwnerBrowserLaunchTerminal(this.operatorBrowserMarker());
|
|
3541
|
-
await Promise.race([
|
|
3542
|
-
context.close().catch(() => undefined),
|
|
3543
|
-
new Promise((resolveWait) => {
|
|
3544
|
-
const timer = setTimeout(resolveWait, PERSISTENT_CONTEXT_CANCELLATION_SETTLE_MS);
|
|
3545
|
-
timer.unref();
|
|
3546
|
-
}),
|
|
3547
|
-
]);
|
|
3548
|
-
const markerClosed = await terminateOwnerBrowserLaunch(this.operatorBrowserMarker(), this.profileDir);
|
|
3549
|
-
if (markerClosed) {
|
|
3550
|
-
untrackOwnerBrowserLaunch(this.operatorBrowserMarker());
|
|
3551
|
-
this.ownerLaunchTracked = false;
|
|
3552
|
-
}
|
|
3553
|
-
this.context = null;
|
|
3554
|
-
this.launchedContext = false;
|
|
3555
|
-
});
|
|
3556
|
-
this.commitProfileLaunch();
|
|
3557
|
-
this.persistentFallbackLaunchInFlight = false;
|
|
3558
|
-
}
|
|
3559
|
-
this.context = context;
|
|
3560
|
-
// We own the profile now — close() may reap a leaked Chrome.
|
|
3561
|
-
this.launchedContext = true;
|
|
3562
|
-
if (!remoteMode) {
|
|
3563
|
-
const holderPid = this.childChrome?.pid ?? currentProfileHolderPid(this.profileDir);
|
|
3564
|
-
this.launchedProfileHolderIdentity =
|
|
3565
|
-
this.childChromeIdentity ??
|
|
3566
|
-
(holderPid === null ? null : profileProcessIdentity(holderPid, this.profileDir));
|
|
3567
|
-
if (this.launchedProfileHolderIdentity !== null) {
|
|
3568
|
-
this.adoptOwnedChromeProcessTree(this.launchedProfileHolderIdentity, this.childChromeIdentity !== null && this.childChromeProcessGroup);
|
|
3569
|
-
}
|
|
3570
|
-
}
|
|
3571
|
-
if (this.startCancellationRequested) {
|
|
3572
|
-
await this.closeBrowser();
|
|
3573
|
-
throw new Error("BrowserController start cancelled");
|
|
3574
|
-
}
|
|
2345
|
+
get launchMode() {
|
|
2346
|
+
return this.processOwner.launchMode;
|
|
2347
|
+
}
|
|
2348
|
+
processOwner;
|
|
2349
|
+
pageDriver;
|
|
2350
|
+
// Experimental (TRUSTY_SQUIRE_EXPERIMENTAL_MULTISESSION only — see
|
|
2351
|
+
// session/lifecycle.ts). True for a controller constructed by
|
|
2352
|
+
// attachSatellite(): it shares ITS PEER's BrowserProcessOwner (same Chrome
|
|
2353
|
+
// process/context) rather than owning one, so start()/close() must never
|
|
2354
|
+
// touch the shared process — only this controller's own page(s).
|
|
2355
|
+
isSatelliteAttachment;
|
|
2356
|
+
constructor(opts = {}, sharedFrom) {
|
|
2357
|
+
this.humanize = opts.humanize ?? true;
|
|
2358
|
+
this.pageDriver = new PageDriver(() => this.processOwner.context, this.humanize);
|
|
2359
|
+
this.processOwner =
|
|
2360
|
+
sharedFrom !== undefined
|
|
2361
|
+
? sharedFrom.processOwner
|
|
2362
|
+
: new BrowserProcessOwner(opts, this.pageDriver, (context, hardened, remoteMode) => this.initializePages(context, hardened, remoteMode));
|
|
2363
|
+
this.isSatelliteAttachment = sharedFrom !== undefined;
|
|
2364
|
+
}
|
|
2365
|
+
// Experimental (TRUSTY_SQUIRE_EXPERIMENTAL_MULTISESSION only). A SECOND
|
|
2366
|
+
// controller sharing `primary`'s already-live BrowserProcessOwner — same
|
|
2367
|
+
// Chrome process and BrowserContext — but with its OWN independent
|
|
2368
|
+
// PageDriver/OwnedPages, so it owns a tab family neither the primary nor
|
|
2369
|
+
// any other satellite can adopt (owned-pages.ts already refuses to
|
|
2370
|
+
// register a page under a second OwnedPages instance). close() on the
|
|
2371
|
+
// result only ever tears down its own page — never the shared process; see
|
|
2372
|
+
// session/lifecycle.ts for the shared-teardown ordering that requires.
|
|
2373
|
+
static async attachSatellite(primary, opts = {}) {
|
|
2374
|
+
const satellite = new BrowserController(opts, primary);
|
|
2375
|
+
await satellite.attachOwnPage();
|
|
2376
|
+
return satellite;
|
|
2377
|
+
}
|
|
2378
|
+
// Opens and registers this controller's OWN page in the shared context.
|
|
2379
|
+
// Mirrors what initializePages() does for the primary's first page — the
|
|
2380
|
+
// same per-page normalization via installPageNormalization — minus the
|
|
2381
|
+
// context-level setup (init scripts, resource-blocking routes), which is
|
|
2382
|
+
// CONTEXT-scoped and already installed once by whichever controller
|
|
2383
|
+
// launched the shared browser. The host-scope guard is NOT shared: each
|
|
2384
|
+
// session installs its own via setHostScopeAllowedHosts, and under the
|
|
2385
|
+
// flag the guard dispatches by page ownership so the two never judge each
|
|
2386
|
+
// other's claimed pages.
|
|
2387
|
+
async attachOwnPage() {
|
|
2388
|
+
const ctx = this.processOwner.context;
|
|
2389
|
+
if (ctx === null) {
|
|
2390
|
+
throw new Error("BrowserController.attachSatellite: shared browser has no live context to attach a page to");
|
|
2391
|
+
}
|
|
2392
|
+
const page = await ctx.newPage();
|
|
2393
|
+
this.page = page;
|
|
2394
|
+
this.primaryPage = page;
|
|
2395
|
+
this.trackOpenedTabs(page);
|
|
2396
|
+
await this.installPageNormalization(page, this.processOwner.launchMode === "remote");
|
|
2397
|
+
}
|
|
2398
|
+
// Closes ONLY this controller's own page(s) — never the shared Chrome
|
|
2399
|
+
// process/context. Used for every session sharing an experimental
|
|
2400
|
+
// multisession identity except whichever one's finish empties the group
|
|
2401
|
+
// (which still runs the real close()); see session/lifecycle.ts.
|
|
2402
|
+
async closeOwnPagesOnly() {
|
|
2403
|
+
// Snapshot the whole tab family — the OAuth recovery tab and any adopted
|
|
2404
|
+
// popups live in OwnedPages, not just in `page` — BEFORE
|
|
2405
|
+
// disposeRegistrations() drops the only map that can enumerate them.
|
|
2406
|
+
const family = new Set(this.ownedPages.live());
|
|
2407
|
+
for (const page of [
|
|
2408
|
+
this.pageDriver.page,
|
|
2409
|
+
this.pageDriver.primaryPage,
|
|
2410
|
+
this.pageDriver.oauthProductPage,
|
|
2411
|
+
this.pageDriver.oauthProviderPage,
|
|
2412
|
+
]) {
|
|
2413
|
+
if (page !== null && !page.isClosed())
|
|
2414
|
+
family.add(page);
|
|
2415
|
+
}
|
|
2416
|
+
await this.uninstallHostScopeGuard();
|
|
2417
|
+
this.pageDriver.disposeRegistrations();
|
|
2418
|
+
this.pageDriver.page = null;
|
|
2419
|
+
this.pageDriver.primaryPage = null;
|
|
2420
|
+
this.pageDriver.oauthProductPage = null;
|
|
2421
|
+
this.pageDriver.oauthProviderPage = null;
|
|
2422
|
+
this.pageDriver.oauthProviderPageClosed = false;
|
|
2423
|
+
for (const page of family)
|
|
2424
|
+
await page.close().catch(() => undefined);
|
|
2425
|
+
return "closed";
|
|
2426
|
+
}
|
|
2427
|
+
async initializePages(context, hardened, remoteMode) {
|
|
3575
2428
|
// Speed: optionally abort heavy/irrelevant requests before any navigation.
|
|
3576
2429
|
await this.installResourceBlocking();
|
|
3577
|
-
// Dev-runtime guard: when the bot is run through `tsx`, esbuild may inject
|
|
3578
|
-
// calls to its `__name(fn, "name")` helper into functions passed to
|
|
3579
|
-
// page.evaluate/addInitScript. Those functions execute in the browser page,
|
|
3580
|
-
// where Node's helper does not exist, causing an immediate
|
|
3581
|
-
// `ReferenceError: __name is not defined` before the real signup even
|
|
3582
|
-
// starts. Define the same no-op helper in every document. Built `dist`
|
|
3583
|
-
// should not emit these calls, but the helper is harmless there too.
|
|
3584
|
-
const evaluateNameShimScript = 'Object.defineProperty(globalThis, "__name", { value: (fn) => fn, configurable: true });';
|
|
3585
2430
|
const contextInitScripts = contextInitScriptsFor({ hardened, remoteMode });
|
|
3586
2431
|
// Never register context init scripts under patchright. Its injection path
|
|
3587
2432
|
// rewrites text/html after decoding the response as UTF-8, corrupting
|
|
@@ -3591,7 +2436,7 @@ export class BrowserController {
|
|
|
3591
2436
|
// Baseline playwright-extra does not rewrite responses and keeps these
|
|
3592
2437
|
// document-start installs. Regression guard: observe-jp-mojibake.test.ts.
|
|
3593
2438
|
if (contextInitScripts.includes("evaluate-name-shim")) {
|
|
3594
|
-
await context.addInitScript({ content:
|
|
2439
|
+
await context.addInitScript({ content: EVALUATE_NAME_SHIM_SCRIPT });
|
|
3595
2440
|
}
|
|
3596
2441
|
// Patch navigator.webdriver — BASELINE ONLY. Measured against the
|
|
3597
2442
|
// rebrowser bot-detector, this manual `defineProperty` is
|
|
@@ -3604,96 +2449,6 @@ export class BrowserController {
|
|
|
3604
2449
|
Object.defineProperty(navigator, "webdriver", { get: () => undefined });
|
|
3605
2450
|
});
|
|
3606
2451
|
}
|
|
3607
|
-
// rc.33 / 2026-06-04 — spoof the WebGL UNMASKED vendor+renderer toward a
|
|
3608
|
-
// stock Intel GPU, so the software Mesa/llvmpipe string (--enable-unsafe-
|
|
3609
|
-
// swiftshader gives us a context, but llvmpipe is itself a VM/headless
|
|
3610
|
-
// tell) doesn't read through. Applied TWO ways because patchright
|
|
3611
|
-
// (hardened) isolates document-start scripts from the page's main world:
|
|
3612
|
-
// • addInitScript — document-start; the effective path in the stealth
|
|
3613
|
-
// BASELINE (non-patchright).
|
|
3614
|
-
// • re-applied via page.evaluate on every navigation — the ONLY path that
|
|
3615
|
-
// reaches the MAIN world under patchright. MEASURED 2026-06-04:
|
|
3616
|
-
// addInitScript AND raw CDP Page.addScriptToEvaluateOnNewDocument both
|
|
3617
|
-
// land in patchright's isolated world (renderer stayed llvmpipe);
|
|
3618
|
-
// page.evaluate does not (renderer became Intel), and the v3 score held
|
|
3619
|
-
// at 1.0. Idempotent via a marker so the per-nav re-apply is cheap, and
|
|
3620
|
-
// getParameter.toString() is masked to the original native source so
|
|
3621
|
-
// the patch itself isn't a tell. Only strings change, not rendering.
|
|
3622
|
-
const installWebglSpoofScript = String.raw `(() => {
|
|
3623
|
-
const VENDOR_WEBGL = 0x9245; // UNMASKED_VENDOR_WEBGL
|
|
3624
|
-
const RENDERER_WEBGL = 0x9246; // UNMASKED_RENDERER_WEBGL
|
|
3625
|
-
const spoof = (proto) => {
|
|
3626
|
-
// The marker lives on the prototype so re-application is a no-op; the
|
|
3627
|
-
// cast is the one typed-alternative-exhausted spot (adding an ad-hoc
|
|
3628
|
-
// brand to a DOM prototype).
|
|
3629
|
-
if (proto.__tsWebglPatched === true) return;
|
|
3630
|
-
const orig = proto.getParameter;
|
|
3631
|
-
const native = orig.toString();
|
|
3632
|
-
proto.getParameter = function (p) {
|
|
3633
|
-
if (p === VENDOR_WEBGL) return "Google Inc. (Intel)";
|
|
3634
|
-
if (p === RENDERER_WEBGL) {
|
|
3635
|
-
return "ANGLE (Intel, Mesa Intel(R) UHD Graphics 620 (KBL GT2), OpenGL 4.6)";
|
|
3636
|
-
}
|
|
3637
|
-
return orig.call(this, p);
|
|
3638
|
-
};
|
|
3639
|
-
Object.defineProperty(proto.getParameter, "toString", {
|
|
3640
|
-
value: () => native,
|
|
3641
|
-
configurable: true,
|
|
3642
|
-
writable: true,
|
|
3643
|
-
});
|
|
3644
|
-
proto.__tsWebglPatched = true;
|
|
3645
|
-
};
|
|
3646
|
-
if (typeof WebGLRenderingContext !== "undefined") {
|
|
3647
|
-
spoof(WebGLRenderingContext.prototype);
|
|
3648
|
-
}
|
|
3649
|
-
if (typeof WebGL2RenderingContext !== "undefined") {
|
|
3650
|
-
spoof(WebGL2RenderingContext.prototype);
|
|
3651
|
-
}
|
|
3652
|
-
// Device-tell normalization. The headless harvester box reports 20
|
|
3653
|
-
// logical cores (navigator.hardwareConcurrency) — a consumer residential
|
|
3654
|
-
// device is 4-16. A 20-core Linux machine behind a "residential" IP is
|
|
3655
|
-
// an internal inconsistency Cloudflare Turnstile scores against
|
|
3656
|
-
// (MEASURED 2026-06-11: exa/cartesia Turnstile won't issue a token on a
|
|
3657
|
-
// clean-fingerprint click; hwConcurrency=20 + Linux is the standout
|
|
3658
|
-
// anomaly). Normalize to a common consumer profile. Same per-nav main-
|
|
3659
|
-
// world application as the WebGL spoof — patchright denies init-world
|
|
3660
|
-
// reach, and Turnstile reads these after the challenge script loads
|
|
3661
|
-
// (seconds in), so the framenavigated re-apply wins the race. Defined on
|
|
3662
|
-
// Navigator.prototype (where the native getters live) so there's no own-
|
|
3663
|
-
// property tell on the instance.
|
|
3664
|
-
const navProto = Navigator.prototype;
|
|
3665
|
-
if (navProto.__tsDevicePatched !== true) {
|
|
3666
|
-
try {
|
|
3667
|
-
Object.defineProperty(Navigator.prototype, "hardwareConcurrency", {
|
|
3668
|
-
get: () => 8,
|
|
3669
|
-
configurable: true,
|
|
3670
|
-
});
|
|
3671
|
-
Object.defineProperty(Navigator.prototype, "deviceMemory", {
|
|
3672
|
-
get: () => 8,
|
|
3673
|
-
configurable: true,
|
|
3674
|
-
});
|
|
3675
|
-
// Screen availHeight tell: a virtual screen reports
|
|
3676
|
-
// availHeight == height (no OS taskbar), whereas a real Windows
|
|
3677
|
-
// desktop reserves ~40px for the taskbar (availHeight = height-40,
|
|
3678
|
-
// availWidth = width). Reinstate that gap so the screen reads like
|
|
3679
|
-
// an ordinary desktop, not a bare framebuffer. Guarded so it only
|
|
3680
|
-
// applies when the two are currently equal (i.e. headless).
|
|
3681
|
-
try {
|
|
3682
|
-
if (screen.availHeight === screen.height) {
|
|
3683
|
-
Object.defineProperty(Screen.prototype, "availHeight", {
|
|
3684
|
-
get: () => screen.height - 40,
|
|
3685
|
-
configurable: true,
|
|
3686
|
-
});
|
|
3687
|
-
}
|
|
3688
|
-
} catch {
|
|
3689
|
-
// leave it
|
|
3690
|
-
}
|
|
3691
|
-
navProto.__tsDevicePatched = true;
|
|
3692
|
-
} catch {
|
|
3693
|
-
// descriptor already locked by something else — leave it.
|
|
3694
|
-
}
|
|
3695
|
-
}
|
|
3696
|
-
})();`;
|
|
3697
2452
|
// Skip under patchright (hardened) — see the mojibake note above: any
|
|
3698
2453
|
// context.addInitScript triggers patchright's charset-lossy text/html
|
|
3699
2454
|
// rewrite. This spoof is already re-applied per navigation via
|
|
@@ -3701,20 +2456,26 @@ export class BrowserController {
|
|
|
3701
2456
|
// the ONLY path that reaches the main world under patchright anyway, so the
|
|
3702
2457
|
// context init copy is dead weight there.
|
|
3703
2458
|
if (contextInitScripts.includes("webgl-spoof")) {
|
|
3704
|
-
await context.addInitScript({ content:
|
|
2459
|
+
await context.addInitScript({ content: INSTALL_WEBGL_SPOOF_SCRIPT });
|
|
3705
2460
|
}
|
|
3706
|
-
for (const page of context.pages())
|
|
3707
|
-
this.trackMainDocument(page);
|
|
3708
|
-
this.trackOpenedTabs(context);
|
|
3709
2461
|
this.page = context.pages()[0] ?? (await context.newPage());
|
|
3710
|
-
this.
|
|
2462
|
+
this.trackOpenedTabs(this.page);
|
|
3711
2463
|
this.primaryPage = this.page;
|
|
2464
|
+
await this.installPageNormalization(this.page, remoteMode);
|
|
2465
|
+
}
|
|
2466
|
+
// Every per-page install the primary's first page gets — the evaluate-name
|
|
2467
|
+
// shim, the per-navigation main-world spoof re-apply, the captcha-iframe
|
|
2468
|
+
// in-frame spoof, and the optional captcha trace. Under patchright the
|
|
2469
|
+
// context init scripts are skipped, so this per-navigation path is the ONLY
|
|
2470
|
+
// fingerprint normalization a page gets; a satellite's page
|
|
2471
|
+
// (attachOwnPage) must therefore go through it too.
|
|
2472
|
+
async installPageNormalization(page, remoteMode) {
|
|
3712
2473
|
// In baseline mode addInitScript covers document-start page JS, but
|
|
3713
2474
|
// Playwright's page.evaluate utility execution can run in a separate realm.
|
|
3714
2475
|
// Install the same no-op helper there with a STRING evaluate (tsx cannot
|
|
3715
2476
|
// wrap strings with __name). This prevents dev-runtime source runs from
|
|
3716
2477
|
// crashing before replay reaches the service page.
|
|
3717
|
-
await
|
|
2478
|
+
await page.evaluate(EVALUATE_NAME_SHIM_SCRIPT).catch(() => undefined);
|
|
3718
2479
|
// Re-apply on every navigation — the main-world reach patchright's isolated
|
|
3719
2480
|
// init world denies us. framenavigated fires at navigation-commit (before
|
|
3720
2481
|
// most page JS), so a late WebGL query (reCAPTCHA scores seconds in) sees
|
|
@@ -3726,8 +2487,8 @@ export class BrowserController {
|
|
|
3726
2487
|
if (pg === null)
|
|
3727
2488
|
return;
|
|
3728
2489
|
void (async () => {
|
|
3729
|
-
await pg.evaluate(
|
|
3730
|
-
await pg.evaluate(
|
|
2490
|
+
await pg.evaluate(EVALUATE_NAME_SHIM_SCRIPT).catch(() => undefined);
|
|
2491
|
+
await pg.evaluate(INSTALL_WEBGL_SPOOF_SCRIPT).catch(() => {
|
|
3731
2492
|
// mid-navigation / closed page — the next navigation re-applies.
|
|
3732
2493
|
});
|
|
3733
2494
|
})();
|
|
@@ -3747,7 +2508,7 @@ export class BrowserController {
|
|
|
3747
2508
|
// a captcha would read. Logged only under CAPTCHA_TRACE to prove the fix.
|
|
3748
2509
|
const RENDERER_PROBE = String.raw `(() => { try { const c = document.createElement("canvas"); const gl = c.getContext("webgl") || c.getContext("webgl2"); if (!gl) return "no-gl"; const e = gl.getExtension("WEBGL_debug_renderer_info"); return e ? String(gl.getParameter(e.UNMASKED_RENDERER_WEBGL)) : "no-ext"; } catch (err) { return "err:" + (err && err.message); } })()`;
|
|
3749
2510
|
const trace = process.env.UNIVERSAL_BOT_CAPTCHA_TRACE === "1";
|
|
3750
|
-
|
|
2511
|
+
page.on("framenavigated", (frame) => {
|
|
3751
2512
|
if (remoteMode)
|
|
3752
2513
|
return; // real-GPU remote host: no in-iframe spoof
|
|
3753
2514
|
if (this.page === null)
|
|
@@ -3779,7 +2540,7 @@ export class BrowserController {
|
|
|
3779
2540
|
// place before the scoring read.
|
|
3780
2541
|
let landed = false;
|
|
3781
2542
|
for (let i = 0; i < 20 && !landed; i++) {
|
|
3782
|
-
await frame.evaluate(
|
|
2543
|
+
await frame.evaluate(INSTALL_WEBGL_SPOOF_SCRIPT).catch(() => undefined);
|
|
3783
2544
|
const r = await frame.evaluate(RENDERER_PROBE).catch(() => "eval-fail");
|
|
3784
2545
|
if (typeof r === "string" && r.includes("Intel"))
|
|
3785
2546
|
landed = true;
|
|
@@ -3791,7 +2552,7 @@ export class BrowserController {
|
|
|
3791
2552
|
}
|
|
3792
2553
|
})();
|
|
3793
2554
|
});
|
|
3794
|
-
|
|
2555
|
+
page.on("load", reapplyWebglSpoof);
|
|
3795
2556
|
// rc.33 — captcha tracing. When UNIVERSAL_BOT_CAPTCHA_TRACE=1 is
|
|
3796
2557
|
// set, log every response from Cloudflare/Google's challenge
|
|
3797
2558
|
// endpoints plus any console message that mentions captcha-y
|
|
@@ -3801,102 +2562,150 @@ export class BrowserController {
|
|
|
3801
2562
|
// it CAN observe its network. Off by default; opt in for
|
|
3802
2563
|
// diagnostic runs only since the bodies can be large.
|
|
3803
2564
|
if (process.env.UNIVERSAL_BOT_CAPTCHA_TRACE === "1") {
|
|
3804
|
-
|
|
2565
|
+
page.on("response", async (resp) => {
|
|
3805
2566
|
const url = resp.url();
|
|
3806
2567
|
if (!/challenges\.cloudflare\.com|google\.com\/recaptcha|hcaptcha\.com|newassets\.hcaptcha\.com/.test(url)) {
|
|
3807
2568
|
return;
|
|
3808
2569
|
}
|
|
3809
|
-
const status = resp.status();
|
|
3810
|
-
const ct = resp.headers()["content-type"] ?? "";
|
|
3811
|
-
let bodyPreview = "";
|
|
3812
|
-
if (/json|javascript|html|plain/.test(ct) ||
|
|
3813
|
-
/api\.hcaptcha\.com\/(?:checksiteconfig|getcaptcha|checkcaptcha)/.test(url)) {
|
|
3814
|
-
try {
|
|
3815
|
-
const body = await resp.text();
|
|
3816
|
-
bodyPreview = body.length > 400 ? body.slice(0, 400) + "…" : body;
|
|
3817
|
-
}
|
|
3818
|
-
catch {
|
|
3819
|
-
// body may be evicted; ignore
|
|
3820
|
-
}
|
|
3821
|
-
}
|
|
3822
|
-
console.error(`[captcha-trace] ${status} ${url}${bodyPreview ? "\n body: " + bodyPreview.replace(/\n/g, "\\n") : ""}`);
|
|
3823
|
-
});
|
|
3824
|
-
|
|
3825
|
-
const text = msg.text();
|
|
3826
|
-
if (!/turnstile|cloudflare|challenge|recaptcha/i.test(text))
|
|
2570
|
+
const status = resp.status();
|
|
2571
|
+
const ct = resp.headers()["content-type"] ?? "";
|
|
2572
|
+
let bodyPreview = "";
|
|
2573
|
+
if (/json|javascript|html|plain/.test(ct) ||
|
|
2574
|
+
/api\.hcaptcha\.com\/(?:checksiteconfig|getcaptcha|checkcaptcha)/.test(url)) {
|
|
2575
|
+
try {
|
|
2576
|
+
const body = await resp.text();
|
|
2577
|
+
bodyPreview = body.length > 400 ? body.slice(0, 400) + "…" : body;
|
|
2578
|
+
}
|
|
2579
|
+
catch {
|
|
2580
|
+
// body may be evicted; ignore
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
console.error(`[captcha-trace] ${status} ${url}${bodyPreview ? "\n body: " + bodyPreview.replace(/\n/g, "\\n") : ""}`);
|
|
2584
|
+
});
|
|
2585
|
+
page.on("console", (msg) => {
|
|
2586
|
+
const text = msg.text();
|
|
2587
|
+
if (!/turnstile|cloudflare|challenge|recaptcha/i.test(text))
|
|
2588
|
+
return;
|
|
2589
|
+
console.error(`[captcha-trace] console.${msg.type()}: ${text}`);
|
|
2590
|
+
});
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
trackMainDocument(page) {
|
|
2594
|
+
return this.pageDriver.trackMainDocument(page);
|
|
2595
|
+
}
|
|
2596
|
+
mainDocumentIdentity() {
|
|
2597
|
+
return this.pageDriver.mainDocumentIdentity();
|
|
2598
|
+
}
|
|
2599
|
+
/** Attach normal controller behavior to a harness-owned Playwright page. */
|
|
2600
|
+
static fromHarnessPage(page) {
|
|
2601
|
+
const controller = new BrowserController({ humanize: false });
|
|
2602
|
+
controller.processOwner.context = page.context();
|
|
2603
|
+
controller.page = page;
|
|
2604
|
+
controller.primaryPage = page;
|
|
2605
|
+
controller.trackOpenedTabs(page);
|
|
2606
|
+
controller.harnessAttachedPage = true;
|
|
2607
|
+
controller.processOwner.launchedMode = "headless";
|
|
2608
|
+
return controller;
|
|
2609
|
+
}
|
|
2610
|
+
trackOpenedTabs(page) {
|
|
2611
|
+
return this.pageDriver.trackOpenedTabs(page);
|
|
2612
|
+
}
|
|
2613
|
+
operatorBrowserMarker() {
|
|
2614
|
+
return this.processOwner.operatorBrowserMarker();
|
|
2615
|
+
}
|
|
2616
|
+
isConnected() {
|
|
2617
|
+
return this.processOwner.isConnected();
|
|
2618
|
+
}
|
|
2619
|
+
get channel() {
|
|
2620
|
+
return this.processOwner.channel;
|
|
2621
|
+
}
|
|
2622
|
+
get proxied() {
|
|
2623
|
+
return this.processOwner.proxied;
|
|
2624
|
+
}
|
|
2625
|
+
get stealthProfile() {
|
|
2626
|
+
return this.processOwner.stealthProfile;
|
|
2627
|
+
}
|
|
2628
|
+
// Resource blocking for speed (BOT_BLOCK_RESOURCES, default OFF). Aborts
|
|
2629
|
+
// image/media/font requests + known analytics/tracker hosts to cut page-load
|
|
2630
|
+
// wall-clock (3-5x on byte-heavy pages; also stops trackers from holding the
|
|
2631
|
+
// network "busy"). HARD ALLOW-GUARD first for captcha/challenge + payment
|
|
2632
|
+
// scripts (blocking those breaks the Turnstile/hCaptcha token poll and the
|
|
2633
|
+
// signup form). CSS + first-party JS are never blocked (not in BLOCK_TYPES) —
|
|
2634
|
+
// the SPA form renders from them and the vision planner reads the styled
|
|
2635
|
+
// render. DUAL RISK, hence default-OFF + an OF#2 A/B before flipping on:
|
|
2636
|
+
// (1) a browser that loads ZERO images is itself an anti-bot fingerprint;
|
|
2637
|
+
// (2) the screenshot the vision planner reads loses detail — mitigated
|
|
2638
|
+
// because the DOM inventory is the authoritative action space, but
|
|
2639
|
+
// still a regression risk on image-only affordances.
|
|
2640
|
+
// Registered on the CONTEXT so it covers OAuth popups + iframes.
|
|
2641
|
+
async installResourceBlocking() {
|
|
2642
|
+
const ctx = this.context;
|
|
2643
|
+
if (ctx === null)
|
|
2644
|
+
return;
|
|
2645
|
+
if (!/^(1|true|on)$/i.test(process.env.BOT_BLOCK_RESOURCES ?? ""))
|
|
2646
|
+
return;
|
|
2647
|
+
const BLOCK_TYPES = new Set(["image", "media", "font"]);
|
|
2648
|
+
const BLOCK_HOSTS = [
|
|
2649
|
+
"google-analytics.com",
|
|
2650
|
+
"googletagmanager.com",
|
|
2651
|
+
"analytics.google.com",
|
|
2652
|
+
"doubleclick.net",
|
|
2653
|
+
"static.hotjar.com",
|
|
2654
|
+
"script.hotjar.com",
|
|
2655
|
+
"segment.com",
|
|
2656
|
+
"segment.io",
|
|
2657
|
+
"cdn.segment.com",
|
|
2658
|
+
"fullstory.com",
|
|
2659
|
+
"mixpanel.com",
|
|
2660
|
+
"bugsnag.com",
|
|
2661
|
+
"intercom.io",
|
|
2662
|
+
"intercomcdn.com",
|
|
2663
|
+
"widget.intercom.io",
|
|
2664
|
+
"connect.facebook.net",
|
|
2665
|
+
"analytics.tiktok.com",
|
|
2666
|
+
"clarity.ms",
|
|
2667
|
+
"cdn.heapanalytics.com",
|
|
2668
|
+
"wistia.com",
|
|
2669
|
+
];
|
|
2670
|
+
// NEVER block — these break signup (captcha/challenge widgets + payment SDK).
|
|
2671
|
+
const ALWAYS_ALLOW = [
|
|
2672
|
+
"challenges.cloudflare.com",
|
|
2673
|
+
"turnstile",
|
|
2674
|
+
"hcaptcha.com",
|
|
2675
|
+
"newassets.hcaptcha.com",
|
|
2676
|
+
"recaptcha",
|
|
2677
|
+
"gstatic.com/recaptcha",
|
|
2678
|
+
"js.stripe.com",
|
|
2679
|
+
];
|
|
2680
|
+
await ctx.route("**/*", async (route) => {
|
|
2681
|
+
try {
|
|
2682
|
+
const url = route.request().url();
|
|
2683
|
+
if (ALWAYS_ALLOW.some((h) => url.includes(h))) {
|
|
2684
|
+
await route.continue();
|
|
2685
|
+
return;
|
|
2686
|
+
}
|
|
2687
|
+
const type = route.request().resourceType();
|
|
2688
|
+
if (BLOCK_TYPES.has(type) || BLOCK_HOSTS.some((h) => url.includes(h))) {
|
|
2689
|
+
await route.abort();
|
|
3827
2690
|
return;
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
}
|
|
3831
|
-
}
|
|
3832
|
-
// Probe the run's actual egress geo by loading ipinfo.io. Launches a
|
|
3833
|
-
// throwaway browser: the persistent context isn't up yet, and its
|
|
3834
|
-
// timezone has to be known before it is. The throwaway inherits the
|
|
3835
|
-
// same channel + proxy so it reports the real egress. Best-effort —
|
|
3836
|
-
// any failure returns null and start() keeps a default timezone.
|
|
3837
|
-
async probeEgressGeo(channel, proxy, browserEnv) {
|
|
3838
|
-
if (proxy === null) {
|
|
3839
|
-
try {
|
|
3840
|
-
const resp = await fetch("https://ipinfo.io/json", { signal: AbortSignal.timeout(10_000) });
|
|
3841
|
-
if (!resp.ok)
|
|
3842
|
-
throw new Error(`HTTP ${resp.status}`);
|
|
3843
|
-
return parseEgressGeo(await resp.text());
|
|
2691
|
+
}
|
|
2692
|
+
await route.continue();
|
|
3844
2693
|
}
|
|
3845
|
-
catch
|
|
3846
|
-
|
|
3847
|
-
`timezone: ${err instanceof Error ? err.message : String(err)}`);
|
|
3848
|
-
return null;
|
|
2694
|
+
catch {
|
|
2695
|
+
// Routing race / already-handled — never let a decision crash nav.
|
|
3849
2696
|
}
|
|
3850
|
-
}
|
|
3851
|
-
|
|
3852
|
-
try {
|
|
3853
|
-
probe = await getChromium().launch({
|
|
3854
|
-
headless: OPERATOR_BROWSER_HEADLESS,
|
|
3855
|
-
env: browserEnv,
|
|
3856
|
-
...(channel !== null ? { channel } : {}),
|
|
3857
|
-
...(proxy !== null ? { proxy } : {}),
|
|
3858
|
-
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
|
3859
|
-
});
|
|
3860
|
-
const page = await probe.newPage();
|
|
3861
|
-
await page.goto("https://ipinfo.io/json", {
|
|
3862
|
-
timeout: 10000,
|
|
3863
|
-
waitUntil: "domcontentloaded",
|
|
3864
|
-
});
|
|
3865
|
-
const body = await page.evaluate(() => document.body.innerText);
|
|
3866
|
-
return parseEgressGeo(body);
|
|
3867
|
-
}
|
|
3868
|
-
catch (err) {
|
|
3869
|
-
console.error(`[operator] egress geo probe failed — using default ` +
|
|
3870
|
-
`timezone: ${err instanceof Error ? err.message : String(err)}`);
|
|
3871
|
-
return null;
|
|
3872
|
-
}
|
|
3873
|
-
finally {
|
|
3874
|
-
if (probe !== undefined)
|
|
3875
|
-
await probe.close();
|
|
3876
|
-
}
|
|
2697
|
+
});
|
|
2698
|
+
console.error("[operator] resource blocking ON (image/media/font + analytics aborted; captcha/CSS/JS allowed)");
|
|
3877
2699
|
}
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
return null;
|
|
3885
|
-
return resolveExplicitProxy(this.proxyOverride);
|
|
2700
|
+
async start() {
|
|
2701
|
+
// A satellite's page is already attached by attachSatellite() — there is
|
|
2702
|
+
// no process for it to start.
|
|
2703
|
+
if (this.isSatelliteAttachment)
|
|
2704
|
+
return;
|
|
2705
|
+
return await this.processOwner.start();
|
|
3886
2706
|
}
|
|
3887
|
-
// Reload the current page. Used by the post-verify flow to make a SPA
|
|
3888
|
-
// re-read a server-side state change (email verified) that the client
|
|
3889
|
-
// hasn't picked up yet. Best-effort: a reload failure is non-fatal — the
|
|
3890
|
-
// caller re-reads the page state regardless.
|
|
3891
2707
|
async reload() {
|
|
3892
|
-
|
|
3893
|
-
throw new Error("Browser not started");
|
|
3894
|
-
try {
|
|
3895
|
-
await this.page.reload({ waitUntil: "domcontentloaded", timeout: 20_000 });
|
|
3896
|
-
}
|
|
3897
|
-
catch {
|
|
3898
|
-
// reload failed (slow SPA / transient) — caller re-inspects anyway
|
|
3899
|
-
}
|
|
2708
|
+
return await this.pageDriver.reload();
|
|
3900
2709
|
}
|
|
3901
2710
|
// Open the first conversation in a Gmail search-results list so the email
|
|
3902
2711
|
// BODY renders. The results LIST only carries snippets + Gmail chrome links —
|
|
@@ -3932,106 +2741,7 @@ export class BrowserController {
|
|
|
3932
2741
|
return false;
|
|
3933
2742
|
}
|
|
3934
2743
|
async goto(url) {
|
|
3935
|
-
|
|
3936
|
-
throw new Error("Browser not started");
|
|
3937
|
-
// Retry transient network/proxy drops. A residential SOCKS tunnel
|
|
3938
|
-
// intermittently resets a connection mid-navigation (Chrome surfaces
|
|
3939
|
-
// net::ERR_SOCKS_CONNECTION_FAILED / ERR_CONNECTION_RESET / ERR_NETWORK_
|
|
3940
|
-
// CHANGED / ERR_TIMED_OUT), especially on heavy onboarding pages that
|
|
3941
|
-
// open many subresource connections at once (algolia's dashboard_setup).
|
|
3942
|
-
// The host is reachable on the next attempt — a single goto failure
|
|
3943
|
-
// shouldn't fail the whole signup. Only retry these connection-level
|
|
3944
|
-
// errors; HTTP statuses and selector/logic errors fall straight through.
|
|
3945
|
-
// net::ERR_ABORTED — a navigation superseded by a redirect/JS-nav during
|
|
3946
|
-
// the domcontentloaded wait. Usually transient (a redirect race on the
|
|
3947
|
-
// first hit of an auth-gated portal — MEASURED 2026-06-11: defang's
|
|
3948
|
-
// portal.defang.io aborted on the initial goto); a retry lands the
|
|
3949
|
-
// settled page. Distinct from ERR_CONNECTION_ABORTED (a dropped socket).
|
|
3950
|
-
const TRANSIENT_NET = /ERR_SOCKS_CONNECTION_FAILED|ERR_CONNECTION_(?:RESET|CLOSED|FAILED|ABORTED)|ERR_NETWORK_CHANGED|ERR_TIMED_OUT|ERR_NAME_NOT_RESOLVED|net::ERR_EMPTY_RESPONSE|net::ERR_ABORTED/i;
|
|
3951
|
-
const MAX_GOTO_ATTEMPTS = 3;
|
|
3952
|
-
const sameOriginPathAndSearch = (a, b) => {
|
|
3953
|
-
try {
|
|
3954
|
-
const left = new URL(a);
|
|
3955
|
-
const right = new URL(b);
|
|
3956
|
-
return (left.origin === right.origin &&
|
|
3957
|
-
left.pathname === right.pathname &&
|
|
3958
|
-
left.search === right.search);
|
|
3959
|
-
}
|
|
3960
|
-
catch {
|
|
3961
|
-
return false;
|
|
3962
|
-
}
|
|
3963
|
-
};
|
|
3964
|
-
const landedAuthGateForTarget = (landedRaw, targetRaw) => {
|
|
3965
|
-
try {
|
|
3966
|
-
const landed = new URL(landedRaw);
|
|
3967
|
-
const target = new URL(targetRaw);
|
|
3968
|
-
if (landed.origin !== target.origin)
|
|
3969
|
-
return false;
|
|
3970
|
-
return /\/(?:sign[_-]?in|login|log[_-]?in|auth)(?:\/|$)/i.test(landed.pathname);
|
|
3971
|
-
}
|
|
3972
|
-
catch {
|
|
3973
|
-
return false;
|
|
3974
|
-
}
|
|
3975
|
-
};
|
|
3976
|
-
for (let attempt = 1;; attempt++) {
|
|
3977
|
-
try {
|
|
3978
|
-
await this.page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
3979
|
-
// A SOCKS/connection drop does NOT always throw: Chrome resolves
|
|
3980
|
-
// domcontentloaded on its own `chrome-error://chromewebdata/`
|
|
3981
|
-
// interstitial and goto returns cleanly. The bot then ran the whole
|
|
3982
|
-
// planner on a dead error page and gave up after one round (MEASURED
|
|
3983
|
-
// 2026-06-11: galileo/lancedb landed on chrome-error with the app
|
|
3984
|
-
// host as the title, never retried). Treat a chrome-error landing as
|
|
3985
|
-
// the same transient class and retry it like a thrown net error.
|
|
3986
|
-
const landed = this.page.url();
|
|
3987
|
-
if (landed.startsWith("chrome-error://")) {
|
|
3988
|
-
if (attempt >= MAX_GOTO_ATTEMPTS) {
|
|
3989
|
-
throw new Error(`net::navigation landed on a Chrome error page for ${url} ` +
|
|
3990
|
-
`after ${attempt} attempts (transient proxy/host failure)`);
|
|
3991
|
-
}
|
|
3992
|
-
await this.sleep(1500 * attempt);
|
|
3993
|
-
continue;
|
|
3994
|
-
}
|
|
3995
|
-
break;
|
|
3996
|
-
}
|
|
3997
|
-
catch (err) {
|
|
3998
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
3999
|
-
// Some client-routed apps commit the address bar to the requested SPA
|
|
4000
|
-
// route but never fire the lifecycle event Playwright is waiting for.
|
|
4001
|
-
// Treat that as a successful navigation: callers immediately inspect
|
|
4002
|
-
// the DOM and have their own element-level waits.
|
|
4003
|
-
if (/Timeout \d+ms exceeded/i.test(msg)) {
|
|
4004
|
-
await this.sleep(500);
|
|
4005
|
-
if (sameOriginPathAndSearch(this.page.url(), url))
|
|
4006
|
-
break;
|
|
4007
|
-
if (landedAuthGateForTarget(this.page.url(), url))
|
|
4008
|
-
break;
|
|
4009
|
-
await this.page
|
|
4010
|
-
.waitForURL((landed) => sameOriginPathAndSearch(landed.toString(), url), {
|
|
4011
|
-
timeout: 5000,
|
|
4012
|
-
})
|
|
4013
|
-
.then(() => undefined)
|
|
4014
|
-
.catch(() => undefined);
|
|
4015
|
-
if (sameOriginPathAndSearch(this.page.url(), url))
|
|
4016
|
-
break;
|
|
4017
|
-
if (landedAuthGateForTarget(this.page.url(), url))
|
|
4018
|
-
break;
|
|
4019
|
-
}
|
|
4020
|
-
if (attempt >= MAX_GOTO_ATTEMPTS || !TRANSIENT_NET.test(msg))
|
|
4021
|
-
throw err;
|
|
4022
|
-
// Linear backoff — give the tunnel a moment to recover a slot.
|
|
4023
|
-
await this.sleep(1500 * attempt);
|
|
4024
|
-
}
|
|
4025
|
-
}
|
|
4026
|
-
// Post-load dwell. Cloudflare/reCAPTCHA scoring runs JS that
|
|
4027
|
-
// collects behavior signals over a window (typically 500-2000ms);
|
|
4028
|
-
// landing on a page and immediately interacting reads as bot-like.
|
|
4029
|
-
// The "dwell" gives the scoring window enough wall-clock to settle
|
|
4030
|
-
// and also gives any deferred JS time to register event listeners
|
|
4031
|
-
// we'll later fire.
|
|
4032
|
-
if (this.humanize) {
|
|
4033
|
-
await this.sleep(rand(800, 2000));
|
|
4034
|
-
}
|
|
2744
|
+
return await this.pageDriver.goto(url);
|
|
4035
2745
|
}
|
|
4036
2746
|
// Pre-warm a domain by visiting its root. Useful before navigating
|
|
4037
2747
|
// to a deep signup URL on a strict-Cloudflare service: the root sets
|
|
@@ -13412,12 +12122,11 @@ export class BrowserController {
|
|
|
13412
12122
|
}
|
|
13413
12123
|
this.oauthProviderPage = null;
|
|
13414
12124
|
this.oauthProviderPageClosed = false;
|
|
13415
|
-
//
|
|
13416
|
-
|
|
13417
|
-
const popupPromise = this.context.waitForEvent("page", { timeout: 8000 }).catch(() => null);
|
|
12125
|
+
// Only the current page's creation-attributed popup can carry this handshake.
|
|
12126
|
+
const popupPromise = this.page.waitForEvent("popup", { timeout: 8000 }).catch(() => null);
|
|
13418
12127
|
await this.click(selector);
|
|
13419
12128
|
const popup = await popupPromise;
|
|
13420
|
-
if (popup !== null && popup !== this.page &&
|
|
12129
|
+
if (popup !== null && popup !== this.page && this.ownedPages.has(popup)) {
|
|
13421
12130
|
this.page = popup;
|
|
13422
12131
|
this.oauthProviderPage = popup;
|
|
13423
12132
|
// A provider returning from OAuth is allowed to close its own popup.
|
|
@@ -13456,14 +12165,26 @@ export class BrowserController {
|
|
|
13456
12165
|
const productUrl = product.url();
|
|
13457
12166
|
const oauthDeadline = Date.now() + oauthBudgetMs;
|
|
13458
12167
|
const remainingBudgetMs = () => Math.max(1, oauthDeadline - Date.now());
|
|
13459
|
-
const
|
|
13460
|
-
|
|
13461
|
-
|
|
13462
|
-
|
|
13463
|
-
|
|
12168
|
+
const safeOrigin = (url) => {
|
|
12169
|
+
try {
|
|
12170
|
+
return new URL(url).origin;
|
|
12171
|
+
}
|
|
12172
|
+
catch {
|
|
12173
|
+
return url;
|
|
12174
|
+
}
|
|
12175
|
+
};
|
|
12176
|
+
// Fix C: a timed-out wait only proves control has not returned to the
|
|
12177
|
+
// product origin yet — never assert WHY (expired session, denial, etc.).
|
|
12178
|
+
// A consent screen or 2FA challenge is routinely still showing; the
|
|
12179
|
+
// caller should keep waiting/retry, not treat this as a dead end.
|
|
12180
|
+
const awaitingHumanError = () => new OAuthAwaitingHumanError(oauthAwaitingHumanMessage(safeOrigin(productUrl), oauthBudgetMs));
|
|
12181
|
+
const oauthFailedError = (reason) => new OAuthFailedError(reason);
|
|
13464
12182
|
let recovery = null;
|
|
13465
12183
|
let providerPage = null;
|
|
13466
12184
|
let productDeparted = false;
|
|
12185
|
+
let pendingOnProvider = false;
|
|
12186
|
+
let lastTransientUrl = productUrl;
|
|
12187
|
+
let onTransientNavigation = null;
|
|
13467
12188
|
let resolveProductDeparture = () => undefined;
|
|
13468
12189
|
const productDeparturePromise = new Promise((resolve) => {
|
|
13469
12190
|
resolveProductDeparture = resolve;
|
|
@@ -13479,6 +12200,7 @@ export class BrowserController {
|
|
|
13479
12200
|
product.on("framenavigated", onProductNavigation);
|
|
13480
12201
|
try {
|
|
13481
12202
|
recovery = await context.newPage();
|
|
12203
|
+
this.trackOpenedTabs(recovery);
|
|
13482
12204
|
await recovery.goto(productUrl, {
|
|
13483
12205
|
waitUntil: "domcontentloaded",
|
|
13484
12206
|
timeout: remainingBudgetMs(),
|
|
@@ -13488,13 +12210,24 @@ export class BrowserController {
|
|
|
13488
12210
|
resolvePopup = resolve;
|
|
13489
12211
|
});
|
|
13490
12212
|
const onPopup = (page) => {
|
|
13491
|
-
|
|
12213
|
+
if (!this.ownedPages.has(page))
|
|
12214
|
+
return;
|
|
12215
|
+
product.off("popup", onPopup);
|
|
13492
12216
|
resolvePopup(page);
|
|
13493
12217
|
};
|
|
13494
|
-
|
|
12218
|
+
const onProductClose = () => {
|
|
12219
|
+
product.off("popup", onPopup);
|
|
12220
|
+
product.off("framenavigated", onProductNavigation);
|
|
12221
|
+
resolvePopup(null);
|
|
12222
|
+
};
|
|
12223
|
+
product.on("popup", onPopup);
|
|
12224
|
+
product.once("close", onProductClose);
|
|
13495
12225
|
try {
|
|
13496
|
-
if (Date.now() >= oauthDeadline)
|
|
13497
|
-
throw
|
|
12226
|
+
if (Date.now() >= oauthDeadline) {
|
|
12227
|
+
throw new OAuthAwaitingHumanError(`OAuth has not been attempted yet: the ${Math.ceil(oauthBudgetMs / 1000)}-second ` +
|
|
12228
|
+
`budget elapsed before the OAuth control on ${safeOrigin(productUrl)} was clicked. ` +
|
|
12229
|
+
"Retry oauth_login.", "not_attempted");
|
|
12230
|
+
}
|
|
13498
12231
|
try {
|
|
13499
12232
|
await this.click(selector);
|
|
13500
12233
|
}
|
|
@@ -13512,11 +12245,18 @@ export class BrowserController {
|
|
|
13512
12245
|
]);
|
|
13513
12246
|
}
|
|
13514
12247
|
finally {
|
|
13515
|
-
|
|
12248
|
+
product.off("popup", onPopup);
|
|
12249
|
+
product.off("close", onProductClose);
|
|
13516
12250
|
resolvePopup(null);
|
|
13517
12251
|
resolveProductDeparture();
|
|
13518
12252
|
}
|
|
13519
12253
|
const transient = providerPage ?? product;
|
|
12254
|
+
lastTransientUrl = transient.url();
|
|
12255
|
+
onTransientNavigation = (frame) => {
|
|
12256
|
+
if (frame === transient.mainFrame())
|
|
12257
|
+
lastTransientUrl = frame.url();
|
|
12258
|
+
};
|
|
12259
|
+
transient.on("framenavigated", onTransientNavigation);
|
|
13520
12260
|
productDeparted = productDeparted || !this.isOAuthProductUrl(transient.url(), productUrl);
|
|
13521
12261
|
const durableProduct = providerPage === null ? recovery : product;
|
|
13522
12262
|
this.oauthProductPage = durableProduct;
|
|
@@ -13544,27 +12284,69 @@ export class BrowserController {
|
|
|
13544
12284
|
await this.sleep(Math.min(250, remainingBudgetMs()));
|
|
13545
12285
|
}
|
|
13546
12286
|
}
|
|
12287
|
+
const observedUrls = [
|
|
12288
|
+
...(providerPage !== null || productDeparted
|
|
12289
|
+
? [transient.isClosed() ? lastTransientUrl : transient.url()]
|
|
12290
|
+
: []),
|
|
12291
|
+
...(providerPage !== null && !product.isClosed() && product.url() !== productUrl
|
|
12292
|
+
? [product.url()]
|
|
12293
|
+
: []),
|
|
12294
|
+
];
|
|
12295
|
+
for (const observedUrl of observedUrls) {
|
|
12296
|
+
const denial = oauthErrorFromReturnUrl(observedUrl);
|
|
12297
|
+
if (denial === null)
|
|
12298
|
+
continue;
|
|
12299
|
+
throw oauthFailedError(`OAuth returned to ${safeOrigin(observedUrl)} with error=${denial.error}` +
|
|
12300
|
+
(denial.description === null ? "" : ` (${denial.description})`) +
|
|
12301
|
+
".");
|
|
12302
|
+
}
|
|
13547
12303
|
if (settled === null) {
|
|
13548
|
-
|
|
12304
|
+
// Timed out without a confirmed origin-return. Re-check honestly
|
|
12305
|
+
// rather than assume failure — see classifyOAuthTimeout's doc comment
|
|
12306
|
+
// for the false-negative this recovers.
|
|
12307
|
+
const outcome = classifyOAuthTimeout(transient.isClosed(), !transient.isClosed() &&
|
|
12308
|
+
providerPage === null &&
|
|
12309
|
+
productDeparted &&
|
|
12310
|
+
this.isOAuthProductUrl(transient.url(), productUrl));
|
|
12311
|
+
if (outcome === "awaiting_human") {
|
|
12312
|
+
pendingOnProvider = true;
|
|
12313
|
+
throw awaitingHumanError();
|
|
12314
|
+
}
|
|
12315
|
+
settled = outcome;
|
|
13549
12316
|
}
|
|
13550
12317
|
if (providerPage === null && product.isClosed()) {
|
|
13551
|
-
|
|
13552
|
-
|
|
13553
|
-
await recovery.reload({
|
|
12318
|
+
const reloaded = await recovery
|
|
12319
|
+
.reload({
|
|
13554
12320
|
waitUntil: "domcontentloaded",
|
|
13555
|
-
timeout: remainingBudgetMs(),
|
|
13556
|
-
})
|
|
12321
|
+
timeout: Math.max(remainingBudgetMs(), 500),
|
|
12322
|
+
})
|
|
12323
|
+
.then(() => true)
|
|
12324
|
+
.catch(() => false);
|
|
12325
|
+
if (!reloaded) {
|
|
12326
|
+
throw new OAuthAwaitingHumanError(`${safeOrigin(productUrl)} closed during OAuth and could not be reloaded within the ` +
|
|
12327
|
+
"budget, so completion could not be confirmed. Call operate_observe to check the " +
|
|
12328
|
+
"current page.");
|
|
12329
|
+
}
|
|
13557
12330
|
}
|
|
13558
12331
|
}
|
|
13559
12332
|
finally {
|
|
13560
12333
|
product.off("framenavigated", onProductNavigation);
|
|
13561
|
-
|
|
13562
|
-
|
|
13563
|
-
|
|
13564
|
-
|
|
13565
|
-
|
|
13566
|
-
|
|
13567
|
-
|
|
12334
|
+
if (onTransientNavigation !== null) {
|
|
12335
|
+
(providerPage ?? product).off("framenavigated", onTransientNavigation);
|
|
12336
|
+
}
|
|
12337
|
+
const providerStillShowing = pendingOnProvider && providerPage !== null && !providerPage.isClosed();
|
|
12338
|
+
if (providerStillShowing) {
|
|
12339
|
+
this.page = providerPage;
|
|
12340
|
+
}
|
|
12341
|
+
else {
|
|
12342
|
+
const retained = product.isClosed() ? recovery : product;
|
|
12343
|
+
this.page = retained?.isClosed() === false ? retained : this.primaryPage;
|
|
12344
|
+
this.oauthProductPage = null;
|
|
12345
|
+
this.oauthProviderPage = null;
|
|
12346
|
+
this.oauthProviderPageClosed = false;
|
|
12347
|
+
if (providerPage !== null && !providerPage.isClosed()) {
|
|
12348
|
+
await providerPage.close().catch(() => undefined);
|
|
12349
|
+
}
|
|
13568
12350
|
}
|
|
13569
12351
|
if (recovery !== null && recovery !== this.page && !recovery.isClosed()) {
|
|
13570
12352
|
await recovery.close().catch(() => undefined);
|
|
@@ -13806,8 +12588,8 @@ export class BrowserController {
|
|
|
13806
12588
|
cdp = null; // FedCm domain unavailable — the popup path still works
|
|
13807
12589
|
console.error(`[operator] FedCm.enable failed (${err instanceof Error ? err.message : String(err)}) — FedCM path disabled, relying on popup`);
|
|
13808
12590
|
}
|
|
13809
|
-
const popupPromise = this.
|
|
13810
|
-
.waitForEvent("
|
|
12591
|
+
const popupPromise = this.page
|
|
12592
|
+
.waitForEvent("popup", { timeout: timeoutMs })
|
|
13811
12593
|
.then((p) => p)
|
|
13812
12594
|
.catch(() => null);
|
|
13813
12595
|
await this.click(triggerSelector);
|
|
@@ -13855,7 +12637,7 @@ export class BrowserController {
|
|
|
13855
12637
|
// best-effort
|
|
13856
12638
|
}
|
|
13857
12639
|
}
|
|
13858
|
-
if (popup !== null && popup !== this.page &&
|
|
12640
|
+
if (popup !== null && popup !== this.page && this.ownedPages.has(popup)) {
|
|
13859
12641
|
this.page = popup;
|
|
13860
12642
|
try {
|
|
13861
12643
|
await this.page.waitForLoadState("domcontentloaded", { timeout: 15_000 });
|
|
@@ -13880,91 +12662,20 @@ export class BrowserController {
|
|
|
13880
12662
|
`fedcmResolved=${fedcmResolved} pages=${this.context.pages().length}`);
|
|
13881
12663
|
return { ok: false, via: "none" };
|
|
13882
12664
|
}
|
|
13883
|
-
// URL of the active page (the OAuth page mid-handshake, the product
|
|
13884
|
-
// page otherwise). Cheap — no screenshot, unlike getState().
|
|
13885
12665
|
currentUrl() {
|
|
13886
|
-
return this.
|
|
12666
|
+
return this.pageDriver.currentUrl();
|
|
13887
12667
|
}
|
|
13888
12668
|
recoverActivePage() {
|
|
13889
|
-
return this.
|
|
12669
|
+
return this.pageDriver.recoverActivePage();
|
|
13890
12670
|
}
|
|
13891
|
-
// ───────────── new-tab adoption ─────────────
|
|
13892
|
-
//
|
|
13893
|
-
// Arm adoption immediately BEFORE an action that may open a tab. Anything
|
|
13894
|
-
// already queued belonged to an earlier action and is not this action's to
|
|
13895
|
-
// follow.
|
|
13896
12671
|
armOpenedTabAdoption() {
|
|
13897
|
-
this.
|
|
12672
|
+
return this.pageDriver.armOpenedTabAdoption();
|
|
13898
12673
|
}
|
|
13899
|
-
// Adopt the newest live tab opened since armOpenedTabAdoption() as the active
|
|
13900
|
-
// page, so the next observe/act reads the tab the click actually opened.
|
|
13901
|
-
// Returns the adopted URL, or null when the action opened no followable tab.
|
|
13902
|
-
//
|
|
13903
|
-
// `graceMs` covers the window between the click returning and Playwright
|
|
13904
|
-
// delivering the context "page" event; a caller that has already waited for
|
|
13905
|
-
// the page to settle passes 0 and just drains what arrived.
|
|
13906
12674
|
async adoptOpenedTab(graceMs = 0) {
|
|
13907
|
-
|
|
13908
|
-
let candidate = this.takeFollowableTab();
|
|
13909
|
-
while (candidate === null && Date.now() < deadline) {
|
|
13910
|
-
await this.sleep(50);
|
|
13911
|
-
candidate = this.takeFollowableTab();
|
|
13912
|
-
}
|
|
13913
|
-
if (candidate === null)
|
|
13914
|
-
return null;
|
|
13915
|
-
this.openedTabs.length = 0;
|
|
13916
|
-
// A window.open target starts at about:blank and is navigated a tick later.
|
|
13917
|
-
// Adopting it while blank would report an empty page to the host, so wait
|
|
13918
|
-
// (bounded) for the document it was opened for.
|
|
13919
|
-
const blank = (url) => url === "" || url === "about:blank" || url === "about:srcdoc";
|
|
13920
|
-
for (let i = 0; i < 40 && !candidate.isClosed() && blank(candidate.url()); i++) {
|
|
13921
|
-
await this.sleep(50);
|
|
13922
|
-
}
|
|
13923
|
-
if (candidate.isClosed())
|
|
13924
|
-
return null;
|
|
13925
|
-
this.page = candidate;
|
|
13926
|
-
this.trackMainDocument(candidate);
|
|
13927
|
-
await candidate.bringToFront().catch(() => undefined);
|
|
13928
|
-
await candidate
|
|
13929
|
-
.waitForLoadState("domcontentloaded", { timeout: 15_000 })
|
|
13930
|
-
.catch(() => undefined);
|
|
13931
|
-
return candidate.isClosed() ? null : candidate.url();
|
|
13932
|
-
}
|
|
13933
|
-
// Newest queued tab the operator may follow. Pages the controller itself owns
|
|
13934
|
-
// (the active page, the primary page, either side of an OAuth handshake, a
|
|
13935
|
-
// recovery tab) are never adoption candidates — those lifecycles are managed
|
|
13936
|
-
// by the code that created them.
|
|
13937
|
-
takeFollowableTab() {
|
|
13938
|
-
for (let i = this.openedTabs.length - 1; i >= 0; i--) {
|
|
13939
|
-
const tab = this.openedTabs[i];
|
|
13940
|
-
if (tab.isClosed())
|
|
13941
|
-
continue;
|
|
13942
|
-
if (tab === this.page ||
|
|
13943
|
-
tab === this.primaryPage ||
|
|
13944
|
-
tab === this.oauthProductPage ||
|
|
13945
|
-
tab === this.oauthProviderPage) {
|
|
13946
|
-
continue;
|
|
13947
|
-
}
|
|
13948
|
-
return tab;
|
|
13949
|
-
}
|
|
13950
|
-
return null;
|
|
12675
|
+
return await this.pageDriver.adoptOpenedTab(graceMs);
|
|
13951
12676
|
}
|
|
13952
12677
|
adoptLivePage() {
|
|
13953
|
-
|
|
13954
|
-
return true;
|
|
13955
|
-
if (this.context === null)
|
|
13956
|
-
return false;
|
|
13957
|
-
const pages = this.context.pages().filter((p) => !p.isClosed());
|
|
13958
|
-
if (pages.length === 0)
|
|
13959
|
-
return false;
|
|
13960
|
-
const product = this.oauthProductPage !== null && !this.oauthProductPage.isClosed()
|
|
13961
|
-
? this.oauthProductPage
|
|
13962
|
-
: null;
|
|
13963
|
-
const nonAuth = [...pages]
|
|
13964
|
-
.reverse()
|
|
13965
|
-
.find((p) => !/accounts\.google\.com|github\.com\/login|login\.microsoftonline\.com/i.test(p.url()));
|
|
13966
|
-
this.page = nonAuth ?? product ?? pages[pages.length - 1] ?? null;
|
|
13967
|
-
return this.page !== null;
|
|
12678
|
+
return this.pageDriver.adoptLivePage();
|
|
13968
12679
|
}
|
|
13969
12680
|
// Press a keyboard key (e.g. "Escape" to dismiss a focus-trapped modal that
|
|
13970
12681
|
// exposes no in-DOM close control). Best-effort. Used by the nav-search
|
|
@@ -14202,6 +12913,8 @@ export class BrowserController {
|
|
|
14202
12913
|
return null;
|
|
14203
12914
|
let identityPage = null;
|
|
14204
12915
|
try {
|
|
12916
|
+
// Deliberately unregistered: this identity probe (and its popups) must
|
|
12917
|
+
// never become the session's working page.
|
|
14205
12918
|
identityPage = await this.context.newPage();
|
|
14206
12919
|
const identityUrl = new URL("https://myaccount.google.com/");
|
|
14207
12920
|
const expectedEmail = expectedGoogleAccountEmail?.trim();
|
|
@@ -14621,287 +13334,19 @@ export class BrowserController {
|
|
|
14621
13334
|
}
|
|
14622
13335
|
}
|
|
14623
13336
|
async close(options = {}) {
|
|
14624
|
-
if (
|
|
14625
|
-
this.
|
|
14626
|
-
|
|
14627
|
-
this.resolveStartCancellation = null;
|
|
14628
|
-
}
|
|
14629
|
-
this.closePromise ??= this.closeAfterStart();
|
|
14630
|
-
return await this.closePromise;
|
|
13337
|
+
if (this.isSatelliteAttachment)
|
|
13338
|
+
return await this.closeOwnPagesOnly();
|
|
13339
|
+
return await this.processOwner.close(options);
|
|
14631
13340
|
}
|
|
14632
13341
|
async waitForCancelledStartQuiescence() {
|
|
14633
|
-
if (
|
|
13342
|
+
if (this.isSatelliteAttachment)
|
|
14634
13343
|
return;
|
|
14635
|
-
await
|
|
14636
|
-
this.startPromise ?? Promise.resolve(),
|
|
14637
|
-
this.reapCancelledStartProcess(),
|
|
14638
|
-
]);
|
|
14639
|
-
await this.persistentFallbackOwnershipMonitor?.catch(() => undefined);
|
|
13344
|
+
return await this.processOwner.waitForCancelledStartQuiescence();
|
|
14640
13345
|
}
|
|
14641
13346
|
async forceCloseOwnedProcessTree() {
|
|
14642
|
-
this.
|
|
14643
|
-
|
|
14644
|
-
this.
|
|
14645
|
-
const marker = this.operatorBrowserMarker();
|
|
14646
|
-
if (this.ownerLaunchTracked)
|
|
14647
|
-
markOwnerBrowserLaunchTerminal(marker);
|
|
14648
|
-
const proof = this.ownedChromeProcessTreeProof;
|
|
14649
|
-
const identity = proof?.identity ?? this.currentOwnedProfileIdentity();
|
|
14650
|
-
if (identity !== null) {
|
|
14651
|
-
signalOwnedChromeProcessTree(identity, proof?.processGroup ?? false, "SIGKILL", {
|
|
14652
|
-
...(proof === null ? {} : { proof }),
|
|
14653
|
-
});
|
|
14654
|
-
reapProfileHolderIfOwned(this.profileDir, identity);
|
|
14655
|
-
}
|
|
14656
|
-
const closed = identity === null
|
|
14657
|
-
? this.startSettled && !this.launchedContext
|
|
14658
|
-
: await this.waitForOwnedProfileExit(identity, proof);
|
|
14659
|
-
if (closed && proof !== null) {
|
|
14660
|
-
releaseOwnedChromeProcessTree(proof);
|
|
14661
|
-
const tracked = selfManagedChromes.get(proof.identity.pid);
|
|
14662
|
-
if (tracked?.proof === proof)
|
|
14663
|
-
selfManagedChromes.delete(proof.identity.pid);
|
|
14664
|
-
if (this.ownedChromeProcessTreeProof === proof)
|
|
14665
|
-
this.ownedChromeProcessTreeProof = null;
|
|
14666
|
-
}
|
|
14667
|
-
const markerClosed = !this.ownerLaunchTracked || (await terminateOwnerBrowserLaunch(marker, this.profileDir));
|
|
14668
|
-
if (closed && markerClosed && this.ownerLaunchTracked) {
|
|
14669
|
-
untrackOwnerBrowserLaunch(marker);
|
|
14670
|
-
this.ownerLaunchTracked = false;
|
|
14671
|
-
}
|
|
14672
|
-
await this.teardownOwnedDisplay().catch(() => undefined);
|
|
14673
|
-
return closed && markerClosed ? "closed" : "unknown";
|
|
14674
|
-
}
|
|
14675
|
-
async closeCancelledStart() {
|
|
14676
|
-
void this.reapCancelledStartProcess().catch(() => undefined);
|
|
14677
|
-
if (this.persistentFallbackCancellationState !== null) {
|
|
14678
|
-
return this.persistentFallbackCancellationState;
|
|
14679
|
-
}
|
|
14680
|
-
if (!this.startLaunchCommitted) {
|
|
14681
|
-
const closeState = await this.closeBrowser();
|
|
14682
|
-
return this.startSettled ? closeState : "unknown";
|
|
14683
|
-
}
|
|
14684
|
-
return await this.closeBrowser();
|
|
14685
|
-
}
|
|
14686
|
-
async reapCancelledStartProcess() {
|
|
14687
|
-
this.cancelledStartReaper ??= this.monitorCancelledStartProcess();
|
|
14688
|
-
await this.cancelledStartReaper;
|
|
14689
|
-
}
|
|
14690
|
-
async monitorCancelledStartProcess() {
|
|
14691
|
-
while (!this.startSettled) {
|
|
14692
|
-
const identity = this.currentOwnedProfileIdentity();
|
|
14693
|
-
if (identity !== null) {
|
|
14694
|
-
this.signalCurrentSelfManagedChrome(identity, "SIGKILL");
|
|
14695
|
-
reapProfileHolderIfOwned(this.profileDir, identity);
|
|
14696
|
-
}
|
|
14697
|
-
await new Promise((resolveWait) => {
|
|
14698
|
-
const timer = setTimeout(resolveWait, 25);
|
|
14699
|
-
timer.unref();
|
|
14700
|
-
});
|
|
14701
|
-
}
|
|
14702
|
-
}
|
|
14703
|
-
currentOwnedProfileIdentity() {
|
|
14704
|
-
const known = this.ownedChromeProcessTreeProof?.identity ??
|
|
14705
|
-
this.childChromeIdentity ??
|
|
14706
|
-
this.launchedProfileHolderIdentity;
|
|
14707
|
-
if (known !== null)
|
|
14708
|
-
return known;
|
|
14709
|
-
const holderPid = currentProfileHolderPid(this.profileDir);
|
|
14710
|
-
if (holderPid === null)
|
|
14711
|
-
return null;
|
|
14712
|
-
const identity = profileProcessIdentity(holderPid, this.profileDir);
|
|
14713
|
-
if (identity === null)
|
|
14714
|
-
return null;
|
|
14715
|
-
if (!this.startCancellationRequested)
|
|
14716
|
-
return identity;
|
|
14717
|
-
return operatorBrowserProcessMatchesMarker(identity.pid, this.operatorBrowserMarker())
|
|
14718
|
-
? identity
|
|
14719
|
-
: null;
|
|
14720
|
-
}
|
|
14721
|
-
async waitForPersistentFallbackIdentity() {
|
|
14722
|
-
if (this.ownedChromeProcessTreeProof !== null) {
|
|
14723
|
-
return { state: "owned", identity: this.ownedChromeProcessTreeProof.identity };
|
|
14724
|
-
}
|
|
14725
|
-
const proof = await resolvePersistentFallbackIdentity({ profileDir: this.profileDir });
|
|
14726
|
-
if (proof.state === "owned" &&
|
|
14727
|
-
this.startCancellationRequested &&
|
|
14728
|
-
!operatorBrowserProcessMatchesMarker(proof.identity.pid, this.operatorBrowserMarker())) {
|
|
14729
|
-
return { state: "unknown" };
|
|
14730
|
-
}
|
|
14731
|
-
if (proof.state === "owned")
|
|
14732
|
-
this.adoptOwnedChromeProcessTree(proof.identity, false);
|
|
14733
|
-
return proof;
|
|
14734
|
-
}
|
|
14735
|
-
async requirePersistentFallbackOwnership(cleanupUnproven) {
|
|
14736
|
-
try {
|
|
14737
|
-
const proof = await this.waitForPersistentFallbackIdentity();
|
|
14738
|
-
if (proof.state !== "owned" || this.ownedChromeProcessTreeProof === null) {
|
|
14739
|
-
throw new Error("persistent browser launch identity could not be bound to owner custody");
|
|
14740
|
-
}
|
|
14741
|
-
return proof.identity;
|
|
14742
|
-
}
|
|
14743
|
-
catch (error) {
|
|
14744
|
-
await cleanupUnproven().catch(() => undefined);
|
|
14745
|
-
this.persistentFallbackLaunchInFlight = false;
|
|
14746
|
-
throw error;
|
|
14747
|
-
}
|
|
14748
|
-
}
|
|
14749
|
-
startPersistentFallbackOwnershipMonitor() {
|
|
14750
|
-
if (this.persistentFallbackOwnershipMonitor !== null)
|
|
14751
|
-
return;
|
|
14752
|
-
this.persistentFallbackOwnershipMonitor = (async () => {
|
|
14753
|
-
while (this.persistentFallbackLaunchInFlight && this.ownedChromeProcessTreeProof === null) {
|
|
14754
|
-
const holderPid = currentProfileHolderPid(this.profileDir);
|
|
14755
|
-
const identity = holderPid === null ? null : profileProcessIdentity(holderPid, this.profileDir);
|
|
14756
|
-
const controllerOwnsIdentity = identity !== null &&
|
|
14757
|
-
(!this.startCancellationRequested ||
|
|
14758
|
-
operatorBrowserProcessMatchesMarker(identity.pid, this.operatorBrowserMarker()));
|
|
14759
|
-
if (identity !== null && controllerOwnsIdentity) {
|
|
14760
|
-
this.launchedProfileHolderIdentity = identity;
|
|
14761
|
-
try {
|
|
14762
|
-
this.adoptOwnedChromeProcessTree(identity, false);
|
|
14763
|
-
}
|
|
14764
|
-
catch { }
|
|
14765
|
-
return;
|
|
14766
|
-
}
|
|
14767
|
-
await new Promise((resolveWait) => {
|
|
14768
|
-
const timer = setTimeout(resolveWait, PROFILE_IDENTITY_POLL_MS);
|
|
14769
|
-
timer.unref();
|
|
14770
|
-
});
|
|
14771
|
-
}
|
|
14772
|
-
})();
|
|
14773
|
-
}
|
|
14774
|
-
async waitForOwnedProfileExit(identity, existingProof) {
|
|
14775
|
-
const deadline = Date.now() + PROFILE_IDENTITY_PROOF_TIMEOUT_MS;
|
|
14776
|
-
const proof = existingProof ?? captureOwnedChromeProcessTreeProof(identity, false);
|
|
14777
|
-
let state = proof === null
|
|
14778
|
-
? profileProcessIdentityState(identity, this.profileDir)
|
|
14779
|
-
: ownedChromeProcessTreeState(proof);
|
|
14780
|
-
while (state !== "stale" && Date.now() < deadline) {
|
|
14781
|
-
if (proof !== null) {
|
|
14782
|
-
signalOwnedChromeProcessTree(identity, false, "SIGKILL", { proof });
|
|
14783
|
-
}
|
|
14784
|
-
else if (profileProcessMatches(identity, this.profileDir)) {
|
|
14785
|
-
signalOwnedChromeProcessTree(identity, false, "SIGKILL");
|
|
14786
|
-
}
|
|
14787
|
-
await new Promise((resolveWait) => {
|
|
14788
|
-
const timer = setTimeout(resolveWait, PROFILE_IDENTITY_POLL_MS);
|
|
14789
|
-
timer.unref();
|
|
14790
|
-
});
|
|
14791
|
-
state =
|
|
14792
|
-
proof === null
|
|
14793
|
-
? profileProcessIdentityState(identity, this.profileDir)
|
|
14794
|
-
: ownedChromeProcessTreeState(proof);
|
|
14795
|
-
}
|
|
14796
|
-
if (state !== "stale")
|
|
14797
|
-
return false;
|
|
14798
|
-
reapProfileHolderIfOwned(this.profileDir, identity);
|
|
14799
|
-
return true;
|
|
14800
|
-
}
|
|
14801
|
-
async closeAfterStart() {
|
|
14802
|
-
if (this.startPromise !== null && !this.startSettled) {
|
|
14803
|
-
await Promise.race([this.startPromise.catch(() => undefined), this.startCancellation]);
|
|
14804
|
-
}
|
|
14805
|
-
if (this.startCancellationRequested)
|
|
14806
|
-
return await this.closeCancelledStart();
|
|
14807
|
-
return await this.closeBrowser();
|
|
14808
|
-
}
|
|
14809
|
-
async closeBrowser() {
|
|
14810
|
-
if (this.harnessAttachedPage) {
|
|
14811
|
-
this.page = null;
|
|
14812
|
-
this.primaryPage = null;
|
|
14813
|
-
this.oauthProductPage = null;
|
|
14814
|
-
this.oauthProviderPage = null;
|
|
14815
|
-
this.oauthProviderPageClosed = false;
|
|
14816
|
-
this.context = null;
|
|
14817
|
-
return "closed";
|
|
14818
|
-
}
|
|
14819
|
-
const marker = this.operatorBrowserMarker();
|
|
14820
|
-
if (this.ownerLaunchTracked)
|
|
14821
|
-
markOwnerBrowserLaunchTerminal(marker);
|
|
14822
|
-
// Each step is best-effort and independent: a throw closing the page
|
|
14823
|
-
// or context must NOT skip the browser reap below, or an un-closed Chrome
|
|
14824
|
-
// keeps the profile's
|
|
14825
|
-
// SingletonLock held — bricking the next signup + `mcp connect`).
|
|
14826
|
-
//
|
|
14827
|
-
// EVERY close call is timeout-capped. On a wedged headed Chrome (e.g. a
|
|
14828
|
-
// run that crashed mid-captcha-click), BOTH page.close() AND
|
|
14829
|
-
// context.close() can hang INDEFINITELY — and an un-capped page.close()
|
|
14830
|
-
// blocked the reap below from ever running, so the browser leaked for
|
|
14831
|
-
// minutes and bricked the next 3 services (MEASURED 2026-06-09: supabase
|
|
14832
|
-
// crash → cockroachdb/weaviate/honeycomb all "profile held"). The cap
|
|
14833
|
-
// guarantees we always reach the SIGKILL reap.
|
|
14834
|
-
const page = this.page;
|
|
14835
|
-
const context = this.context;
|
|
14836
|
-
const cdpBrowser = this.cdpBrowser;
|
|
14837
|
-
const childIdentity = this.childChromeIdentity;
|
|
14838
|
-
const childChromeProcessGroup = this.childChromeProcessGroup;
|
|
14839
|
-
const holderIdentity = this.launchedProfileHolderIdentity ?? this.currentOwnedProfileIdentity();
|
|
14840
|
-
const identity = this.ownedChromeProcessTreeProof?.identity ?? childIdentity ?? holderIdentity;
|
|
14841
|
-
const treeProof = identity === null
|
|
14842
|
-
? null
|
|
14843
|
-
: (this.ownedChromeProcessTreeProof ??
|
|
14844
|
-
this.adoptOwnedChromeProcessTree(identity, childIdentity !== null ? childChromeProcessGroup : false));
|
|
14845
|
-
this.page = null;
|
|
14846
|
-
this.primaryPage = null;
|
|
14847
|
-
this.oauthProductPage = null;
|
|
14848
|
-
this.oauthProviderPage = null;
|
|
14849
|
-
this.oauthProviderPageClosed = false;
|
|
14850
|
-
this.context = null;
|
|
14851
|
-
this.cdpBrowser = null;
|
|
14852
|
-
this.childChrome = null;
|
|
14853
|
-
this.childChromeIdentity = null;
|
|
14854
|
-
this.childChromeProcessGroup = false;
|
|
14855
|
-
this.launchedContext = false;
|
|
14856
|
-
this.launchedProfileHolderIdentity = null;
|
|
14857
|
-
const closeState = await closeProfileWithProof({
|
|
14858
|
-
profileDir: this.profileDir,
|
|
14859
|
-
identity,
|
|
14860
|
-
close: async () => {
|
|
14861
|
-
if (identity !== null) {
|
|
14862
|
-
signalOwnedChromeProcessTree(identity, treeProof?.processGroup ?? (childIdentity !== null ? childChromeProcessGroup : false), "SIGTERM", { ...(treeProof === null ? {} : { proof: treeProof }) });
|
|
14863
|
-
}
|
|
14864
|
-
// A process-tree SIGTERM can close the CDP target before Playwright
|
|
14865
|
-
// observes it. That is successful teardown, not a reason to skip the
|
|
14866
|
-
// proof/reap path or retain a cleanly closed ephemeral profile.
|
|
14867
|
-
if (page !== null)
|
|
14868
|
-
await page.close().catch(() => undefined);
|
|
14869
|
-
if (context !== null)
|
|
14870
|
-
await context.close().catch(() => undefined);
|
|
14871
|
-
if (cdpBrowser !== null)
|
|
14872
|
-
await cdpBrowser.close().catch(() => undefined);
|
|
14873
|
-
},
|
|
14874
|
-
forceClose: () => {
|
|
14875
|
-
if (identity !== null) {
|
|
14876
|
-
signalOwnedChromeProcessTree(identity, treeProof?.processGroup ?? (childIdentity !== null ? childChromeProcessGroup : false), "SIGKILL", { ...(treeProof === null ? {} : { proof: treeProof }) });
|
|
14877
|
-
}
|
|
14878
|
-
reapProfileHolderIfOwned(this.profileDir, identity);
|
|
14879
|
-
},
|
|
14880
|
-
...(treeProof === null
|
|
14881
|
-
? {}
|
|
14882
|
-
: { identityState: () => ownedChromeProcessTreeState(treeProof) }),
|
|
14883
|
-
});
|
|
14884
|
-
// Self-launch path: disconnect the CDP browser and SIGKILL the Chrome we
|
|
14885
|
-
// spawned. context.close() on a connectOverCDP context only disconnects —
|
|
14886
|
-
// it does NOT necessarily exit the browser process, which would leak the
|
|
14887
|
-
// SingletonLock and brick the next run (the reap below is the backstop, but
|
|
14888
|
-
// killing our own child directly is cleaner and faster).
|
|
14889
|
-
if (treeProof !== null && ownedChromeProcessTreeState(treeProof) === "stale") {
|
|
14890
|
-
releaseOwnedChromeProcessTree(treeProof);
|
|
14891
|
-
const tracked = selfManagedChromes.get(treeProof.identity.pid);
|
|
14892
|
-
if (tracked?.proof === treeProof)
|
|
14893
|
-
selfManagedChromes.delete(treeProof.identity.pid);
|
|
14894
|
-
if (this.ownedChromeProcessTreeProof === treeProof) {
|
|
14895
|
-
this.ownedChromeProcessTreeProof = null;
|
|
14896
|
-
}
|
|
14897
|
-
}
|
|
14898
|
-
const markerClosed = !this.ownerLaunchTracked || (await terminateOwnerBrowserLaunch(marker, this.profileDir));
|
|
14899
|
-
if (markerClosed && this.ownerLaunchTracked) {
|
|
14900
|
-
untrackOwnerBrowserLaunch(marker);
|
|
14901
|
-
this.ownerLaunchTracked = false;
|
|
14902
|
-
}
|
|
14903
|
-
await this.teardownOwnedDisplay().catch(() => undefined);
|
|
14904
|
-
return closeState === "closed" && !markerClosed ? "force_closed_unproven" : closeState;
|
|
13347
|
+
if (this.isSatelliteAttachment)
|
|
13348
|
+
return await this.closeOwnPagesOnly();
|
|
13349
|
+
return await this.processOwner.forceCloseOwnedProcessTree();
|
|
14905
13350
|
}
|
|
14906
13351
|
}
|
|
14907
13352
|
// Random integer in [min, max]. We use Math.random() (not crypto)
|
|
@@ -15064,144 +13509,6 @@ export function isSafeSignupChoiceText(text) {
|
|
|
15064
13509
|
!AGREEMENT_TEXT_RE.test(text) &&
|
|
15065
13510
|
!MARKETING_TEXT_RE.test(text));
|
|
15066
13511
|
}
|
|
15067
|
-
export function proxyHasCredentials(proxy) {
|
|
15068
|
-
return (proxy !== null &&
|
|
15069
|
-
((typeof proxy.username === "string" && proxy.username.length > 0) ||
|
|
15070
|
-
(typeof proxy.password === "string" && proxy.password.length > 0)));
|
|
15071
|
-
}
|
|
15072
|
-
// Parse a per-session proxy URL — e.g. "http://user:pass@host:8080" or
|
|
15073
|
-
// "socks5://host:1080" — into Playwright's proxy option shape. Playwright
|
|
15074
|
-
// wants credentials separate from `server`, so we split them out and
|
|
15075
|
-
// percent-decode them (residential providers embed session IDs with
|
|
15076
|
-
// reserved characters in the username, which arrive %-encoded).
|
|
15077
|
-
//
|
|
15078
|
-
// Throws on a URL the WHATWG parser rejects, or one with no host (a bare
|
|
15079
|
-
// "host:port" parses as a scheme with an empty host).
|
|
15080
|
-
//
|
|
15081
|
-
// Exported for unit testing — URL parsing is the error-prone bit.
|
|
15082
|
-
// Cheap TCP liveness probe for a proxy `server` string ("socks5://host:port").
|
|
15083
|
-
// A SOCKS5 proxy listens on TCP; if a connect succeeds within the timeout the
|
|
15084
|
-
// proxy is up. Resolves false on connect error / timeout / a malformed server.
|
|
15085
|
-
// Pure (no class state) so resolveProxy can call it before launching Chrome.
|
|
15086
|
-
export async function isProxyReachable(server, timeoutMs = 4000) {
|
|
15087
|
-
let host;
|
|
15088
|
-
let port;
|
|
15089
|
-
try {
|
|
15090
|
-
const u = new URL(server);
|
|
15091
|
-
host = u.hostname;
|
|
15092
|
-
port = Number(u.port) || proxyDefaultPort(u.protocol);
|
|
15093
|
-
}
|
|
15094
|
-
catch {
|
|
15095
|
-
return false;
|
|
15096
|
-
}
|
|
15097
|
-
if (host.length === 0 || !Number.isFinite(port))
|
|
15098
|
-
return false;
|
|
15099
|
-
return await new Promise((resolve) => {
|
|
15100
|
-
const sock = new Socket();
|
|
15101
|
-
let settled = false;
|
|
15102
|
-
const finish = (ok) => {
|
|
15103
|
-
if (settled)
|
|
15104
|
-
return;
|
|
15105
|
-
settled = true;
|
|
15106
|
-
try {
|
|
15107
|
-
sock.destroy();
|
|
15108
|
-
}
|
|
15109
|
-
catch {
|
|
15110
|
-
// already closed
|
|
15111
|
-
}
|
|
15112
|
-
resolve(ok);
|
|
15113
|
-
};
|
|
15114
|
-
sock.setTimeout(timeoutMs);
|
|
15115
|
-
sock.once("connect", () => finish(true));
|
|
15116
|
-
sock.once("timeout", () => finish(false));
|
|
15117
|
-
sock.once("error", () => finish(false));
|
|
15118
|
-
sock.connect(port, host);
|
|
15119
|
-
});
|
|
15120
|
-
}
|
|
15121
|
-
export function proxyDefaultPort(protocol) {
|
|
15122
|
-
if (protocol === "http:")
|
|
15123
|
-
return 80;
|
|
15124
|
-
if (protocol === "https:")
|
|
15125
|
-
return 443;
|
|
15126
|
-
if (protocol.startsWith("socks"))
|
|
15127
|
-
return 1080;
|
|
15128
|
-
return 8080;
|
|
15129
|
-
}
|
|
15130
|
-
export function parseProxyUrl(raw) {
|
|
15131
|
-
const u = new URL(raw.trim());
|
|
15132
|
-
if (u.hostname.length === 0) {
|
|
15133
|
-
throw new Error("proxy URL has no host");
|
|
15134
|
-
}
|
|
15135
|
-
// `host` includes the port; `protocol` keeps its trailing ":".
|
|
15136
|
-
const settings = { server: `${u.protocol}//${u.host}` };
|
|
15137
|
-
if (u.username.length > 0)
|
|
15138
|
-
settings.username = decodeURIComponent(u.username);
|
|
15139
|
-
if (u.password.length > 0)
|
|
15140
|
-
settings.password = decodeURIComponent(u.password);
|
|
15141
|
-
return settings;
|
|
15142
|
-
}
|
|
15143
|
-
/** Resolve an explicit session proxy, refusing an unsafe direct fallback. */
|
|
15144
|
-
export async function resolveExplicitProxy(raw, probe = isProxyReachable) {
|
|
15145
|
-
let proxy;
|
|
15146
|
-
try {
|
|
15147
|
-
proxy = parseProxyUrl(raw);
|
|
15148
|
-
}
|
|
15149
|
-
catch (err) {
|
|
15150
|
-
throw new Error(`explicit session proxy is malformed; refusing direct egress: ${err instanceof Error ? err.message : String(err)}`);
|
|
15151
|
-
}
|
|
15152
|
-
if (!(await probe(proxy.server))) {
|
|
15153
|
-
throw new Error(`explicit session proxy ${proxy.server} is unreachable; refusing direct egress`);
|
|
15154
|
-
}
|
|
15155
|
-
return proxy;
|
|
15156
|
-
}
|
|
15157
|
-
/** Self-launched Chrome cannot authenticate an HTTP/SOCKS proxy. */
|
|
15158
|
-
export function canSelfLaunchWithProxy(proxy) {
|
|
15159
|
-
return !proxyHasCredentials(proxy);
|
|
15160
|
-
}
|
|
15161
|
-
/** Options passed to launchPersistentContext, including proxy credentials. */
|
|
15162
|
-
export function persistentProxyOptions(proxy) {
|
|
15163
|
-
return proxy === null ? {} : { proxy };
|
|
15164
|
-
}
|
|
15165
|
-
// Parse an ipinfo.io/json response body into EgressGeo. Returns null
|
|
15166
|
-
// when the timezone is absent or not a plausible IANA zone — the
|
|
15167
|
-
// caller then keeps a default rather than handing Playwright a bad
|
|
15168
|
-
// timezoneId (which would throw inside newContext()).
|
|
15169
|
-
//
|
|
15170
|
-
// geolocation is optional: a valid `loc` ("lat,long") sets it; a
|
|
15171
|
-
// missing or malformed one leaves a timezone-only result. Exported
|
|
15172
|
-
// for unit testing — JSON-shape handling is the error-prone bit.
|
|
15173
|
-
export function parseEgressGeo(text) {
|
|
15174
|
-
let data;
|
|
15175
|
-
try {
|
|
15176
|
-
data = JSON.parse(text);
|
|
15177
|
-
}
|
|
15178
|
-
catch {
|
|
15179
|
-
return null;
|
|
15180
|
-
}
|
|
15181
|
-
if (data === null || typeof data !== "object")
|
|
15182
|
-
return null;
|
|
15183
|
-
const d = data;
|
|
15184
|
-
const tz = typeof d.timezone === "string" ? d.timezone : null;
|
|
15185
|
-
// IANA zones look like "Asia/Seoul" or "America/Argentina/Buenos_Aires".
|
|
15186
|
-
// Reject anything else so a garbage value never reaches newContext().
|
|
15187
|
-
if (tz === null || !/^[A-Za-z]+(?:\/[A-Za-z0-9_+-]+)+$/.test(tz))
|
|
15188
|
-
return null;
|
|
15189
|
-
const geo = { timezoneId: tz };
|
|
15190
|
-
if (typeof d.loc === "string") {
|
|
15191
|
-
const parts = d.loc.split(",");
|
|
15192
|
-
if (parts.length === 2) {
|
|
15193
|
-
const latitude = Number(parts[0]);
|
|
15194
|
-
const longitude = Number(parts[1]);
|
|
15195
|
-
if (Number.isFinite(latitude) &&
|
|
15196
|
-
Number.isFinite(longitude) &&
|
|
15197
|
-
Math.abs(latitude) <= 90 &&
|
|
15198
|
-
Math.abs(longitude) <= 180) {
|
|
15199
|
-
geo.geolocation = { latitude, longitude };
|
|
15200
|
-
}
|
|
15201
|
-
}
|
|
15202
|
-
}
|
|
15203
|
-
return geo;
|
|
15204
|
-
}
|
|
15205
13512
|
// T38 — pure clustering logic. Identifies card-radio groups from a
|
|
15206
13513
|
// flat list of inventory candidates: each candidate carries its
|
|
15207
13514
|
// parent's identity (an integer assigned in DOM-walk order) plus
|
|
@@ -15367,4 +13674,5 @@ export function rankAndCapInventory(elements, buttonCap = 25, oauthProviders) {
|
|
|
15367
13674
|
buttonsDropped: Math.max(0, ranked.length - keptButtons.length),
|
|
15368
13675
|
};
|
|
15369
13676
|
}
|
|
13677
|
+
export { canSelfLaunchWithProxy, captureOwnedChromeProcessTreeProof, childProcessIsRunning, closeBrowserContextWithin, closeLocalBrowserLaunch, isProxyReachable, isSelfManagedChromeTerminationSignalExitEnabled, launchCancellablePersistentContext, ownedChromeProcessTreeState, parseEgressGeo, parseProxyUrl, persistentProxyOptions, proxyDefaultPort, proxyHasCredentials, registerLocalBrowserLaunch, resolveAttachedProfileChildIdentity, resolveChannelBinary, resolveExplicitProxy, resolvePersistentFallbackIdentity, selfLaunchEnabled, setSelfManagedChromeTerminationSignalExitEnabled, signalOwnedChromeProcessTree, synchronizeSelfManagedChromeTerminationSignalHandlers, terminateTrackedProfileChild, waitForOwnedDevtoolsEndpoint, withChromeStartupLock, } from "./browser-process-runtime.js";
|
|
15370
13678
|
//# sourceMappingURL=browser.js.map
|