@trusty-squire/mcp 1.1.13 → 1.1.14-rc.1
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 +10 -7
- 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 +22 -193
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +203 -2162
- package/dist/bot/browser.js.map +1 -1
- package/dist/bot/identity-runtime.d.ts +55 -0
- package/dist/bot/identity-runtime.d.ts.map +1 -0
- package/dist/bot/identity-runtime.js +132 -0
- package/dist/bot/identity-runtime.js.map +1 -0
- package/dist/bot/owned-pages.d.ts +14 -0
- package/dist/bot/owned-pages.d.ts.map +1 -0
- package/dist/bot/owned-pages.js +53 -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/session/lifecycle.d.ts.map +1 -1
- package/dist/bot/session/lifecycle.js +49 -11
- package/dist/bot/session/lifecycle.js.map +1 -1
- package/dist/tools/provision-drive.d.ts +12 -12
- package/package.json +1 -1
package/dist/bot/browser.js
CHANGED
|
@@ -21,84 +21,14 @@
|
|
|
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
|
-
// stealth as best-effort — a missing dep should never crash the bot.
|
|
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 { BrowserProcessOwner } from "./browser-process-owner.js";
|
|
29
|
+
import { bindOwnerBrowserLaunch, untrackOwnerBrowserLaunch } from "./owner-process-reaper.js";
|
|
30
|
+
import { PageDriver } from "./page-driver.js";
|
|
31
|
+
import { clearStaleSingletonLock, profileProcessIdentity, reapProfileHolderIfOwned, signalProfileProcess, } from "./profile.js";
|
|
102
32
|
export function contextInitScriptsFor(options) {
|
|
103
33
|
if (options.hardened)
|
|
104
34
|
return [];
|
|
@@ -108,81 +38,6 @@ export function contextInitScriptsFor(options) {
|
|
|
108
38
|
...(options.remoteMode ? [] : ["webgl-spoof"]),
|
|
109
39
|
];
|
|
110
40
|
}
|
|
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
41
|
export class BrowserClickDispatchError extends Error {
|
|
187
42
|
dispatchStatus;
|
|
188
43
|
constructor(dispatchStatus, error) {
|
|
@@ -1920,612 +1775,6 @@ function pngDimensions(buf) {
|
|
|
1920
1775
|
}
|
|
1921
1776
|
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
1922
1777
|
}
|
|
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
1778
|
// Classify an anti-bot interstitial page from its (title + body) text.
|
|
2530
1779
|
// `onInterstitial` matches the static Cloudflare/Turnstile challenge copy.
|
|
2531
1780
|
// `verificationPassed` is the signal the challenge SUCCEEDED — but
|
|
@@ -2569,14 +1818,6 @@ export function stripCloudflareChallengeParams(rawUrl) {
|
|
|
2569
1818
|
}
|
|
2570
1819
|
return changed ? u.toString() : null;
|
|
2571
1820
|
}
|
|
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
1821
|
// The signal that quits the plain login browser.
|
|
2581
1822
|
//
|
|
2582
1823
|
// It MUST NOT be SIGTERM. Chrome routes SIGTERM to its "session ending" path,
|
|
@@ -2762,73 +2003,62 @@ export async function launchPlainLoginBrowser(params) {
|
|
|
2762
2003
|
};
|
|
2763
2004
|
}
|
|
2764
2005
|
export class BrowserController {
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2006
|
+
get context() {
|
|
2007
|
+
return this.processOwner.context;
|
|
2008
|
+
}
|
|
2009
|
+
set context(value) {
|
|
2010
|
+
this.processOwner.context = value;
|
|
2011
|
+
}
|
|
2012
|
+
get page() {
|
|
2013
|
+
return this.pageDriver.page;
|
|
2014
|
+
}
|
|
2015
|
+
set page(value) {
|
|
2016
|
+
this.pageDriver.page = value;
|
|
2017
|
+
}
|
|
2018
|
+
get primaryPage() {
|
|
2019
|
+
return this.pageDriver.primaryPage;
|
|
2020
|
+
}
|
|
2021
|
+
set primaryPage(value) {
|
|
2022
|
+
this.pageDriver.primaryPage = value;
|
|
2023
|
+
}
|
|
2024
|
+
get oauthProductPage() {
|
|
2025
|
+
return this.pageDriver.oauthProductPage;
|
|
2026
|
+
}
|
|
2027
|
+
set oauthProductPage(value) {
|
|
2028
|
+
this.pageDriver.oauthProductPage = value;
|
|
2029
|
+
}
|
|
2030
|
+
get oauthProviderPage() {
|
|
2031
|
+
return this.pageDriver.oauthProviderPage;
|
|
2032
|
+
}
|
|
2033
|
+
set oauthProviderPage(value) {
|
|
2034
|
+
this.pageDriver.oauthProviderPage = value;
|
|
2035
|
+
}
|
|
2036
|
+
get oauthProviderPageClosed() {
|
|
2037
|
+
return this.pageDriver.oauthProviderPageClosed;
|
|
2038
|
+
}
|
|
2039
|
+
set oauthProviderPageClosed(value) {
|
|
2040
|
+
this.pageDriver.oauthProviderPageClosed = value;
|
|
2041
|
+
}
|
|
2042
|
+
get harnessAttachedPage() {
|
|
2043
|
+
return this.pageDriver.harnessAttachedPage;
|
|
2044
|
+
}
|
|
2045
|
+
set harnessAttachedPage(value) {
|
|
2046
|
+
this.pageDriver.harnessAttachedPage = value;
|
|
2047
|
+
}
|
|
2048
|
+
get ownedPages() {
|
|
2049
|
+
return this.pageDriver.ownedPages;
|
|
2050
|
+
}
|
|
2768
2051
|
checkoutCardGroupScope;
|
|
2769
2052
|
checkoutOutcomeBaseline;
|
|
2770
2053
|
paymentInstrumentExpectation;
|
|
2771
2054
|
observedPaymentInstrumentMismatch;
|
|
2772
2055
|
checkoutSubmitSequence = 0;
|
|
2773
2056
|
clickDispatchSequence = 0;
|
|
2774
|
-
mainDocumentSequence = 0;
|
|
2775
|
-
mainDocumentIdentities = new WeakMap();
|
|
2776
|
-
trackedMainDocumentPages = new WeakSet();
|
|
2777
|
-
// The page start() configured with the controller's navigation/captcha
|
|
2778
|
-
// handlers. OAuth may temporarily switch `this.page` to a popup, but session
|
|
2779
|
-
// reuse must always restore this original page rather than adopting a popup
|
|
2780
|
-
// whose lifecycle handlers were never installed.
|
|
2781
|
-
primaryPage = null;
|
|
2782
|
-
// Tabs the PAGE opened (target=_blank / window.open) since the operator armed
|
|
2783
|
-
// adoption for an action, oldest first. A real user lands on the tab their
|
|
2784
|
-
// click opened — an email magic-link button in Gmail is the case this exists
|
|
2785
|
-
// for — so the operator has to follow it too. Reading the link's href instead
|
|
2786
|
-
// is not an option: a single-use login token is sealed and must never be
|
|
2787
|
-
// handed to the model as text.
|
|
2788
|
-
openedTabs = [];
|
|
2789
|
-
// Self-launch path (Turnstile-safe; see selfLaunchEnabled). When we spawn
|
|
2790
|
-
// Chrome ourselves and attach over CDP, these hold the child process and
|
|
2791
|
-
// the connected Browser so close() can tear both down.
|
|
2792
|
-
childChrome = null;
|
|
2793
|
-
childChromeIdentity = null;
|
|
2794
|
-
childChromeProcessGroup = false;
|
|
2795
|
-
ownedDisplayRig = null;
|
|
2796
|
-
ownedChromeProcessTreeProof = null;
|
|
2797
|
-
operatorProcessMarker = null;
|
|
2798
|
-
ownerLaunchTracked = false;
|
|
2799
|
-
cdpBrowser = null;
|
|
2800
|
-
// True once a local browser context launched this session.
|
|
2801
|
-
launchedContext = false;
|
|
2802
|
-
launchedProfileHolderIdentity = null;
|
|
2803
|
-
startPromise = null;
|
|
2804
|
-
closePromise = null;
|
|
2805
|
-
startCancellationRequested = false;
|
|
2806
|
-
startLaunchCommitted = false;
|
|
2807
|
-
startSettled = false;
|
|
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
2057
|
humanize;
|
|
2817
2058
|
// Tracks the simulated mouse position so successive clicks can move
|
|
2818
2059
|
// along a continuous path (humans don't teleport between clicks).
|
|
2819
2060
|
mouseX = 100;
|
|
2820
2061
|
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
2062
|
// Optional live provider of the session's current allowed hosts, used by the
|
|
2833
2063
|
// fail-fast request-scope guard. Set by provision-session once a session
|
|
2834
2064
|
// exists so the guard auto-scopes same-registrable-domain merchant API
|
|
@@ -2899,679 +2129,19 @@ export class BrowserController {
|
|
|
2899
2129
|
catch {
|
|
2900
2130
|
await route.fallback().catch(() => undefined);
|
|
2901
2131
|
}
|
|
2902
|
-
});
|
|
2903
|
-
}
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
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;
|
|
3039
|
-
}
|
|
3040
|
-
// Which browser channel the most recent .start() actually used.
|
|
3041
|
-
// `null` means bundled Chromium; a string like "chrome" means a
|
|
3042
|
-
// real installed browser of that channel. Throws if .start() hasn't
|
|
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()");
|
|
3047
|
-
}
|
|
3048
|
-
return this.launchedChannel;
|
|
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()");
|
|
3056
|
-
}
|
|
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
|
-
}
|
|
3213
|
-
// Resource blocking for speed (BOT_BLOCK_RESOURCES, default OFF). Aborts
|
|
3214
|
-
// image/media/font requests + known analytics/tracker hosts to cut page-load
|
|
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() {
|
|
3227
|
-
const ctx = this.context;
|
|
3228
|
-
if (ctx === null)
|
|
3229
|
-
return;
|
|
3230
|
-
if (!/^(1|true|on)$/i.test(process.env.BOT_BLOCK_RESOURCES ?? ""))
|
|
3231
|
-
return;
|
|
3232
|
-
const BLOCK_TYPES = new Set(["image", "media", "font"]);
|
|
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)");
|
|
3284
|
-
}
|
|
3285
|
-
async start() {
|
|
3286
|
-
if (this.profileDir.length === 0) {
|
|
3287
|
-
throw new Error("BrowserController.start requires a per-session profile directory");
|
|
3288
|
-
}
|
|
3289
|
-
if (this.closePromise !== null)
|
|
3290
|
-
throw new Error("BrowserController is already closing");
|
|
3291
|
-
this.startPromise ??= this.startOnce();
|
|
3292
|
-
await this.startPromise;
|
|
3293
|
-
}
|
|
3294
|
-
async startOnce() {
|
|
3295
|
-
const remoteMode = (process.env.BOT_CDP_ENDPOINT ?? "").trim().length > 0;
|
|
3296
|
-
if (!remoteMode)
|
|
3297
|
-
startGlobalOperatorBrowserProcessWatchdog();
|
|
3298
|
-
try {
|
|
3299
|
-
await this.startBrowser();
|
|
3300
|
-
if (this.startCancellationRequested) {
|
|
3301
|
-
await this.closeBrowser();
|
|
3302
|
-
throw new Error("BrowserController start cancelled");
|
|
3303
|
-
}
|
|
3304
|
-
}
|
|
3305
|
-
catch (err) {
|
|
3306
|
-
await this.teardownOwnedDisplay().catch(() => undefined);
|
|
3307
|
-
if (this.startCancellationRequested && this.persistentFallbackCancellationState === null) {
|
|
3308
|
-
await this.closeBrowser().catch(() => undefined);
|
|
3309
|
-
}
|
|
3310
|
-
throw err;
|
|
3311
|
-
}
|
|
3312
|
-
finally {
|
|
3313
|
-
this.startSettled = true;
|
|
3314
|
-
}
|
|
3315
|
-
}
|
|
3316
|
-
throwIfStartCancelled() {
|
|
3317
|
-
if (this.startCancellationRequested)
|
|
3318
|
-
throw new Error("BrowserController start cancelled");
|
|
3319
|
-
}
|
|
3320
|
-
commitProfileLaunch() {
|
|
3321
|
-
this.throwIfStartCancelled();
|
|
3322
|
-
this.startLaunchCommitted = true;
|
|
3323
|
-
}
|
|
3324
|
-
async startBrowser() {
|
|
3325
|
-
this.throwIfStartCancelled();
|
|
3326
|
-
const channel = await detectChromiumChannel();
|
|
3327
|
-
this.throwIfStartCancelled();
|
|
3328
|
-
this.launchedChannel = channel;
|
|
3329
|
-
const proxy = await this.resolveProxy();
|
|
3330
|
-
this.throwIfStartCancelled();
|
|
3331
|
-
this.proxyServer = proxy?.server ?? null;
|
|
3332
|
-
// Stderr so the MCP stdio transport's framing stays clean (the
|
|
3333
|
-
// module's existing logging convention).
|
|
3334
|
-
console.error(`[operator] launching browser channel=${channel ?? "bundled-chromium"} ` +
|
|
3335
|
-
`proxy=${proxy === null ? "direct" : "configured"}`);
|
|
3336
|
-
// Remote-CDP mode (BOT_CDP_ENDPOINT): the browser runs on a REMOTE host
|
|
3337
|
-
// (e.g. a Mac with a real GPU + residential egress) and we attach over CDP
|
|
3338
|
-
// across Tailscale. The remote machine IS a real device, so we spoof
|
|
3339
|
-
// NOTHING — no WebGL/device fingerprint patch (a fake-Intel string over a
|
|
3340
|
-
// real Apple-GPU output would be its own mismatch tell), no local display, no
|
|
3341
|
-
// egress-geo override (the remote host's real timezone + residential IP are
|
|
3342
|
-
// authentic). software-WebGL output is exactly what the toughest anti-bot
|
|
3343
|
-
// (hCaptcha Enterprise) scores; only real hardware fixes the pixel
|
|
3344
|
-
// fingerprint, which is the whole point of this path.
|
|
3345
|
-
const remoteMode = (process.env.BOT_CDP_ENDPOINT ?? "").trim().length > 0;
|
|
3346
|
-
if (remoteMode) {
|
|
3347
|
-
console.error(`[operator] REMOTE-CDP mode — attaching to ${(process.env.BOT_CDP_ENDPOINT ?? "").trim()} ` +
|
|
3348
|
-
`(real-host GPU + egress; local fingerprint spoof + display setup disabled)`);
|
|
3349
|
-
}
|
|
3350
|
-
if (!remoteMode && !this.ownerLaunchTracked) {
|
|
3351
|
-
registerLocalBrowserLaunch(this.profileDir, process.env, this.operatorBrowserMarker());
|
|
3352
|
-
this.ownerLaunchTracked = true;
|
|
3353
|
-
}
|
|
3354
|
-
const browserEnv = remoteMode ? process.env : await this.ownedHeadedBrowserEnvironment();
|
|
3355
|
-
// T3.1: probe where this run's traffic actually exits so the
|
|
3356
|
-
// browser's declared timezone matches its egress IP (a US-timezone
|
|
3357
|
-
// browser on a foreign proxy IP is itself an anti-bot signal).
|
|
3358
|
-
// Done before the real launch: launchPersistentContext bakes the
|
|
3359
|
-
// timezone in at creation, with no way to set it afterward. Skipped in
|
|
3360
|
-
// remote mode — the remote host's own clock/IP are the authentic truth.
|
|
3361
|
-
const geo = remoteMode ? null : await this.probeEgressGeo(channel, proxy, browserEnv);
|
|
3362
|
-
this.throwIfStartCancelled();
|
|
3363
|
-
if (geo !== null) {
|
|
3364
|
-
console.error(`[operator] egress geo: timezone=${geo.timezoneId}` +
|
|
3365
|
-
(geo.geolocation !== undefined
|
|
3366
|
-
? ` loc=${geo.geolocation.latitude},${geo.geolocation.longitude}`
|
|
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
|
-
}
|
|
2132
|
+
});
|
|
2133
|
+
}
|
|
2134
|
+
get launchMode() {
|
|
2135
|
+
return this.processOwner.launchMode;
|
|
2136
|
+
}
|
|
2137
|
+
processOwner;
|
|
2138
|
+
pageDriver;
|
|
2139
|
+
constructor(opts = {}) {
|
|
2140
|
+
this.humanize = opts.humanize ?? true;
|
|
2141
|
+
this.pageDriver = new PageDriver(() => this.processOwner.context, this.humanize);
|
|
2142
|
+
this.processOwner = new BrowserProcessOwner(opts, this.pageDriver, (context, hardened, remoteMode) => this.initializePages(context, hardened, remoteMode));
|
|
2143
|
+
}
|
|
2144
|
+
async initializePages(context, hardened, remoteMode) {
|
|
3575
2145
|
// Speed: optionally abort heavy/irrelevant requests before any navigation.
|
|
3576
2146
|
await this.installResourceBlocking();
|
|
3577
2147
|
// Dev-runtime guard: when the bot is run through `tsx`, esbuild may inject
|
|
@@ -3703,11 +2273,8 @@ export class BrowserController {
|
|
|
3703
2273
|
if (contextInitScripts.includes("webgl-spoof")) {
|
|
3704
2274
|
await context.addInitScript({ content: installWebglSpoofScript });
|
|
3705
2275
|
}
|
|
3706
|
-
for (const page of context.pages())
|
|
3707
|
-
this.trackMainDocument(page);
|
|
3708
|
-
this.trackOpenedTabs(context);
|
|
3709
2276
|
this.page = context.pages()[0] ?? (await context.newPage());
|
|
3710
|
-
this.
|
|
2277
|
+
this.trackOpenedTabs(this.page);
|
|
3711
2278
|
this.primaryPage = this.page;
|
|
3712
2279
|
// In baseline mode addInitScript covers document-start page JS, but
|
|
3713
2280
|
// Playwright's page.evaluate utility execution can run in a separate realm.
|
|
@@ -3829,74 +2396,118 @@ export class BrowserController {
|
|
|
3829
2396
|
});
|
|
3830
2397
|
}
|
|
3831
2398
|
}
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
2399
|
+
trackMainDocument(page) {
|
|
2400
|
+
return this.pageDriver.trackMainDocument(page);
|
|
2401
|
+
}
|
|
2402
|
+
mainDocumentIdentity() {
|
|
2403
|
+
return this.pageDriver.mainDocumentIdentity();
|
|
2404
|
+
}
|
|
2405
|
+
/** Attach normal controller behavior to a harness-owned Playwright page. */
|
|
2406
|
+
static fromHarnessPage(page) {
|
|
2407
|
+
const controller = new BrowserController({ humanize: false });
|
|
2408
|
+
controller.processOwner.context = page.context();
|
|
2409
|
+
controller.page = page;
|
|
2410
|
+
controller.primaryPage = page;
|
|
2411
|
+
controller.trackOpenedTabs(page);
|
|
2412
|
+
controller.harnessAttachedPage = true;
|
|
2413
|
+
controller.processOwner.launchedMode = "headless";
|
|
2414
|
+
return controller;
|
|
2415
|
+
}
|
|
2416
|
+
trackOpenedTabs(page) {
|
|
2417
|
+
return this.pageDriver.trackOpenedTabs(page);
|
|
2418
|
+
}
|
|
2419
|
+
operatorBrowserMarker() {
|
|
2420
|
+
return this.processOwner.operatorBrowserMarker();
|
|
2421
|
+
}
|
|
2422
|
+
isConnected() {
|
|
2423
|
+
return this.processOwner.isConnected();
|
|
2424
|
+
}
|
|
2425
|
+
get channel() {
|
|
2426
|
+
return this.processOwner.channel;
|
|
2427
|
+
}
|
|
2428
|
+
get proxied() {
|
|
2429
|
+
return this.processOwner.proxied;
|
|
2430
|
+
}
|
|
2431
|
+
get stealthProfile() {
|
|
2432
|
+
return this.processOwner.stealthProfile;
|
|
2433
|
+
}
|
|
2434
|
+
// Resource blocking for speed (BOT_BLOCK_RESOURCES, default OFF). Aborts
|
|
2435
|
+
// image/media/font requests + known analytics/tracker hosts to cut page-load
|
|
2436
|
+
// wall-clock (3-5x on byte-heavy pages; also stops trackers from holding the
|
|
2437
|
+
// network "busy"). HARD ALLOW-GUARD first for captcha/challenge + payment
|
|
2438
|
+
// scripts (blocking those breaks the Turnstile/hCaptcha token poll and the
|
|
2439
|
+
// signup form). CSS + first-party JS are never blocked (not in BLOCK_TYPES) —
|
|
2440
|
+
// the SPA form renders from them and the vision planner reads the styled
|
|
2441
|
+
// render. DUAL RISK, hence default-OFF + an OF#2 A/B before flipping on:
|
|
2442
|
+
// (1) a browser that loads ZERO images is itself an anti-bot fingerprint;
|
|
2443
|
+
// (2) the screenshot the vision planner reads loses detail — mitigated
|
|
2444
|
+
// because the DOM inventory is the authoritative action space, but
|
|
2445
|
+
// still a regression risk on image-only affordances.
|
|
2446
|
+
// Registered on the CONTEXT so it covers OAuth popups + iframes.
|
|
2447
|
+
async installResourceBlocking() {
|
|
2448
|
+
const ctx = this.context;
|
|
2449
|
+
if (ctx === null)
|
|
2450
|
+
return;
|
|
2451
|
+
if (!/^(1|true|on)$/i.test(process.env.BOT_BLOCK_RESOURCES ?? ""))
|
|
2452
|
+
return;
|
|
2453
|
+
const BLOCK_TYPES = new Set(["image", "media", "font"]);
|
|
2454
|
+
const BLOCK_HOSTS = [
|
|
2455
|
+
"google-analytics.com",
|
|
2456
|
+
"googletagmanager.com",
|
|
2457
|
+
"analytics.google.com",
|
|
2458
|
+
"doubleclick.net",
|
|
2459
|
+
"static.hotjar.com",
|
|
2460
|
+
"script.hotjar.com",
|
|
2461
|
+
"segment.com",
|
|
2462
|
+
"segment.io",
|
|
2463
|
+
"cdn.segment.com",
|
|
2464
|
+
"fullstory.com",
|
|
2465
|
+
"mixpanel.com",
|
|
2466
|
+
"bugsnag.com",
|
|
2467
|
+
"intercom.io",
|
|
2468
|
+
"intercomcdn.com",
|
|
2469
|
+
"widget.intercom.io",
|
|
2470
|
+
"connect.facebook.net",
|
|
2471
|
+
"analytics.tiktok.com",
|
|
2472
|
+
"clarity.ms",
|
|
2473
|
+
"cdn.heapanalytics.com",
|
|
2474
|
+
"wistia.com",
|
|
2475
|
+
];
|
|
2476
|
+
// NEVER block — these break signup (captcha/challenge widgets + payment SDK).
|
|
2477
|
+
const ALWAYS_ALLOW = [
|
|
2478
|
+
"challenges.cloudflare.com",
|
|
2479
|
+
"turnstile",
|
|
2480
|
+
"hcaptcha.com",
|
|
2481
|
+
"newassets.hcaptcha.com",
|
|
2482
|
+
"recaptcha",
|
|
2483
|
+
"gstatic.com/recaptcha",
|
|
2484
|
+
"js.stripe.com",
|
|
2485
|
+
];
|
|
2486
|
+
await ctx.route("**/*", async (route) => {
|
|
3839
2487
|
try {
|
|
3840
|
-
const
|
|
3841
|
-
if (
|
|
3842
|
-
|
|
3843
|
-
|
|
2488
|
+
const url = route.request().url();
|
|
2489
|
+
if (ALWAYS_ALLOW.some((h) => url.includes(h))) {
|
|
2490
|
+
await route.continue();
|
|
2491
|
+
return;
|
|
2492
|
+
}
|
|
2493
|
+
const type = route.request().resourceType();
|
|
2494
|
+
if (BLOCK_TYPES.has(type) || BLOCK_HOSTS.some((h) => url.includes(h))) {
|
|
2495
|
+
await route.abort();
|
|
2496
|
+
return;
|
|
2497
|
+
}
|
|
2498
|
+
await route.continue();
|
|
3844
2499
|
}
|
|
3845
|
-
catch
|
|
3846
|
-
|
|
3847
|
-
`timezone: ${err instanceof Error ? err.message : String(err)}`);
|
|
3848
|
-
return null;
|
|
2500
|
+
catch {
|
|
2501
|
+
// Routing race / already-handled — never let a decision crash nav.
|
|
3849
2502
|
}
|
|
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
|
-
}
|
|
2503
|
+
});
|
|
2504
|
+
console.error("[operator] resource blocking ON (image/media/font + analytics aborted; captcha/CSS/JS allowed)");
|
|
3877
2505
|
}
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
// geo-gated flow from the wrong country, so malformed or unreachable values
|
|
3881
|
-
// abort startup rather than silently egressing directly.
|
|
3882
|
-
async resolveProxy() {
|
|
3883
|
-
if (this.proxyOverride === null)
|
|
3884
|
-
return null;
|
|
3885
|
-
return resolveExplicitProxy(this.proxyOverride);
|
|
2506
|
+
async start() {
|
|
2507
|
+
return await this.processOwner.start();
|
|
3886
2508
|
}
|
|
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
2509
|
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
|
-
}
|
|
2510
|
+
return await this.pageDriver.reload();
|
|
3900
2511
|
}
|
|
3901
2512
|
// Open the first conversation in a Gmail search-results list so the email
|
|
3902
2513
|
// BODY renders. The results LIST only carries snippets + Gmail chrome links —
|
|
@@ -3932,106 +2543,7 @@ export class BrowserController {
|
|
|
3932
2543
|
return false;
|
|
3933
2544
|
}
|
|
3934
2545
|
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
|
-
}
|
|
2546
|
+
return await this.pageDriver.goto(url);
|
|
4035
2547
|
}
|
|
4036
2548
|
// Pre-warm a domain by visiting its root. Useful before navigating
|
|
4037
2549
|
// to a deep signup URL on a strict-Cloudflare service: the root sets
|
|
@@ -13412,12 +11924,11 @@ export class BrowserController {
|
|
|
13412
11924
|
}
|
|
13413
11925
|
this.oauthProviderPage = null;
|
|
13414
11926
|
this.oauthProviderPageClosed = false;
|
|
13415
|
-
//
|
|
13416
|
-
|
|
13417
|
-
const popupPromise = this.context.waitForEvent("page", { timeout: 8000 }).catch(() => null);
|
|
11927
|
+
// Only the current page's creation-attributed popup can carry this handshake.
|
|
11928
|
+
const popupPromise = this.page.waitForEvent("popup", { timeout: 8000 }).catch(() => null);
|
|
13418
11929
|
await this.click(selector);
|
|
13419
11930
|
const popup = await popupPromise;
|
|
13420
|
-
if (popup !== null && popup !== this.page &&
|
|
11931
|
+
if (popup !== null && popup !== this.page && this.ownedPages.has(popup)) {
|
|
13421
11932
|
this.page = popup;
|
|
13422
11933
|
this.oauthProviderPage = popup;
|
|
13423
11934
|
// A provider returning from OAuth is allowed to close its own popup.
|
|
@@ -13479,6 +11990,7 @@ export class BrowserController {
|
|
|
13479
11990
|
product.on("framenavigated", onProductNavigation);
|
|
13480
11991
|
try {
|
|
13481
11992
|
recovery = await context.newPage();
|
|
11993
|
+
this.trackOpenedTabs(recovery);
|
|
13482
11994
|
await recovery.goto(productUrl, {
|
|
13483
11995
|
waitUntil: "domcontentloaded",
|
|
13484
11996
|
timeout: remainingBudgetMs(),
|
|
@@ -13488,10 +12000,18 @@ export class BrowserController {
|
|
|
13488
12000
|
resolvePopup = resolve;
|
|
13489
12001
|
});
|
|
13490
12002
|
const onPopup = (page) => {
|
|
13491
|
-
|
|
12003
|
+
if (!this.ownedPages.has(page))
|
|
12004
|
+
return;
|
|
12005
|
+
product.off("popup", onPopup);
|
|
13492
12006
|
resolvePopup(page);
|
|
13493
12007
|
};
|
|
13494
|
-
|
|
12008
|
+
const onProductClose = () => {
|
|
12009
|
+
product.off("popup", onPopup);
|
|
12010
|
+
product.off("framenavigated", onProductNavigation);
|
|
12011
|
+
resolvePopup(null);
|
|
12012
|
+
};
|
|
12013
|
+
product.on("popup", onPopup);
|
|
12014
|
+
product.once("close", onProductClose);
|
|
13495
12015
|
try {
|
|
13496
12016
|
if (Date.now() >= oauthDeadline)
|
|
13497
12017
|
throw deadlineError();
|
|
@@ -13512,7 +12032,8 @@ export class BrowserController {
|
|
|
13512
12032
|
]);
|
|
13513
12033
|
}
|
|
13514
12034
|
finally {
|
|
13515
|
-
|
|
12035
|
+
product.off("popup", onPopup);
|
|
12036
|
+
product.off("close", onProductClose);
|
|
13516
12037
|
resolvePopup(null);
|
|
13517
12038
|
resolveProductDeparture();
|
|
13518
12039
|
}
|
|
@@ -13806,8 +12327,8 @@ export class BrowserController {
|
|
|
13806
12327
|
cdp = null; // FedCm domain unavailable — the popup path still works
|
|
13807
12328
|
console.error(`[operator] FedCm.enable failed (${err instanceof Error ? err.message : String(err)}) — FedCM path disabled, relying on popup`);
|
|
13808
12329
|
}
|
|
13809
|
-
const popupPromise = this.
|
|
13810
|
-
.waitForEvent("
|
|
12330
|
+
const popupPromise = this.page
|
|
12331
|
+
.waitForEvent("popup", { timeout: timeoutMs })
|
|
13811
12332
|
.then((p) => p)
|
|
13812
12333
|
.catch(() => null);
|
|
13813
12334
|
await this.click(triggerSelector);
|
|
@@ -13855,7 +12376,7 @@ export class BrowserController {
|
|
|
13855
12376
|
// best-effort
|
|
13856
12377
|
}
|
|
13857
12378
|
}
|
|
13858
|
-
if (popup !== null && popup !== this.page &&
|
|
12379
|
+
if (popup !== null && popup !== this.page && this.ownedPages.has(popup)) {
|
|
13859
12380
|
this.page = popup;
|
|
13860
12381
|
try {
|
|
13861
12382
|
await this.page.waitForLoadState("domcontentloaded", { timeout: 15_000 });
|
|
@@ -13880,91 +12401,20 @@ export class BrowserController {
|
|
|
13880
12401
|
`fedcmResolved=${fedcmResolved} pages=${this.context.pages().length}`);
|
|
13881
12402
|
return { ok: false, via: "none" };
|
|
13882
12403
|
}
|
|
13883
|
-
// URL of the active page (the OAuth page mid-handshake, the product
|
|
13884
|
-
// page otherwise). Cheap — no screenshot, unlike getState().
|
|
13885
12404
|
currentUrl() {
|
|
13886
|
-
return this.
|
|
12405
|
+
return this.pageDriver.currentUrl();
|
|
13887
12406
|
}
|
|
13888
12407
|
recoverActivePage() {
|
|
13889
|
-
return this.
|
|
12408
|
+
return this.pageDriver.recoverActivePage();
|
|
13890
12409
|
}
|
|
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
12410
|
armOpenedTabAdoption() {
|
|
13897
|
-
this.
|
|
12411
|
+
return this.pageDriver.armOpenedTabAdoption();
|
|
13898
12412
|
}
|
|
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
12413
|
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;
|
|
12414
|
+
return await this.pageDriver.adoptOpenedTab(graceMs);
|
|
13951
12415
|
}
|
|
13952
12416
|
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;
|
|
12417
|
+
return this.pageDriver.adoptLivePage();
|
|
13968
12418
|
}
|
|
13969
12419
|
// Press a keyboard key (e.g. "Escape" to dismiss a focus-trapped modal that
|
|
13970
12420
|
// exposes no in-DOM close control). Best-effort. Used by the nav-search
|
|
@@ -14202,6 +12652,8 @@ export class BrowserController {
|
|
|
14202
12652
|
return null;
|
|
14203
12653
|
let identityPage = null;
|
|
14204
12654
|
try {
|
|
12655
|
+
// Deliberately unregistered: this identity probe (and its popups) must
|
|
12656
|
+
// never become the session's working page.
|
|
14205
12657
|
identityPage = await this.context.newPage();
|
|
14206
12658
|
const identityUrl = new URL("https://myaccount.google.com/");
|
|
14207
12659
|
const expectedEmail = expectedGoogleAccountEmail?.trim();
|
|
@@ -14621,287 +13073,13 @@ export class BrowserController {
|
|
|
14621
13073
|
}
|
|
14622
13074
|
}
|
|
14623
13075
|
async close(options = {}) {
|
|
14624
|
-
|
|
14625
|
-
this.startCancellationRequested = true;
|
|
14626
|
-
this.resolveStartCancellation?.();
|
|
14627
|
-
this.resolveStartCancellation = null;
|
|
14628
|
-
}
|
|
14629
|
-
this.closePromise ??= this.closeAfterStart();
|
|
14630
|
-
return await this.closePromise;
|
|
13076
|
+
return await this.processOwner.close(options);
|
|
14631
13077
|
}
|
|
14632
13078
|
async waitForCancelledStartQuiescence() {
|
|
14633
|
-
|
|
14634
|
-
return;
|
|
14635
|
-
await Promise.allSettled([
|
|
14636
|
-
this.startPromise ?? Promise.resolve(),
|
|
14637
|
-
this.reapCancelledStartProcess(),
|
|
14638
|
-
]);
|
|
14639
|
-
await this.persistentFallbackOwnershipMonitor?.catch(() => undefined);
|
|
13079
|
+
return await this.processOwner.waitForCancelledStartQuiescence();
|
|
14640
13080
|
}
|
|
14641
13081
|
async forceCloseOwnedProcessTree() {
|
|
14642
|
-
this.
|
|
14643
|
-
this.resolveStartCancellation?.();
|
|
14644
|
-
this.resolveStartCancellation = null;
|
|
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;
|
|
13082
|
+
return await this.processOwner.forceCloseOwnedProcessTree();
|
|
14905
13083
|
}
|
|
14906
13084
|
}
|
|
14907
13085
|
// Random integer in [min, max]. We use Math.random() (not crypto)
|
|
@@ -15064,144 +13242,6 @@ export function isSafeSignupChoiceText(text) {
|
|
|
15064
13242
|
!AGREEMENT_TEXT_RE.test(text) &&
|
|
15065
13243
|
!MARKETING_TEXT_RE.test(text));
|
|
15066
13244
|
}
|
|
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
13245
|
// T38 — pure clustering logic. Identifies card-radio groups from a
|
|
15206
13246
|
// flat list of inventory candidates: each candidate carries its
|
|
15207
13247
|
// parent's identity (an integer assigned in DOM-walk order) plus
|
|
@@ -15367,4 +13407,5 @@ export function rankAndCapInventory(elements, buttonCap = 25, oauthProviders) {
|
|
|
15367
13407
|
buttonsDropped: Math.max(0, ranked.length - keptButtons.length),
|
|
15368
13408
|
};
|
|
15369
13409
|
}
|
|
13410
|
+
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
13411
|
//# sourceMappingURL=browser.js.map
|