@trusty-squire/mcp 1.1.13 → 1.1.14-rc.2

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