@trusty-squire/mcp 1.1.13-rc.8 → 1.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +139 -62
- package/assets/login/vnc-input.js +9 -0
- package/assets/login/vnc.html +214 -202
- package/assets/screencaps/connect-walkthrough.png +0 -0
- package/assets/screencaps/connect-walkthrough.svg +1 -1
- package/dist/api-client.d.ts +26 -3
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +19 -31
- package/dist/api-client.js.map +1 -1
- package/dist/bin.js +2 -3
- package/dist/bin.js.map +1 -1
- package/dist/bot/browser.d.ts +134 -77
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +1788 -1543
- package/dist/bot/browser.js.map +1 -1
- package/dist/bot/compact-observation-v2.d.ts +167 -0
- package/dist/bot/compact-observation-v2.d.ts.map +1 -0
- package/dist/bot/compact-observation-v2.js +1038 -0
- package/dist/bot/compact-observation-v2.js.map +1 -0
- package/dist/bot/credential-shape.d.ts +2 -0
- package/dist/bot/credential-shape.d.ts.map +1 -1
- package/dist/bot/credential-shape.js +21 -8
- package/dist/bot/credential-shape.js.map +1 -1
- package/dist/bot/element-fingerprint.d.ts +9 -0
- package/dist/bot/element-fingerprint.d.ts.map +1 -0
- package/dist/bot/element-fingerprint.js +147 -0
- package/dist/bot/element-fingerprint.js.map +1 -0
- package/dist/bot/email-verification.d.ts +7 -1
- package/dist/bot/email-verification.d.ts.map +1 -1
- package/dist/bot/email-verification.js +48 -14
- package/dist/bot/email-verification.js.map +1 -1
- package/dist/bot/google-login.d.ts +28 -52
- package/dist/bot/google-login.d.ts.map +1 -1
- package/dist/bot/google-login.js +246 -1119
- package/dist/bot/google-login.js.map +1 -1
- package/dist/bot/install-completion.d.ts +1 -2
- package/dist/bot/install-completion.d.ts.map +1 -1
- package/dist/bot/install-completion.js +1 -2
- package/dist/bot/install-completion.js.map +1 -1
- package/dist/bot/login-state.d.ts +13 -7
- package/dist/bot/login-state.d.ts.map +1 -1
- package/dist/bot/login-state.js +50 -114
- package/dist/bot/login-state.js.map +1 -1
- package/dist/bot/oauth-providers.d.ts +1 -1
- package/dist/bot/oauth-providers.d.ts.map +1 -1
- package/dist/bot/oauth-providers.js +3 -3
- package/dist/bot/oauth-providers.js.map +1 -1
- package/dist/bot/oauth-scope.d.ts +4 -0
- package/dist/bot/oauth-scope.d.ts.map +1 -0
- package/dist/bot/oauth-scope.js +58 -0
- package/dist/bot/oauth-scope.js.map +1 -0
- package/dist/bot/operator-browser-watchdog.d.ts +127 -0
- package/dist/bot/operator-browser-watchdog.d.ts.map +1 -0
- package/dist/bot/operator-browser-watchdog.js +438 -0
- package/dist/bot/operator-browser-watchdog.js.map +1 -0
- package/dist/bot/owner-process-reaper-worker.d.ts +2 -0
- package/dist/bot/owner-process-reaper-worker.d.ts.map +1 -0
- package/dist/bot/owner-process-reaper-worker.js +17 -0
- package/dist/bot/owner-process-reaper-worker.js.map +1 -0
- package/dist/bot/owner-process-reaper.d.ts +103 -0
- package/dist/bot/owner-process-reaper.d.ts.map +1 -0
- package/dist/bot/owner-process-reaper.js +1058 -0
- package/dist/bot/owner-process-reaper.js.map +1 -0
- package/dist/bot/pay-operator.d.ts +13 -2
- package/dist/bot/pay-operator.d.ts.map +1 -1
- package/dist/bot/pay-operator.js +319 -123
- package/dist/bot/pay-operator.js.map +1 -1
- package/dist/bot/profile.d.ts +10 -1
- package/dist/bot/profile.d.ts.map +1 -1
- package/dist/bot/profile.js +137 -11
- package/dist/bot/profile.js.map +1 -1
- package/dist/bot/promote-to-skill.js +1 -1
- package/dist/bot/promote-to-skill.js.map +1 -1
- package/dist/bot/provision-session.d.ts +60 -164
- package/dist/bot/provision-session.d.ts.map +1 -1
- package/dist/bot/provision-session.js +2049 -1546
- package/dist/bot/provision-session.js.map +1 -1
- package/dist/bot/remote-login-display.d.ts +61 -0
- package/dist/bot/remote-login-display.d.ts.map +1 -0
- package/dist/bot/remote-login-display.js +765 -0
- package/dist/bot/remote-login-display.js.map +1 -0
- package/dist/bot/session/hosts.d.ts +8 -0
- package/dist/bot/session/hosts.d.ts.map +1 -0
- package/dist/bot/session/hosts.js +36 -0
- package/dist/bot/session/hosts.js.map +1 -0
- package/dist/bot/session/lifecycle.d.ts +56 -0
- package/dist/bot/session/lifecycle.d.ts.map +1 -0
- package/dist/bot/session/lifecycle.js +962 -0
- package/dist/bot/session/lifecycle.js.map +1 -0
- package/dist/bot/session/model.d.ts +175 -0
- package/dist/bot/session/model.d.ts.map +1 -0
- package/dist/bot/session/model.js +77 -0
- package/dist/bot/session/model.js.map +1 -0
- package/dist/install/cli.d.ts +29 -9
- package/dist/install/cli.d.ts.map +1 -1
- package/dist/install/cli.js +243 -322
- package/dist/install/cli.js.map +1 -1
- package/dist/install/interactive.d.ts +1 -0
- package/dist/install/interactive.d.ts.map +1 -1
- package/dist/install/interactive.js +19 -5
- package/dist/install/interactive.js.map +1 -1
- package/dist/install/ui.d.ts.map +1 -1
- package/dist/install/ui.js +3 -15
- package/dist/install/ui.js.map +1 -1
- package/dist/operator-env.js +1 -1
- package/dist/operator-env.js.map +1 -1
- package/dist/server-instance-registry.d.ts +103 -0
- package/dist/server-instance-registry.d.ts.map +1 -0
- package/dist/server-instance-registry.js +392 -0
- package/dist/server-instance-registry.js.map +1 -0
- package/dist/server.d.ts +15 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +164 -62
- package/dist/server.js.map +1 -1
- package/dist/session-guard.d.ts +28 -0
- package/dist/session-guard.d.ts.map +1 -0
- package/dist/session-guard.js +84 -0
- package/dist/session-guard.js.map +1 -0
- package/dist/session.d.ts +57 -13
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +246 -127
- package/dist/session.js.map +1 -1
- package/dist/tools/audit-log.d.ts +16 -2
- package/dist/tools/audit-log.d.ts.map +1 -1
- package/dist/tools/audit-log.js +223 -13
- package/dist/tools/audit-log.js.map +1 -1
- package/dist/tools/audit-rollup.d.ts +80 -0
- package/dist/tools/audit-rollup.d.ts.map +1 -0
- package/dist/tools/audit-rollup.js +0 -0
- package/dist/tools/audit-rollup.js.map +1 -0
- package/dist/tools/fetch-credential.d.ts +37 -0
- package/dist/tools/fetch-credential.d.ts.map +1 -0
- package/dist/tools/fetch-credential.js +153 -0
- package/dist/tools/fetch-credential.js.map +1 -0
- package/dist/tools/index.d.ts +3 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +7 -3
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/operate-pay.d.ts +4 -4
- package/dist/tools/operate-pay.d.ts.map +1 -1
- package/dist/tools/operate-pay.js +163 -96
- package/dist/tools/operate-pay.js.map +1 -1
- package/dist/tools/provision-drive.d.ts +43 -35
- package/dist/tools/provision-drive.d.ts.map +1 -1
- package/dist/tools/provision-drive.js +268 -164
- package/dist/tools/provision-drive.js.map +1 -1
- package/dist/tools/use-credential.d.ts.map +1 -1
- package/dist/tools/use-credential.js +3 -2
- package/dist/tools/use-credential.js.map +1 -1
- package/package.json +4 -6
- package/dist/bot/operator-direct-identity.d.ts +0 -26
- package/dist/bot/operator-direct-identity.d.ts.map +0 -1
- package/dist/bot/operator-direct-identity.js +0 -120
- package/dist/bot/operator-direct-identity.js.map +0 -1
- package/dist/bot/operator-profile-pool.d.ts +0 -97
- package/dist/bot/operator-profile-pool.d.ts.map +0 -1
- package/dist/bot/operator-profile-pool.js +0 -855
- package/dist/bot/operator-profile-pool.js.map +0 -1
- package/dist/bot/xvfb.d.ts +0 -13
- package/dist/bot/xvfb.d.ts.map +0 -1
- package/dist/bot/xvfb.js +0 -146
- package/dist/bot/xvfb.js.map +0 -1
package/dist/bot/browser.js
CHANGED
|
@@ -22,20 +22,83 @@
|
|
|
22
22
|
// captchas still need the click-and-wait pattern (the Tier 2 captcha
|
|
23
23
|
// gate).
|
|
24
24
|
import { chromium as baseChromium } from "playwright";
|
|
25
|
-
import sharp from "sharp";
|
|
26
25
|
import { createRequire } from "node:module";
|
|
27
|
-
import { Socket
|
|
28
|
-
import { existsSync, mkdirSync, rmSync, statSync } from "node:fs";
|
|
29
|
-
import {
|
|
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";
|
|
30
31
|
import { isSameRecipeDomain } from "@trusty-squire/recipe-schema";
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
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";
|
|
34
35
|
// Lazy registration: installing the plugin mutates the chromium singleton
|
|
35
36
|
// from playwright-extra so we only do it once per process. We require()
|
|
36
37
|
// the CJS modules lazily (the stealth toolchain only ships CJS) and treat
|
|
37
38
|
// stealth as best-effort — a missing dep should never crash the bot.
|
|
38
39
|
const require = createRequire(import.meta.url);
|
|
40
|
+
// Operator signup runs are deliberately headed. Google, Stytch, and Cloudflare
|
|
41
|
+
// routinely reject a headless Chrome even when it is otherwise self-launched.
|
|
42
|
+
const OPERATOR_BROWSER_HEADLESS = false;
|
|
43
|
+
export function registerLocalBrowserLaunch(profileDir, baseEnv = process.env, marker = createOperatorBrowserMarker()) {
|
|
44
|
+
trackOwnerBrowserLaunch(marker, profileDir);
|
|
45
|
+
return {
|
|
46
|
+
marker,
|
|
47
|
+
env: { ...baseEnv, [OPERATOR_BROWSER_MARKER_ENV]: marker },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export async function closeBrowserContextWithin(context, timeoutMs = 2_000) {
|
|
51
|
+
let timer;
|
|
52
|
+
const outcome = await Promise.race([
|
|
53
|
+
Promise.resolve()
|
|
54
|
+
.then(() => context.close())
|
|
55
|
+
.then(() => true, () => false),
|
|
56
|
+
new Promise((resolveTimeout) => {
|
|
57
|
+
timer = setTimeout(() => resolveTimeout(false), timeoutMs);
|
|
58
|
+
}),
|
|
59
|
+
]);
|
|
60
|
+
if (timer !== undefined)
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
return outcome;
|
|
63
|
+
}
|
|
64
|
+
function spawnLocalBrowser(binary, args, profileDir, options) {
|
|
65
|
+
const ownership = registerLocalBrowserLaunch(profileDir, options.env, options.marker);
|
|
66
|
+
try {
|
|
67
|
+
const child = spawn(binary, [...args], {
|
|
68
|
+
env: ownership.env,
|
|
69
|
+
stdio: options.stdio,
|
|
70
|
+
detached: options.detached,
|
|
71
|
+
});
|
|
72
|
+
localBrowserLaunchMarkers.set(child, ownership.marker);
|
|
73
|
+
child.once("exit", () => {
|
|
74
|
+
setTimeout(() => {
|
|
75
|
+
reconcileOwnerBrowserLaunchAfterLeaderExit(ownership.marker, profileDir);
|
|
76
|
+
}, 0).unref();
|
|
77
|
+
});
|
|
78
|
+
return child;
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
untrackOwnerBrowserLaunch(ownership.marker);
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const localBrowserLaunchMarkers = new WeakMap();
|
|
86
|
+
function markLocalBrowserLaunchTerminal(child) {
|
|
87
|
+
if (child === null)
|
|
88
|
+
return;
|
|
89
|
+
const marker = localBrowserLaunchMarkers.get(child);
|
|
90
|
+
if (marker !== undefined)
|
|
91
|
+
markOwnerBrowserLaunchTerminal(marker);
|
|
92
|
+
}
|
|
93
|
+
export async function closeLocalBrowserLaunch(marker, profileDir, runtime = {}) {
|
|
94
|
+
if (marker === undefined)
|
|
95
|
+
return;
|
|
96
|
+
(runtime.markTerminal ?? markOwnerBrowserLaunchTerminal)(marker);
|
|
97
|
+
if (!(await (runtime.terminate ?? terminateOwnerBrowserLaunch)(marker, profileDir))) {
|
|
98
|
+
throw new Error("local login browser closure unproven");
|
|
99
|
+
}
|
|
100
|
+
(runtime.untrack ?? untrackOwnerBrowserLaunch)(marker);
|
|
101
|
+
}
|
|
39
102
|
export function contextInitScriptsFor(options) {
|
|
40
103
|
if (options.hardened)
|
|
41
104
|
return [];
|
|
@@ -223,6 +286,34 @@ export class PaymentSubmitOutcomeUnknownError extends Error {
|
|
|
223
286
|
this.name = "PaymentSubmitOutcomeUnknownError";
|
|
224
287
|
}
|
|
225
288
|
}
|
|
289
|
+
export async function runCaptureConfirmedPaymentSubmit(options) {
|
|
290
|
+
let clickError;
|
|
291
|
+
let inputDispatchPossible = false;
|
|
292
|
+
try {
|
|
293
|
+
await options.click(() => {
|
|
294
|
+
inputDispatchPossible = true;
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
clickError = error;
|
|
299
|
+
}
|
|
300
|
+
if (clickError instanceof BrowserClickDispatchError &&
|
|
301
|
+
clickError.dispatchStatus === "not_dispatched") {
|
|
302
|
+
await options.clear();
|
|
303
|
+
throw clickError;
|
|
304
|
+
}
|
|
305
|
+
const evidence = await options.readEvidence();
|
|
306
|
+
await options.clear();
|
|
307
|
+
if (!evidence.dispatched) {
|
|
308
|
+
if (clickError !== undefined && !inputDispatchPossible)
|
|
309
|
+
throw clickError;
|
|
310
|
+
throw new PaymentSubmitOutcomeUnknownError();
|
|
311
|
+
}
|
|
312
|
+
options.onSubmitDispatched?.();
|
|
313
|
+
if (clickError !== undefined)
|
|
314
|
+
throw new PaymentSubmitOutcomeUnknownError();
|
|
315
|
+
return evidence.baseline;
|
|
316
|
+
}
|
|
226
317
|
const CHECKOUT_TERMINAL_RESERVED_SEGMENTS = new Set([
|
|
227
318
|
"about_blank",
|
|
228
319
|
"blank",
|
|
@@ -414,6 +505,22 @@ function checkoutUrlOrderIdentities(rawUrl) {
|
|
|
414
505
|
return null;
|
|
415
506
|
}
|
|
416
507
|
}
|
|
508
|
+
// Shopify's thank-you route keeps the checkout token in a nested path
|
|
509
|
+
// (`/checkouts/cn/<token>/<locale>/thank-you`), so it has no terminal order
|
|
510
|
+
// identity for checkoutUrlOrderIdentities to compare. Treat it as terminal
|
|
511
|
+
// only when the post-submit page also exposes unambiguous confirmation copy.
|
|
512
|
+
function isShopifyCheckoutThankYouRoute(rawUrl) {
|
|
513
|
+
try {
|
|
514
|
+
const segments = new URL(rawUrl).pathname
|
|
515
|
+
.split("/")
|
|
516
|
+
.filter(Boolean)
|
|
517
|
+
.map((segment) => decodeURIComponent(segment).toLowerCase().replace(/-/g, "_"));
|
|
518
|
+
return ((segments[0] === "checkout" || segments[0] === "checkouts") && segments.at(-1) === "thank_you");
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
417
524
|
// EbisuMart — a widely-deployed Japanese EC platform (the Hibiya Kadan
|
|
418
525
|
// checkout runs on it) — names its card fields CREDIT_NO / CREDIT_NAME /
|
|
419
526
|
// SECURITY_CD / CREDIT_LIMIT_MONTH / CREDIT_LIMIT_YEAR. PAN name/id and JP
|
|
@@ -532,36 +639,6 @@ const CHECKOUT_CARD_VALUE_FIELD_SELECTORS = [
|
|
|
532
639
|
CHECKOUT_CVV_FIELD_SELECTORS,
|
|
533
640
|
CHECKOUT_CARD_NAME_FIELD_SELECTORS,
|
|
534
641
|
].join(",");
|
|
535
|
-
// Defense-in-depth redaction set for operate_screenshot. The capture-scoped
|
|
536
|
-
// guard refuses nonempty secrets before pixels are read; these selectors also
|
|
537
|
-
// identify empty sensitive controls and provide rectangles that are composited
|
|
538
|
-
// over the captured bytes. The set mirrors the card fields
|
|
539
|
-
// fillCheckoutCardIntoFrames writes into, plus payment/type_secret markers and
|
|
540
|
-
// other secret-shaped inputs.
|
|
541
|
-
const SCREENSHOT_SECRET_FIELD_SELECTORS = [
|
|
542
|
-
'[data-ts-sealed-payment="1"]',
|
|
543
|
-
'input[type="password" i]',
|
|
544
|
-
'input[autocomplete~="one-time-code" i]',
|
|
545
|
-
'input[name*="otp" i]',
|
|
546
|
-
'input[id*="otp" i]',
|
|
547
|
-
'input[name*="pin" i]',
|
|
548
|
-
'input[id*="pin" i]',
|
|
549
|
-
].join(",");
|
|
550
|
-
const SCREENSHOT_REDACTION_SELECTORS = `${CHECKOUT_CARD_VALUE_FIELD_SELECTORS},${SCREENSHOT_SECRET_FIELD_SELECTORS}`;
|
|
551
|
-
function sealedElementSemanticKeys(descriptor) {
|
|
552
|
-
const clean = (value) => (value ?? "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
553
|
-
const tag = clean(descriptor.tag);
|
|
554
|
-
const type = clean(descriptor.type);
|
|
555
|
-
const landmark = clean(descriptor.landmark);
|
|
556
|
-
const label = clean(descriptor.labelText ?? descriptor.ariaLabel ?? descriptor.placeholder);
|
|
557
|
-
return Array.from(new Set([
|
|
558
|
-
clean(descriptor.testId) ? `test:${clean(descriptor.testId)}` : "",
|
|
559
|
-
clean(descriptor.id) ? `id:${clean(descriptor.id)}` : "",
|
|
560
|
-
clean(descriptor.name) ? `name:${tag}:${type}:${clean(descriptor.name)}` : "",
|
|
561
|
-
label ? `label:${landmark}:${tag}:${type}:${label}` : "",
|
|
562
|
-
`position:${landmark}:${tag}:${type}:${descriptor.ordinal}`,
|
|
563
|
-
].filter((key) => key.length > 0)));
|
|
564
|
-
}
|
|
565
642
|
// Charge-verb button labels — the click that may move money. Used by
|
|
566
643
|
// submitFilledCheckout to find the charge control, and by operate_act's
|
|
567
644
|
// pending-card-fill guard to recognize and cap caller-placed attempts while a
|
|
@@ -575,6 +652,112 @@ function sealedElementSemanticKeys(descriptor) {
|
|
|
575
652
|
// where a bare noun like 購入 would otherwise swallow navigation labels such
|
|
576
653
|
// as 購入手続きへ). 確定 (finalize) is deliberate — 確認 (review) must NOT match.
|
|
577
654
|
export const CHECKOUT_SUBMIT_LABEL_RE = /^(?:pay(?:\s+now)?|place\s+order|complete\s+(?:order|purchase|payment)|submit\s+payment|buy\s+now|confirm\s+(?:order|payment))\b|^ご?注文(?:内容)?[をの]?確定|^ご?注文する|^確定(?:する|$)|^購入(?:する|を確定|$)|^今すぐ(?:購入|注文|支払)|^支払う|^お?支払い(?:を確定|$)/i;
|
|
655
|
+
const CHECKOUT_PAYMENT_EXECUTION_PATHS = new Set([
|
|
656
|
+
"authorize",
|
|
657
|
+
"capture",
|
|
658
|
+
"charge",
|
|
659
|
+
"charges",
|
|
660
|
+
"completecheckout",
|
|
661
|
+
"completeorder",
|
|
662
|
+
"confirmpayment",
|
|
663
|
+
"orders",
|
|
664
|
+
"placeorder",
|
|
665
|
+
"purchase",
|
|
666
|
+
]);
|
|
667
|
+
const CHECKOUT_PAYMENT_EXECUTION_MUTATIONS = new Set([
|
|
668
|
+
"authorizepayment",
|
|
669
|
+
"capturepayment",
|
|
670
|
+
"completecheckout",
|
|
671
|
+
"completeorder",
|
|
672
|
+
"completepayment",
|
|
673
|
+
"confirmpayment",
|
|
674
|
+
"createcharge",
|
|
675
|
+
"placeorder",
|
|
676
|
+
"submitpayment",
|
|
677
|
+
]);
|
|
678
|
+
const CHECKOUT_PAYMENT_EXCLUDED_PATH_SEGMENTS = new Set([
|
|
679
|
+
"analytics",
|
|
680
|
+
"collect",
|
|
681
|
+
"events",
|
|
682
|
+
"logs",
|
|
683
|
+
"metrics",
|
|
684
|
+
"paymentmethods",
|
|
685
|
+
"setupintents",
|
|
686
|
+
"telemetry",
|
|
687
|
+
"tokenization",
|
|
688
|
+
"tokens",
|
|
689
|
+
"tracking",
|
|
690
|
+
]);
|
|
691
|
+
const CHECKOUT_PAYMENT_REQUEST_OBSERVATION_MS = 15_000;
|
|
692
|
+
function normalizedPaymentOperation(value) {
|
|
693
|
+
return value.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
694
|
+
}
|
|
695
|
+
function isCheckoutPaymentExecutionOperation(value) {
|
|
696
|
+
return CHECKOUT_PAYMENT_EXECUTION_MUTATIONS.has(normalizedPaymentOperation(value));
|
|
697
|
+
}
|
|
698
|
+
function collectGraphqlMutationCandidates(query, candidates) {
|
|
699
|
+
const mutation = /\bmutation\b(?:\s+([A-Za-z_][A-Za-z0-9_-]*))?(?:\s*\([^{}]*\))?(?:\s+@[^{]+)?\s*\{\s*(?:([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*)?([A-Za-z_][A-Za-z0-9_-]*)/gi;
|
|
700
|
+
for (const match of query.matchAll(mutation)) {
|
|
701
|
+
if (match[1] !== undefined)
|
|
702
|
+
candidates.push(match[1]);
|
|
703
|
+
if (match[3] !== undefined)
|
|
704
|
+
candidates.push(match[3]);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
function hasCheckoutPaymentExecutionPayload(request) {
|
|
708
|
+
const payload = request.postData();
|
|
709
|
+
if (payload === null)
|
|
710
|
+
return false;
|
|
711
|
+
const candidates = [];
|
|
712
|
+
try {
|
|
713
|
+
const parsed = JSON.parse(payload);
|
|
714
|
+
if (parsed === null || Array.isArray(parsed) || typeof parsed !== "object")
|
|
715
|
+
return false;
|
|
716
|
+
const record = parsed;
|
|
717
|
+
if (typeof record.operationName === "string")
|
|
718
|
+
candidates.push(record.operationName);
|
|
719
|
+
if (typeof record.query === "string")
|
|
720
|
+
collectGraphqlMutationCandidates(record.query, candidates);
|
|
721
|
+
}
|
|
722
|
+
catch {
|
|
723
|
+
const form = new URLSearchParams(payload);
|
|
724
|
+
const operationName = form.get("operationName");
|
|
725
|
+
if (operationName !== null)
|
|
726
|
+
candidates.push(operationName);
|
|
727
|
+
const query = form.get("query");
|
|
728
|
+
collectGraphqlMutationCandidates(query ?? payload, candidates);
|
|
729
|
+
}
|
|
730
|
+
return candidates.some(isCheckoutPaymentExecutionOperation);
|
|
731
|
+
}
|
|
732
|
+
function isCheckoutPaymentRequest(request) {
|
|
733
|
+
if (!["document", "fetch", "xhr"].includes(request.resourceType()))
|
|
734
|
+
return false;
|
|
735
|
+
const method = request.method().toUpperCase();
|
|
736
|
+
if (method === "GET" || method === "HEAD" || method === "OPTIONS")
|
|
737
|
+
return false;
|
|
738
|
+
try {
|
|
739
|
+
const segments = new URL(request.url()).pathname.split("/").filter(Boolean);
|
|
740
|
+
const normalizedSegments = segments.map(normalizedPaymentOperation);
|
|
741
|
+
if (normalizedSegments.some((segment) => CHECKOUT_PAYMENT_EXCLUDED_PATH_SEGMENTS.has(segment))) {
|
|
742
|
+
return false;
|
|
743
|
+
}
|
|
744
|
+
const lastSegment = segments.at(-1);
|
|
745
|
+
if (lastSegment !== undefined &&
|
|
746
|
+
CHECKOUT_PAYMENT_EXECUTION_PATHS.has(normalizedPaymentOperation(lastSegment))) {
|
|
747
|
+
return true;
|
|
748
|
+
}
|
|
749
|
+
if (lastSegment !== undefined &&
|
|
750
|
+
(normalizedPaymentOperation(lastSegment) === "confirm" ||
|
|
751
|
+
normalizedPaymentOperation(lastSegment) === "capture") &&
|
|
752
|
+
segments.some((segment) => normalizedPaymentOperation(segment) === "paymentintents")) {
|
|
753
|
+
return true;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
catch {
|
|
757
|
+
return false;
|
|
758
|
+
}
|
|
759
|
+
return hasCheckoutPaymentExecutionPayload(request);
|
|
760
|
+
}
|
|
578
761
|
export function checkoutSubmitLabel(signals) {
|
|
579
762
|
return (signals.ariaLabel || signals.inputValue || signals.textContent || "").trim();
|
|
580
763
|
}
|
|
@@ -1700,19 +1883,13 @@ export function extractGoogleAccountEmail(pageText) {
|
|
|
1700
1883
|
}
|
|
1701
1884
|
// Map a cookie jar to the OAuth providers that have a LIVE logged-in session.
|
|
1702
1885
|
// The auth cookies that mean "signed in": GitHub → `user_session`; Google →
|
|
1703
|
-
//
|
|
1704
|
-
// logged out, so they are deliberately NOT signals
|
|
1886
|
+
// a legacy *SID cookie. NID / CONSENT / 1P_JAR and the current account-chooser
|
|
1887
|
+
// family are set even when logged out, so they are deliberately NOT signals.
|
|
1888
|
+
// Host-scoped so a
|
|
1705
1889
|
// google.com cookie can't pass for github. Cookie NAMES + presence only;
|
|
1706
1890
|
// values are checked for non-triviality, never logged. Exported for tests.
|
|
1707
1891
|
export function sessionProvidersFromCookies(cookies) {
|
|
1708
|
-
const SIGNATURES = [
|
|
1709
|
-
{ provider: "github", host: /(^|\.)github\.com$/i, names: ["user_session"] },
|
|
1710
|
-
{
|
|
1711
|
-
provider: "google",
|
|
1712
|
-
host: /(^|\.)google\.com$/i,
|
|
1713
|
-
names: GOOGLE_LOGIN_COOKIE_MARKERS,
|
|
1714
|
-
},
|
|
1715
|
-
];
|
|
1892
|
+
const SIGNATURES = [{ provider: "github", host: /(^|\.)github\.com$/i, names: ["user_session"] }];
|
|
1716
1893
|
const live = [];
|
|
1717
1894
|
for (const sig of SIGNATURES) {
|
|
1718
1895
|
const present = cookies.some((c) => sig.host.test(c.domain.replace(/^\./, "")) &&
|
|
@@ -1721,6 +1898,11 @@ export function sessionProvidersFromCookies(cookies) {
|
|
|
1721
1898
|
if (present)
|
|
1722
1899
|
live.push(sig.provider);
|
|
1723
1900
|
}
|
|
1901
|
+
const googleSession = cookies.some((cookie) => /(^|\.)google\.com$/i.test(cookie.domain.replace(/^\./, "")) &&
|
|
1902
|
+
["__Secure-1PSID", "SID", "HSID", "SSID", "APISID", "SAPISID"].includes(cookie.name) &&
|
|
1903
|
+
cookie.value.length > 10);
|
|
1904
|
+
if (googleSession)
|
|
1905
|
+
live.push("google");
|
|
1724
1906
|
return live;
|
|
1725
1907
|
}
|
|
1726
1908
|
function isCaptchaVariant(v) {
|
|
@@ -1852,7 +2034,7 @@ export function resolveChannelBinary(channel) {
|
|
|
1852
2034
|
// Cloudflare Turnstile's interactive challenge FAILS a Playwright/patchright
|
|
1853
2035
|
// launchPersistentContext-driven Chrome and PASSES a Chrome the operator
|
|
1854
2036
|
// launches itself and then attaches to over CDP — every other variable held
|
|
1855
|
-
// constant (same box, same datacenter IP, same
|
|
2037
|
+
// constant (same box, same datacenter IP, same headed display, same Chrome 148
|
|
1856
2038
|
// binary, same software-WebGL, same humanized click). The discriminator
|
|
1857
2039
|
// matrix:
|
|
1858
2040
|
// launchPersistentContext + CDP click → "Verification failed"
|
|
@@ -1964,41 +2146,36 @@ export async function launchCancellablePersistentContext(opts) {
|
|
|
1964
2146
|
void lateCleanup;
|
|
1965
2147
|
return { status: "cancelled", closeState: "unknown" };
|
|
1966
2148
|
}
|
|
1967
|
-
|
|
1968
|
-
function
|
|
1969
|
-
|
|
1970
|
-
const srv = createServer();
|
|
1971
|
-
srv.on("error", reject);
|
|
1972
|
-
srv.listen(0, "127.0.0.1", () => {
|
|
1973
|
-
const addr = srv.address();
|
|
1974
|
-
const port = typeof addr === "object" && addr !== null ? addr.port : 0;
|
|
1975
|
-
srv.close(() => (port > 0 ? resolve(port) : reject(new Error("no port"))));
|
|
1976
|
-
});
|
|
1977
|
-
});
|
|
1978
|
-
}
|
|
1979
|
-
// Poll Chrome's DevTools HTTP endpoint until it answers (the browser is up
|
|
1980
|
-
// and accepting CDP), or the deadline passes. Returns the base endpoint URL
|
|
1981
|
-
// connectOverCDP accepts.
|
|
1982
|
-
async function waitForDevtools(port, deadlineMs, child) {
|
|
1983
|
-
const base = `http://127.0.0.1:${port}`;
|
|
2149
|
+
const DEVTOOLS_ACTIVE_PORT_FILE = "DevToolsActivePort";
|
|
2150
|
+
export async function waitForOwnedDevtoolsEndpoint(profileDir, deadlineMs, child) {
|
|
2151
|
+
const activePortPath = join(profileDir, DEVTOOLS_ACTIVE_PORT_FILE);
|
|
1984
2152
|
const deadline = Date.now() + deadlineMs;
|
|
1985
2153
|
let lastErr = "";
|
|
1986
2154
|
while (Date.now() < deadline) {
|
|
1987
|
-
if (
|
|
1988
|
-
throw new Error("Chrome exited before its DevTools endpoint became available");
|
|
2155
|
+
if (!childProcessIsRunning(child)) {
|
|
2156
|
+
throw new Error("Chrome exited before its owned DevTools endpoint became available");
|
|
1989
2157
|
}
|
|
1990
2158
|
try {
|
|
1991
|
-
const
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
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}`;
|
|
1995
2169
|
}
|
|
1996
|
-
catch (
|
|
1997
|
-
lastErr =
|
|
2170
|
+
catch (error) {
|
|
2171
|
+
lastErr = error instanceof Error ? error.message : String(error);
|
|
1998
2172
|
}
|
|
1999
|
-
await new Promise((
|
|
2173
|
+
await new Promise((resolveWait) => {
|
|
2174
|
+
const timer = setTimeout(resolveWait, 200);
|
|
2175
|
+
timer.unref();
|
|
2176
|
+
});
|
|
2000
2177
|
}
|
|
2001
|
-
throw new Error(`Chrome DevTools endpoint
|
|
2178
|
+
throw new Error(`Owned Chrome DevTools endpoint was not published (${lastErr})`);
|
|
2002
2179
|
}
|
|
2003
2180
|
export async function withChromeStartupLock(fn, opts = {}) {
|
|
2004
2181
|
const lockDir = opts.lockDir ?? "/tmp/trusty-squire-chrome-start.lock";
|
|
@@ -2037,13 +2214,13 @@ export async function withChromeStartupLock(fn, opts = {}) {
|
|
|
2037
2214
|
}
|
|
2038
2215
|
}
|
|
2039
2216
|
const selfManagedChromes = new Map();
|
|
2217
|
+
const ownedChromeProcessTrees = new Set();
|
|
2040
2218
|
let selfManagedCleanupInstalled = false;
|
|
2041
2219
|
let selfManagedTerminationSignalExitEnabled = true;
|
|
2042
|
-
let orphanVerifierReapRan = false;
|
|
2043
|
-
const orphanOperatorProfilesReaped = new Set();
|
|
2044
2220
|
function cleanupSelfManagedChromes() {
|
|
2045
|
-
for (const
|
|
2046
|
-
|
|
2221
|
+
for (const proof of ownedChromeProcessTrees) {
|
|
2222
|
+
signalOwnedChromeProcessTree(proof.identity, proof.processGroup, "SIGKILL", { proof });
|
|
2223
|
+
untrackOwnerProcess(proof.identity);
|
|
2047
2224
|
}
|
|
2048
2225
|
selfManagedChromes.clear();
|
|
2049
2226
|
}
|
|
@@ -2054,7 +2231,20 @@ const exitForSelfManagedSignal = (code) => {
|
|
|
2054
2231
|
const onSelfManagedSigint = () => exitForSelfManagedSignal(2);
|
|
2055
2232
|
const onSelfManagedSigterm = () => exitForSelfManagedSignal(15);
|
|
2056
2233
|
const onSelfManagedSighup = () => exitForSelfManagedSignal(1);
|
|
2057
|
-
|
|
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.
|
|
2058
2248
|
// False means another shutdown owner (the MCP server's disconnect coordinator,
|
|
2059
2249
|
// or an in-flight interactive login) holds process-exit responsibility.
|
|
2060
2250
|
export function isSelfManagedChromeTerminationSignalExitEnabled() {
|
|
@@ -2066,14 +2256,7 @@ export function setSelfManagedChromeTerminationSignalExitEnabled(enabled) {
|
|
|
2066
2256
|
selfManagedTerminationSignalExitEnabled = enabled;
|
|
2067
2257
|
if (!selfManagedCleanupInstalled)
|
|
2068
2258
|
return;
|
|
2069
|
-
|
|
2070
|
-
process.once("SIGINT", onSelfManagedSigint);
|
|
2071
|
-
process.once("SIGTERM", onSelfManagedSigterm);
|
|
2072
|
-
}
|
|
2073
|
-
else {
|
|
2074
|
-
process.removeListener("SIGINT", onSelfManagedSigint);
|
|
2075
|
-
process.removeListener("SIGTERM", onSelfManagedSigterm);
|
|
2076
|
-
}
|
|
2259
|
+
synchronizeSelfManagedChromeTerminationSignalHandlers(enabled);
|
|
2077
2260
|
}
|
|
2078
2261
|
function installSelfManagedChromeCleanup() {
|
|
2079
2262
|
if (selfManagedCleanupInstalled)
|
|
@@ -2081,28 +2264,47 @@ function installSelfManagedChromeCleanup() {
|
|
|
2081
2264
|
selfManagedCleanupInstalled = true;
|
|
2082
2265
|
process.once("exit", cleanupSelfManagedChromes);
|
|
2083
2266
|
if (selfManagedTerminationSignalExitEnabled) {
|
|
2084
|
-
|
|
2085
|
-
process.once("SIGTERM", onSelfManagedSigterm);
|
|
2267
|
+
synchronizeSelfManagedChromeTerminationSignalHandlers(true);
|
|
2086
2268
|
}
|
|
2087
|
-
process.once("SIGHUP", onSelfManagedSighup);
|
|
2088
2269
|
}
|
|
2089
|
-
function registerSelfManagedChrome(child, profileDir) {
|
|
2270
|
+
function registerSelfManagedChrome(child, profileDir, processGroup = false) {
|
|
2090
2271
|
installSelfManagedChromeCleanup();
|
|
2091
2272
|
const identity = child.pid === undefined ? null : profileProcessIdentity(child.pid, profileDir);
|
|
2092
|
-
if (identity !== null)
|
|
2093
|
-
|
|
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
|
+
}
|
|
2094
2284
|
child.once("exit", () => {
|
|
2095
|
-
if (child.pid
|
|
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);
|
|
2096
2292
|
selfManagedChromes.delete(child.pid);
|
|
2293
|
+
}
|
|
2097
2294
|
});
|
|
2098
2295
|
return identity;
|
|
2099
2296
|
}
|
|
2100
|
-
async function waitForTrackedProfileChildIdentity(child, profileDir, readIdentity, timeoutMs, pollMs) {
|
|
2297
|
+
async function waitForTrackedProfileChildIdentity(child, profileDir, readIdentity, timeoutMs, pollMs, processGroup = false) {
|
|
2101
2298
|
const deadline = Date.now() + timeoutMs;
|
|
2102
2299
|
while (childProcessIsRunning(child)) {
|
|
2103
2300
|
const identity = child.pid === undefined ? null : readIdentity(child.pid, profileDir);
|
|
2104
2301
|
if (identity !== null) {
|
|
2105
|
-
selfManagedChromes.
|
|
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 });
|
|
2106
2308
|
return identity;
|
|
2107
2309
|
}
|
|
2108
2310
|
if (Date.now() >= deadline)
|
|
@@ -2117,7 +2319,172 @@ async function waitForTrackedProfileChildIdentity(child, profileDir, readIdentit
|
|
|
2117
2319
|
export async function resolveAttachedProfileChildIdentity(child, profileDir, identity, options = {}) {
|
|
2118
2320
|
if (identity !== null || (options.platform ?? process.platform) !== "linux")
|
|
2119
2321
|
return identity;
|
|
2120
|
-
return await waitForTrackedProfileChildIdentity(child, profileDir, options.readIdentity ?? profileProcessIdentity, options.identityTimeoutMs ?? PROFILE_IDENTITY_PROOF_TIMEOUT_MS, options.identityPollMs ?? PROFILE_IDENTITY_POLL_MS);
|
|
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
|
+
}
|
|
2121
2488
|
}
|
|
2122
2489
|
export async function terminateTrackedProfileChild(child, profileDir, options = {}) {
|
|
2123
2490
|
const readIdentity = options.readIdentity ?? profileProcessIdentity;
|
|
@@ -2131,10 +2498,20 @@ export async function terminateTrackedProfileChild(child, profileDir, options =
|
|
|
2131
2498
|
if (identity === null && (options.platform ?? process.platform) !== "linux")
|
|
2132
2499
|
return null;
|
|
2133
2500
|
while (childProcessIsRunning(child)) {
|
|
2134
|
-
identity ??= await waitForTrackedProfileChildIdentity(child, profileDir, readIdentity, options.identityTimeoutMs ?? PROFILE_IDENTITY_PROOF_TIMEOUT_MS, options.identityPollMs ?? PROFILE_IDENTITY_POLL_MS);
|
|
2501
|
+
identity ??= await waitForTrackedProfileChildIdentity(child, profileDir, readIdentity, options.identityTimeoutMs ?? PROFILE_IDENTITY_PROOF_TIMEOUT_MS, options.identityPollMs ?? PROFILE_IDENTITY_POLL_MS, options.processGroup ?? false);
|
|
2135
2502
|
if (identity === null)
|
|
2136
2503
|
break;
|
|
2137
|
-
selfManagedChromes.
|
|
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
|
+
}
|
|
2138
2515
|
const terminated = terminate(identity, profileDir);
|
|
2139
2516
|
if (!terminated) {
|
|
2140
2517
|
identity = null;
|
|
@@ -2147,101 +2524,8 @@ export async function terminateTrackedProfileChild(child, profileDir, options =
|
|
|
2147
2524
|
});
|
|
2148
2525
|
}
|
|
2149
2526
|
}
|
|
2150
|
-
if (child.pid !== undefined)
|
|
2151
|
-
selfManagedChromes.delete(child.pid);
|
|
2152
2527
|
return identity;
|
|
2153
2528
|
}
|
|
2154
|
-
// Stale verifier/operator browsers are the expensive leak mode: if the MCP process dies
|
|
2155
|
-
// mid-verify, self-launched Chrome survives as PPID=1 with a
|
|
2156
|
-
// ~/.trusty-squire/profiles/verify-* or ~/.trusty-squire/chrome-profile
|
|
2157
|
-
// user-data-dir. It keeps the profile lock,
|
|
2158
|
-
// burns memory, and may leave defunct helper children. A live verifier should
|
|
2159
|
-
// never be parented to init, so these are safe to reap at the next browser
|
|
2160
|
-
// startup. Best-effort and Linux-only; failure must not block signups.
|
|
2161
|
-
export function matchesReapableBrowserArgs(args, profileDirs, includeVerifier) {
|
|
2162
|
-
return reapableBrowserProfile(args, profileDirs, includeVerifier) !== null;
|
|
2163
|
-
}
|
|
2164
|
-
function reapableBrowserProfile(args, profileDirs, includeVerifier) {
|
|
2165
|
-
if (!/(?:chrome|chromium)/i.test(args))
|
|
2166
|
-
return null;
|
|
2167
|
-
const configuredProfiles = new Map(profileDirs.map((profileDir) => [profileDir, profilePathIdentity(profileDir)]));
|
|
2168
|
-
const match = /--user-data-dir=(?:"([^"]+)"|'([^']+)'|([^\s]+))(?=\s|$)/.exec(args);
|
|
2169
|
-
const candidate = match?.[1] ?? match?.[2] ?? match?.[3];
|
|
2170
|
-
if (candidate !== undefined) {
|
|
2171
|
-
const identity = profilePathIdentity(candidate);
|
|
2172
|
-
if ([...configuredProfiles.values()].includes(identity))
|
|
2173
|
-
return identity;
|
|
2174
|
-
if (includeVerifier && /[/\\]\.trusty-squire[/\\]profiles[/\\]verify-[^/\\]+$/.test(identity)) {
|
|
2175
|
-
return identity;
|
|
2176
|
-
}
|
|
2177
|
-
}
|
|
2178
|
-
for (const [profileDir, identity] of configuredProfiles) {
|
|
2179
|
-
for (const exactPath of new Set([profileDir, identity])) {
|
|
2180
|
-
const variants = [exactPath, `"${exactPath}"`, `'${exactPath}'`];
|
|
2181
|
-
if (variants.some((variant) => {
|
|
2182
|
-
const option = `--user-data-dir=${variant}`;
|
|
2183
|
-
const offset = args.indexOf(option);
|
|
2184
|
-
return (offset >= 0 &&
|
|
2185
|
-
/\s|$/.test(args.slice(offset + option.length, offset + option.length + 1)));
|
|
2186
|
-
})) {
|
|
2187
|
-
return identity;
|
|
2188
|
-
}
|
|
2189
|
-
}
|
|
2190
|
-
}
|
|
2191
|
-
return null;
|
|
2192
|
-
}
|
|
2193
|
-
export function claimOrphanBrowserReapScope(profileDir) {
|
|
2194
|
-
const includeVerifier = !orphanVerifierReapRan;
|
|
2195
|
-
const profileDirs = [...new Set([CHROME_PROFILE_DIR, profileDir])].filter((candidate) => !orphanOperatorProfilesReaped.has(candidate));
|
|
2196
|
-
if (!includeVerifier && profileDirs.length === 0)
|
|
2197
|
-
return null;
|
|
2198
|
-
orphanVerifierReapRan = true;
|
|
2199
|
-
for (const candidate of profileDirs)
|
|
2200
|
-
orphanOperatorProfilesReaped.add(candidate);
|
|
2201
|
-
return { includeVerifier, profileDirs };
|
|
2202
|
-
}
|
|
2203
|
-
function reapOrphanedBrowsersOnce(profileDir) {
|
|
2204
|
-
if (process.platform !== "linux")
|
|
2205
|
-
return;
|
|
2206
|
-
const scope = claimOrphanBrowserReapScope(profileDir);
|
|
2207
|
-
if (scope === null)
|
|
2208
|
-
return;
|
|
2209
|
-
let rows = "";
|
|
2210
|
-
try {
|
|
2211
|
-
rows = execFileSync("ps", ["-eo", "pid=,ppid=,args="], {
|
|
2212
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
2213
|
-
}).toString("utf8");
|
|
2214
|
-
}
|
|
2215
|
-
catch {
|
|
2216
|
-
return;
|
|
2217
|
-
}
|
|
2218
|
-
const identities = [];
|
|
2219
|
-
for (const line of rows.split("\n")) {
|
|
2220
|
-
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
|
2221
|
-
if (match === null)
|
|
2222
|
-
continue;
|
|
2223
|
-
const pid = Number(match[1]);
|
|
2224
|
-
const ppid = Number(match[2]);
|
|
2225
|
-
const args = match[3] ?? "";
|
|
2226
|
-
const expectedProfile = reapableBrowserProfile(args, scope.profileDirs, scope.includeVerifier);
|
|
2227
|
-
if (Number.isFinite(pid) && ppid === 1 && expectedProfile !== null) {
|
|
2228
|
-
const identity = profileProcessIdentity(pid, expectedProfile);
|
|
2229
|
-
if (identity !== null)
|
|
2230
|
-
identities.push(identity);
|
|
2231
|
-
}
|
|
2232
|
-
}
|
|
2233
|
-
if (identities.length === 0)
|
|
2234
|
-
return;
|
|
2235
|
-
console.error(`[operator] reaping ${identities.length} orphaned Chrome process(es)`);
|
|
2236
|
-
for (const identity of identities) {
|
|
2237
|
-
signalProfileProcess(identity, identity.user_data_dir, "SIGTERM");
|
|
2238
|
-
}
|
|
2239
|
-
setTimeout(() => {
|
|
2240
|
-
for (const identity of identities) {
|
|
2241
|
-
signalProfileProcess(identity, identity.user_data_dir, "SIGKILL");
|
|
2242
|
-
}
|
|
2243
|
-
}, 2_000).unref();
|
|
2244
|
-
}
|
|
2245
2529
|
// Classify an anti-bot interstitial page from its (title + body) text.
|
|
2246
2530
|
// `onInterstitial` matches the static Cloudflare/Turnstile challenge copy.
|
|
2247
2531
|
// `verificationPassed` is the signal the challenge SUCCEEDED — but
|
|
@@ -2288,122 +2572,45 @@ export function stripCloudflareChallengeParams(rawUrl) {
|
|
|
2288
2572
|
export function childProcessIsRunning(child) {
|
|
2289
2573
|
return child !== null && child.exitCode === null && child.signalCode === null;
|
|
2290
2574
|
}
|
|
2291
|
-
export async function attachSelfManagedLoginContext(endpoint, child, profileDir, identity, options = {}) {
|
|
2292
|
-
let browser = null;
|
|
2293
|
-
try {
|
|
2294
|
-
browser = await (options.launcher ?? getChromium()).connectOverCDP(endpoint);
|
|
2295
|
-
const context = browser.contexts()[0];
|
|
2296
|
-
if (context === undefined) {
|
|
2297
|
-
throw new Error("self-launched login Chrome exposed no default browser context");
|
|
2298
|
-
}
|
|
2299
|
-
return { browser, context };
|
|
2300
|
-
}
|
|
2301
|
-
catch (error) {
|
|
2302
|
-
await browser?.close().catch(() => undefined);
|
|
2303
|
-
const terminateChild = options.terminateChild ??
|
|
2304
|
-
((ownedChild, ownedProfileDir, ownedIdentity) => terminateTrackedProfileChild(ownedChild, ownedProfileDir, {
|
|
2305
|
-
identity: ownedIdentity,
|
|
2306
|
-
}));
|
|
2307
|
-
await terminateChild(child, profileDir, identity);
|
|
2308
|
-
throw error;
|
|
2309
|
-
}
|
|
2310
|
-
}
|
|
2311
2575
|
function profileCollisionFromStderr(stderr) {
|
|
2312
2576
|
return /ProcessSingleton|SingletonLock|profile.*in use/i.test(stderr)
|
|
2313
2577
|
? new ProfileBusyError(PROFILE_BUSY_MESSAGE)
|
|
2314
2578
|
: null;
|
|
2315
2579
|
}
|
|
2316
|
-
//
|
|
2317
|
-
// `mcp login`), instead of Playwright's launchPersistentContext.
|
|
2318
|
-
//
|
|
2319
|
-
// This is the STATE.md 2026-06-12 finding — the same launcher tell that fails
|
|
2320
|
-
// Cloudflare Turnstile from a launchPersistentContext-driven Chrome and passes
|
|
2321
|
-
// from a self-launched one — ported to the login path. BrowserController (the
|
|
2322
|
-
// signup path) already migrated; the connect login was the last consumer of
|
|
2323
|
-
// the detectable launcher. Kept STANDALONE (not a BrowserController method) so
|
|
2324
|
-
// the working signup path is untouched.
|
|
2580
|
+
// The signal that quits the plain login browser.
|
|
2325
2581
|
//
|
|
2326
|
-
//
|
|
2327
|
-
//
|
|
2328
|
-
//
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
let chromeStderr = "";
|
|
2359
|
-
spawned.stderr?.on("data", (chunk) => {
|
|
2360
|
-
chromeStderr = (chromeStderr + chunk.toString("utf8")).slice(-4_000);
|
|
2361
|
-
});
|
|
2362
|
-
try {
|
|
2363
|
-
const endpoint = await waitForDevtools(port, 30_000, spawned);
|
|
2364
|
-
childIdentity = await resolveAttachedProfileChildIdentity(spawned, params.profileDir, childIdentity);
|
|
2365
|
-
if (process.platform === "linux" && childIdentity === null) {
|
|
2366
|
-
throw new Error("self-launched login Chrome exited before identity was proven");
|
|
2367
|
-
}
|
|
2368
|
-
return endpoint;
|
|
2369
|
-
}
|
|
2370
|
-
catch (err) {
|
|
2371
|
-
childIdentity = await terminateTrackedProfileChild(spawned, params.profileDir, {
|
|
2372
|
-
identity: childIdentity,
|
|
2373
|
-
});
|
|
2374
|
-
const detail = chromeStderr.trim();
|
|
2375
|
-
const collision = profileCollisionFromStderr(detail);
|
|
2376
|
-
if (collision !== null)
|
|
2377
|
-
throw collision;
|
|
2378
|
-
throw new Error(`${err instanceof Error ? err.message : String(err)}` +
|
|
2379
|
-
`${detail.length > 0 ? `; Chrome stderr: ${detail}` : ""}`);
|
|
2380
|
-
}
|
|
2381
|
-
}, { deadlineMs: 0 });
|
|
2382
|
-
if (child === null) {
|
|
2383
|
-
throw new Error("self-launched login Chrome lost its process handle");
|
|
2384
|
-
}
|
|
2385
|
-
const { browser, context } = await attachSelfManagedLoginContext(endpoint, child, params.profileDir, childIdentity);
|
|
2386
|
-
let torn = false;
|
|
2387
|
-
const teardown = async () => {
|
|
2388
|
-
if (torn)
|
|
2389
|
-
return;
|
|
2390
|
-
torn = true;
|
|
2391
|
-
// Disconnect the CDP browser first; over connectOverCDP this leaves the
|
|
2392
|
-
// real Chrome running, so kill the child explicitly (SIGTERM, then a
|
|
2393
|
-
// hard SIGKILL after a short grace) to avoid the zombie-chrome leak.
|
|
2394
|
-
await browser.close();
|
|
2395
|
-
if (child !== null && childIdentity !== null) {
|
|
2396
|
-
signalProfileProcess(childIdentity, params.profileDir, "SIGTERM");
|
|
2397
|
-
}
|
|
2398
|
-
};
|
|
2399
|
-
const forceTeardown = () => {
|
|
2400
|
-
if (childIdentity !== null) {
|
|
2401
|
-
signalProfileProcess(childIdentity, params.profileDir, "SIGKILL");
|
|
2402
|
-
selfManagedChromes.delete(childIdentity.pid);
|
|
2403
|
-
}
|
|
2404
|
-
reapProfileHolderIfOwned(params.profileDir, childIdentity);
|
|
2405
|
-
};
|
|
2406
|
-
return { context, teardown, forceTeardown, identity: childIdentity };
|
|
2582
|
+
// It MUST NOT be SIGTERM. Chrome routes SIGTERM to its "session ending" path,
|
|
2583
|
+
// which exits abruptly on the assumption the OS is tearing the machine down —
|
|
2584
|
+
// it does NOT flush the SQLite cookie store, and the store's own commit timer
|
|
2585
|
+
// is ~30s away. `connect` kills this browser within a couple of seconds of the
|
|
2586
|
+
// user finishing the Google OAuth dance, so a SIGTERM teardown discarded the
|
|
2587
|
+
// very session the ceremony existed to establish: the claim landed, the session
|
|
2588
|
+
// file was written, and the follow-up provider probe correctly reported "Google
|
|
2589
|
+
// not connected". SIGINT takes Chrome's graceful shutdown path, which flushes.
|
|
2590
|
+
// Measured on real Chrome 2026-09-04: cookie set 6s before the signal survives
|
|
2591
|
+
// SIGINT/SIGHUP and is lost on SIGTERM, deterministically, for both a bare pid
|
|
2592
|
+
// and a process-group signal.
|
|
2593
|
+
export const PLAIN_LOGIN_BROWSER_QUIT_SIGNAL = "SIGINT";
|
|
2594
|
+
// How long to let Chrome's graceful shutdown run before handing over to the
|
|
2595
|
+
// owner-launch reaper, whose own escalation starts at SIGTERM and would undo
|
|
2596
|
+
// the flush we just asked for.
|
|
2597
|
+
const PLAIN_LOGIN_BROWSER_QUIT_DEADLINE_MS = 10_000;
|
|
2598
|
+
// Quit the plain login browser and only THEN run the ownership-proving
|
|
2599
|
+
// teardown. Exported for tests: the ordering here is the fix, not an
|
|
2600
|
+
// implementation detail — `finalize` (the reaper) escalates SIGTERM →
|
|
2601
|
+
// SIGKILL, so running it while Chrome is still flushing reintroduces the
|
|
2602
|
+
// abrupt exit this signal choice exists to avoid.
|
|
2603
|
+
export async function quitPlainLoginBrowser(opts) {
|
|
2604
|
+
const pollMs = opts.pollMs ?? 25;
|
|
2605
|
+
const wait = opts.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
2606
|
+
// An undelivered quit has nothing to wait for — hand straight over to the
|
|
2607
|
+
// reaper rather than burning the grace window on a process we cannot signal.
|
|
2608
|
+
if (opts.signalQuit(PLAIN_LOGIN_BROWSER_QUIT_SIGNAL)) {
|
|
2609
|
+
const deadline = Date.now() + (opts.deadlineMs ?? PLAIN_LOGIN_BROWSER_QUIT_DEADLINE_MS);
|
|
2610
|
+
while (opts.isRunning() && Date.now() < deadline)
|
|
2611
|
+
await wait(pollMs);
|
|
2612
|
+
}
|
|
2613
|
+
await opts.finalize();
|
|
2407
2614
|
}
|
|
2408
2615
|
// Launch a TRULY PLAIN Chrome for the interactive connect claim — NO
|
|
2409
2616
|
// `--remote-debugging-port`, NO `connectOverCDP`, NO Playwright attach at all.
|
|
@@ -2416,93 +2623,146 @@ export async function launchSelfManagedLoginContext(params) {
|
|
|
2416
2623
|
// though the same CDP browser passes a DIRECT accounts.google.com sign-in. The
|
|
2417
2624
|
// tell is the CDP attachment itself (NOT the launcher, NOT the flags, NOT
|
|
2418
2625
|
// `navigator.webdriver` — all separately ruled out). The connect claim doesn't
|
|
2419
|
-
// need to drive the browser: the USER signs in
|
|
2420
|
-
// from the API (`installPoll`)
|
|
2421
|
-
//
|
|
2422
|
-
// only ever kill it — never attach.
|
|
2626
|
+
// need to drive the browser: the USER signs in interactively, completion comes
|
|
2627
|
+
// from the API (`installPoll`) plus its explicit Finish callback. So we spawn
|
|
2628
|
+
// Chrome and only ever kill it — never attach.
|
|
2423
2629
|
//
|
|
2424
2630
|
// Persistent profile is preserved (--user-data-dir=profileDir) so the Google/
|
|
2425
2631
|
// GitHub session still lands in the bot's profile for later signups.
|
|
2426
2632
|
export async function launchPlainLoginBrowser(params) {
|
|
2427
2633
|
let child = null;
|
|
2428
2634
|
let childIdentity = null;
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2635
|
+
// Login's plain browser is the no-CDP path used for Google sign-in. Reserve
|
|
2636
|
+
// its marker before launch so the rc.15 fail-closed reaper bind is legitimate.
|
|
2637
|
+
const ownership = registerLocalBrowserLaunch(params.profileDir, params.env);
|
|
2638
|
+
const launchMarker = ownership.marker;
|
|
2639
|
+
let spawned = false;
|
|
2640
|
+
try {
|
|
2641
|
+
await withChromeStartupLock(async () => {
|
|
2642
|
+
clearStaleSingletonLock(params.profileDir);
|
|
2643
|
+
const argv = [
|
|
2644
|
+
`--user-data-dir=${params.profileDir}`,
|
|
2645
|
+
"--no-first-run",
|
|
2646
|
+
"--no-default-browser-check",
|
|
2647
|
+
"--password-store=basic",
|
|
2648
|
+
"--window-position=0,0",
|
|
2649
|
+
`--window-size=${params.window.width},${params.window.height}`,
|
|
2650
|
+
"--lang=en-US",
|
|
2651
|
+
...(params.extraArgs ?? []),
|
|
2652
|
+
...(params.proxyServer !== null ? [`--proxy-server=${params.proxyServer}`] : []),
|
|
2653
|
+
`--app=${params.url}`,
|
|
2654
|
+
];
|
|
2655
|
+
const launched = spawnLocalBrowser(params.binary, argv, params.profileDir, {
|
|
2656
|
+
detached: process.platform !== "win32",
|
|
2657
|
+
env: ownership.env,
|
|
2658
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
2659
|
+
marker: launchMarker,
|
|
2660
|
+
});
|
|
2661
|
+
child = launched;
|
|
2662
|
+
spawned = true;
|
|
2663
|
+
let chromeStderr = "";
|
|
2664
|
+
launched.stderr?.on("data", (chunk) => {
|
|
2665
|
+
chromeStderr = (chromeStderr + chunk.toString("utf8")).slice(-4_000);
|
|
2666
|
+
});
|
|
2667
|
+
// Give Chrome a moment to actually come up (or die). Unlike the CDP path
|
|
2668
|
+
// there is no devtools endpoint to poll — but a crash-on-launch (bad
|
|
2669
|
+
// profile, missing lib) should surface here, not 15min later as a blank
|
|
2670
|
+
// browser. If the process is already dead, throw with its stderr.
|
|
2671
|
+
await new Promise((r) => setTimeout(r, 1_200));
|
|
2672
|
+
childIdentity ??=
|
|
2673
|
+
launched.pid === undefined
|
|
2674
|
+
? null
|
|
2675
|
+
: profileProcessIdentity(launched.pid, params.profileDir);
|
|
2676
|
+
childIdentity = await resolveAttachedProfileChildIdentity(launched, params.profileDir, childIdentity);
|
|
2677
|
+
childIdentity = registerSelfManagedChrome(launched, params.profileDir) ?? childIdentity;
|
|
2678
|
+
if (childIdentity !== null) {
|
|
2679
|
+
const existing = selfManagedChromes.get(childIdentity.pid);
|
|
2680
|
+
const proof = existing?.identity.start_time === childIdentity.start_time
|
|
2681
|
+
? existing.proof
|
|
2682
|
+
: trackOwnedChromeProcessTree(childIdentity, false);
|
|
2683
|
+
if (proof !== null) {
|
|
2684
|
+
selfManagedChromes.set(childIdentity.pid, {
|
|
2685
|
+
identity: childIdentity,
|
|
2686
|
+
processGroup: false,
|
|
2687
|
+
proof,
|
|
2688
|
+
});
|
|
2689
|
+
}
|
|
2690
|
+
// Bind the owner-launch anchor to the profile-proven child identity.
|
|
2691
|
+
// registerSelfManagedChrome only binds when it can read the marker back
|
|
2692
|
+
// from the process, but Chrome erases the marker from its own environ
|
|
2693
|
+
// when it rewrites process titles, so that bind is skipped here. Use
|
|
2694
|
+
// the marker we generated for THIS launch, keyed to the tracked launch
|
|
2695
|
+
// record, so teardown can trust this anchor and reap the marker-only
|
|
2696
|
+
// wrapper processes (the google-chrome launcher's stdout/stderr `cat`
|
|
2697
|
+
// relays and crashpad) instead of reporting closure unproven.
|
|
2698
|
+
bindOwnerBrowserLaunch(launchMarker, childIdentity);
|
|
2699
|
+
}
|
|
2700
|
+
if (!childProcessIsRunning(launched)) {
|
|
2701
|
+
reapProfileHolderIfOwned(params.profileDir, childIdentity);
|
|
2702
|
+
const detail = chromeStderr.trim();
|
|
2703
|
+
const collision = profileCollisionFromStderr(detail);
|
|
2704
|
+
if (collision !== null)
|
|
2705
|
+
throw collision;
|
|
2706
|
+
const termination = launched.exitCode !== null
|
|
2707
|
+
? `code ${launched.exitCode}`
|
|
2708
|
+
: `signal ${launched.signalCode ?? "unknown"}`;
|
|
2709
|
+
throw new Error(`plain login Chrome exited immediately (${termination})` +
|
|
2710
|
+
`${detail.length > 0 ? `; Chrome stderr: ${detail}` : ""}`);
|
|
2711
|
+
}
|
|
2712
|
+
if (process.platform === "linux" && childIdentity === null) {
|
|
2713
|
+
throw new Error("plain login Chrome identity could not be proven");
|
|
2714
|
+
}
|
|
2715
|
+
}, { deadlineMs: 0 });
|
|
2716
|
+
}
|
|
2717
|
+
catch (error) {
|
|
2718
|
+
if (!spawned)
|
|
2719
|
+
untrackOwnerBrowserLaunch(launchMarker);
|
|
2720
|
+
throw error;
|
|
2721
|
+
}
|
|
2722
|
+
let teardownPromise;
|
|
2480
2723
|
const forceTeardown = () => {
|
|
2724
|
+
markLocalBrowserLaunchTerminal(child);
|
|
2481
2725
|
if (childIdentity !== null) {
|
|
2482
|
-
|
|
2483
|
-
|
|
2726
|
+
const tracked = selfManagedChromes.get(childIdentity.pid);
|
|
2727
|
+
signalOwnedChromeProcessTree(childIdentity, false, "SIGKILL", {
|
|
2728
|
+
...(tracked === undefined ? {} : { proof: tracked.proof }),
|
|
2729
|
+
});
|
|
2730
|
+
}
|
|
2731
|
+
else if (childProcessIsRunning(child)) {
|
|
2732
|
+
child?.kill("SIGKILL");
|
|
2484
2733
|
}
|
|
2485
2734
|
reapProfileHolderIfOwned(params.profileDir, childIdentity);
|
|
2486
2735
|
};
|
|
2487
|
-
const teardown =
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2736
|
+
const teardown = () => {
|
|
2737
|
+
teardownPromise ??= (async () => {
|
|
2738
|
+
markLocalBrowserLaunchTerminal(child);
|
|
2739
|
+
await quitPlainLoginBrowser({
|
|
2740
|
+
signalQuit: (signal) => {
|
|
2741
|
+
if (child !== null && childIdentity !== null) {
|
|
2742
|
+
return signalProfileProcess(childIdentity, params.profileDir, signal);
|
|
2743
|
+
}
|
|
2744
|
+
if (childProcessIsRunning(child)) {
|
|
2745
|
+
child?.kill(signal);
|
|
2746
|
+
return true;
|
|
2747
|
+
}
|
|
2748
|
+
return false;
|
|
2749
|
+
},
|
|
2750
|
+
isRunning: () => childProcessIsRunning(child),
|
|
2751
|
+
finalize: async () => await closeLocalBrowserLaunch(launchMarker, params.profileDir),
|
|
2752
|
+
});
|
|
2753
|
+
})();
|
|
2754
|
+
return teardownPromise;
|
|
2494
2755
|
};
|
|
2495
2756
|
return {
|
|
2496
2757
|
teardown,
|
|
2497
2758
|
forceTeardown,
|
|
2498
2759
|
isRunning: () => childProcessIsRunning(child),
|
|
2499
2760
|
identity: childIdentity,
|
|
2761
|
+
marker: launchMarker,
|
|
2500
2762
|
};
|
|
2501
2763
|
}
|
|
2502
2764
|
export class BrowserController {
|
|
2503
|
-
//
|
|
2504
|
-
// rather than an ephemeral context so the profile carries the user's
|
|
2505
|
-
// Google session across runs — see profile.ts / google-login.ts.
|
|
2765
|
+
// A persistent browser context backed by the user's real Chrome profile.
|
|
2506
2766
|
context = null;
|
|
2507
2767
|
page = null;
|
|
2508
2768
|
checkoutCardGroupScope;
|
|
@@ -2511,31 +2771,43 @@ export class BrowserController {
|
|
|
2511
2771
|
observedPaymentInstrumentMismatch;
|
|
2512
2772
|
checkoutSubmitSequence = 0;
|
|
2513
2773
|
clickDispatchSequence = 0;
|
|
2514
|
-
|
|
2515
|
-
|
|
2774
|
+
mainDocumentSequence = 0;
|
|
2775
|
+
mainDocumentIdentities = new WeakMap();
|
|
2776
|
+
trackedMainDocumentPages = new WeakSet();
|
|
2516
2777
|
// The page start() configured with the controller's navigation/captcha
|
|
2517
|
-
// handlers. OAuth may temporarily switch `this.page` to a popup, but
|
|
2778
|
+
// handlers. OAuth may temporarily switch `this.page` to a popup, but session
|
|
2518
2779
|
// reuse must always restore this original page rather than adopting a popup
|
|
2519
2780
|
// whose lifecycle handlers were never installed.
|
|
2520
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 = [];
|
|
2521
2789
|
// Self-launch path (Turnstile-safe; see selfLaunchEnabled). When we spawn
|
|
2522
2790
|
// Chrome ourselves and attach over CDP, these hold the child process and
|
|
2523
2791
|
// the connected Browser so close() can tear both down.
|
|
2524
2792
|
childChrome = null;
|
|
2525
2793
|
childChromeIdentity = null;
|
|
2794
|
+
childChromeProcessGroup = false;
|
|
2795
|
+
ownedDisplayRig = null;
|
|
2796
|
+
ownedChromeProcessTreeProof = null;
|
|
2797
|
+
operatorProcessMarker = null;
|
|
2798
|
+
ownerLaunchTracked = false;
|
|
2526
2799
|
cdpBrowser = null;
|
|
2527
2800
|
// True once a local browser context launched this session.
|
|
2528
2801
|
launchedContext = false;
|
|
2529
2802
|
launchedProfileHolderIdentity = null;
|
|
2530
|
-
profileOperationLease = null;
|
|
2531
2803
|
startPromise = null;
|
|
2532
2804
|
closePromise = null;
|
|
2533
2805
|
startCancellationRequested = false;
|
|
2534
2806
|
startLaunchCommitted = false;
|
|
2535
2807
|
startSettled = false;
|
|
2536
2808
|
persistentFallbackLaunchInFlight = false;
|
|
2809
|
+
persistentFallbackOwnershipMonitor = null;
|
|
2537
2810
|
persistentFallbackCancellationState = null;
|
|
2538
|
-
profilePoolReusable = true;
|
|
2539
2811
|
resolveStartCancellation = null;
|
|
2540
2812
|
startCancellation = new Promise((resolveCancellation) => {
|
|
2541
2813
|
this.resolveStartCancellation = resolveCancellation;
|
|
@@ -2563,6 +2835,7 @@ export class BrowserController {
|
|
|
2563
2835
|
// siblings and fails-fast on genuinely out-of-scope in-page API calls. When
|
|
2564
2836
|
// null/never set (harness replay, non-session use) the guard is inert.
|
|
2565
2837
|
hostScopeAllowedHostsProvider = null;
|
|
2838
|
+
operationScopedAllowedHosts = new Map();
|
|
2566
2839
|
hostScopeGuardInstallation = null;
|
|
2567
2840
|
// Feed the current session's allowed hosts to the request-scope guard. Read
|
|
2568
2841
|
// lazily per request, so allow_host / auto-widen updates take effect without
|
|
@@ -2571,7 +2844,7 @@ export class BrowserController {
|
|
|
2571
2844
|
// sessions, never for harness/replay or non-session browsers.
|
|
2572
2845
|
async setHostScopeAllowedHosts(provider, siblingDomainProvider = provider) {
|
|
2573
2846
|
this.hostScopeAllowedHostsProvider = () => ({
|
|
2574
|
-
allowedHosts: provider(),
|
|
2847
|
+
allowedHosts: [...provider(), ...this.operationScopedAllowedHosts.keys()],
|
|
2575
2848
|
siblingDomainHosts: siblingDomainProvider(),
|
|
2576
2849
|
});
|
|
2577
2850
|
this.hostScopeGuardInstallation ??= this.installHostScopeGuard().catch((error) => {
|
|
@@ -2580,6 +2853,24 @@ export class BrowserController {
|
|
|
2580
2853
|
});
|
|
2581
2854
|
await this.hostScopeGuardInstallation;
|
|
2582
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
|
+
}
|
|
2583
2874
|
// Defect-A fail-fast request-scope guard. Only XHR/fetch subresource API
|
|
2584
2875
|
// calls are scope-guarded; page-load resources (scripts/styles/images/frames)
|
|
2585
2876
|
// always continue, so a legitimate render is never broken by the guard. A
|
|
@@ -2622,58 +2913,125 @@ export class BrowserController {
|
|
|
2622
2913
|
oauthProductPage = null;
|
|
2623
2914
|
oauthProviderPage = null;
|
|
2624
2915
|
oauthProviderPageClosed = false;
|
|
2625
|
-
//
|
|
2626
|
-
//
|
|
2627
|
-
// Stytch SSO callback fails to persist a session (cookie not set, FAPI
|
|
2628
|
-
// rejection, etc.) without guessing. Off by default; zero cost when unset.
|
|
2629
|
-
oauthNetLog = [];
|
|
2630
|
-
oauthNetListenerAttached = false;
|
|
2631
|
-
// F13 — on-demand Xvfb. Set when start() determined the host has no
|
|
2632
|
-
// display surface but Xvfb is available, so Chrome can run with
|
|
2633
|
-
// `headless: false` against a virtual display (Cloudflare/Stytch et
|
|
2634
|
-
// al. detect Chromium-headless and block their signup forms). Torn
|
|
2635
|
-
// down by close().
|
|
2636
|
-
xvfb = null;
|
|
2637
|
-
// F13 — which launch path start() took. Surfaced via .launchMode so
|
|
2638
|
-
// the agent can push it into the run's step trail and we can see
|
|
2639
|
-
// (from outside the box) whether the bot ran headed.
|
|
2916
|
+
// Surfaced in the run trail so operators can distinguish local headed,
|
|
2917
|
+
// remote, and headless launches.
|
|
2640
2918
|
launchedMode = "unknown";
|
|
2641
2919
|
get launchMode() {
|
|
2642
2920
|
return this.launchedMode;
|
|
2643
2921
|
}
|
|
2644
2922
|
constructor(opts = {}) {
|
|
2645
2923
|
this.humanize = opts.humanize ?? true;
|
|
2646
|
-
this.profileDir = opts.profileDir ??
|
|
2647
|
-
this.profileOperationLease = opts.profileOperationLease ?? null;
|
|
2924
|
+
this.profileDir = opts.profileDir ?? "";
|
|
2648
2925
|
this.proxyOverride =
|
|
2649
2926
|
opts.proxyUrl !== undefined && opts.proxyUrl.trim().length > 0 ? opts.proxyUrl.trim() : null;
|
|
2650
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
|
+
}
|
|
2651
2954
|
/** Attach normal controller behavior to a harness-owned Playwright page. */
|
|
2652
2955
|
static fromHarnessPage(page) {
|
|
2653
2956
|
const controller = new BrowserController({ humanize: false });
|
|
2654
2957
|
controller.context = page.context();
|
|
2655
2958
|
controller.page = page;
|
|
2656
2959
|
controller.primaryPage = page;
|
|
2960
|
+
controller.trackMainDocument(page);
|
|
2961
|
+
controller.trackOpenedTabs(page.context());
|
|
2657
2962
|
controller.harnessAttachedPage = true;
|
|
2658
2963
|
controller.launchedMode = "headless";
|
|
2659
2964
|
return controller;
|
|
2660
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
|
+
}
|
|
2661
2980
|
// Per-launch egress override. null means direct egress. Explicit overrides
|
|
2662
2981
|
// are never subject to host-network classification.
|
|
2663
2982
|
proxyOverride;
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
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
|
|
2677
3035
|
// sufficient signal: a dead CDP transport can leave stale JS objects behind.
|
|
2678
3036
|
isConnected() {
|
|
2679
3037
|
const browser = this.cdpBrowser ?? this.context?.browser() ?? null;
|
|
@@ -2740,13 +3098,12 @@ export class BrowserController {
|
|
|
2740
3098
|
}
|
|
2741
3099
|
return ctx;
|
|
2742
3100
|
}
|
|
2743
|
-
const endpoint = await
|
|
2744
|
-
this.throwIfStartCancelled();
|
|
2745
|
-
const port = await findFreePort();
|
|
3101
|
+
const endpoint = await (async () => {
|
|
2746
3102
|
this.throwIfStartCancelled();
|
|
2747
3103
|
clearStaleSingletonLock(this.profileDir);
|
|
3104
|
+
rmSync(join(this.profileDir, DEVTOOLS_ACTIVE_PORT_FILE), { force: true });
|
|
2748
3105
|
const argv = [
|
|
2749
|
-
|
|
3106
|
+
"--remote-debugging-port=0",
|
|
2750
3107
|
"--remote-debugging-address=127.0.0.1",
|
|
2751
3108
|
`--user-data-dir=${this.profileDir}`,
|
|
2752
3109
|
"--no-first-run",
|
|
@@ -2757,16 +3114,23 @@ export class BrowserController {
|
|
|
2757
3114
|
"--lang=en-US",
|
|
2758
3115
|
...params.args,
|
|
2759
3116
|
...(params.proxy !== null ? [`--proxy-server=${params.proxy.server}`] : []),
|
|
2760
|
-
...(params.headless ? ["--headless=new"] : []),
|
|
2761
3117
|
"about:blank",
|
|
2762
3118
|
];
|
|
2763
3119
|
this.commitProfileLaunch();
|
|
2764
|
-
const child =
|
|
3120
|
+
const child = spawnLocalBrowser(params.binary, argv, this.profileDir, {
|
|
2765
3121
|
env: params.env,
|
|
2766
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(),
|
|
2767
3127
|
});
|
|
2768
3128
|
this.childChrome = child;
|
|
2769
|
-
this.
|
|
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
|
+
}
|
|
2770
3134
|
let chromeStderr = "";
|
|
2771
3135
|
let chromeExit = "";
|
|
2772
3136
|
child.stderr?.on("data", (chunk) => {
|
|
@@ -2780,11 +3144,14 @@ export class BrowserController {
|
|
|
2780
3144
|
throw new Error("BrowserController start cancelled");
|
|
2781
3145
|
}
|
|
2782
3146
|
try {
|
|
2783
|
-
const endpoint = await
|
|
2784
|
-
this.childChromeIdentity = await resolveAttachedProfileChildIdentity(child, this.profileDir, this.childChromeIdentity);
|
|
3147
|
+
const endpoint = await waitForOwnedDevtoolsEndpoint(this.profileDir, 30_000, child);
|
|
3148
|
+
this.childChromeIdentity = await resolveAttachedProfileChildIdentity(child, this.profileDir, this.childChromeIdentity, { processGroup: this.childChromeProcessGroup });
|
|
2785
3149
|
if (process.platform === "linux" && this.childChromeIdentity === null) {
|
|
2786
3150
|
throw new Error("self-launched Chrome exited before identity was proven");
|
|
2787
3151
|
}
|
|
3152
|
+
if (this.childChromeIdentity !== null) {
|
|
3153
|
+
this.adoptOwnedChromeProcessTree(this.childChromeIdentity, this.childChromeProcessGroup);
|
|
3154
|
+
}
|
|
2788
3155
|
return endpoint;
|
|
2789
3156
|
}
|
|
2790
3157
|
catch (err) {
|
|
@@ -2792,14 +3159,25 @@ export class BrowserController {
|
|
|
2792
3159
|
profileProcessMatches(this.childChromeIdentity, this.profileDir);
|
|
2793
3160
|
this.childChromeIdentity = await terminateTrackedProfileChild(child, this.profileDir, {
|
|
2794
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,
|
|
2795
3172
|
});
|
|
2796
3173
|
this.childChrome = null;
|
|
2797
3174
|
this.childChromeIdentity = null;
|
|
3175
|
+
this.childChromeProcessGroup = false;
|
|
2798
3176
|
const detail = chromeStderr.trim();
|
|
2799
3177
|
throw new Error(`${err instanceof Error ? err.message : String(err)}; Chrome pid=${child.pid ?? "unknown"} alive=${alive ? 1 : 0}` +
|
|
2800
3178
|
`${chromeExit}${detail.length > 0 ? `; Chrome stderr: ${detail}` : ""}`);
|
|
2801
3179
|
}
|
|
2802
|
-
}
|
|
3180
|
+
})();
|
|
2803
3181
|
// Use the patchright launcher's connectOverCDP — it's the exact path the
|
|
2804
3182
|
// falsification experiment validated (its connect avoids Runtime.enable,
|
|
2805
3183
|
// which a plain attach would emit). The anti-detection that matters here
|
|
@@ -2816,10 +3194,21 @@ export class BrowserController {
|
|
|
2816
3194
|
async cancelSpawnedSelfManagedChrome(child) {
|
|
2817
3195
|
this.childChromeIdentity = await terminateTrackedProfileChild(child, this.profileDir, {
|
|
2818
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,
|
|
2819
3207
|
});
|
|
2820
3208
|
if (this.childChrome === child)
|
|
2821
3209
|
this.childChrome = null;
|
|
2822
3210
|
this.childChromeIdentity = null;
|
|
3211
|
+
this.childChromeProcessGroup = false;
|
|
2823
3212
|
}
|
|
2824
3213
|
// Resource blocking for speed (BOT_BLOCK_RESOURCES, default OFF). Aborts
|
|
2825
3214
|
// image/media/font requests + known analytics/tracker hosts to cut page-load
|
|
@@ -2894,6 +3283,9 @@ export class BrowserController {
|
|
|
2894
3283
|
console.error("[operator] resource blocking ON (image/media/font + analytics aborted; captcha/CSS/JS allowed)");
|
|
2895
3284
|
}
|
|
2896
3285
|
async start() {
|
|
3286
|
+
if (this.profileDir.length === 0) {
|
|
3287
|
+
throw new Error("BrowserController.start requires a per-session profile directory");
|
|
3288
|
+
}
|
|
2897
3289
|
if (this.closePromise !== null)
|
|
2898
3290
|
throw new Error("BrowserController is already closing");
|
|
2899
3291
|
this.startPromise ??= this.startOnce();
|
|
@@ -2901,24 +3293,19 @@ export class BrowserController {
|
|
|
2901
3293
|
}
|
|
2902
3294
|
async startOnce() {
|
|
2903
3295
|
const remoteMode = (process.env.BOT_CDP_ENDPOINT ?? "").trim().length > 0;
|
|
2904
|
-
|
|
2905
|
-
(
|
|
2906
|
-
this.profileOperationLease = lease;
|
|
3296
|
+
if (!remoteMode)
|
|
3297
|
+
startGlobalOperatorBrowserProcessWatchdog();
|
|
2907
3298
|
try {
|
|
2908
|
-
await this.
|
|
3299
|
+
await this.startBrowser();
|
|
2909
3300
|
if (this.startCancellationRequested) {
|
|
2910
|
-
await this.
|
|
3301
|
+
await this.closeBrowser();
|
|
2911
3302
|
throw new Error("BrowserController start cancelled");
|
|
2912
3303
|
}
|
|
2913
3304
|
}
|
|
2914
3305
|
catch (err) {
|
|
3306
|
+
await this.teardownOwnedDisplay().catch(() => undefined);
|
|
2915
3307
|
if (this.startCancellationRequested && this.persistentFallbackCancellationState === null) {
|
|
2916
|
-
await this.
|
|
2917
|
-
}
|
|
2918
|
-
if (this.persistentFallbackCancellationState === null ||
|
|
2919
|
-
this.persistentFallbackCancellationState === "closed") {
|
|
2920
|
-
this.profileOperationLease = null;
|
|
2921
|
-
lease?.release();
|
|
3308
|
+
await this.closeBrowser().catch(() => undefined);
|
|
2922
3309
|
}
|
|
2923
3310
|
throw err;
|
|
2924
3311
|
}
|
|
@@ -2934,9 +3321,8 @@ export class BrowserController {
|
|
|
2934
3321
|
this.throwIfStartCancelled();
|
|
2935
3322
|
this.startLaunchCommitted = true;
|
|
2936
3323
|
}
|
|
2937
|
-
async
|
|
3324
|
+
async startBrowser() {
|
|
2938
3325
|
this.throwIfStartCancelled();
|
|
2939
|
-
reapOrphanedBrowsersOnce(this.profileDir);
|
|
2940
3326
|
const channel = await detectChromiumChannel();
|
|
2941
3327
|
this.throwIfStartCancelled();
|
|
2942
3328
|
this.launchedChannel = channel;
|
|
@@ -2951,7 +3337,7 @@ export class BrowserController {
|
|
|
2951
3337
|
// (e.g. a Mac with a real GPU + residential egress) and we attach over CDP
|
|
2952
3338
|
// across Tailscale. The remote machine IS a real device, so we spoof
|
|
2953
3339
|
// NOTHING — no WebGL/device fingerprint patch (a fake-Intel string over a
|
|
2954
|
-
// real Apple-GPU output would be its own mismatch tell), no local
|
|
3340
|
+
// real Apple-GPU output would be its own mismatch tell), no local display, no
|
|
2955
3341
|
// egress-geo override (the remote host's real timezone + residential IP are
|
|
2956
3342
|
// authentic). software-WebGL output is exactly what the toughest anti-bot
|
|
2957
3343
|
// (hCaptcha Enterprise) scores; only real hardware fixes the pixel
|
|
@@ -2959,15 +3345,20 @@ export class BrowserController {
|
|
|
2959
3345
|
const remoteMode = (process.env.BOT_CDP_ENDPOINT ?? "").trim().length > 0;
|
|
2960
3346
|
if (remoteMode) {
|
|
2961
3347
|
console.error(`[operator] REMOTE-CDP mode — attaching to ${(process.env.BOT_CDP_ENDPOINT ?? "").trim()} ` +
|
|
2962
|
-
`(real-host GPU + egress; local fingerprint spoof +
|
|
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;
|
|
2963
3353
|
}
|
|
3354
|
+
const browserEnv = remoteMode ? process.env : await this.ownedHeadedBrowserEnvironment();
|
|
2964
3355
|
// T3.1: probe where this run's traffic actually exits so the
|
|
2965
3356
|
// browser's declared timezone matches its egress IP (a US-timezone
|
|
2966
3357
|
// browser on a foreign proxy IP is itself an anti-bot signal).
|
|
2967
3358
|
// Done before the real launch: launchPersistentContext bakes the
|
|
2968
3359
|
// timezone in at creation, with no way to set it afterward. Skipped in
|
|
2969
3360
|
// remote mode — the remote host's own clock/IP are the authentic truth.
|
|
2970
|
-
const geo = remoteMode ? null : await this.probeEgressGeo(channel, proxy);
|
|
3361
|
+
const geo = remoteMode ? null : await this.probeEgressGeo(channel, proxy, browserEnv);
|
|
2971
3362
|
this.throwIfStartCancelled();
|
|
2972
3363
|
if (geo !== null) {
|
|
2973
3364
|
console.error(`[operator] egress geo: timezone=${geo.timezoneId}` +
|
|
@@ -2975,84 +3366,12 @@ export class BrowserController {
|
|
|
2975
3366
|
? ` loc=${geo.geolocation.latitude},${geo.geolocation.longitude}`
|
|
2976
3367
|
: ""));
|
|
2977
3368
|
}
|
|
2978
|
-
//
|
|
2979
|
-
//
|
|
2980
|
-
|
|
2981
|
-
//
|
|
2982
|
-
//
|
|
2983
|
-
//
|
|
2984
|
-
// The decision matrix:
|
|
2985
|
-
// - UNIVERSAL_BOT_HEADLESS=true (explicit opt-in): keep true
|
|
2986
|
-
// headless. CI / Codespaces that lack Xvfb.
|
|
2987
|
-
// - UNIVERSAL_BOT_HEADLESS=false (explicit opt-out): the
|
|
2988
|
-
// current pre-F13 behavior — DISPLAY must exist already.
|
|
2989
|
-
// - default + DISPLAY set: run headed against the existing
|
|
2990
|
-
// display (laptop/desktop install).
|
|
2991
|
-
// - default + no DISPLAY + Xvfb on PATH: spawn Xvfb, run
|
|
2992
|
-
// headed against it (the headless-server install — what
|
|
2993
|
-
// Cloudflare needed).
|
|
2994
|
-
// - default + no DISPLAY + no Xvfb: fall back to true
|
|
2995
|
-
// headless with a clear stderr warning.
|
|
2996
|
-
let chromeEnv;
|
|
2997
|
-
let chromeHeadless;
|
|
2998
|
-
const explicitHeadless = process.env.UNIVERSAL_BOT_HEADLESS;
|
|
2999
|
-
const hostHasDisplay = process.platform === "darwin" ||
|
|
3000
|
-
process.platform === "win32" ||
|
|
3001
|
-
(typeof process.env.DISPLAY === "string" && process.env.DISPLAY.length > 0);
|
|
3002
|
-
if (explicitHeadless === "true") {
|
|
3003
|
-
chromeHeadless = true;
|
|
3004
|
-
this.launchedMode = "headless";
|
|
3005
|
-
}
|
|
3006
|
-
else if (explicitHeadless === "false") {
|
|
3007
|
-
chromeHeadless = false;
|
|
3008
|
-
this.launchedMode = "display";
|
|
3009
|
-
}
|
|
3010
|
-
else if (hostHasDisplay) {
|
|
3011
|
-
chromeHeadless = false;
|
|
3012
|
-
this.launchedMode = "display";
|
|
3013
|
-
}
|
|
3014
|
-
else if (xvfbAvailable()) {
|
|
3015
|
-
try {
|
|
3016
|
-
// 1920×1080 — the most common real desktop resolution. The old
|
|
3017
|
-
// 1280×720 here was exactly Playwright's emulated-device viewport
|
|
3018
|
-
// default (the code's own comments flag that as an anti-bot tell),
|
|
3019
|
-
// and with viewport:null the page read it straight back. A 720p
|
|
3020
|
-
// screen whose availHeight==height (no taskbar) is a headless
|
|
3021
|
-
// signature strict Turnstiles (exa/cartesia) score against.
|
|
3022
|
-
const xvfb = await startXvfb({ width: 1920, height: 1080 });
|
|
3023
|
-
if (this.startCancellationRequested) {
|
|
3024
|
-
xvfb.stop();
|
|
3025
|
-
this.throwIfStartCancelled();
|
|
3026
|
-
}
|
|
3027
|
-
this.xvfb = xvfb;
|
|
3028
|
-
chromeEnv = { ...process.env, DISPLAY: xvfb.display };
|
|
3029
|
-
chromeHeadless = false;
|
|
3030
|
-
this.launchedMode = "xvfb";
|
|
3031
|
-
console.error(`[operator] no DISPLAY — spawned Xvfb at ${this.xvfb.display} for headed Chrome`);
|
|
3032
|
-
}
|
|
3033
|
-
catch (err) {
|
|
3034
|
-
console.error(`[operator] Xvfb failed (${err instanceof Error ? err.message : String(err)}) — ` +
|
|
3035
|
-
`falling back to true headless; Cloudflare/Stytch-class signups may fail`);
|
|
3036
|
-
chromeHeadless = true;
|
|
3037
|
-
this.launchedMode = "headless";
|
|
3038
|
-
}
|
|
3039
|
-
}
|
|
3040
|
-
else {
|
|
3041
|
-
console.error(`[operator] no DISPLAY and Xvfb not installed — running true headless. ` +
|
|
3042
|
-
`For Cloudflare/Stytch-class signups install xvfb: apt-get install -y xvfb`);
|
|
3043
|
-
chromeHeadless = true;
|
|
3044
|
-
this.launchedMode = "headless";
|
|
3045
|
-
}
|
|
3046
|
-
const free = await waitForProfileFree(this.profileDir, { deadlineMs: 0 });
|
|
3047
|
-
this.throwIfStartCancelled();
|
|
3048
|
-
if (!free) {
|
|
3049
|
-
throw new ProfileBusyError(PROFILE_BUSY_MESSAGE);
|
|
3050
|
-
}
|
|
3051
|
-
// T3: a PERSISTENT context. The profile dir carries the user's
|
|
3052
|
-
// Google session (established by `mcp login` — see google-login.ts),
|
|
3053
|
-
// so the OAuth-first signup path reuses it instead of starting
|
|
3054
|
-
// logged-out. launchPersistentContext takes launch + context
|
|
3055
|
-
// options in one call.
|
|
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.
|
|
3056
3375
|
// Resolve the launcher first so activeStealthProfile is set before we
|
|
3057
3376
|
// decide on executablePath below.
|
|
3058
3377
|
const launcher = getChromium();
|
|
@@ -3066,7 +3385,7 @@ export class BrowserController {
|
|
|
3066
3385
|
this.launchedChannel = channel;
|
|
3067
3386
|
// Launch args shared by BOTH paths (launchPersistentContext and the
|
|
3068
3387
|
// self-launch). See the per-flag rationale: swiftshader gives a real
|
|
3069
|
-
// (software) WebGL context on
|
|
3388
|
+
// (software) WebGL context on GPU-less hosts; the others are the
|
|
3070
3389
|
// standard headless/sandbox flags. The three background-throttling disables
|
|
3071
3390
|
// are payment correctness controls: a backgrounded CardinalCommerce ACS
|
|
3072
3391
|
// frame must keep running its timers long enough to finish the issuer's OOB
|
|
@@ -3101,24 +3420,23 @@ export class BrowserController {
|
|
|
3101
3420
|
this.throwIfStartCancelled();
|
|
3102
3421
|
if (useSelfLaunch && selfLaunchBinary !== null) {
|
|
3103
3422
|
console.error(`[operator] self-launch + connectOverCDP (Turnstile-safe launch) binary=${selfLaunchBinary}`);
|
|
3104
|
-
const window =
|
|
3105
|
-
? { width: 1920, height: 1080 }
|
|
3106
|
-
: { width: 1280, height: 1024 };
|
|
3423
|
+
const window = { width: 1280, height: 1024 };
|
|
3107
3424
|
const selfEnv = {
|
|
3108
|
-
...
|
|
3425
|
+
...browserEnv,
|
|
3109
3426
|
TZ: geo?.timezoneId ?? "America/New_York",
|
|
3427
|
+
[OPERATOR_BROWSER_MARKER_ENV]: this.operatorBrowserMarker(),
|
|
3110
3428
|
};
|
|
3111
|
-
|
|
3429
|
+
const launch = () => {
|
|
3112
3430
|
this.throwIfStartCancelled();
|
|
3113
3431
|
return this.launchSelfManagedContext({
|
|
3114
3432
|
binary: selfLaunchBinary,
|
|
3115
|
-
headless: chromeHeadless,
|
|
3116
3433
|
args: launchArgs,
|
|
3117
3434
|
proxy,
|
|
3118
3435
|
env: selfEnv,
|
|
3119
3436
|
window,
|
|
3120
3437
|
});
|
|
3121
|
-
}
|
|
3438
|
+
};
|
|
3439
|
+
context = await launch();
|
|
3122
3440
|
try {
|
|
3123
3441
|
await context.grantPermissions(grantedPermissions);
|
|
3124
3442
|
if (geo?.geolocation !== undefined) {
|
|
@@ -3130,8 +3448,8 @@ export class BrowserController {
|
|
|
3130
3448
|
}
|
|
3131
3449
|
}
|
|
3132
3450
|
else {
|
|
3133
|
-
this.profilePoolReusable = false;
|
|
3134
3451
|
this.persistentFallbackLaunchInFlight = true;
|
|
3452
|
+
this.startPersistentFallbackOwnershipMonitor();
|
|
3135
3453
|
const cleanupProfileHolder = async () => {
|
|
3136
3454
|
const proof = await this.waitForPersistentFallbackIdentity();
|
|
3137
3455
|
if (proof.state === "absent")
|
|
@@ -3139,36 +3457,48 @@ export class BrowserController {
|
|
|
3139
3457
|
if (proof.state === "unknown")
|
|
3140
3458
|
return "unknown";
|
|
3141
3459
|
const { identity } = proof;
|
|
3142
|
-
|
|
3143
|
-
|
|
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";
|
|
3144
3465
|
};
|
|
3145
3466
|
const cleanupCancelled = async (lateContext) => {
|
|
3146
|
-
const proof = await this.waitForPersistentFallbackIdentity();
|
|
3467
|
+
const proof = await this.waitForPersistentFallbackIdentity().catch(() => ({ state: "unknown" }));
|
|
3147
3468
|
if (proof.state !== "owned") {
|
|
3148
3469
|
await lateContext.close().catch(() => undefined);
|
|
3149
3470
|
return proof.state === "absent" ? "closed" : "unknown";
|
|
3150
3471
|
}
|
|
3151
3472
|
const { identity } = proof;
|
|
3473
|
+
const treeProof = this.adoptOwnedChromeProcessTree(identity, false);
|
|
3152
3474
|
const closeState = await closeProfileWithProof({
|
|
3153
3475
|
profileDir: this.profileDir,
|
|
3154
3476
|
identity,
|
|
3155
3477
|
close: () => lateContext.close(),
|
|
3156
3478
|
forceClose: () => {
|
|
3157
|
-
|
|
3479
|
+
signalOwnedChromeProcessTree(identity, false, "SIGKILL", {
|
|
3480
|
+
...(treeProof === null ? {} : { proof: treeProof }),
|
|
3481
|
+
});
|
|
3158
3482
|
reapProfileHolderIfOwned(this.profileDir, identity);
|
|
3159
3483
|
},
|
|
3484
|
+
...(treeProof === null
|
|
3485
|
+
? {}
|
|
3486
|
+
: { identityState: () => ownedChromeProcessTreeState(treeProof) }),
|
|
3160
3487
|
});
|
|
3161
3488
|
if (closeState === "closed")
|
|
3162
3489
|
return closeState;
|
|
3163
|
-
return (await this.waitForOwnedProfileExit(identity)) ? "closed" : "unknown";
|
|
3490
|
+
return (await this.waitForOwnedProfileExit(identity, treeProof)) ? "closed" : "unknown";
|
|
3164
3491
|
};
|
|
3165
3492
|
const outcome = await (async () => {
|
|
3166
3493
|
try {
|
|
3167
3494
|
return await launchCancellablePersistentContext({
|
|
3168
|
-
launch: (options) =>
|
|
3495
|
+
launch: (options) => launcher.launchPersistentContext(this.profileDir, options),
|
|
3169
3496
|
options: {
|
|
3170
|
-
headless:
|
|
3171
|
-
|
|
3497
|
+
headless: OPERATOR_BROWSER_HEADLESS,
|
|
3498
|
+
env: {
|
|
3499
|
+
...browserEnv,
|
|
3500
|
+
[OPERATOR_BROWSER_MARKER_ENV]: this.operatorBrowserMarker(),
|
|
3501
|
+
},
|
|
3172
3502
|
...(channel !== null ? { channel } : {}),
|
|
3173
3503
|
...persistentProxyOptions(proxy),
|
|
3174
3504
|
args: [...launchArgs],
|
|
@@ -3206,9 +3536,23 @@ export class BrowserController {
|
|
|
3206
3536
|
}
|
|
3207
3537
|
this.context = context;
|
|
3208
3538
|
this.launchedContext = true;
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
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
|
+
});
|
|
3212
3556
|
this.commitProfileLaunch();
|
|
3213
3557
|
this.persistentFallbackLaunchInFlight = false;
|
|
3214
3558
|
}
|
|
@@ -3220,9 +3564,12 @@ export class BrowserController {
|
|
|
3220
3564
|
this.launchedProfileHolderIdentity =
|
|
3221
3565
|
this.childChromeIdentity ??
|
|
3222
3566
|
(holderPid === null ? null : profileProcessIdentity(holderPid, this.profileDir));
|
|
3567
|
+
if (this.launchedProfileHolderIdentity !== null) {
|
|
3568
|
+
this.adoptOwnedChromeProcessTree(this.launchedProfileHolderIdentity, this.childChromeIdentity !== null && this.childChromeProcessGroup);
|
|
3569
|
+
}
|
|
3223
3570
|
}
|
|
3224
3571
|
if (this.startCancellationRequested) {
|
|
3225
|
-
await this.
|
|
3572
|
+
await this.closeBrowser();
|
|
3226
3573
|
throw new Error("BrowserController start cancelled");
|
|
3227
3574
|
}
|
|
3228
3575
|
// Speed: optionally abort heavy/irrelevant requests before any navigation.
|
|
@@ -3325,7 +3672,7 @@ export class BrowserController {
|
|
|
3325
3672
|
get: () => 8,
|
|
3326
3673
|
configurable: true,
|
|
3327
3674
|
});
|
|
3328
|
-
// Screen availHeight tell: a
|
|
3675
|
+
// Screen availHeight tell: a virtual screen reports
|
|
3329
3676
|
// availHeight == height (no OS taskbar), whereas a real Windows
|
|
3330
3677
|
// desktop reserves ~40px for the taskbar (availHeight = height-40,
|
|
3331
3678
|
// availWidth = width). Reinstate that gap so the screen reads like
|
|
@@ -3356,7 +3703,11 @@ export class BrowserController {
|
|
|
3356
3703
|
if (contextInitScripts.includes("webgl-spoof")) {
|
|
3357
3704
|
await context.addInitScript({ content: installWebglSpoofScript });
|
|
3358
3705
|
}
|
|
3706
|
+
for (const page of context.pages())
|
|
3707
|
+
this.trackMainDocument(page);
|
|
3708
|
+
this.trackOpenedTabs(context);
|
|
3359
3709
|
this.page = context.pages()[0] ?? (await context.newPage());
|
|
3710
|
+
this.trackMainDocument(this.page);
|
|
3360
3711
|
this.primaryPage = this.page;
|
|
3361
3712
|
// In baseline mode addInitScript covers document-start page JS, but
|
|
3362
3713
|
// Playwright's page.evaluate utility execution can run in a separate realm.
|
|
@@ -3483,7 +3834,7 @@ export class BrowserController {
|
|
|
3483
3834
|
// timezone has to be known before it is. The throwaway inherits the
|
|
3484
3835
|
// same channel + proxy so it reports the real egress. Best-effort —
|
|
3485
3836
|
// any failure returns null and start() keeps a default timezone.
|
|
3486
|
-
async probeEgressGeo(channel, proxy) {
|
|
3837
|
+
async probeEgressGeo(channel, proxy, browserEnv) {
|
|
3487
3838
|
if (proxy === null) {
|
|
3488
3839
|
try {
|
|
3489
3840
|
const resp = await fetch("https://ipinfo.io/json", { signal: AbortSignal.timeout(10_000) });
|
|
@@ -3500,7 +3851,8 @@ export class BrowserController {
|
|
|
3500
3851
|
let probe;
|
|
3501
3852
|
try {
|
|
3502
3853
|
probe = await getChromium().launch({
|
|
3503
|
-
headless:
|
|
3854
|
+
headless: OPERATOR_BROWSER_HEADLESS,
|
|
3855
|
+
env: browserEnv,
|
|
3504
3856
|
...(channel !== null ? { channel } : {}),
|
|
3505
3857
|
...(proxy !== null ? { proxy } : {}),
|
|
3506
3858
|
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
|
@@ -3791,7 +4143,28 @@ export class BrowserController {
|
|
|
3791
4143
|
async type(selector, text, sealed = false) {
|
|
3792
4144
|
if (!this.page)
|
|
3793
4145
|
throw new Error("Browser not started");
|
|
3794
|
-
|
|
4146
|
+
await this.withModalInertNeutralized(selector, () => this.typeInner(selector, text, sealed));
|
|
4147
|
+
}
|
|
4148
|
+
/**
|
|
4149
|
+
* Commit Shopify's required shipping street field after type/autocomplete.
|
|
4150
|
+
* Shopify Places only begins geocoding on this field's change/blur boundary;
|
|
4151
|
+
* dispatching change and moving focus away mirrors the user's Tab action.
|
|
4152
|
+
* This is intentionally not a generic post-type event mechanism.
|
|
4153
|
+
*/
|
|
4154
|
+
async commitRequiredShippingAddressLine1(selector) {
|
|
4155
|
+
if (!this.page)
|
|
4156
|
+
throw new Error("Browser not started");
|
|
4157
|
+
await this.page
|
|
4158
|
+
.locator(selector)
|
|
4159
|
+
.first()
|
|
4160
|
+
.evaluate((field) => {
|
|
4161
|
+
field.dispatchEvent(new Event("change", { bubbles: true }));
|
|
4162
|
+
if (field instanceof HTMLElement)
|
|
4163
|
+
field.blur();
|
|
4164
|
+
});
|
|
4165
|
+
// Allow Shopify's bounded Places/geocoding request to begin before the
|
|
4166
|
+
// caller observes the delivery-method section.
|
|
4167
|
+
await this.sleep(500);
|
|
3795
4168
|
}
|
|
3796
4169
|
async typeInner(selector, text, sealed = false) {
|
|
3797
4170
|
if (!this.page)
|
|
@@ -3799,16 +4172,16 @@ export class BrowserController {
|
|
|
3799
4172
|
// Wait for element to be visible and enabled before typing.
|
|
3800
4173
|
await this.page.waitForSelector(selector, { state: "visible", timeout: 10000 });
|
|
3801
4174
|
const locator = this.page.locator(selector);
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
4175
|
+
// The marker is payment machinery — the card-clearing and saved-card
|
|
4176
|
+
// resolution passes find the fields they filled through it. It is not a
|
|
4177
|
+
// read seal: nothing masks or refuses a read because of it.
|
|
3805
4178
|
if (sealed) {
|
|
3806
4179
|
await locator.evaluate((el) => el.setAttribute("data-ts-sealed-payment", "1"));
|
|
3807
4180
|
}
|
|
3808
4181
|
if (!this.humanize) {
|
|
3809
4182
|
// Fast path for tests / non-humanized runs.
|
|
3810
4183
|
await this.page.fill(selector, text);
|
|
3811
|
-
return
|
|
4184
|
+
return;
|
|
3812
4185
|
}
|
|
3813
4186
|
// Humanized typing:
|
|
3814
4187
|
// - Click into the field first (moves mouse, generates focus event)
|
|
@@ -3839,7 +4212,6 @@ export class BrowserController {
|
|
|
3839
4212
|
// hook, and over-engineering it added zero observable behavior-
|
|
3840
4213
|
// score improvement.
|
|
3841
4214
|
await locator.pressSequentially(text, { delay: rand(40, 110) });
|
|
3842
|
-
return sealedFieldKeys;
|
|
3843
4215
|
}
|
|
3844
4216
|
// Best-effort scan for the SPECIFIC unfilled required field(s) blocking a
|
|
3845
4217
|
// disabled submit. Returns a " Unfilled required field(s) — …" suffix for the
|
|
@@ -4827,20 +5199,16 @@ export class BrowserController {
|
|
|
4827
5199
|
const ownerFrame = await handle.ownerFrame();
|
|
4828
5200
|
if (ownerFrame === null)
|
|
4829
5201
|
throw new Error("locator target has no owning frame");
|
|
4830
|
-
const sealedFieldKeys = sealed
|
|
4831
|
-
? await this.operatorScreenshotIdentityKeys(handle, ownerFrame)
|
|
4832
|
-
: [];
|
|
4833
5202
|
if (sealed) {
|
|
4834
5203
|
await handle.evaluate((el) => el.setAttribute("data-ts-sealed-payment", "1"));
|
|
4835
5204
|
}
|
|
4836
5205
|
if (!this.humanize) {
|
|
4837
5206
|
await handle.fill(text);
|
|
4838
|
-
return
|
|
5207
|
+
return;
|
|
4839
5208
|
}
|
|
4840
5209
|
await handle.click({ timeout: 8000 }).catch(() => undefined);
|
|
4841
5210
|
await handle.fill("").catch(() => undefined);
|
|
4842
5211
|
await handle.type(text, { delay: rand(40, 110) });
|
|
4843
|
-
return sealedFieldKeys;
|
|
4844
5212
|
}
|
|
4845
5213
|
// Dispatch a DOM .click() in the page context. Some React copy buttons fire
|
|
4846
5214
|
// their onClick (and thus navigator.clipboard.writeText) on the synthetic
|
|
@@ -6439,7 +6807,7 @@ export class BrowserController {
|
|
|
6439
6807
|
// device memory, screen, languages, webdriver flag. Turnstile
|
|
6440
6808
|
// error 600010 ("internal client execution error") usually points
|
|
6441
6809
|
// at one of these returning something the challenge JS can't
|
|
6442
|
-
// handle (e.g. SwiftShader/llvmpipe renderer
|
|
6810
|
+
// handle (e.g. a SwiftShader/llvmpipe renderer).
|
|
6443
6811
|
if (process.env.UNIVERSAL_BOT_CAPTCHA_TRACE === "1") {
|
|
6444
6812
|
try {
|
|
6445
6813
|
const fp = await this.page.evaluate(() => {
|
|
@@ -7320,10 +7688,6 @@ export class BrowserController {
|
|
|
7320
7688
|
hasHcaptchaGlobal: win.hcaptcha !== undefined,
|
|
7321
7689
|
};
|
|
7322
7690
|
}, { tok: token, key: responseKey });
|
|
7323
|
-
if (process.env.UNIVERSAL_BOT_OAUTH_DEBUG) {
|
|
7324
|
-
console.error(`[captcha] hCaptcha inject diag: ok=${diag.ok} textareas=${diag.textareas} ` +
|
|
7325
|
-
`widgets=${diag.widgets} callbackFired=${diag.callbackFired} hcaptchaGlobal=${diag.hasHcaptchaGlobal}`);
|
|
7326
|
-
}
|
|
7327
7691
|
return diag.ok;
|
|
7328
7692
|
}
|
|
7329
7693
|
catch {
|
|
@@ -7542,582 +7906,78 @@ export class BrowserController {
|
|
|
7542
7906
|
}
|
|
7543
7907
|
return null;
|
|
7544
7908
|
}
|
|
7545
|
-
|
|
7546
|
-
|
|
7547
|
-
|
|
7548
|
-
|
|
7549
|
-
|
|
7550
|
-
return { frames, strictFrames: new Set(frames), clip: null };
|
|
7551
|
-
}
|
|
7552
|
-
const strictFrames = new Set();
|
|
7553
|
-
const visit = (frame) => {
|
|
7554
|
-
strictFrames.add(frame);
|
|
7555
|
-
for (const child of frame.childFrames())
|
|
7556
|
-
visit(child);
|
|
7557
|
-
};
|
|
7558
|
-
visit(targetFrame);
|
|
7559
|
-
const targetHandle = await targetFrame.frameElement();
|
|
7560
|
-
const clip = await targetHandle.boundingBox();
|
|
7561
|
-
await targetHandle.dispose().catch(() => undefined);
|
|
7562
|
-
if (clip === null)
|
|
7563
|
-
throw new Error("screenshot_redaction_unresolved");
|
|
7564
|
-
const intersects = (box) => box.x < clip.x + clip.width &&
|
|
7565
|
-
box.x + box.width > clip.x &&
|
|
7566
|
-
box.y < clip.y + clip.height &&
|
|
7567
|
-
box.y + box.height > clip.y;
|
|
7568
|
-
const frames = [];
|
|
7569
|
-
for (const frame of this.page.frames()) {
|
|
7570
|
-
if (frame === this.page.mainFrame() || strictFrames.has(frame)) {
|
|
7571
|
-
frames.push(frame);
|
|
7572
|
-
continue;
|
|
7573
|
-
}
|
|
7574
|
-
const handle = await frame.frameElement();
|
|
7575
|
-
try {
|
|
7576
|
-
const box = await handle.boundingBox();
|
|
7577
|
-
if (box !== null && intersects(box))
|
|
7578
|
-
frames.push(frame);
|
|
7579
|
-
}
|
|
7580
|
-
finally {
|
|
7581
|
-
await handle.dispose().catch(() => undefined);
|
|
7582
|
-
}
|
|
7583
|
-
}
|
|
7584
|
-
return { frames, strictFrames, clip };
|
|
7585
|
-
}
|
|
7586
|
-
async sealedDocumentIdentity(frame) {
|
|
7587
|
-
const current = this.sealedDocuments.get(frame);
|
|
7588
|
-
if (current !== undefined &&
|
|
7589
|
-
!frame.isDetached() &&
|
|
7590
|
-
(await frame.evaluate((expected) => document === expected, current.handle).catch(() => false))) {
|
|
7591
|
-
return current.identity;
|
|
7592
|
-
}
|
|
7593
|
-
await current?.handle.dispose().catch(() => undefined);
|
|
7594
|
-
const handle = await frame.evaluateHandle(() => document);
|
|
7595
|
-
const identity = `document-${this.sealedDocumentSequence++}`;
|
|
7596
|
-
this.sealedDocuments.set(frame, { handle, identity });
|
|
7597
|
-
return identity;
|
|
7598
|
-
}
|
|
7599
|
-
async operatorScreenshotIdentityKeys(target, frame) {
|
|
7600
|
-
const descriptor = await target.evaluate((el) => {
|
|
7601
|
-
const clean = (value) => {
|
|
7602
|
-
const normalized = (value ?? "").replace(/\s+/g, " ").trim();
|
|
7603
|
-
return normalized.length > 0 ? normalized : null;
|
|
7604
|
-
};
|
|
7605
|
-
const labelFor = (element) => {
|
|
7606
|
-
const id = element.getAttribute("id");
|
|
7607
|
-
if (id !== null && id.length > 0) {
|
|
7608
|
-
const label = document.querySelector(`label[for="${CSS.escape(id)}"]`);
|
|
7609
|
-
if (label !== null)
|
|
7610
|
-
return clean(label.textContent);
|
|
7611
|
-
}
|
|
7612
|
-
const labelledBy = element.getAttribute("aria-labelledby");
|
|
7613
|
-
if (labelledBy !== null) {
|
|
7614
|
-
const text = labelledBy
|
|
7615
|
-
.split(/\s+/)
|
|
7616
|
-
.map((part) => clean(document.getElementById(part)?.textContent))
|
|
7617
|
-
.filter((part) => part !== null)
|
|
7618
|
-
.join(" ");
|
|
7619
|
-
if (text.length > 0)
|
|
7620
|
-
return text;
|
|
7621
|
-
}
|
|
7622
|
-
return clean(element.closest("label")?.textContent);
|
|
7623
|
-
};
|
|
7624
|
-
const controls = Array.from(el.getRootNode().querySelectorAll("input,textarea,select,[contenteditable='true']"));
|
|
7625
|
-
return {
|
|
7626
|
-
tag: el.tagName.toLowerCase(),
|
|
7627
|
-
type: el.getAttribute("type"),
|
|
7628
|
-
id: el.getAttribute("id"),
|
|
7629
|
-
name: el.getAttribute("name"),
|
|
7630
|
-
testId: el.getAttribute("data-testid") ??
|
|
7631
|
-
el.getAttribute("data-test-id") ??
|
|
7632
|
-
el.getAttribute("data-test") ??
|
|
7633
|
-
el.getAttribute("data-cy") ??
|
|
7634
|
-
el.getAttribute("data-qa"),
|
|
7635
|
-
labelText: labelFor(el),
|
|
7636
|
-
ariaLabel: el.getAttribute("aria-label"),
|
|
7637
|
-
placeholder: el.getAttribute("placeholder"),
|
|
7638
|
-
landmark: el.closest("header,main,footer,nav,aside,article,section")?.tagName.toLowerCase() ?? null,
|
|
7639
|
-
ordinal: controls.indexOf(el),
|
|
7640
|
-
};
|
|
7641
|
-
});
|
|
7642
|
-
const documentIdentity = await this.sealedDocumentIdentity(frame);
|
|
7643
|
-
return sealedElementSemanticKeys(descriptor).map((key) => `${documentIdentity}:${key}`);
|
|
7644
|
-
}
|
|
7645
|
-
async resolveOperatorScreenshotSealedLocators(frames, sealedFieldKeys) {
|
|
7646
|
-
const byFrame = new Map();
|
|
7647
|
-
for (const frame of frames) {
|
|
7648
|
-
const matches = [];
|
|
7649
|
-
const candidates = await frame
|
|
7650
|
-
.locator("input,textarea,select,[contenteditable='true']")
|
|
7651
|
-
.all();
|
|
7652
|
-
for (const candidate of candidates) {
|
|
7653
|
-
const keys = await this.operatorScreenshotIdentityKeys(candidate, frame);
|
|
7654
|
-
if (keys.some((key) => sealedFieldKeys.has(key)))
|
|
7655
|
-
matches.push(candidate);
|
|
7656
|
-
}
|
|
7657
|
-
byFrame.set(frame, matches);
|
|
7658
|
-
}
|
|
7659
|
-
return byFrame;
|
|
7660
|
-
}
|
|
7661
|
-
// Resolve every card-shaped/sealed field in the captured frames to a
|
|
7662
|
-
// screenshot redaction rectangles — text-masking (presentFieldValue in
|
|
7663
|
-
// provision-session.ts) only covers the JSON observation, not a rendered
|
|
7664
|
-
// image. Beyond the attribute-based
|
|
7665
|
-
// selector set, every renderable input/textarea whose CURRENT value contains
|
|
7666
|
-
// a Luhn-valid PAN span is masked too (containsLuhnPanSpan — the same
|
|
7667
|
-
// detection the payment paths use), so a card number sitting in a field the
|
|
7668
|
-
// fixed selectors don't recognize still never reaches the image. Fail-closed
|
|
7669
|
-
// throughout: a selector that cannot be queried, a value that cannot be
|
|
7670
|
-
// read, or a matched element whose geometry cannot be resolved (boundingBox
|
|
7671
|
-
// returns null rather than throwing) aborts the whole capture rather than
|
|
7672
|
-
// shrinking the redaction. The per-frame count signature lets the caller
|
|
7673
|
-
// verify the redaction set stayed stable across the capture window.
|
|
7674
|
-
async collectOperatorScreenshotMask(frames, extraRedactionSelectors, sealedLocators = new Map(), captureClip = null, strictFrames = new Set(frames)) {
|
|
7675
|
-
const selector = [SCREENSHOT_REDACTION_SELECTORS, ...extraRedactionSelectors].join(",");
|
|
7676
|
-
const rectangles = [];
|
|
7677
|
-
const handles = [];
|
|
7678
|
-
const signatureParts = [];
|
|
7679
|
-
try {
|
|
7680
|
-
for (const [frameIndex, frame] of frames.entries()) {
|
|
7681
|
-
let frameCount = 0;
|
|
7682
|
-
const frameHandles = await frame.locator(selector).elementHandles();
|
|
7683
|
-
for (const locator of sealedLocators.get(frame) ?? []) {
|
|
7684
|
-
const handle = await locator.elementHandle({ timeout: 5_000 });
|
|
7685
|
-
if (handle === null)
|
|
7686
|
-
throw new Error("screenshot_redaction_unresolved");
|
|
7687
|
-
if (!(await handle.evaluate((el, matchSelector) => el.matches(matchSelector), selector))) {
|
|
7688
|
-
frameHandles.push(handle);
|
|
7689
|
-
}
|
|
7690
|
-
else {
|
|
7691
|
-
await handle.dispose();
|
|
7692
|
-
}
|
|
7693
|
-
}
|
|
7694
|
-
const valueCandidates = await frame
|
|
7695
|
-
.locator('input:not([type="hidden" i]),textarea')
|
|
7696
|
-
.elementHandles();
|
|
7697
|
-
for (const candidate of valueCandidates) {
|
|
7698
|
-
const value = await candidate.inputValue({ timeout: 5_000 });
|
|
7699
|
-
if (!containsLuhnPanSpan(value)) {
|
|
7700
|
-
await candidate.dispose();
|
|
7701
|
-
continue;
|
|
7702
|
-
}
|
|
7703
|
-
const duplicate = await Promise.all(frameHandles.map(async (existing) => await candidate.evaluate((el, other) => el === other, existing)));
|
|
7704
|
-
if (duplicate.includes(true)) {
|
|
7705
|
-
await candidate.dispose();
|
|
7706
|
-
continue;
|
|
7707
|
-
}
|
|
7708
|
-
frameHandles.push(candidate);
|
|
7709
|
-
}
|
|
7710
|
-
for (const handle of frameHandles) {
|
|
7711
|
-
if (!(await handle.evaluate((el) => el.isConnected))) {
|
|
7712
|
-
throw new Error("screenshot_redaction_unresolved");
|
|
7713
|
-
}
|
|
7714
|
-
const box = await handle.boundingBox();
|
|
7715
|
-
if (captureClip !== null &&
|
|
7716
|
-
!strictFrames.has(frame) &&
|
|
7717
|
-
(box === null ||
|
|
7718
|
-
box.x >= captureClip.x + captureClip.width ||
|
|
7719
|
-
box.x + box.width <= captureClip.x ||
|
|
7720
|
-
box.y >= captureClip.y + captureClip.height ||
|
|
7721
|
-
box.y + box.height <= captureClip.y)) {
|
|
7722
|
-
await handle.dispose();
|
|
7723
|
-
continue;
|
|
7724
|
-
}
|
|
7725
|
-
handles.push(handle);
|
|
7726
|
-
if (box !== null)
|
|
7727
|
-
rectangles.push(box);
|
|
7728
|
-
signatureParts.push(`${frameIndex}:${box === null ? "hidden" : [box.x, box.y, box.width, box.height].join(":")}`);
|
|
7729
|
-
frameCount += 1;
|
|
7730
|
-
}
|
|
7731
|
-
signatureParts.push(`count:${frameIndex}:${frameCount}`);
|
|
7732
|
-
}
|
|
7733
|
-
}
|
|
7734
|
-
catch {
|
|
7735
|
-
await Promise.all(handles.map(async (handle) => await handle.dispose().catch(() => undefined)));
|
|
7736
|
-
throw new Error("screenshot_redaction_unresolved");
|
|
7737
|
-
}
|
|
7738
|
-
return {
|
|
7739
|
-
rectangles,
|
|
7740
|
-
redactedCount: handles.length,
|
|
7741
|
-
signature: signatureParts.join("|"),
|
|
7742
|
-
handles,
|
|
7743
|
-
};
|
|
7744
|
-
}
|
|
7745
|
-
async redactOperatorScreenshot(buffer, rectangles, origin, captureSize) {
|
|
7746
|
-
const metadata = await sharp(buffer).metadata();
|
|
7747
|
-
if (metadata.width === undefined || metadata.height === undefined) {
|
|
7748
|
-
throw new Error("screenshot_redaction_unresolved");
|
|
7749
|
-
}
|
|
7750
|
-
const scaleX = metadata.width / captureSize.width;
|
|
7751
|
-
const scaleY = metadata.height / captureSize.height;
|
|
7752
|
-
const rects = rectangles
|
|
7753
|
-
.map((box) => `<rect x="${(box.x - origin.x) * scaleX}" y="${(box.y - origin.y) * scaleY}" width="${box.width * scaleX}" height="${box.height * scaleY}" fill="#ff00ff"/>`)
|
|
7754
|
-
.join("");
|
|
7755
|
-
const overlay = Buffer.from(`<svg width="${metadata.width}" height="${metadata.height}" xmlns="http://www.w3.org/2000/svg">${rects}</svg>`);
|
|
7756
|
-
return (await sharp(buffer)
|
|
7757
|
-
.composite([{ input: overlay, blend: "over" }])
|
|
7758
|
-
.jpeg({ quality: 80 })
|
|
7759
|
-
.toBuffer()).toString("base64");
|
|
7760
|
-
}
|
|
7761
|
-
// Verify the capture set before pixels are read. This is deliberately
|
|
7762
|
-
// narrower than the capture-time mask: empty checkout controls are harmless,
|
|
7763
|
-
// but a nonempty type_secret target, password, payment-sealed node, or
|
|
7764
|
-
// Luhn-valid PAN means the requested image could contain a secret. Every
|
|
7765
|
-
// frame included by the image must be readable; a detached or navigating
|
|
7766
|
-
// frame is not evidence that it is safe to capture.
|
|
7767
|
-
async assertOperatorScreenshotFramesNoSealedValues(frames, extraRedactionSelectors, sealedLocators = new Map(), captureClip = null, strictFrames = new Set(frames)) {
|
|
7768
|
-
const sealedSelector = [SCREENSHOT_REDACTION_SELECTORS, ...extraRedactionSelectors].join(",");
|
|
7769
|
-
try {
|
|
7770
|
-
for (const frame of frames) {
|
|
7771
|
-
if (frame.isDetached())
|
|
7772
|
-
throw new Error("frame detached");
|
|
7773
|
-
const sealedMatches = [
|
|
7774
|
-
...(await frame.locator(sealedSelector).all()),
|
|
7775
|
-
...(sealedLocators.get(frame) ?? []),
|
|
7776
|
-
];
|
|
7777
|
-
for (const match of sealedMatches) {
|
|
7778
|
-
const box = strictFrames.has(frame) ? null : await match.boundingBox();
|
|
7779
|
-
if (captureClip !== null &&
|
|
7780
|
-
!strictFrames.has(frame) &&
|
|
7781
|
-
(box === null ||
|
|
7782
|
-
box.x >= captureClip.x + captureClip.width ||
|
|
7783
|
-
box.x + box.width <= captureClip.x ||
|
|
7784
|
-
box.y >= captureClip.y + captureClip.height ||
|
|
7785
|
-
box.y + box.height <= captureClip.y)) {
|
|
7786
|
-
continue;
|
|
7787
|
-
}
|
|
7788
|
-
const hasValue = await match.evaluate((el) => {
|
|
7789
|
-
if (el instanceof HTMLSelectElement) {
|
|
7790
|
-
const optionText = el.options[el.selectedIndex]?.textContent ?? "";
|
|
7791
|
-
const normalizedOption = optionText.replace(/\s+/g, " ").trim();
|
|
7792
|
-
const placeholder = /^(?:select|choose)?\s*(?:a\s+)?(?:month|year|mm|yy)?\s*(?:\.\.\.|[-–—]*)$/i;
|
|
7793
|
-
return (el.value.trim().length > 0 ||
|
|
7794
|
-
(normalizedOption.length > 0 && !placeholder.test(normalizedOption)));
|
|
7795
|
-
}
|
|
7796
|
-
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
|
7797
|
-
return el.value.trim().length > 0;
|
|
7798
|
-
}
|
|
7799
|
-
return (el.textContent ?? "").trim().length > 0;
|
|
7800
|
-
});
|
|
7801
|
-
if (hasValue)
|
|
7802
|
-
throw new Error("sealed value");
|
|
7803
|
-
}
|
|
7804
|
-
const candidates = await frame.locator('input:not([type="hidden" i]),textarea').all();
|
|
7805
|
-
for (const candidate of candidates) {
|
|
7806
|
-
const box = strictFrames.has(frame) ? null : await candidate.boundingBox();
|
|
7807
|
-
if (captureClip !== null &&
|
|
7808
|
-
!strictFrames.has(frame) &&
|
|
7809
|
-
(box === null ||
|
|
7810
|
-
box.x >= captureClip.x + captureClip.width ||
|
|
7811
|
-
box.x + box.width <= captureClip.x ||
|
|
7812
|
-
box.y >= captureClip.y + captureClip.height ||
|
|
7813
|
-
box.y + box.height <= captureClip.y)) {
|
|
7814
|
-
continue;
|
|
7815
|
-
}
|
|
7816
|
-
const hasPan = await candidate.evaluate((el) => {
|
|
7817
|
-
const text = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement ? el.value : "";
|
|
7818
|
-
const digits = Array.from(text.matchAll(/\d/g), (match) => match.index);
|
|
7819
|
-
const luhn = (value) => {
|
|
7820
|
-
let sum = 0;
|
|
7821
|
-
let double = false;
|
|
7822
|
-
for (let index = value.length - 1; index >= 0; index -= 1) {
|
|
7823
|
-
let digit = Number(value[index]);
|
|
7824
|
-
if (double) {
|
|
7825
|
-
digit *= 2;
|
|
7826
|
-
if (digit > 9)
|
|
7827
|
-
digit -= 9;
|
|
7828
|
-
}
|
|
7829
|
-
sum += digit;
|
|
7830
|
-
double = !double;
|
|
7831
|
-
}
|
|
7832
|
-
return sum % 10 === 0;
|
|
7833
|
-
};
|
|
7834
|
-
for (let start = 0; start + 13 <= digits.length; start += 1) {
|
|
7835
|
-
const maxLength = Math.min(19, digits.length - start);
|
|
7836
|
-
for (let length = 13; length <= maxLength; length += 1) {
|
|
7837
|
-
const positions = digits.slice(start, start + length);
|
|
7838
|
-
if (positions[positions.length - 1] - positions[0] + 1 >
|
|
7839
|
-
PAYMENT_PAN_MAX_SPAN_CHARS) {
|
|
7840
|
-
break;
|
|
7841
|
-
}
|
|
7842
|
-
if (luhn(positions.map((position) => text[position]).join("")))
|
|
7843
|
-
return true;
|
|
7844
|
-
}
|
|
7845
|
-
}
|
|
7846
|
-
return false;
|
|
7847
|
-
});
|
|
7848
|
-
if (hasPan)
|
|
7849
|
-
throw new Error("card value");
|
|
7850
|
-
}
|
|
7851
|
-
if (strictFrames.has(frame) || captureClip === null || frame !== this.page?.mainFrame()) {
|
|
7852
|
-
const renderedText = await frame.evaluate(() => document.body?.innerText ?? document.documentElement?.innerText ?? "");
|
|
7853
|
-
if (containsLuhnPanSpan(renderedText))
|
|
7854
|
-
throw new Error("rendered card value");
|
|
7855
|
-
}
|
|
7856
|
-
else {
|
|
7857
|
-
const renderedTextInClip = await frame.evaluate((clip) => {
|
|
7858
|
-
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
7859
|
-
const texts = [];
|
|
7860
|
-
let node = walker.nextNode();
|
|
7861
|
-
while (node !== null) {
|
|
7862
|
-
const text = node.textContent ?? "";
|
|
7863
|
-
if (/\d/.test(text)) {
|
|
7864
|
-
const range = document.createRange();
|
|
7865
|
-
range.selectNodeContents(node);
|
|
7866
|
-
const overlaps = Array.from(range.getClientRects()).some((rect) => rect.left < clip.x + clip.width &&
|
|
7867
|
-
rect.right > clip.x &&
|
|
7868
|
-
rect.top < clip.y + clip.height &&
|
|
7869
|
-
rect.bottom > clip.y);
|
|
7870
|
-
if (overlaps)
|
|
7871
|
-
texts.push(text);
|
|
7872
|
-
}
|
|
7873
|
-
node = walker.nextNode();
|
|
7874
|
-
}
|
|
7875
|
-
return texts.join(" ");
|
|
7876
|
-
}, captureClip);
|
|
7877
|
-
if (containsLuhnPanSpan(renderedTextInClip))
|
|
7878
|
-
throw new Error("rendered card value");
|
|
7879
|
-
}
|
|
7880
|
-
}
|
|
7881
|
-
}
|
|
7882
|
-
catch {
|
|
7883
|
-
throw new Error("screenshot_unavailable_sealed_context");
|
|
7884
|
-
}
|
|
7885
|
-
}
|
|
7886
|
-
async captureOperatorScreenshot(opts = {}, sealedFieldKeys = []) {
|
|
7887
|
-
if (!this.page)
|
|
7888
|
-
throw new Error("Browser not started");
|
|
7889
|
-
const targetFrame = this.resolveOperatorScreenshotFrame(opts);
|
|
7890
|
-
const scope = await this.operatorScreenshotCaptureScope(targetFrame);
|
|
7891
|
-
const { frames } = scope;
|
|
7892
|
-
const documents = [];
|
|
7893
|
-
try {
|
|
7894
|
-
for (const frame of frames) {
|
|
7895
|
-
if (frame.isDetached())
|
|
7896
|
-
throw new Error("frame detached");
|
|
7897
|
-
documents.push(await frame.evaluateHandle(() => document));
|
|
7898
|
-
}
|
|
7899
|
-
const sealedLocators = await this.resolveOperatorScreenshotSealedLocators(frames, new Set(sealedFieldKeys));
|
|
7900
|
-
await this.assertOperatorScreenshotFramesNoSealedValues(frames, [], sealedLocators, scope.clip, scope.strictFrames);
|
|
7901
|
-
const documentsStillCurrent = async () => {
|
|
7902
|
-
if (documents.length !== frames.length)
|
|
7903
|
-
return false;
|
|
7904
|
-
for (let index = 0; index < frames.length; index += 1) {
|
|
7905
|
-
const frame = frames[index];
|
|
7906
|
-
if (frame.isDetached())
|
|
7907
|
-
return false;
|
|
7908
|
-
if (!(await frame.evaluate((expected) => document === expected, documents[index]))) {
|
|
7909
|
-
return false;
|
|
7910
|
-
}
|
|
7911
|
-
}
|
|
7912
|
-
return true;
|
|
7913
|
-
};
|
|
7914
|
-
if (!(await documentsStillCurrent())) {
|
|
7915
|
-
throw new Error("screenshot_unavailable_sealed_context");
|
|
7916
|
-
}
|
|
7917
|
-
let captured;
|
|
7918
|
-
let attemptScope = scope;
|
|
7919
|
-
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
7920
|
-
if (attempt > 0) {
|
|
7921
|
-
attemptScope = await this.operatorScreenshotCaptureScope(targetFrame);
|
|
7922
|
-
if (attemptScope.frames.length !== frames.length ||
|
|
7923
|
-
!frames.every((frame, index) => attemptScope.frames[index] === frame)) {
|
|
7924
|
-
throw new Error("screenshot_unavailable_sealed_context");
|
|
7925
|
-
}
|
|
7926
|
-
}
|
|
7927
|
-
try {
|
|
7928
|
-
captured = await this.screenshotForOperatorResolved(opts, targetFrame, frames, sealedLocators, attemptScope, async () => {
|
|
7929
|
-
if (!(await documentsStillCurrent())) {
|
|
7930
|
-
throw new Error("screenshot_unavailable_sealed_context");
|
|
7931
|
-
}
|
|
7932
|
-
await this.assertOperatorScreenshotFramesNoSealedValues(frames, [], sealedLocators, attemptScope.clip, attemptScope.strictFrames);
|
|
7933
|
-
if (!(await documentsStillCurrent())) {
|
|
7934
|
-
throw new Error("screenshot_unavailable_sealed_context");
|
|
7935
|
-
}
|
|
7936
|
-
});
|
|
7937
|
-
break;
|
|
7938
|
-
}
|
|
7939
|
-
catch (error) {
|
|
7940
|
-
if (attempt === 0 &&
|
|
7941
|
-
error instanceof Error &&
|
|
7942
|
-
error.message === "screenshot_redaction_unstable") {
|
|
7943
|
-
continue;
|
|
7944
|
-
}
|
|
7945
|
-
throw error;
|
|
7946
|
-
}
|
|
7947
|
-
}
|
|
7948
|
-
if (captured === undefined)
|
|
7949
|
-
throw new Error("screenshot_unavailable_sealed_context");
|
|
7950
|
-
if (!(await documentsStillCurrent())) {
|
|
7951
|
-
throw new Error("screenshot_unavailable_sealed_context");
|
|
7952
|
-
}
|
|
7953
|
-
await this.assertOperatorScreenshotFramesNoSealedValues(frames, [], sealedLocators, attemptScope.clip, attemptScope.strictFrames);
|
|
7954
|
-
return captured;
|
|
7955
|
-
}
|
|
7956
|
-
catch (error) {
|
|
7957
|
-
if (error instanceof Error && error.message === "screenshot_frame_not_found")
|
|
7958
|
-
throw error;
|
|
7959
|
-
if (error instanceof Error && error.message === "screenshot_unavailable_sealed_context") {
|
|
7960
|
-
throw error;
|
|
7961
|
-
}
|
|
7962
|
-
throw new Error("screenshot_unavailable_sealed_context");
|
|
7963
|
-
}
|
|
7964
|
-
finally {
|
|
7965
|
-
await Promise.all(documents.map(async (document) => await document.dispose().catch(() => undefined)));
|
|
7966
|
-
}
|
|
7909
|
+
// Read-only pixel capture for operate_screenshot. It never navigates, clicks,
|
|
7910
|
+
// types, focuses, or mutates the DOM — and it never masks anything. What the
|
|
7911
|
+
// page renders is what the driving agent gets back.
|
|
7912
|
+
async captureOperatorScreenshot(opts = {}) {
|
|
7913
|
+
return await this.screenshotForOperator(opts);
|
|
7967
7914
|
}
|
|
7968
|
-
// Low-level read-only capture used by captureOperatorScreenshot after its
|
|
7969
|
-
// capture-scoped sealed-value checks, and directly by redaction tests. It
|
|
7970
|
-
// never navigates, clicks, types, focuses, or mutates the DOM. Redaction
|
|
7971
|
-
// covers every frame included in the image: the whole page composites its
|
|
7972
|
-
// visible frames, while an isolated frame includes only that frame.
|
|
7973
|
-
// `extraRedactionSelectors` is retained for callers that already resolved
|
|
7974
|
-
// additional sensitive elements; production capture passes durable locators.
|
|
7975
7915
|
async screenshotForOperator(opts = {}) {
|
|
7976
|
-
if (!this.page)
|
|
7977
|
-
throw new Error("Browser not started");
|
|
7978
|
-
const targetFrame = this.resolveOperatorScreenshotFrame(opts);
|
|
7979
|
-
const scope = await this.operatorScreenshotCaptureScope(targetFrame);
|
|
7980
|
-
return await this.screenshotForOperatorResolved(opts, targetFrame, scope.frames, new Map(), scope);
|
|
7981
|
-
}
|
|
7982
|
-
async screenshotForOperatorResolved(opts, targetFrame, framesBefore, sealedLocators = new Map(), scope = { frames: [...framesBefore], strictFrames: new Set(framesBefore), clip: null }, beforeCapture) {
|
|
7983
7916
|
if (!this.page)
|
|
7984
7917
|
throw new Error("Browser not started");
|
|
7985
7918
|
const page = this.page;
|
|
7986
|
-
const
|
|
7987
|
-
const
|
|
7988
|
-
const { rectangles, redactedCount, signature } = before;
|
|
7989
|
-
let recheck;
|
|
7919
|
+
const targetFrame = this.resolveOperatorScreenshotFrame(opts);
|
|
7920
|
+
const cdp = await page.context().newCDPSession(page);
|
|
7990
7921
|
try {
|
|
7991
|
-
// caret:"initial"
|
|
7992
|
-
//
|
|
7993
|
-
|
|
7994
|
-
|
|
7995
|
-
|
|
7996
|
-
|
|
7997
|
-
|
|
7998
|
-
|
|
7999
|
-
|
|
8000
|
-
const
|
|
8001
|
-
try {
|
|
8002
|
-
const box = await handle.boundingBox();
|
|
8003
|
-
if (box === null)
|
|
8004
|
-
throw new Error("screenshot_redaction_unresolved");
|
|
8005
|
-
if (scope.clip === null ||
|
|
8006
|
-
box.x !== scope.clip.x ||
|
|
8007
|
-
box.y !== scope.clip.y ||
|
|
8008
|
-
box.width !== scope.clip.width ||
|
|
8009
|
-
box.height !== scope.clip.height) {
|
|
8010
|
-
throw new Error("screenshot_redaction_unresolved");
|
|
8011
|
-
}
|
|
8012
|
-
const scroll = await page.evaluate(() => ({ x: window.scrollX, y: window.scrollY }));
|
|
8013
|
-
origin = { x: box.x, y: box.y };
|
|
8014
|
-
captureSize = { width: box.width, height: box.height };
|
|
8015
|
-
await beforeCapture?.();
|
|
8016
|
-
const result = await cdp.send("Page.captureScreenshot", {
|
|
8017
|
-
format: "jpeg",
|
|
8018
|
-
quality: 80,
|
|
8019
|
-
fromSurface: true,
|
|
8020
|
-
captureBeyondViewport: true,
|
|
8021
|
-
clip: {
|
|
8022
|
-
x: box.x + scroll.x,
|
|
8023
|
-
y: box.y + scroll.y,
|
|
8024
|
-
width: box.width,
|
|
8025
|
-
height: box.height,
|
|
8026
|
-
scale: 1,
|
|
8027
|
-
},
|
|
8028
|
-
});
|
|
8029
|
-
buffer = Buffer.from(result.data, "base64");
|
|
8030
|
-
}
|
|
8031
|
-
finally {
|
|
8032
|
-
await handle.dispose().catch(() => undefined);
|
|
8033
|
-
}
|
|
8034
|
-
}
|
|
8035
|
-
else if (opts.fullPage === true) {
|
|
8036
|
-
const dimensions = await page.evaluate(() => ({
|
|
8037
|
-
origin: { x: -window.scrollX, y: -window.scrollY },
|
|
8038
|
-
size: {
|
|
8039
|
-
width: document.documentElement.scrollWidth,
|
|
8040
|
-
height: document.documentElement.scrollHeight,
|
|
8041
|
-
},
|
|
8042
|
-
}));
|
|
8043
|
-
origin = dimensions.origin;
|
|
8044
|
-
captureSize = dimensions.size;
|
|
8045
|
-
await beforeCapture?.();
|
|
7922
|
+
// caret:"initial" is not needed here — the CDP capture never runs
|
|
7923
|
+
// Playwright's caret-hiding pass, so element styles stay untouched.
|
|
7924
|
+
let base64;
|
|
7925
|
+
if (targetFrame !== null && targetFrame !== page.mainFrame()) {
|
|
7926
|
+
const handle = await targetFrame.frameElement();
|
|
7927
|
+
try {
|
|
7928
|
+
const box = await handle.boundingBox();
|
|
7929
|
+
if (box === null)
|
|
7930
|
+
throw new Error("screenshot_frame_not_visible");
|
|
7931
|
+
const scroll = await page.evaluate(() => ({ x: window.scrollX, y: window.scrollY }));
|
|
8046
7932
|
const result = await cdp.send("Page.captureScreenshot", {
|
|
8047
7933
|
format: "jpeg",
|
|
8048
7934
|
quality: 80,
|
|
8049
7935
|
fromSurface: true,
|
|
8050
7936
|
captureBeyondViewport: true,
|
|
8051
7937
|
clip: {
|
|
8052
|
-
x:
|
|
8053
|
-
y:
|
|
8054
|
-
width:
|
|
8055
|
-
height:
|
|
7938
|
+
x: box.x + scroll.x,
|
|
7939
|
+
y: box.y + scroll.y,
|
|
7940
|
+
width: box.width,
|
|
7941
|
+
height: box.height,
|
|
8056
7942
|
scale: 1,
|
|
8057
7943
|
},
|
|
8058
7944
|
});
|
|
8059
|
-
|
|
7945
|
+
base64 = result.data;
|
|
8060
7946
|
}
|
|
8061
|
-
|
|
8062
|
-
|
|
8063
|
-
if (viewport === null)
|
|
8064
|
-
throw new Error("screenshot_redaction_unresolved");
|
|
8065
|
-
captureSize = viewport;
|
|
8066
|
-
await beforeCapture?.();
|
|
8067
|
-
const result = await cdp.send("Page.captureScreenshot", {
|
|
8068
|
-
format: "jpeg",
|
|
8069
|
-
quality: 80,
|
|
8070
|
-
fromSurface: true,
|
|
8071
|
-
});
|
|
8072
|
-
buffer = Buffer.from(result.data, "base64");
|
|
7947
|
+
finally {
|
|
7948
|
+
await handle.dispose().catch(() => undefined);
|
|
8073
7949
|
}
|
|
8074
7950
|
}
|
|
8075
|
-
|
|
8076
|
-
await
|
|
8077
|
-
|
|
8078
|
-
|
|
8079
|
-
|
|
8080
|
-
|
|
8081
|
-
|
|
8082
|
-
|
|
8083
|
-
|
|
8084
|
-
|
|
8085
|
-
|
|
8086
|
-
|
|
8087
|
-
|
|
8088
|
-
|
|
8089
|
-
|
|
8090
|
-
|
|
8091
|
-
|
|
8092
|
-
|
|
8093
|
-
|
|
8094
|
-
|
|
8095
|
-
|
|
8096
|
-
stable = false;
|
|
8097
|
-
break;
|
|
8098
|
-
}
|
|
8099
|
-
}
|
|
8100
|
-
}
|
|
8101
|
-
}
|
|
8102
|
-
catch {
|
|
8103
|
-
stable = false;
|
|
8104
|
-
}
|
|
7951
|
+
else if (opts.fullPage === true) {
|
|
7952
|
+
const size = await page.evaluate(() => ({
|
|
7953
|
+
width: document.documentElement.scrollWidth,
|
|
7954
|
+
height: document.documentElement.scrollHeight,
|
|
7955
|
+
}));
|
|
7956
|
+
const result = await cdp.send("Page.captureScreenshot", {
|
|
7957
|
+
format: "jpeg",
|
|
7958
|
+
quality: 80,
|
|
7959
|
+
fromSurface: true,
|
|
7960
|
+
captureBeyondViewport: true,
|
|
7961
|
+
clip: { x: 0, y: 0, width: size.width, height: size.height, scale: 1 },
|
|
7962
|
+
});
|
|
7963
|
+
base64 = result.data;
|
|
7964
|
+
}
|
|
7965
|
+
else {
|
|
7966
|
+
const result = await cdp.send("Page.captureScreenshot", {
|
|
7967
|
+
format: "jpeg",
|
|
7968
|
+
quality: 80,
|
|
7969
|
+
fromSurface: true,
|
|
7970
|
+
});
|
|
7971
|
+
base64 = result.data;
|
|
8105
7972
|
}
|
|
8106
|
-
if (!stable)
|
|
8107
|
-
throw new Error("screenshot_redaction_unstable");
|
|
8108
|
-
const base64 = await this.redactOperatorScreenshot(buffer, rectangles, origin, captureSize);
|
|
8109
7973
|
return {
|
|
8110
7974
|
base64,
|
|
8111
7975
|
frameUrl: targetFrame?.url() ?? null,
|
|
8112
7976
|
frameCount: page.frames().length,
|
|
8113
|
-
redactedCount,
|
|
8114
7977
|
};
|
|
8115
7978
|
}
|
|
8116
7979
|
finally {
|
|
8117
|
-
await
|
|
8118
|
-
...before.handles.map(async (handle) => await handle.dispose().catch(() => undefined)),
|
|
8119
|
-
...(recheck?.handles ?? []).map(async (handle) => await handle.dispose().catch(() => undefined)),
|
|
8120
|
-
]);
|
|
7980
|
+
await cdp.detach().catch(() => undefined);
|
|
8121
7981
|
}
|
|
8122
7982
|
}
|
|
8123
7983
|
async getState() {
|
|
@@ -8167,6 +8027,32 @@ export class BrowserController {
|
|
|
8167
8027
|
throw new Error("Browser not started");
|
|
8168
8028
|
return await this.page.evaluate(extractObservationVisibleText);
|
|
8169
8029
|
}
|
|
8030
|
+
/**
|
|
8031
|
+
* Tiny structural source for compact V2. The raw values never leave the
|
|
8032
|
+
* provision session: compact-observation-v2 applies its allowlist seal
|
|
8033
|
+
* before the result is stored, delta'd, or emitted.
|
|
8034
|
+
*/
|
|
8035
|
+
async extractObservationSemantics() {
|
|
8036
|
+
if (!this.page)
|
|
8037
|
+
throw new Error("Browser not started");
|
|
8038
|
+
return await this.page.evaluate(() => {
|
|
8039
|
+
const visible = (element) => {
|
|
8040
|
+
const html = element;
|
|
8041
|
+
const style = window.getComputedStyle(html);
|
|
8042
|
+
const rect = html.getBoundingClientRect();
|
|
8043
|
+
return (style.display !== "none" &&
|
|
8044
|
+
style.visibility !== "hidden" &&
|
|
8045
|
+
rect.width > 0 &&
|
|
8046
|
+
rect.height > 0);
|
|
8047
|
+
};
|
|
8048
|
+
const headings = Array.from(document.querySelectorAll("h1,h2"))
|
|
8049
|
+
.filter(visible)
|
|
8050
|
+
.map((element) => (element.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 160))
|
|
8051
|
+
.filter(Boolean)
|
|
8052
|
+
.slice(0, 2);
|
|
8053
|
+
return { title: document.title.slice(0, 160), headings };
|
|
8054
|
+
});
|
|
8055
|
+
}
|
|
8170
8056
|
async readCheckoutSummary(fallbackCurrency) {
|
|
8171
8057
|
if (!this.page)
|
|
8172
8058
|
throw new Error("Browser not started");
|
|
@@ -8461,6 +8347,28 @@ export class BrowserController {
|
|
|
8461
8347
|
});
|
|
8462
8348
|
return includeDetails ? items : items.map(({ title, quantity }) => ({ title, quantity }));
|
|
8463
8349
|
}
|
|
8350
|
+
// Deterministic cart-normalize primitive: Shopify's standard Ajax Cart API
|
|
8351
|
+
// exposes POST /cart/clear.js on every storefront's own origin — no DOM
|
|
8352
|
+
// markup dependency, so it works regardless of theme. This is how an
|
|
8353
|
+
// operator reaches a KNOWN (empty) cart quantity before a fresh cart_add,
|
|
8354
|
+
// rather than accumulating quantity across separate operate_start sessions
|
|
8355
|
+
// on the shared persistent profile.
|
|
8356
|
+
async clearCart() {
|
|
8357
|
+
if (!this.page)
|
|
8358
|
+
throw new Error("Browser not started");
|
|
8359
|
+
return await this.page.evaluate(async () => {
|
|
8360
|
+
try {
|
|
8361
|
+
const response = await fetch("/cart/clear.js", {
|
|
8362
|
+
method: "POST",
|
|
8363
|
+
headers: { "Content-Type": "application/json" },
|
|
8364
|
+
});
|
|
8365
|
+
return response.ok;
|
|
8366
|
+
}
|
|
8367
|
+
catch {
|
|
8368
|
+
return false;
|
|
8369
|
+
}
|
|
8370
|
+
});
|
|
8371
|
+
}
|
|
8464
8372
|
async readSettledCheckoutReviewSummary(fallbackCurrency, timeoutMs = 12_000) {
|
|
8465
8373
|
if (!this.page)
|
|
8466
8374
|
throw new Error("Browser not started");
|
|
@@ -8521,10 +8429,10 @@ export class BrowserController {
|
|
|
8521
8429
|
}
|
|
8522
8430
|
}
|
|
8523
8431
|
// The first frame that actually renders a visible card (PAN) input, or null.
|
|
8524
|
-
async panFieldFrame() {
|
|
8432
|
+
async panFieldFrame(frames) {
|
|
8525
8433
|
if (!this.page)
|
|
8526
8434
|
return null;
|
|
8527
|
-
for (const frame of this.page.frames()) {
|
|
8435
|
+
for (const frame of frames ?? this.page.frames()) {
|
|
8528
8436
|
const locator = frame.locator(CHECKOUT_PAN_FIELD_SELECTORS);
|
|
8529
8437
|
const count = await locator.count().catch(() => 0);
|
|
8530
8438
|
for (let i = 0; i < count; i += 1) {
|
|
@@ -8537,7 +8445,7 @@ export class BrowserController {
|
|
|
8537
8445
|
}
|
|
8538
8446
|
return null;
|
|
8539
8447
|
}
|
|
8540
|
-
// Bounded wait for a PAN field to appear
|
|
8448
|
+
// Bounded wait for a PAN field to appear in caller-eligible frames. A
|
|
8541
8449
|
// single-page checkout's card entry can live in a cross-origin PCI iframe
|
|
8542
8450
|
// (e.g. Shopify's checkout.pci.shopifyinc.com) that mounts only after the
|
|
8543
8451
|
// payment section itself renders — later than the total becomes readable,
|
|
@@ -8545,7 +8453,7 @@ export class BrowserController {
|
|
|
8545
8453
|
// fillAndSubmitCheckout/fillCheckoutCardFields used to take one frames()
|
|
8546
8454
|
// snapshot at call time; a frame that hadn't mounted YET at that exact
|
|
8547
8455
|
// instant made a genuinely fillable checkout fail closed.
|
|
8548
|
-
async waitForPanField(timeoutMs) {
|
|
8456
|
+
async waitForPanField(timeoutMs, frameAllowed = () => true) {
|
|
8549
8457
|
if (!this.page)
|
|
8550
8458
|
return;
|
|
8551
8459
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -8555,14 +8463,108 @@ export class BrowserController {
|
|
|
8555
8463
|
// CHECKOUT_PAN_FIELD_SELECTORS once stamped — without this,
|
|
8556
8464
|
// panFieldFrame() below can never match and every call here burns its
|
|
8557
8465
|
// full timeoutMs even though the field was on the page from the start.
|
|
8558
|
-
|
|
8559
|
-
|
|
8466
|
+
const frames = this.page.frames().filter(frameAllowed);
|
|
8467
|
+
await this.stampJapaneseCardLabelFields(frames);
|
|
8468
|
+
if ((await this.panFieldFrame(frames)) !== null)
|
|
8560
8469
|
return;
|
|
8561
8470
|
if (Date.now() >= deadline)
|
|
8562
8471
|
return;
|
|
8563
8472
|
await this.page.waitForTimeout(200).catch(() => undefined);
|
|
8564
8473
|
}
|
|
8565
8474
|
}
|
|
8475
|
+
// Split checkout waits are event-driven: a trusted hosted-field frame can
|
|
8476
|
+
// attach as about:blank and only later navigate to its processor URL. Watch
|
|
8477
|
+
// both lifecycle events and then wait inside that exact frame for the PAN.
|
|
8478
|
+
// A settled page with a PAN only in excluded frames returns immediately so
|
|
8479
|
+
// the caller can produce its existing fail-closed frame-origin refusal.
|
|
8480
|
+
async waitForRecognizedPanField(pageUrl, deadline) {
|
|
8481
|
+
if (!this.page)
|
|
8482
|
+
return;
|
|
8483
|
+
const page = this.page;
|
|
8484
|
+
const frameAllowed = (frame) => frame === page.mainFrame() || recognizedPaymentProviderFrame(frame.url(), pageUrl);
|
|
8485
|
+
const remaining = () => deadline === undefined ? 0 : Math.max(0, deadline - Date.now());
|
|
8486
|
+
let done = false;
|
|
8487
|
+
let resolveDone;
|
|
8488
|
+
const complete = () => {
|
|
8489
|
+
if (done)
|
|
8490
|
+
return;
|
|
8491
|
+
done = true;
|
|
8492
|
+
resolveDone();
|
|
8493
|
+
};
|
|
8494
|
+
const completed = new Promise((resolve) => {
|
|
8495
|
+
resolveDone = resolve;
|
|
8496
|
+
});
|
|
8497
|
+
const observeTrustedFrame = (frame) => {
|
|
8498
|
+
if (done || !frameAllowed(frame))
|
|
8499
|
+
return;
|
|
8500
|
+
void (async () => {
|
|
8501
|
+
// JP checkouts can expose their PAN only through an associated label,
|
|
8502
|
+
// so normalize the trusted frame before starting its selector wait.
|
|
8503
|
+
// This preserves the existing conservative label-to-control contract
|
|
8504
|
+
// while never evaluating or mutating an excluded payment frame.
|
|
8505
|
+
await this.stampJapaneseCardLabelFields([frame]);
|
|
8506
|
+
if ((await this.panFieldFrame([frame])) !== null) {
|
|
8507
|
+
complete();
|
|
8508
|
+
return;
|
|
8509
|
+
}
|
|
8510
|
+
await frame
|
|
8511
|
+
.waitForSelector(CHECKOUT_PAN_FIELD_SELECTORS, {
|
|
8512
|
+
state: "visible",
|
|
8513
|
+
timeout: remaining(),
|
|
8514
|
+
})
|
|
8515
|
+
.then(() => complete())
|
|
8516
|
+
.catch(() => undefined);
|
|
8517
|
+
})();
|
|
8518
|
+
};
|
|
8519
|
+
const settleOrRefuse = () => {
|
|
8520
|
+
void page
|
|
8521
|
+
.waitForLoadState("networkidle", { timeout: remaining() })
|
|
8522
|
+
.then(async () => {
|
|
8523
|
+
if (done)
|
|
8524
|
+
return;
|
|
8525
|
+
const frames = page.frames();
|
|
8526
|
+
const trustedFrames = frames.filter(frameAllowed);
|
|
8527
|
+
await this.stampJapaneseCardLabelFields(trustedFrames);
|
|
8528
|
+
if ((await this.panFieldFrame(trustedFrames)) !== null) {
|
|
8529
|
+
complete();
|
|
8530
|
+
return;
|
|
8531
|
+
}
|
|
8532
|
+
// Do not reject while a recognized provider frame is still live:
|
|
8533
|
+
// its PAN may appear after the provider's own hydration work. The
|
|
8534
|
+
// deadline governs that wait; there is no local timing heuristic.
|
|
8535
|
+
if (frames.some((frame) => frame !== page.mainFrame() && frameAllowed(frame)))
|
|
8536
|
+
return;
|
|
8537
|
+
complete();
|
|
8538
|
+
})
|
|
8539
|
+
.catch(() => undefined);
|
|
8540
|
+
};
|
|
8541
|
+
const onFrameLifecycle = (frame) => {
|
|
8542
|
+
observeTrustedFrame(frame);
|
|
8543
|
+
settleOrRefuse();
|
|
8544
|
+
};
|
|
8545
|
+
page.on("frameattached", onFrameLifecycle);
|
|
8546
|
+
page.on("framenavigated", onFrameLifecycle);
|
|
8547
|
+
for (const frame of page.frames())
|
|
8548
|
+
observeTrustedFrame(frame);
|
|
8549
|
+
settleOrRefuse();
|
|
8550
|
+
let deadlineTimer;
|
|
8551
|
+
if (deadline !== undefined) {
|
|
8552
|
+
const timeout = remaining();
|
|
8553
|
+
if (timeout <= 0)
|
|
8554
|
+
complete();
|
|
8555
|
+
else
|
|
8556
|
+
deadlineTimer = setTimeout(complete, timeout);
|
|
8557
|
+
}
|
|
8558
|
+
try {
|
|
8559
|
+
await completed;
|
|
8560
|
+
}
|
|
8561
|
+
finally {
|
|
8562
|
+
if (deadlineTimer !== undefined)
|
|
8563
|
+
clearTimeout(deadlineTimer);
|
|
8564
|
+
page.off("frameattached", onFrameLifecycle);
|
|
8565
|
+
page.off("framenavigated", onFrameLifecycle);
|
|
8566
|
+
}
|
|
8567
|
+
}
|
|
8566
8568
|
// Normalizes conservative PAN name/id conventions and scans
|
|
8567
8569
|
// dt/th/label/"table-label" elements for カード番号 / カード名義 /
|
|
8568
8570
|
// セキュリティコード and stamps the associated single text input with
|
|
@@ -9610,7 +9612,7 @@ export class BrowserController {
|
|
|
9610
9612
|
? undefined
|
|
9611
9613
|
: { selected: cardGroup, groups: [...groups.values()] };
|
|
9612
9614
|
}
|
|
9613
|
-
async fillAndSubmitCheckout(card) {
|
|
9615
|
+
async fillAndSubmitCheckout(card, options = {}) {
|
|
9614
9616
|
if (!this.page)
|
|
9615
9617
|
throw new Error("Browser not started");
|
|
9616
9618
|
this.checkoutCardGroupScope = undefined;
|
|
@@ -9644,7 +9646,7 @@ export class BrowserController {
|
|
|
9644
9646
|
this.rememberPaymentInstrumentExpectation(card);
|
|
9645
9647
|
primary = {
|
|
9646
9648
|
kind: "outcome",
|
|
9647
|
-
value: await this.submitFilledCheckoutInScope(cardGroup),
|
|
9649
|
+
value: await this.submitFilledCheckoutInScope(cardGroup, options.onSubmitDispatched, options.beforeSubmitDispatch),
|
|
9648
9650
|
};
|
|
9649
9651
|
}
|
|
9650
9652
|
catch (error) {
|
|
@@ -9678,7 +9680,7 @@ export class BrowserController {
|
|
|
9678
9680
|
// left behind. On success the filled values STAY in the page (the site
|
|
9679
9681
|
// needs them at its confirm step) marked data-ts-sealed-payment, which
|
|
9680
9682
|
// extractInteractiveElements reports as sealed so observations mask them.
|
|
9681
|
-
async fillCheckoutCardFields(card) {
|
|
9683
|
+
async fillCheckoutCardFields(card, options = {}) {
|
|
9682
9684
|
if (!this.page)
|
|
9683
9685
|
throw new Error("Browser not started");
|
|
9684
9686
|
this.checkoutCardGroupScope = undefined;
|
|
@@ -9687,11 +9689,10 @@ export class BrowserController {
|
|
|
9687
9689
|
if (!recognizedPaymentProviderFrame(pageUrl, pageUrl)) {
|
|
9688
9690
|
throw new Error("payment_checkout_https_required");
|
|
9689
9691
|
}
|
|
9690
|
-
|
|
9691
|
-
|
|
9692
|
-
|
|
9693
|
-
|
|
9694
|
-
await this.waitForPanField(10_000);
|
|
9692
|
+
await this.waitForRecognizedPanField(pageUrl, options.deadline);
|
|
9693
|
+
if (options.deadline !== undefined && Date.now() >= options.deadline) {
|
|
9694
|
+
throw new Error("payment_approval_expired");
|
|
9695
|
+
}
|
|
9695
9696
|
const allowed = page
|
|
9696
9697
|
.frames()
|
|
9697
9698
|
.filter((frame) => frame === page.mainFrame() || recognizedPaymentProviderFrame(frame.url(), pageUrl));
|
|
@@ -9884,7 +9885,7 @@ export class BrowserController {
|
|
|
9884
9885
|
async submitFilledCheckout() {
|
|
9885
9886
|
return await this.submitFilledCheckoutInScope(this.checkoutCardGroupScope);
|
|
9886
9887
|
}
|
|
9887
|
-
async submitFilledCheckoutInScope(cardGroup) {
|
|
9888
|
+
async submitFilledCheckoutInScope(cardGroup, onSubmitDispatched, beforeSubmitDispatch) {
|
|
9888
9889
|
if (!this.page)
|
|
9889
9890
|
throw new Error("Browser not started");
|
|
9890
9891
|
const savedCardSelection = await this.resolveCompetingSavedCardSelection();
|
|
@@ -9894,6 +9895,7 @@ export class BrowserController {
|
|
|
9894
9895
|
let outcomeBaseline;
|
|
9895
9896
|
this.checkoutOutcomeBaseline = undefined;
|
|
9896
9897
|
let submitted = false;
|
|
9898
|
+
let clearSubmittedDispatchTracking = null;
|
|
9897
9899
|
for (const frame of this.page.frames()) {
|
|
9898
9900
|
const matches = frame.locator('button,input[type="submit"],[role="button"]');
|
|
9899
9901
|
const count = Math.min(await matches.count().catch(() => 0), 100);
|
|
@@ -9943,115 +9945,147 @@ export class BrowserController {
|
|
|
9943
9945
|
if (!CHECKOUT_SUBMIT_LABEL_RE.test(label))
|
|
9944
9946
|
continue;
|
|
9945
9947
|
const dispatchToken = `ts-payment-submit-${this.checkoutSubmitSequence++}`;
|
|
9946
|
-
const dispatchBaselineStorageKey = `__trusty_squire_payment_baseline_${dispatchToken}`;
|
|
9947
9948
|
const preDispatchFrameUrls = this.page.frames().map((pageFrame) => pageFrame.url());
|
|
9949
|
+
const clickOnlyOutcomeBaseline = checkoutOutcomeBaselineFromDispatchSnapshot({
|
|
9950
|
+
url: this.page.url(),
|
|
9951
|
+
urls: preDispatchFrameUrls,
|
|
9952
|
+
});
|
|
9953
|
+
let submitDispatchedReported = false;
|
|
9954
|
+
const reportSubmitDispatched = () => {
|
|
9955
|
+
if (submitDispatchedReported)
|
|
9956
|
+
return;
|
|
9957
|
+
onSubmitDispatched?.();
|
|
9958
|
+
submitDispatchedReported = true;
|
|
9959
|
+
};
|
|
9948
9960
|
const dispatchTrackingInstalled = await candidate
|
|
9949
|
-
.evaluate((element,
|
|
9950
|
-
const { baselineStorageKey, frameUrls, token } = options;
|
|
9961
|
+
.evaluate((element, token) => {
|
|
9951
9962
|
const stateWindow = window;
|
|
9952
|
-
const tracked = element;
|
|
9953
|
-
|
|
9954
|
-
|
|
9955
|
-
|
|
9956
|
-
|
|
9957
|
-
token,
|
|
9958
|
-
dispatched: false,
|
|
9959
|
-
};
|
|
9960
|
-
const listener = () => {
|
|
9961
|
-
const state = stateWindow.__trustySquirePaymentSubmitDispatch;
|
|
9962
|
-
if (state?.token !== token)
|
|
9963
|
-
return;
|
|
9964
|
-
try {
|
|
9965
|
-
const merchantWindow = window.top;
|
|
9966
|
-
if (merchantWindow === null)
|
|
9967
|
-
return;
|
|
9968
|
-
const urls = new Set(frameUrls);
|
|
9969
|
-
const collectFrameUrls = (currentWindow) => {
|
|
9970
|
-
try {
|
|
9971
|
-
urls.add(currentWindow.location.href);
|
|
9972
|
-
for (let index = 0; index < currentWindow.frames.length; index += 1) {
|
|
9973
|
-
collectFrameUrls(currentWindow.frames[index]);
|
|
9974
|
-
}
|
|
9975
|
-
}
|
|
9976
|
-
catch (error) {
|
|
9977
|
-
void error;
|
|
9978
|
-
}
|
|
9979
|
-
};
|
|
9980
|
-
collectFrameUrls(merchantWindow);
|
|
9981
|
-
const snapshot = {
|
|
9982
|
-
url: merchantWindow.location.href,
|
|
9983
|
-
urls: [...urls],
|
|
9984
|
-
};
|
|
9985
|
-
const baselineWindow = merchantWindow;
|
|
9986
|
-
baselineWindow.__trustySquirePaymentDispatchBaselines ??= {};
|
|
9987
|
-
baselineWindow.__trustySquirePaymentDispatchBaselines[token] = snapshot;
|
|
9988
|
-
try {
|
|
9989
|
-
merchantWindow.sessionStorage.setItem(baselineStorageKey, JSON.stringify(snapshot));
|
|
9990
|
-
}
|
|
9991
|
-
catch (error) {
|
|
9992
|
-
void error;
|
|
9993
|
-
}
|
|
9994
|
-
}
|
|
9995
|
-
finally {
|
|
9996
|
-
state.dispatched = true;
|
|
9963
|
+
const tracked = element;
|
|
9964
|
+
const priorTracking = tracked.__tsPaymentSubmitDispatchListeners;
|
|
9965
|
+
if (priorTracking !== undefined) {
|
|
9966
|
+
for (const registration of priorTracking) {
|
|
9967
|
+
registration.target.removeEventListener(registration.event, registration.listener, registration.capture);
|
|
9997
9968
|
}
|
|
9969
|
+
}
|
|
9970
|
+
stateWindow.__trustySquirePaymentSubmitDispatch = {
|
|
9971
|
+
token,
|
|
9972
|
+
validationBlocked: false,
|
|
9998
9973
|
};
|
|
9999
|
-
|
|
10000
|
-
|
|
10001
|
-
|
|
10002
|
-
|
|
10003
|
-
|
|
10004
|
-
|
|
10005
|
-
|
|
9974
|
+
const form = element instanceof HTMLButtonElement || element instanceof HTMLInputElement
|
|
9975
|
+
? element.form
|
|
9976
|
+
: element.closest("form");
|
|
9977
|
+
const submitTargets = form !== null ? [form] : Array.from(document.forms);
|
|
9978
|
+
const registrations = [];
|
|
9979
|
+
const invalidListener = () => {
|
|
9980
|
+
const state = stateWindow.__trustySquirePaymentSubmitDispatch;
|
|
9981
|
+
if (state?.token === token)
|
|
9982
|
+
state.validationBlocked = true;
|
|
9983
|
+
};
|
|
9984
|
+
registrations.push(...submitTargets.map((target) => ({
|
|
9985
|
+
capture: true,
|
|
9986
|
+
event: "invalid",
|
|
9987
|
+
listener: invalidListener,
|
|
9988
|
+
target,
|
|
9989
|
+
})));
|
|
9990
|
+
tracked.__tsPaymentSubmitDispatchListeners = registrations;
|
|
9991
|
+
for (const registration of registrations) {
|
|
9992
|
+
registration.target.addEventListener(registration.event, registration.listener, {
|
|
9993
|
+
capture: registration.capture,
|
|
9994
|
+
once: true,
|
|
9995
|
+
});
|
|
9996
|
+
}
|
|
9997
|
+
}, dispatchToken)
|
|
10006
9998
|
.then(() => true)
|
|
10007
9999
|
.catch(() => false);
|
|
10008
|
-
if (!dispatchTrackingInstalled)
|
|
10000
|
+
if (!dispatchTrackingInstalled) {
|
|
10009
10001
|
continue;
|
|
10010
|
-
|
|
10011
|
-
|
|
10012
|
-
|
|
10013
|
-
|
|
10014
|
-
|
|
10015
|
-
|
|
10016
|
-
|
|
10017
|
-
|
|
10018
|
-
|
|
10019
|
-
|
|
10020
|
-
|
|
10021
|
-
|
|
10022
|
-
|
|
10023
|
-
|
|
10024
|
-
|
|
10025
|
-
|
|
10026
|
-
|
|
10027
|
-
|
|
10028
|
-
|
|
10029
|
-
|
|
10030
|
-
|
|
10002
|
+
}
|
|
10003
|
+
let paymentRequestTrackingArmed = false;
|
|
10004
|
+
let concretePaymentRequestObserved = false;
|
|
10005
|
+
let resolveConcretePaymentRequest = () => undefined;
|
|
10006
|
+
const concretePaymentRequest = new Promise((resolve) => {
|
|
10007
|
+
resolveConcretePaymentRequest = resolve;
|
|
10008
|
+
});
|
|
10009
|
+
let navigationObserved = false;
|
|
10010
|
+
let navigationTerminalObserved = false;
|
|
10011
|
+
let navigationThreeDsObserved = false;
|
|
10012
|
+
let resolveNavigationOutcome = () => undefined;
|
|
10013
|
+
const navigationOutcome = new Promise((resolve) => {
|
|
10014
|
+
resolveNavigationOutcome = resolve;
|
|
10015
|
+
});
|
|
10016
|
+
const paymentRequestListener = (request) => {
|
|
10017
|
+
const activePage = this.page;
|
|
10018
|
+
if (!paymentRequestTrackingArmed || activePage === null)
|
|
10019
|
+
return;
|
|
10020
|
+
let sourceFrame;
|
|
10021
|
+
try {
|
|
10022
|
+
sourceFrame = request.frame();
|
|
10023
|
+
}
|
|
10024
|
+
catch {
|
|
10025
|
+
return;
|
|
10026
|
+
}
|
|
10027
|
+
if (sourceFrame !== frame && sourceFrame !== activePage.mainFrame())
|
|
10028
|
+
return;
|
|
10029
|
+
if (!isCheckoutPaymentRequest(request))
|
|
10030
|
+
return;
|
|
10031
|
+
concretePaymentRequestObserved = true;
|
|
10032
|
+
resolveConcretePaymentRequest();
|
|
10033
|
+
};
|
|
10034
|
+
const navigationListener = () => {
|
|
10035
|
+
if (!paymentRequestTrackingArmed || this.page === null)
|
|
10036
|
+
return;
|
|
10037
|
+
navigationObserved = true;
|
|
10038
|
+
void (async () => {
|
|
10039
|
+
if (await this.hasConfirmedCheckoutOutcome(clickOnlyOutcomeBaseline)) {
|
|
10040
|
+
navigationTerminalObserved = true;
|
|
10041
|
+
resolveNavigationOutcome();
|
|
10042
|
+
return;
|
|
10031
10043
|
}
|
|
10032
|
-
|
|
10033
|
-
|
|
10044
|
+
const challenge = await this.detectThreeDsChallenge().catch(() => undefined);
|
|
10045
|
+
if (challenge?.three_ds_required === true) {
|
|
10046
|
+
navigationThreeDsObserved = true;
|
|
10047
|
+
resolveNavigationOutcome();
|
|
10034
10048
|
}
|
|
10035
|
-
|
|
10036
|
-
|
|
10037
|
-
|
|
10038
|
-
|
|
10039
|
-
|
|
10040
|
-
|
|
10041
|
-
|
|
10042
|
-
|
|
10043
|
-
|
|
10044
|
-
|
|
10049
|
+
})();
|
|
10050
|
+
};
|
|
10051
|
+
this.page.on("request", paymentRequestListener);
|
|
10052
|
+
this.page.on("framenavigated", navigationListener);
|
|
10053
|
+
const waitForDispatchEvidence = async () => {
|
|
10054
|
+
let timer;
|
|
10055
|
+
await Promise.race([
|
|
10056
|
+
concretePaymentRequest,
|
|
10057
|
+
navigationOutcome,
|
|
10058
|
+
new Promise((resolve) => {
|
|
10059
|
+
timer = setTimeout(resolve, CHECKOUT_PAYMENT_REQUEST_OBSERVATION_MS);
|
|
10060
|
+
}),
|
|
10061
|
+
]);
|
|
10062
|
+
if (timer !== undefined)
|
|
10063
|
+
clearTimeout(timer);
|
|
10045
10064
|
};
|
|
10065
|
+
const readDispatchState = async () => await frame
|
|
10066
|
+
.evaluate((token) => {
|
|
10067
|
+
const stateWindow = window;
|
|
10068
|
+
const state = stateWindow.__trustySquirePaymentSubmitDispatch;
|
|
10069
|
+
return {
|
|
10070
|
+
validationBlocked: state?.token === token && state.validationBlocked,
|
|
10071
|
+
};
|
|
10072
|
+
}, dispatchToken)
|
|
10073
|
+
.catch(() => null);
|
|
10046
10074
|
const clearDispatchTracking = async () => {
|
|
10075
|
+
paymentRequestTrackingArmed = false;
|
|
10076
|
+
this.page?.off("request", paymentRequestListener);
|
|
10077
|
+
this.page?.off("framenavigated", navigationListener);
|
|
10047
10078
|
await candidate
|
|
10048
10079
|
.evaluate((element) => {
|
|
10049
10080
|
const tracked = element;
|
|
10050
|
-
|
|
10051
|
-
|
|
10052
|
-
|
|
10081
|
+
const tracking = tracked.__tsPaymentSubmitDispatchListeners;
|
|
10082
|
+
if (tracking !== undefined) {
|
|
10083
|
+
for (const registration of tracking) {
|
|
10084
|
+
registration.target.removeEventListener(registration.event, registration.listener, registration.capture);
|
|
10085
|
+
}
|
|
10086
|
+
delete tracked.__tsPaymentSubmitDispatchListeners;
|
|
10053
10087
|
}
|
|
10054
|
-
})
|
|
10088
|
+
}, undefined, { timeout: 250 })
|
|
10055
10089
|
.catch(() => undefined);
|
|
10056
10090
|
await frame
|
|
10057
10091
|
.evaluate((token) => {
|
|
@@ -10061,7 +10095,6 @@ export class BrowserController {
|
|
|
10061
10095
|
}
|
|
10062
10096
|
}, dispatchToken)
|
|
10063
10097
|
.catch(() => undefined);
|
|
10064
|
-
await readDispatchOutcomeBaseline();
|
|
10065
10098
|
};
|
|
10066
10099
|
// Money-fence boundary: the async pay-button scan above (visibility/
|
|
10067
10100
|
// enabled/ownership/label checks, dispatch-tracking install) is a real
|
|
@@ -10071,42 +10104,76 @@ export class BrowserController {
|
|
|
10071
10104
|
// can themselves trigger a merchant default-selection revert; the
|
|
10072
10105
|
// re-verification is then the LAST thing before the charge click is
|
|
10073
10106
|
// dispatched — never proceed on a stale check.
|
|
10074
|
-
|
|
10075
|
-
|
|
10107
|
+
let capturedBaseline = null;
|
|
10108
|
+
try {
|
|
10109
|
+
await this.page.bringToFront().catch(() => undefined);
|
|
10110
|
+
await candidate.click({ trial: true });
|
|
10111
|
+
if (!(await this.savedCardSelectionVerified(savedCardSelection.verification))) {
|
|
10112
|
+
throw new Error("payment_card_selection_ambiguous");
|
|
10113
|
+
}
|
|
10114
|
+
capturedBaseline = await runCaptureConfirmedPaymentSubmit({
|
|
10115
|
+
click: async (markInputDispatchPossible) => {
|
|
10116
|
+
let remainingMs;
|
|
10117
|
+
try {
|
|
10118
|
+
remainingMs = beforeSubmitDispatch?.();
|
|
10119
|
+
}
|
|
10120
|
+
catch (error) {
|
|
10121
|
+
throw new BrowserClickDispatchError("not_dispatched", error);
|
|
10122
|
+
}
|
|
10123
|
+
paymentRequestTrackingArmed = true;
|
|
10124
|
+
markInputDispatchPossible();
|
|
10125
|
+
await candidate.click({
|
|
10126
|
+
noWaitAfter: true,
|
|
10127
|
+
...(typeof remainingMs === "number"
|
|
10128
|
+
? { timeout: Math.max(1, Math.ceil(remainingMs)) }
|
|
10129
|
+
: {}),
|
|
10130
|
+
});
|
|
10131
|
+
},
|
|
10132
|
+
readEvidence: async () => {
|
|
10133
|
+
await waitForDispatchEvidence();
|
|
10134
|
+
const dispatchState = await readDispatchState();
|
|
10135
|
+
const clickOnlyOutcomeConfirmed = navigationTerminalObserved ||
|
|
10136
|
+
(await this.hasConfirmedCheckoutOutcome(clickOnlyOutcomeBaseline));
|
|
10137
|
+
const clickOnlyThreeDsObserved = navigationObserved &&
|
|
10138
|
+
(navigationThreeDsObserved ||
|
|
10139
|
+
(await this.detectThreeDsChallenge().catch(() => undefined))
|
|
10140
|
+
?.three_ds_required === true);
|
|
10141
|
+
const clickOnlyDispatchObserved = (concretePaymentRequestObserved && dispatchState?.validationBlocked !== true) ||
|
|
10142
|
+
clickOnlyOutcomeConfirmed ||
|
|
10143
|
+
clickOnlyThreeDsObserved;
|
|
10144
|
+
return {
|
|
10145
|
+
baseline: clickOnlyDispatchObserved ? clickOnlyOutcomeBaseline : null,
|
|
10146
|
+
dispatched: clickOnlyDispatchObserved,
|
|
10147
|
+
};
|
|
10148
|
+
},
|
|
10149
|
+
clear: async () => undefined,
|
|
10150
|
+
onSubmitDispatched: reportSubmitDispatched,
|
|
10151
|
+
});
|
|
10152
|
+
clearSubmittedDispatchTracking = clearDispatchTracking;
|
|
10153
|
+
}
|
|
10154
|
+
catch (error) {
|
|
10076
10155
|
await clearDispatchTracking();
|
|
10077
|
-
throw
|
|
10156
|
+
throw error;
|
|
10078
10157
|
}
|
|
10079
10158
|
try {
|
|
10080
|
-
await
|
|
10159
|
+
outcomeBaseline = capturedBaseline ?? (await this.captureCheckoutOutcomeBaseline());
|
|
10081
10160
|
}
|
|
10082
10161
|
catch (error) {
|
|
10083
|
-
|
|
10084
|
-
|
|
10085
|
-
|
|
10086
|
-
const state = stateWindow.__trustySquirePaymentSubmitDispatch;
|
|
10087
|
-
return {
|
|
10088
|
-
sameDocument: state?.token === token,
|
|
10089
|
-
dispatched: state?.token === token && state.dispatched,
|
|
10090
|
-
};
|
|
10091
|
-
}, dispatchToken)
|
|
10092
|
-
.catch(() => null);
|
|
10093
|
-
await clearDispatchTracking();
|
|
10094
|
-
if (dispatchState?.sameDocument === true && !dispatchState.dispatched)
|
|
10095
|
-
throw error;
|
|
10096
|
-
throw new PaymentSubmitOutcomeUnknownError();
|
|
10162
|
+
await clearSubmittedDispatchTracking?.();
|
|
10163
|
+
clearSubmittedDispatchTracking = null;
|
|
10164
|
+
throw error;
|
|
10097
10165
|
}
|
|
10098
|
-
outcomeBaseline =
|
|
10099
|
-
(await readDispatchOutcomeBaseline()) ?? (await this.captureCheckoutOutcomeBaseline());
|
|
10100
10166
|
this.checkoutOutcomeBaseline = outcomeBaseline;
|
|
10101
|
-
await clearDispatchTracking();
|
|
10102
10167
|
submitted = true;
|
|
10103
10168
|
break;
|
|
10104
10169
|
}
|
|
10105
10170
|
if (submitted)
|
|
10106
10171
|
break;
|
|
10107
10172
|
}
|
|
10108
|
-
if (!submitted || outcomeBaseline === undefined)
|
|
10173
|
+
if (!submitted || outcomeBaseline === undefined) {
|
|
10174
|
+
await clearSubmittedDispatchTracking?.();
|
|
10109
10175
|
throw new Error("payment_submit_not_found");
|
|
10176
|
+
}
|
|
10110
10177
|
try {
|
|
10111
10178
|
const challengeDeadline = Date.now() + 15_000;
|
|
10112
10179
|
while (Date.now() < challengeDeadline) {
|
|
@@ -10114,17 +10181,23 @@ export class BrowserController {
|
|
|
10114
10181
|
return { three_ds_required: false, order_confirmed: true };
|
|
10115
10182
|
}
|
|
10116
10183
|
const challenge = await this.detectThreeDsChallenge();
|
|
10117
|
-
if (challenge.three_ds_required)
|
|
10184
|
+
if (challenge.three_ds_required) {
|
|
10118
10185
|
return challenge;
|
|
10186
|
+
}
|
|
10119
10187
|
await this.page.waitForTimeout(250).catch(() => undefined);
|
|
10120
10188
|
}
|
|
10121
10189
|
return { three_ds_required: false, order_confirmed: false };
|
|
10122
10190
|
}
|
|
10123
10191
|
catch (error) {
|
|
10124
|
-
if (error instanceof PaymentSubmitOutcomeUnknownError
|
|
10192
|
+
if (error instanceof PaymentSubmitOutcomeUnknownError ||
|
|
10193
|
+
error instanceof BrowserClickDispatchError) {
|
|
10125
10194
|
throw error;
|
|
10195
|
+
}
|
|
10126
10196
|
throw new PaymentSubmitOutcomeUnknownError();
|
|
10127
10197
|
}
|
|
10198
|
+
finally {
|
|
10199
|
+
await clearSubmittedDispatchTracking?.();
|
|
10200
|
+
}
|
|
10128
10201
|
}
|
|
10129
10202
|
async clearSealedPaymentFields() {
|
|
10130
10203
|
this.checkoutCardGroupScope = undefined;
|
|
@@ -10586,9 +10659,23 @@ export class BrowserController {
|
|
|
10586
10659
|
catch {
|
|
10587
10660
|
sameCheckoutOrigin = current.url === baseline.url;
|
|
10588
10661
|
}
|
|
10589
|
-
|
|
10662
|
+
const newTerminalOrderIdentity = sameCheckoutOrigin &&
|
|
10590
10663
|
current.terminalUrlIdentity !== null &&
|
|
10591
|
-
!baseline.orderUrlIdentities.includes(current.terminalUrlIdentity)
|
|
10664
|
+
!baseline.orderUrlIdentities.includes(current.terminalUrlIdentity);
|
|
10665
|
+
if (newTerminalOrderIdentity)
|
|
10666
|
+
return true;
|
|
10667
|
+
if (!sameCheckoutOrigin ||
|
|
10668
|
+
current.url === baseline.url ||
|
|
10669
|
+
!isShopifyCheckoutThankYouRoute(current.url)) {
|
|
10670
|
+
return false;
|
|
10671
|
+
}
|
|
10672
|
+
return await this.page.mainFrame().evaluate(() => {
|
|
10673
|
+
const visibleText = document.body?.innerText ?? "";
|
|
10674
|
+
const confirmationNumber = /\bconfirmation\s*#\s*[a-z0-9][a-z0-9-]{3,}\b/i.test(visibleText);
|
|
10675
|
+
const confirmedOrder = /\byour order is confirmed\b/i.test(visibleText);
|
|
10676
|
+
const thankYouHeading = Array.from(document.querySelectorAll("h1, h2, [role=heading]")).some((element) => /\bthank you\b/i.test(element.textContent ?? ""));
|
|
10677
|
+
return confirmedOrder || (thankYouHeading && confirmationNumber);
|
|
10678
|
+
});
|
|
10592
10679
|
}
|
|
10593
10680
|
// Let the browser complete the challenge natively (including out-of-band
|
|
10594
10681
|
// bank-app 3DS): just poll for the same terminal-order signal a plain
|
|
@@ -10601,11 +10688,14 @@ export class BrowserController {
|
|
|
10601
10688
|
const failureText = /(?:payment|card|transaction) (?:was )?declined|authentication failed|could not be (?:authenticated|processed|completed)|(?:please )?try (?:a |another )?(?:different )?card|3-?d ?secure (?:failed|unsuccessful)/i;
|
|
10602
10689
|
const deadline = Date.now() + Math.max(timeoutMs, 0);
|
|
10603
10690
|
const mismatchAtEntry = this.observedPaymentInstrumentMismatch;
|
|
10691
|
+
let challengeObserved = false;
|
|
10604
10692
|
while (true) {
|
|
10605
10693
|
await this.page.bringToFront().catch(() => undefined);
|
|
10606
|
-
await this.detectThreeDsChallenge().catch(() => undefined);
|
|
10694
|
+
const challenge = await this.detectThreeDsChallenge().catch(() => undefined);
|
|
10695
|
+
if (challenge?.three_ds_required === true)
|
|
10696
|
+
challengeObserved = true;
|
|
10607
10697
|
if (mismatchAtEntry === undefined && this.observedPaymentInstrumentMismatch !== undefined) {
|
|
10608
|
-
return "timeout";
|
|
10698
|
+
return challengeObserved ? "challenge_pending" : "timeout";
|
|
10609
10699
|
}
|
|
10610
10700
|
if (await this.hasConfirmedCheckoutOutcome(outcomeBaseline))
|
|
10611
10701
|
return "succeeded";
|
|
@@ -10617,7 +10707,7 @@ export class BrowserController {
|
|
|
10617
10707
|
return "failed";
|
|
10618
10708
|
const remainingMs = deadline - Date.now();
|
|
10619
10709
|
if (remainingMs <= 0)
|
|
10620
|
-
return "timeout";
|
|
10710
|
+
return challengeObserved ? "challenge_pending" : "timeout";
|
|
10621
10711
|
await this.page.waitForTimeout(Math.min(1_000, remainingMs)).catch(() => undefined);
|
|
10622
10712
|
}
|
|
10623
10713
|
}
|
|
@@ -12379,6 +12469,18 @@ export class BrowserController {
|
|
|
12379
12469
|
el instanceof HTMLTextAreaElement ||
|
|
12380
12470
|
el instanceof HTMLSelectElement;
|
|
12381
12471
|
const regionFor = (el) => el.closest('[role="dialog"],dialog,[aria-modal="true"],nav,main,header,footer,aside,form,section,article');
|
|
12472
|
+
const regionIds = new Map();
|
|
12473
|
+
let nextRegionId = 1;
|
|
12474
|
+
const regionId = (region) => {
|
|
12475
|
+
if (region === null)
|
|
12476
|
+
return null;
|
|
12477
|
+
const existing = regionIds.get(region);
|
|
12478
|
+
if (existing !== undefined)
|
|
12479
|
+
return existing;
|
|
12480
|
+
const id = nextRegionId++;
|
|
12481
|
+
regionIds.set(region, id);
|
|
12482
|
+
return id;
|
|
12483
|
+
};
|
|
12382
12484
|
const regionName = (region) => {
|
|
12383
12485
|
if (region === null)
|
|
12384
12486
|
return null;
|
|
@@ -12666,8 +12768,11 @@ export class BrowserController {
|
|
|
12666
12768
|
// matches it and the OAuth-first scan picks it up.
|
|
12667
12769
|
const isGoogleGSIIframe = el instanceof HTMLIFrameElement &&
|
|
12668
12770
|
(el.getAttribute("src") ?? "").includes("accounts.google.com/gsi/button");
|
|
12669
|
-
const
|
|
12771
|
+
const region = regionFor(el);
|
|
12772
|
+
const container = regionName(region);
|
|
12773
|
+
const containerId = regionId(region);
|
|
12670
12774
|
const inDialog = nearestModalRegion(el) !== null;
|
|
12775
|
+
const formId = regionId(el.closest("form"));
|
|
12671
12776
|
const status = topmostStatus(el);
|
|
12672
12777
|
const pathLabel = isGoogleGSIIframe
|
|
12673
12778
|
? "Continue with Google"
|
|
@@ -12733,6 +12838,8 @@ export class BrowserController {
|
|
|
12733
12838
|
checked: el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio")
|
|
12734
12839
|
? el.checked
|
|
12735
12840
|
: null,
|
|
12841
|
+
disabled: el.matches(":disabled") || el.getAttribute("aria-disabled") === "true" ? true : null,
|
|
12842
|
+
required: el.matches(":required") || el.getAttribute("aria-required") === "true" ? true : null,
|
|
12736
12843
|
// For <select>: the currently-selected option's visible text
|
|
12737
12844
|
// and a short list of available option labels. The combination
|
|
12738
12845
|
// is how the planner detects the "React-defaulted dropdown"
|
|
@@ -12756,12 +12863,12 @@ export class BrowserController {
|
|
|
12756
12863
|
? clean(el.options[el.selectedIndex]?.textContent ?? null)
|
|
12757
12864
|
: null,
|
|
12758
12865
|
interactedThisRun: el.getAttribute("data-ts-touched") === "1",
|
|
12759
|
-
sealed: el.getAttribute("data-ts-sealed-payment") === "1",
|
|
12760
|
-
sealedOrdinal: Array.from(el.getRootNode().querySelectorAll("input,textarea,select,[contenteditable='true']")).indexOf(el),
|
|
12761
12866
|
screenPath: `${container ?? "body:root"} > ${elementKind(el)}:` +
|
|
12762
12867
|
slug(pathLabel, `${elementKind(el)}-${out.length}`),
|
|
12763
12868
|
container,
|
|
12764
12869
|
inDialog,
|
|
12870
|
+
containerId,
|
|
12871
|
+
formId,
|
|
12765
12872
|
topmost: status.topmost,
|
|
12766
12873
|
occludedBy: status.occludedBy,
|
|
12767
12874
|
});
|
|
@@ -13101,20 +13208,16 @@ export class BrowserController {
|
|
|
13101
13208
|
const frame = await handle.ownerFrame();
|
|
13102
13209
|
if (frame === null)
|
|
13103
13210
|
throw new Error("type target has no owning frame");
|
|
13104
|
-
const sealedFieldKeys = sealed
|
|
13105
|
-
? await this.operatorScreenshotIdentityKeys(handle, frame)
|
|
13106
|
-
: [];
|
|
13107
13211
|
if (sealed) {
|
|
13108
13212
|
await handle.evaluate((el) => el.setAttribute("data-ts-sealed-payment", "1"));
|
|
13109
13213
|
}
|
|
13110
13214
|
if (!this.humanize) {
|
|
13111
13215
|
await handle.fill(text);
|
|
13112
|
-
return
|
|
13216
|
+
return;
|
|
13113
13217
|
}
|
|
13114
13218
|
await handle.click({ timeout: 8000 }).catch(() => undefined);
|
|
13115
13219
|
await handle.fill("").catch(() => undefined);
|
|
13116
13220
|
await handle.type(text, { delay: rand(40, 110) });
|
|
13117
|
-
return sealedFieldKeys;
|
|
13118
13221
|
}
|
|
13119
13222
|
finally {
|
|
13120
13223
|
await handle.dispose().catch(() => undefined);
|
|
@@ -13192,21 +13295,8 @@ export class BrowserController {
|
|
|
13192
13295
|
const page = this.page;
|
|
13193
13296
|
const mainRaw = await this.extractElementsFromContext(page);
|
|
13194
13297
|
const mainGroups = assignCardRadioGroups(mainRaw.clusterMeta);
|
|
13195
|
-
const mainDocumentIdentity = await this.sealedDocumentIdentity(page.mainFrame());
|
|
13196
13298
|
const mainElements = mainRaw.out.map((e, i) => ({
|
|
13197
13299
|
...e,
|
|
13198
|
-
sealedIdentityKeys: sealedElementSemanticKeys({
|
|
13199
|
-
tag: e.tag,
|
|
13200
|
-
type: e.type,
|
|
13201
|
-
id: e.id,
|
|
13202
|
-
name: e.name,
|
|
13203
|
-
testId: e.testId,
|
|
13204
|
-
labelText: e.labelText,
|
|
13205
|
-
ariaLabel: e.ariaLabel,
|
|
13206
|
-
placeholder: e.placeholder,
|
|
13207
|
-
landmark: e.landmark,
|
|
13208
|
-
ordinal: e.sealedOrdinal,
|
|
13209
|
-
}).map((key) => `${mainDocumentIdentity}:${key}`),
|
|
13210
13300
|
cardRadioGroup: mainGroups[i] ?? null,
|
|
13211
13301
|
frameOrigin: null,
|
|
13212
13302
|
frameUrl: null,
|
|
@@ -13238,23 +13328,10 @@ export class BrowserController {
|
|
|
13238
13328
|
const raw = await this.extractElementsFromContext(frame);
|
|
13239
13329
|
const security = await this.frameSecurity(frame);
|
|
13240
13330
|
const frameOrigin = security.origin;
|
|
13241
|
-
const frameDocumentIdentity = await this.sealedDocumentIdentity(frame);
|
|
13242
13331
|
const groups = assignCardRadioGroups(raw.clusterMeta);
|
|
13243
13332
|
for (const [i, e] of raw.out.entries()) {
|
|
13244
13333
|
framedElements.push({
|
|
13245
13334
|
...e,
|
|
13246
|
-
sealedIdentityKeys: sealedElementSemanticKeys({
|
|
13247
|
-
tag: e.tag,
|
|
13248
|
-
type: e.type,
|
|
13249
|
-
id: e.id,
|
|
13250
|
-
name: e.name,
|
|
13251
|
-
testId: e.testId,
|
|
13252
|
-
labelText: e.labelText,
|
|
13253
|
-
ariaLabel: e.ariaLabel,
|
|
13254
|
-
placeholder: e.placeholder,
|
|
13255
|
-
landmark: e.landmark,
|
|
13256
|
-
ordinal: e.sealedOrdinal,
|
|
13257
|
-
}).map((key) => `${frameDocumentIdentity}:${key}`),
|
|
13258
13335
|
cardRadioGroup: groups[i] ?? null,
|
|
13259
13336
|
frameOrigin,
|
|
13260
13337
|
frameUrl,
|
|
@@ -13330,7 +13407,6 @@ export class BrowserController {
|
|
|
13330
13407
|
async startOAuth(selector) {
|
|
13331
13408
|
if (!this.page || !this.context)
|
|
13332
13409
|
throw new Error("Browser not started");
|
|
13333
|
-
this.maybeAttachOAuthNetListener();
|
|
13334
13410
|
if (!/accounts\.google\.com|github\.com\/login|login\.microsoftonline\.com/i.test(this.page.url())) {
|
|
13335
13411
|
this.oauthProductPage = this.page;
|
|
13336
13412
|
}
|
|
@@ -13367,30 +13443,46 @@ export class BrowserController {
|
|
|
13367
13443
|
// clicks the product's already-observed OAuth control and waits for the
|
|
13368
13444
|
// provider-owned window to finish; no credentials, frames, or consent scopes
|
|
13369
13445
|
// are read or bypassed here.
|
|
13370
|
-
async loginWithOAuth(selector, settleTimeoutMs =
|
|
13446
|
+
async loginWithOAuth(selector, settleTimeoutMs = 30_000, consentProvider, expectedGoogleAccountEmail) {
|
|
13371
13447
|
const product = this.page;
|
|
13372
13448
|
const context = this.context;
|
|
13373
13449
|
if (product === null || product.isClosed() || context === null) {
|
|
13374
13450
|
throw new Error("OAuth login cannot start because the product page is unavailable");
|
|
13375
13451
|
}
|
|
13376
|
-
this.maybeAttachOAuthNetListener();
|
|
13377
13452
|
this.oauthProductPage = product;
|
|
13378
13453
|
this.oauthProviderPage = null;
|
|
13379
13454
|
this.oauthProviderPageClosed = false;
|
|
13455
|
+
const oauthBudgetMs = Math.max(1, settleTimeoutMs);
|
|
13380
13456
|
const productUrl = product.url();
|
|
13457
|
+
const oauthDeadline = Date.now() + oauthBudgetMs;
|
|
13458
|
+
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.`);
|
|
13381
13464
|
let recovery = null;
|
|
13382
13465
|
let providerPage = null;
|
|
13383
13466
|
let productDeparted = false;
|
|
13467
|
+
let resolveProductDeparture = () => undefined;
|
|
13468
|
+
const productDeparturePromise = new Promise((resolve) => {
|
|
13469
|
+
resolveProductDeparture = resolve;
|
|
13470
|
+
});
|
|
13384
13471
|
const onProductNavigation = (frame) => {
|
|
13385
13472
|
if (frame !== product.mainFrame())
|
|
13386
13473
|
return;
|
|
13387
|
-
if (!this.isOAuthProductUrl(frame.url(), productUrl))
|
|
13474
|
+
if (!this.isOAuthProductUrl(frame.url(), productUrl)) {
|
|
13388
13475
|
productDeparted = true;
|
|
13476
|
+
resolveProductDeparture();
|
|
13477
|
+
}
|
|
13389
13478
|
};
|
|
13390
13479
|
product.on("framenavigated", onProductNavigation);
|
|
13391
13480
|
try {
|
|
13392
13481
|
recovery = await context.newPage();
|
|
13393
|
-
await recovery.goto(productUrl, {
|
|
13482
|
+
await recovery.goto(productUrl, {
|
|
13483
|
+
waitUntil: "domcontentloaded",
|
|
13484
|
+
timeout: remainingBudgetMs(),
|
|
13485
|
+
});
|
|
13394
13486
|
let resolvePopup = () => undefined;
|
|
13395
13487
|
const popupPromise = new Promise((resolve) => {
|
|
13396
13488
|
resolvePopup = resolve;
|
|
@@ -13401,6 +13493,8 @@ export class BrowserController {
|
|
|
13401
13493
|
};
|
|
13402
13494
|
context.on("page", onPopup);
|
|
13403
13495
|
try {
|
|
13496
|
+
if (Date.now() >= oauthDeadline)
|
|
13497
|
+
throw deadlineError();
|
|
13404
13498
|
try {
|
|
13405
13499
|
await this.click(selector);
|
|
13406
13500
|
}
|
|
@@ -13410,25 +13504,56 @@ export class BrowserController {
|
|
|
13410
13504
|
}
|
|
13411
13505
|
providerPage = await Promise.race([
|
|
13412
13506
|
popupPromise,
|
|
13413
|
-
|
|
13507
|
+
// A same-tab provider redirect is just as conclusive as a popup.
|
|
13508
|
+
// Do not burn two seconds of the OAuth budget waiting for a window
|
|
13509
|
+
// that this service will never open.
|
|
13510
|
+
productDeparturePromise.then(() => null),
|
|
13511
|
+
this.sleep(Math.min(remainingBudgetMs(), 2_000)).then(() => null),
|
|
13414
13512
|
]);
|
|
13415
13513
|
}
|
|
13416
13514
|
finally {
|
|
13417
13515
|
context.off("page", onPopup);
|
|
13418
13516
|
resolvePopup(null);
|
|
13517
|
+
resolveProductDeparture();
|
|
13419
13518
|
}
|
|
13420
13519
|
const transient = providerPage ?? product;
|
|
13520
|
+
productDeparted = productDeparted || !this.isOAuthProductUrl(transient.url(), productUrl);
|
|
13421
13521
|
const durableProduct = providerPage === null ? recovery : product;
|
|
13422
13522
|
this.oauthProductPage = durableProduct;
|
|
13423
13523
|
this.oauthProviderPage = transient;
|
|
13424
13524
|
this.oauthProviderPageClosed = transient.isClosed();
|
|
13425
13525
|
this.restoreProductPageWhenOAuthPageCloses(transient, durableProduct);
|
|
13426
|
-
|
|
13526
|
+
this.page = transient;
|
|
13527
|
+
let settled = null;
|
|
13528
|
+
if (consentProvider === undefined) {
|
|
13529
|
+
settled = await this.waitForOAuthLifecycle(transient, productUrl, remainingBudgetMs(), providerPage === null, productDeparted);
|
|
13530
|
+
}
|
|
13531
|
+
else {
|
|
13532
|
+
const deadline = oauthDeadline;
|
|
13533
|
+
while (settled === null && Date.now() < deadline) {
|
|
13534
|
+
const remaining = deadline - Date.now();
|
|
13535
|
+
settled = await this.waitForOAuthLifecycle(transient, productUrl, Math.min(1_000, remaining), providerPage === null, productDeparted);
|
|
13536
|
+
if (settled !== null || transient.isClosed())
|
|
13537
|
+
break;
|
|
13538
|
+
productDeparted = productDeparted || !this.isOAuthProductUrl(transient.url(), productUrl);
|
|
13539
|
+
if (Date.now() >= deadline)
|
|
13540
|
+
break;
|
|
13541
|
+
const consentBudgetMs = deadline - Date.now();
|
|
13542
|
+
const advanced = await this.advanceOAuthConsent(consentProvider, consentBudgetMs, expectedGoogleAccountEmail).catch(() => false);
|
|
13543
|
+
if (!advanced)
|
|
13544
|
+
await this.sleep(Math.min(250, remainingBudgetMs()));
|
|
13545
|
+
}
|
|
13546
|
+
}
|
|
13427
13547
|
if (settled === null) {
|
|
13428
|
-
throw
|
|
13548
|
+
throw deadlineError();
|
|
13429
13549
|
}
|
|
13430
13550
|
if (providerPage === null && product.isClosed()) {
|
|
13431
|
-
|
|
13551
|
+
if (Date.now() >= oauthDeadline)
|
|
13552
|
+
throw deadlineError();
|
|
13553
|
+
await recovery.reload({
|
|
13554
|
+
waitUntil: "domcontentloaded",
|
|
13555
|
+
timeout: remainingBudgetMs(),
|
|
13556
|
+
});
|
|
13432
13557
|
}
|
|
13433
13558
|
}
|
|
13434
13559
|
finally {
|
|
@@ -13447,7 +13572,9 @@ export class BrowserController {
|
|
|
13447
13572
|
if (this.page !== null && !this.page.isClosed()) {
|
|
13448
13573
|
await this.page.bringToFront().catch(() => undefined);
|
|
13449
13574
|
await this.page
|
|
13450
|
-
.waitForLoadState("domcontentloaded", {
|
|
13575
|
+
.waitForLoadState("domcontentloaded", {
|
|
13576
|
+
timeout: remainingBudgetMs(),
|
|
13577
|
+
})
|
|
13451
13578
|
.catch(() => undefined);
|
|
13452
13579
|
}
|
|
13453
13580
|
}
|
|
@@ -13487,12 +13614,23 @@ export class BrowserController {
|
|
|
13487
13614
|
const url = page.url();
|
|
13488
13615
|
const isProduct = this.isOAuthProductUrl(url, productUrl);
|
|
13489
13616
|
if (startsOnProduct && departed && isProduct) {
|
|
13490
|
-
|
|
13491
|
-
|
|
13492
|
-
|
|
13617
|
+
// A return to the relying party is the OAuth completion signal. A
|
|
13618
|
+
// dashboard can keep polling or streaming forever, so networkidle is
|
|
13619
|
+
// not a valid requirement for a completed OAuth redirect.
|
|
13620
|
+
const returnedUrl = url;
|
|
13621
|
+
const ready = await page
|
|
13622
|
+
.waitForLoadState("domcontentloaded", { timeout: Math.max(1, deadline - Date.now()) })
|
|
13493
13623
|
.then(() => true)
|
|
13494
13624
|
.catch(() => false);
|
|
13495
|
-
|
|
13625
|
+
if (!ready || page.isClosed() || !this.isOAuthProductUrl(page.url(), productUrl)) {
|
|
13626
|
+
return null;
|
|
13627
|
+
}
|
|
13628
|
+
// Require the return URL to survive one event-loop turn so a transient
|
|
13629
|
+
// callback hop is never reported as the final product page.
|
|
13630
|
+
await this.sleep(Math.min(50, Math.max(1, deadline - Date.now())));
|
|
13631
|
+
return !page.isClosed() &&
|
|
13632
|
+
page.url() === returnedUrl &&
|
|
13633
|
+
this.isOAuthProductUrl(page.url(), productUrl)
|
|
13496
13634
|
? "returned"
|
|
13497
13635
|
: null;
|
|
13498
13636
|
}
|
|
@@ -13502,118 +13640,6 @@ export class BrowserController {
|
|
|
13502
13640
|
}
|
|
13503
13641
|
return page.isClosed() ? "closed" : null;
|
|
13504
13642
|
}
|
|
13505
|
-
// ── OAuth/SSO debug instrumentation (UNIVERSAL_BOT_OAUTH_DEBUG) ──
|
|
13506
|
-
// Attach a context-level response recorder ONCE. Records SSO-relevant
|
|
13507
|
-
// responses (the service host + Clerk/Stytch/WorkOS FAPI hosts) with their
|
|
13508
|
-
// status + whether they set a cookie — the signal for "did the callback's
|
|
13509
|
-
// session-establish request succeed and set the session cookie?"
|
|
13510
|
-
maybeAttachOAuthNetListener() {
|
|
13511
|
-
if (this.oauthNetListenerAttached)
|
|
13512
|
-
return;
|
|
13513
|
-
if (!/^(1|true|on)$/i.test(process.env.UNIVERSAL_BOT_OAUTH_DEBUG ?? ""))
|
|
13514
|
-
return;
|
|
13515
|
-
if (!this.context)
|
|
13516
|
-
return;
|
|
13517
|
-
this.oauthNetListenerAttached = true;
|
|
13518
|
-
const RELEVANT = /clerk|stytch|workos|accounts\.|\/sso|\/oauth|\/session|\/sign|callback|\/v1\/client/i;
|
|
13519
|
-
this.context.on("response", (res) => {
|
|
13520
|
-
void (async () => {
|
|
13521
|
-
try {
|
|
13522
|
-
const url = res.url();
|
|
13523
|
-
if (!RELEVANT.test(url))
|
|
13524
|
-
return;
|
|
13525
|
-
const headers = res.headers();
|
|
13526
|
-
if (this.oauthNetLog.length >= 200)
|
|
13527
|
-
return;
|
|
13528
|
-
const entry = {
|
|
13529
|
-
url: url.slice(0, 200),
|
|
13530
|
-
status: res.status(),
|
|
13531
|
-
setCookie: "set-cookie" in headers || "Set-Cookie" in headers,
|
|
13532
|
-
ct: (headers["content-type"] ?? "").slice(0, 40),
|
|
13533
|
-
};
|
|
13534
|
-
// Capture the body of a Clerk/Stytch/WorkOS sign-in/up/callback error
|
|
13535
|
-
// (>=400) — its error code is the definitive tell (captcha_invalid vs
|
|
13536
|
-
// transfer vs identifier_*). JSON only, bounded.
|
|
13537
|
-
if (res.status() >= 400 &&
|
|
13538
|
-
/\/v1\/client\/(sign_ins|sign_ups)|oauth_callback|\/session/i.test(url)) {
|
|
13539
|
-
entry.body = (await res.text().catch(() => "")).slice(0, 800);
|
|
13540
|
-
}
|
|
13541
|
-
this.oauthNetLog.push(entry);
|
|
13542
|
-
}
|
|
13543
|
-
catch {
|
|
13544
|
-
// best-effort observability — never perturb the run
|
|
13545
|
-
}
|
|
13546
|
-
})();
|
|
13547
|
-
});
|
|
13548
|
-
}
|
|
13549
|
-
// Dump cookies + the SSO network log to a file for post-mortem. Called at the
|
|
13550
|
-
// oauth_session_not_persisted decision point when UNIVERSAL_BOT_OAUTH_DEBUG.
|
|
13551
|
-
async dumpOAuthDebug(service, label) {
|
|
13552
|
-
if (!/^(1|true|on)$/i.test(process.env.UNIVERSAL_BOT_OAUTH_DEBUG ?? ""))
|
|
13553
|
-
return;
|
|
13554
|
-
if (!this.context)
|
|
13555
|
-
return;
|
|
13556
|
-
try {
|
|
13557
|
-
const cookies = await this.context.cookies();
|
|
13558
|
-
const cookieSummary = cookies.map((c) => ({
|
|
13559
|
-
name: c.name,
|
|
13560
|
-
domain: c.domain,
|
|
13561
|
-
path: c.path,
|
|
13562
|
-
len: c.value.length,
|
|
13563
|
-
httpOnly: c.httpOnly,
|
|
13564
|
-
secure: c.secure,
|
|
13565
|
-
sameSite: c.sameSite,
|
|
13566
|
-
}));
|
|
13567
|
-
const url = this.page ? this.page.url() : "(no page)";
|
|
13568
|
-
// Capture the live Clerk SDK state — the definitive read on whether a
|
|
13569
|
-
// sign-up transfer is available (the new-user-OAuth fix hinges on this).
|
|
13570
|
-
const clerkState = this.page
|
|
13571
|
-
? await this.page
|
|
13572
|
-
.evaluate(() => {
|
|
13573
|
-
const w = window;
|
|
13574
|
-
const c = w.Clerk;
|
|
13575
|
-
if (c === undefined)
|
|
13576
|
-
return { present: false };
|
|
13577
|
-
const client = c.client;
|
|
13578
|
-
const si = client?.["signIn"];
|
|
13579
|
-
const su = client?.["signUp"];
|
|
13580
|
-
const ffv = si?.["firstFactorVerification"];
|
|
13581
|
-
return {
|
|
13582
|
-
present: true,
|
|
13583
|
-
loaded: c.loaded ?? null,
|
|
13584
|
-
signInStatus: si?.["status"] ?? null,
|
|
13585
|
-
signInFFVStatus: ffv?.["status"] ?? null,
|
|
13586
|
-
signInFFVError: ffv?.["error"] ?? null,
|
|
13587
|
-
signUpStatus: su?.["status"] ?? null,
|
|
13588
|
-
signUpMissingFields: su?.["missingFields"] ?? null,
|
|
13589
|
-
hasSignUpCreate: typeof su?.create === "function",
|
|
13590
|
-
};
|
|
13591
|
-
})
|
|
13592
|
-
.catch((e) => ({ present: "evalError", err: String(e).slice(0, 120) }))
|
|
13593
|
-
: { present: false };
|
|
13594
|
-
const consoleText = await this.extractText().catch(() => "");
|
|
13595
|
-
const { writeFileSync, mkdirSync } = await import("node:fs");
|
|
13596
|
-
const { join } = await import("node:path");
|
|
13597
|
-
const { homedir } = await import("node:os");
|
|
13598
|
-
const dir = join(homedir(), ".trusty-squire", "oauth-debug");
|
|
13599
|
-
mkdirSync(dir, { recursive: true });
|
|
13600
|
-
const ts = process.env.OAUTH_DEBUG_TS ?? String(this.oauthNetLog.length);
|
|
13601
|
-
const path = join(dir, `${service}-${label}-${ts}.json`);
|
|
13602
|
-
writeFileSync(path, JSON.stringify({
|
|
13603
|
-
service,
|
|
13604
|
-
label,
|
|
13605
|
-
finalUrl: url,
|
|
13606
|
-
clerkState,
|
|
13607
|
-
cookies: cookieSummary,
|
|
13608
|
-
netLog: this.oauthNetLog,
|
|
13609
|
-
pageText: consoleText.slice(0, 600),
|
|
13610
|
-
}, null, 2));
|
|
13611
|
-
console.error(`[oauth-debug] wrote ${path} (${cookieSummary.length} cookies, ${this.oauthNetLog.length} net entries)`);
|
|
13612
|
-
}
|
|
13613
|
-
catch (err) {
|
|
13614
|
-
console.error(`[oauth-debug] dump failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
13615
|
-
}
|
|
13616
|
-
}
|
|
13617
13643
|
// Read the page's Rails/OmniAuth CSRF token (<meta name="csrf-token">).
|
|
13618
13644
|
// Needed to recover OmniAuth 2.0 POST-only OAuth when the provider button is
|
|
13619
13645
|
// a GET <a> that page-JS upgrades to a POST.
|
|
@@ -13862,6 +13888,67 @@ export class BrowserController {
|
|
|
13862
13888
|
recoverActivePage() {
|
|
13863
13889
|
return this.adoptLivePage();
|
|
13864
13890
|
}
|
|
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
|
+
armOpenedTabAdoption() {
|
|
13897
|
+
this.openedTabs.length = 0;
|
|
13898
|
+
}
|
|
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
|
+
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;
|
|
13951
|
+
}
|
|
13865
13952
|
adoptLivePage() {
|
|
13866
13953
|
if (this.page !== null && !this.page.isClosed())
|
|
13867
13954
|
return true;
|
|
@@ -14099,14 +14186,7 @@ export class BrowserController {
|
|
|
14099
14186
|
return false;
|
|
14100
14187
|
}
|
|
14101
14188
|
}
|
|
14102
|
-
// Which OAuth providers have a
|
|
14103
|
-
// The logged-in-providers.json marker is a memo that drifts out of sync
|
|
14104
|
-
// (a --force-relogin clears it, a misclassified run clears it, a parallel
|
|
14105
|
-
// run overwrites it) — so a session that is genuinely live in the cookies
|
|
14106
|
-
// can go invisible to provider selection, which is exactly how a warm
|
|
14107
|
-
// GitHub session got skipped in favour of a broken Google path. The cookie
|
|
14108
|
-
// jar is the ground truth: read it directly. Cookie NAMES + presence only;
|
|
14109
|
-
// values are never read into logs. Best-effort — a read failure returns [].
|
|
14189
|
+
// Which OAuth providers have a live session in this profile's cookie jar.
|
|
14110
14190
|
async detectSessionProviders() {
|
|
14111
14191
|
if (this.context === null)
|
|
14112
14192
|
return [];
|
|
@@ -14117,13 +14197,18 @@ export class BrowserController {
|
|
|
14117
14197
|
return [];
|
|
14118
14198
|
}
|
|
14119
14199
|
}
|
|
14120
|
-
async detectGoogleAccountEmail() {
|
|
14200
|
+
async detectGoogleAccountEmail(expectedGoogleAccountEmail) {
|
|
14121
14201
|
if (this.context === null)
|
|
14122
14202
|
return null;
|
|
14123
14203
|
let identityPage = null;
|
|
14124
14204
|
try {
|
|
14125
14205
|
identityPage = await this.context.newPage();
|
|
14126
|
-
|
|
14206
|
+
const identityUrl = new URL("https://myaccount.google.com/");
|
|
14207
|
+
const expectedEmail = expectedGoogleAccountEmail?.trim();
|
|
14208
|
+
if (expectedEmail !== undefined && expectedEmail.length > 0) {
|
|
14209
|
+
identityUrl.searchParams.set("authuser", expectedEmail);
|
|
14210
|
+
}
|
|
14211
|
+
await identityPage.goto(identityUrl.href, {
|
|
14127
14212
|
waitUntil: "domcontentloaded",
|
|
14128
14213
|
timeout: 20_000,
|
|
14129
14214
|
});
|
|
@@ -14147,13 +14232,18 @@ export class BrowserController {
|
|
|
14147
14232
|
await identityPage?.close().catch(() => undefined);
|
|
14148
14233
|
}
|
|
14149
14234
|
}
|
|
14150
|
-
// Advance a provider's consent / account-chooser screen by one click
|
|
14151
|
-
//
|
|
14235
|
+
// Advance a provider's consent / account-chooser screen by one click.
|
|
14236
|
+
// Returns false when no
|
|
14152
14237
|
// approve control is present — the agent then aborts rather than
|
|
14153
14238
|
// hang. Clicks only; never types (the critical guarantee holds here).
|
|
14154
|
-
async advanceOAuthConsent(provider) {
|
|
14239
|
+
async advanceOAuthConsent(provider, timeoutMs = 8_000, expectedGoogleAccountEmail) {
|
|
14155
14240
|
if (!this.page)
|
|
14156
14241
|
throw new Error("Browser not started");
|
|
14242
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
14243
|
+
const hasBudget = () => Date.now() < deadline;
|
|
14244
|
+
const boundedTimeout = (limitMs) => Math.max(1, Math.min(limitMs, deadline - Date.now()));
|
|
14245
|
+
if (!hasBudget())
|
|
14246
|
+
return false;
|
|
14157
14247
|
if (provider === "github") {
|
|
14158
14248
|
// GitHub App install flow can include an account target chooser before
|
|
14159
14249
|
// the Install/Authorize screen:
|
|
@@ -14163,8 +14253,12 @@ export class BrowserController {
|
|
|
14163
14253
|
// consent loop re-classify the next GitHub page.
|
|
14164
14254
|
if (/\/apps\/[^/]+\/installations\/select_target\b/.test(new URL(this.page.url()).pathname)) {
|
|
14165
14255
|
const startUrl = this.page.url();
|
|
14256
|
+
if (!hasBudget())
|
|
14257
|
+
return false;
|
|
14166
14258
|
const clicked = await this.page
|
|
14167
|
-
.evaluate(() => {
|
|
14259
|
+
.evaluate((expiresAt) => {
|
|
14260
|
+
if (Date.now() >= expiresAt)
|
|
14261
|
+
return false;
|
|
14168
14262
|
const visible = (el) => {
|
|
14169
14263
|
const r = el.getBoundingClientRect();
|
|
14170
14264
|
const s = window.getComputedStyle(el);
|
|
@@ -14189,13 +14283,17 @@ export class BrowserController {
|
|
|
14189
14283
|
});
|
|
14190
14284
|
if (target === undefined)
|
|
14191
14285
|
return false;
|
|
14286
|
+
if (Date.now() >= expiresAt)
|
|
14287
|
+
return false;
|
|
14192
14288
|
target.click();
|
|
14193
14289
|
return true;
|
|
14194
|
-
})
|
|
14290
|
+
}, deadline)
|
|
14195
14291
|
.catch(() => false);
|
|
14196
14292
|
if (clicked) {
|
|
14197
14293
|
const advanced = await this.page
|
|
14198
|
-
.waitForFunction((s) => window.location.href !== s, startUrl, {
|
|
14294
|
+
.waitForFunction((s) => window.location.href !== s, startUrl, {
|
|
14295
|
+
timeout: boundedTimeout(8_000),
|
|
14296
|
+
})
|
|
14199
14297
|
.then(() => true)
|
|
14200
14298
|
.catch(() => false);
|
|
14201
14299
|
if (advanced)
|
|
@@ -14230,7 +14328,7 @@ export class BrowserController {
|
|
|
14230
14328
|
// (MEASURED 2026-06-11: defang's "Authorize DefangLabs"). Poll up to
|
|
14231
14329
|
// 12s for it to enable before clicking.
|
|
14232
14330
|
{
|
|
14233
|
-
const deadline = Date.now() + 12_000;
|
|
14331
|
+
const deadline = Date.now() + boundedTimeout(12_000);
|
|
14234
14332
|
while (Date.now() < deadline) {
|
|
14235
14333
|
const disabled = await btn
|
|
14236
14334
|
.evaluate((el) => {
|
|
@@ -14247,8 +14345,10 @@ export class BrowserController {
|
|
|
14247
14345
|
await this.sleep(400);
|
|
14248
14346
|
}
|
|
14249
14347
|
}
|
|
14348
|
+
if (!hasBudget())
|
|
14349
|
+
return false;
|
|
14250
14350
|
try {
|
|
14251
|
-
await btn.click({ timeout:
|
|
14351
|
+
await btn.click({ timeout: boundedTimeout(8_000) });
|
|
14252
14352
|
}
|
|
14253
14353
|
catch {
|
|
14254
14354
|
continue;
|
|
@@ -14258,7 +14358,9 @@ export class BrowserController {
|
|
|
14258
14358
|
// click silently failed (wrong element, or button disabled
|
|
14259
14359
|
// behind a hidden iframe). Return false so the caller knows.
|
|
14260
14360
|
const advanced = await this.page
|
|
14261
|
-
.waitForFunction((s) => window.location.href !== s, startUrl, {
|
|
14361
|
+
.waitForFunction((s) => window.location.href !== s, startUrl, {
|
|
14362
|
+
timeout: boundedTimeout(4_000),
|
|
14363
|
+
})
|
|
14262
14364
|
.then(() => true)
|
|
14263
14365
|
.catch(() => false);
|
|
14264
14366
|
if (advanced)
|
|
@@ -14291,10 +14393,89 @@ export class BrowserController {
|
|
|
14291
14393
|
}
|
|
14292
14394
|
// Google. Account chooser: Google renders each account with a
|
|
14293
14395
|
// stable data-identifier attribute (the account email).
|
|
14294
|
-
const
|
|
14295
|
-
|
|
14396
|
+
const tiles = this.page.locator("[data-identifier]");
|
|
14397
|
+
const expectedEmail = expectedGoogleAccountEmail?.trim().toLowerCase() ?? null;
|
|
14398
|
+
const matchingTileIndexes = await tiles
|
|
14399
|
+
.evaluateAll((elements, expected) => {
|
|
14400
|
+
return elements.flatMap((element, index) => {
|
|
14401
|
+
const html = element;
|
|
14402
|
+
const rect = html.getBoundingClientRect();
|
|
14403
|
+
const style = window.getComputedStyle(html);
|
|
14404
|
+
const identifier = element.getAttribute("data-identifier")?.trim().toLowerCase();
|
|
14405
|
+
return rect.width >= 2 &&
|
|
14406
|
+
rect.height >= 2 &&
|
|
14407
|
+
style.display !== "none" &&
|
|
14408
|
+
style.visibility !== "hidden" &&
|
|
14409
|
+
identifier !== undefined &&
|
|
14410
|
+
(expected === null || identifier === expected)
|
|
14411
|
+
? [index]
|
|
14412
|
+
: [];
|
|
14413
|
+
});
|
|
14414
|
+
}, expectedEmail)
|
|
14415
|
+
.catch(() => []);
|
|
14416
|
+
// Without account metadata, a sole visible chooser tile is still
|
|
14417
|
+
// unambiguous. Multiple accounts remain intentionally untouched.
|
|
14418
|
+
if (matchingTileIndexes.length === 1) {
|
|
14419
|
+
if (!hasBudget())
|
|
14420
|
+
return false;
|
|
14421
|
+
try {
|
|
14422
|
+
await tiles.nth(matchingTileIndexes[0]).click({ timeout: boundedTimeout(1_000) });
|
|
14423
|
+
return true;
|
|
14424
|
+
}
|
|
14425
|
+
catch {
|
|
14426
|
+
// fall through to the approve-button path
|
|
14427
|
+
}
|
|
14428
|
+
}
|
|
14429
|
+
// Google's current account chooser also renders an identity as
|
|
14430
|
+
// an ordinary semantic button/link without data-identifier. Select only a
|
|
14431
|
+
// visible account-shaped row carrying an email address, and exclude only
|
|
14432
|
+
// account-management alternatives. Authentication state is not inferred
|
|
14433
|
+
// from provider-page content; completion remains the OAuth lifecycle signal.
|
|
14434
|
+
// The identity text stays inside the provider page and is never returned.
|
|
14435
|
+
const accountRows = this.page.locator('button, [role="button"], [role="link"], a[href]');
|
|
14436
|
+
const accountRowLabels = await accountRows
|
|
14437
|
+
.evaluateAll((elements) => {
|
|
14438
|
+
const EXCLUDED = /(?:\buse another account|\bremove an account|\bmanage accounts?\b)/i;
|
|
14439
|
+
return elements.map((element, index) => {
|
|
14440
|
+
const html = element;
|
|
14441
|
+
const rect = html.getBoundingClientRect();
|
|
14442
|
+
const style = window.getComputedStyle(html);
|
|
14443
|
+
if (rect.width < 2 ||
|
|
14444
|
+
rect.height < 2 ||
|
|
14445
|
+
style.display === "none" ||
|
|
14446
|
+
style.visibility === "hidden") {
|
|
14447
|
+
return null;
|
|
14448
|
+
}
|
|
14449
|
+
const labels = [
|
|
14450
|
+
element.getAttribute("aria-label") ?? "",
|
|
14451
|
+
element.textContent ?? "",
|
|
14452
|
+
...Array.from(element.querySelectorAll("[aria-label], *")).flatMap((descendant) => [
|
|
14453
|
+
descendant.getAttribute("aria-label") ?? "",
|
|
14454
|
+
descendant.textContent ?? "",
|
|
14455
|
+
]),
|
|
14456
|
+
]
|
|
14457
|
+
.map((label) => label.replace(/\s+/g, " ").trim())
|
|
14458
|
+
.filter((label) => label.length > 0);
|
|
14459
|
+
return labels.some((label) => EXCLUDED.test(label)) ? null : { index, labels };
|
|
14460
|
+
});
|
|
14461
|
+
})
|
|
14462
|
+
.catch(() => []);
|
|
14463
|
+
const matchingAccountRows = expectedEmail === null
|
|
14464
|
+
? []
|
|
14465
|
+
: accountRowLabels.filter((candidate) => {
|
|
14466
|
+
if (candidate === null)
|
|
14467
|
+
return false;
|
|
14468
|
+
return candidate.labels.some((label) => {
|
|
14469
|
+
const emails = label.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi) ?? [];
|
|
14470
|
+
return emails.some((email) => email.toLowerCase() === expectedEmail);
|
|
14471
|
+
});
|
|
14472
|
+
});
|
|
14473
|
+
const accountRowIndex = matchingAccountRows.length === 1 ? matchingAccountRows[0].index : -1;
|
|
14474
|
+
if (accountRowIndex >= 0) {
|
|
14475
|
+
if (!hasBudget())
|
|
14476
|
+
return false;
|
|
14296
14477
|
try {
|
|
14297
|
-
await
|
|
14478
|
+
await accountRows.nth(accountRowIndex).click({ timeout: boundedTimeout(1_000) });
|
|
14298
14479
|
return true;
|
|
14299
14480
|
}
|
|
14300
14481
|
catch {
|
|
@@ -14312,14 +14493,16 @@ export class BrowserController {
|
|
|
14312
14493
|
const APPROVE_NAME = /^(?:continue|allow|accept|agree)\b/i;
|
|
14313
14494
|
const approve = this.page.getByRole("button", { name: APPROVE_NAME }).first();
|
|
14314
14495
|
try {
|
|
14315
|
-
await approve.waitFor({ state: "visible", timeout:
|
|
14496
|
+
await approve.waitFor({ state: "visible", timeout: boundedTimeout(1_000) });
|
|
14316
14497
|
}
|
|
14317
14498
|
catch {
|
|
14318
14499
|
// not visible within the window — fall through to the DOM-scan path
|
|
14319
14500
|
}
|
|
14320
14501
|
if ((await approve.count().catch(() => 0)) > 0) {
|
|
14502
|
+
if (!hasBudget())
|
|
14503
|
+
return false;
|
|
14321
14504
|
try {
|
|
14322
|
-
await approve.click({ timeout:
|
|
14505
|
+
await approve.click({ timeout: boundedTimeout(1_000) });
|
|
14323
14506
|
return true;
|
|
14324
14507
|
}
|
|
14325
14508
|
catch {
|
|
@@ -14331,8 +14514,12 @@ export class BrowserController {
|
|
|
14331
14514
|
// <div role>/<span> or an <input type=submit value="Allow access">).
|
|
14332
14515
|
// Click the first visible candidate whose text is an approve verb and
|
|
14333
14516
|
// is NOT a cancel/deny/back. Log what was visible on failure.
|
|
14517
|
+
if (!hasBudget())
|
|
14518
|
+
return false;
|
|
14334
14519
|
const clicked = await this.page
|
|
14335
|
-
.evaluate(() => {
|
|
14520
|
+
.evaluate((expiresAt) => {
|
|
14521
|
+
if (Date.now() >= expiresAt)
|
|
14522
|
+
return null;
|
|
14336
14523
|
const APPROVE = /^(?:continue|allow|accept|agree)\b/i;
|
|
14337
14524
|
const DENY = /\b(?:cancel|deny|back|no\b|not now|reject)\b/i;
|
|
14338
14525
|
const els = Array.from(document.querySelectorAll('button, input[type="submit"], [role="button"], a[href]'));
|
|
@@ -14346,12 +14533,14 @@ export class BrowserController {
|
|
|
14346
14533
|
if (DENY.test(t))
|
|
14347
14534
|
continue;
|
|
14348
14535
|
if (APPROVE.test(t)) {
|
|
14536
|
+
if (Date.now() >= expiresAt)
|
|
14537
|
+
return null;
|
|
14349
14538
|
el.click();
|
|
14350
14539
|
return t.slice(0, 40);
|
|
14351
14540
|
}
|
|
14352
14541
|
}
|
|
14353
14542
|
return null;
|
|
14354
|
-
})
|
|
14543
|
+
}, deadline)
|
|
14355
14544
|
.catch(() => null);
|
|
14356
14545
|
if (clicked !== null)
|
|
14357
14546
|
return true;
|
|
@@ -14431,51 +14620,6 @@ export class BrowserController {
|
|
|
14431
14620
|
this.oauthProviderPageClosed = false;
|
|
14432
14621
|
}
|
|
14433
14622
|
}
|
|
14434
|
-
// End one sequential task without tearing down Chrome. The original,
|
|
14435
|
-
// handler-bound page survives; OAuth popups and any other incidental tabs are
|
|
14436
|
-
// closed. about:blank replaces the task document while the persistent profile
|
|
14437
|
-
// (cookies, OAuth identity, local profile data) intentionally remains intact.
|
|
14438
|
-
async resetPageForReuse() {
|
|
14439
|
-
if (!this.profilePoolReusable)
|
|
14440
|
-
throw new Error("browser profile is not pool-reusable");
|
|
14441
|
-
const context = this.context;
|
|
14442
|
-
const primaryPage = this.primaryPage;
|
|
14443
|
-
if (!this.isConnected() || context === null || primaryPage === null || primaryPage.isClosed()) {
|
|
14444
|
-
throw new Error("warm browser page is not reusable");
|
|
14445
|
-
}
|
|
14446
|
-
this.page = primaryPage;
|
|
14447
|
-
this.oauthProductPage = null;
|
|
14448
|
-
this.oauthProviderPage = null;
|
|
14449
|
-
this.oauthProviderPageClosed = false;
|
|
14450
|
-
this.oauthNetLog = [];
|
|
14451
|
-
this.mouseX = 100;
|
|
14452
|
-
this.mouseY = 100;
|
|
14453
|
-
for (const page of context.pages()) {
|
|
14454
|
-
if (page !== primaryPage && !page.isClosed()) {
|
|
14455
|
-
let timer;
|
|
14456
|
-
try {
|
|
14457
|
-
await Promise.race([
|
|
14458
|
-
page.close(),
|
|
14459
|
-
new Promise((_, reject) => {
|
|
14460
|
-
timer = setTimeout(() => reject(new Error("timed out closing warm browser popup")), 5_000);
|
|
14461
|
-
timer.unref();
|
|
14462
|
-
}),
|
|
14463
|
-
]);
|
|
14464
|
-
}
|
|
14465
|
-
finally {
|
|
14466
|
-
if (timer !== undefined)
|
|
14467
|
-
clearTimeout(timer);
|
|
14468
|
-
}
|
|
14469
|
-
}
|
|
14470
|
-
}
|
|
14471
|
-
if (context.pages().some((page) => page !== primaryPage && !page.isClosed())) {
|
|
14472
|
-
throw new Error("warm browser reset left a non-primary page open");
|
|
14473
|
-
}
|
|
14474
|
-
await primaryPage.goto("about:blank", {
|
|
14475
|
-
waitUntil: "domcontentloaded",
|
|
14476
|
-
timeout: 30_000,
|
|
14477
|
-
});
|
|
14478
|
-
}
|
|
14479
14623
|
async close(options = {}) {
|
|
14480
14624
|
if (options.cancelStart === true) {
|
|
14481
14625
|
this.startCancellationRequested = true;
|
|
@@ -14485,36 +14629,59 @@ export class BrowserController {
|
|
|
14485
14629
|
this.closePromise ??= this.closeAfterStart();
|
|
14486
14630
|
return await this.closePromise;
|
|
14487
14631
|
}
|
|
14632
|
+
async waitForCancelledStartQuiescence() {
|
|
14633
|
+
if (!this.startCancellationRequested)
|
|
14634
|
+
return;
|
|
14635
|
+
await Promise.allSettled([
|
|
14636
|
+
this.startPromise ?? Promise.resolve(),
|
|
14637
|
+
this.reapCancelledStartProcess(),
|
|
14638
|
+
]);
|
|
14639
|
+
await this.persistentFallbackOwnershipMonitor?.catch(() => undefined);
|
|
14640
|
+
}
|
|
14641
|
+
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
|
+
}
|
|
14488
14675
|
async closeCancelledStart() {
|
|
14489
|
-
|
|
14490
|
-
await this.startPromise?.catch(() => undefined);
|
|
14491
|
-
}
|
|
14676
|
+
void this.reapCancelledStartProcess().catch(() => undefined);
|
|
14492
14677
|
if (this.persistentFallbackCancellationState !== null) {
|
|
14493
|
-
|
|
14494
|
-
if (closeState === "closed") {
|
|
14495
|
-
const lease = this.profileOperationLease;
|
|
14496
|
-
this.profileOperationLease = null;
|
|
14497
|
-
lease?.release();
|
|
14498
|
-
}
|
|
14499
|
-
return closeState;
|
|
14678
|
+
return this.persistentFallbackCancellationState;
|
|
14500
14679
|
}
|
|
14501
14680
|
if (!this.startLaunchCommitted) {
|
|
14502
|
-
await this.
|
|
14503
|
-
|
|
14504
|
-
this.profileOperationLease = null;
|
|
14505
|
-
lease?.release();
|
|
14506
|
-
return "closed";
|
|
14507
|
-
}
|
|
14508
|
-
const [closeState] = await Promise.all([
|
|
14509
|
-
this.closeWithProfileGuard(),
|
|
14510
|
-
this.reapCancelledStartProcess(),
|
|
14511
|
-
]);
|
|
14512
|
-
if (this.startSettled) {
|
|
14513
|
-
const lease = this.profileOperationLease;
|
|
14514
|
-
this.profileOperationLease = null;
|
|
14515
|
-
lease?.release();
|
|
14681
|
+
const closeState = await this.closeBrowser();
|
|
14682
|
+
return this.startSettled ? closeState : "unknown";
|
|
14516
14683
|
}
|
|
14517
|
-
return
|
|
14684
|
+
return await this.closeBrowser();
|
|
14518
14685
|
}
|
|
14519
14686
|
async reapCancelledStartProcess() {
|
|
14520
14687
|
this.cancelledStartReaper ??= this.monitorCancelledStartProcess();
|
|
@@ -14524,36 +14691,107 @@ export class BrowserController {
|
|
|
14524
14691
|
while (!this.startSettled) {
|
|
14525
14692
|
const identity = this.currentOwnedProfileIdentity();
|
|
14526
14693
|
if (identity !== null) {
|
|
14527
|
-
|
|
14694
|
+
this.signalCurrentSelfManagedChrome(identity, "SIGKILL");
|
|
14528
14695
|
reapProfileHolderIfOwned(this.profileDir, identity);
|
|
14529
14696
|
}
|
|
14530
14697
|
await new Promise((resolveWait) => {
|
|
14531
|
-
setTimeout(resolveWait, 25);
|
|
14698
|
+
const timer = setTimeout(resolveWait, 25);
|
|
14699
|
+
timer.unref();
|
|
14532
14700
|
});
|
|
14533
14701
|
}
|
|
14534
14702
|
}
|
|
14535
14703
|
currentOwnedProfileIdentity() {
|
|
14536
|
-
const known = this.
|
|
14704
|
+
const known = this.ownedChromeProcessTreeProof?.identity ??
|
|
14705
|
+
this.childChromeIdentity ??
|
|
14706
|
+
this.launchedProfileHolderIdentity;
|
|
14537
14707
|
if (known !== null)
|
|
14538
14708
|
return known;
|
|
14539
14709
|
const holderPid = currentProfileHolderPid(this.profileDir);
|
|
14540
|
-
|
|
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;
|
|
14541
14720
|
}
|
|
14542
14721
|
async waitForPersistentFallbackIdentity() {
|
|
14543
|
-
|
|
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
|
+
})();
|
|
14544
14773
|
}
|
|
14545
|
-
async waitForOwnedProfileExit(identity) {
|
|
14774
|
+
async waitForOwnedProfileExit(identity, existingProof) {
|
|
14546
14775
|
const deadline = Date.now() + PROFILE_IDENTITY_PROOF_TIMEOUT_MS;
|
|
14547
|
-
|
|
14776
|
+
const proof = existingProof ?? captureOwnedChromeProcessTreeProof(identity, false);
|
|
14777
|
+
let state = proof === null
|
|
14778
|
+
? profileProcessIdentityState(identity, this.profileDir)
|
|
14779
|
+
: ownedChromeProcessTreeState(proof);
|
|
14548
14780
|
while (state !== "stale" && Date.now() < deadline) {
|
|
14549
|
-
if (
|
|
14550
|
-
|
|
14781
|
+
if (proof !== null) {
|
|
14782
|
+
signalOwnedChromeProcessTree(identity, false, "SIGKILL", { proof });
|
|
14783
|
+
}
|
|
14784
|
+
else if (profileProcessMatches(identity, this.profileDir)) {
|
|
14785
|
+
signalOwnedChromeProcessTree(identity, false, "SIGKILL");
|
|
14551
14786
|
}
|
|
14552
14787
|
await new Promise((resolveWait) => {
|
|
14553
14788
|
const timer = setTimeout(resolveWait, PROFILE_IDENTITY_POLL_MS);
|
|
14554
14789
|
timer.unref();
|
|
14555
14790
|
});
|
|
14556
|
-
state =
|
|
14791
|
+
state =
|
|
14792
|
+
proof === null
|
|
14793
|
+
? profileProcessIdentityState(identity, this.profileDir)
|
|
14794
|
+
: ownedChromeProcessTreeState(proof);
|
|
14557
14795
|
}
|
|
14558
14796
|
if (state !== "stale")
|
|
14559
14797
|
return false;
|
|
@@ -14566,31 +14804,25 @@ export class BrowserController {
|
|
|
14566
14804
|
}
|
|
14567
14805
|
if (this.startCancellationRequested)
|
|
14568
14806
|
return await this.closeCancelledStart();
|
|
14569
|
-
|
|
14570
|
-
return await this.closeWithProfileGuard();
|
|
14571
|
-
}
|
|
14572
|
-
finally {
|
|
14573
|
-
const lease = this.profileOperationLease;
|
|
14574
|
-
this.profileOperationLease = null;
|
|
14575
|
-
lease?.release();
|
|
14576
|
-
}
|
|
14807
|
+
return await this.closeBrowser();
|
|
14577
14808
|
}
|
|
14578
|
-
async
|
|
14809
|
+
async closeBrowser() {
|
|
14579
14810
|
if (this.harnessAttachedPage) {
|
|
14580
14811
|
this.page = null;
|
|
14581
14812
|
this.primaryPage = null;
|
|
14582
14813
|
this.oauthProductPage = null;
|
|
14583
14814
|
this.oauthProviderPage = null;
|
|
14584
14815
|
this.oauthProviderPageClosed = false;
|
|
14585
|
-
this.oauthNetLog = [];
|
|
14586
14816
|
this.context = null;
|
|
14587
14817
|
return "closed";
|
|
14588
14818
|
}
|
|
14819
|
+
const marker = this.operatorBrowserMarker();
|
|
14820
|
+
if (this.ownerLaunchTracked)
|
|
14821
|
+
markOwnerBrowserLaunchTerminal(marker);
|
|
14589
14822
|
// Each step is best-effort and independent: a throw closing the page
|
|
14590
|
-
// or context must NOT skip the
|
|
14591
|
-
//
|
|
14592
|
-
//
|
|
14593
|
-
// SingletonLock held — bricking the next signup + `mcp login`).
|
|
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`).
|
|
14594
14826
|
//
|
|
14595
14827
|
// EVERY close call is timeout-capped. On a wedged headed Chrome (e.g. a
|
|
14596
14828
|
// run that crashed mid-captcha-click), BOTH page.close() AND
|
|
@@ -14603,60 +14835,73 @@ export class BrowserController {
|
|
|
14603
14835
|
const context = this.context;
|
|
14604
14836
|
const cdpBrowser = this.cdpBrowser;
|
|
14605
14837
|
const childIdentity = this.childChromeIdentity;
|
|
14838
|
+
const childChromeProcessGroup = this.childChromeProcessGroup;
|
|
14606
14839
|
const holderIdentity = this.launchedProfileHolderIdentity ?? this.currentOwnedProfileIdentity();
|
|
14607
|
-
const identity = childIdentity ?? holderIdentity;
|
|
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));
|
|
14608
14845
|
this.page = null;
|
|
14609
14846
|
this.primaryPage = null;
|
|
14610
14847
|
this.oauthProductPage = null;
|
|
14611
14848
|
this.oauthProviderPage = null;
|
|
14612
14849
|
this.oauthProviderPageClosed = false;
|
|
14613
|
-
this.oauthNetLog = [];
|
|
14614
14850
|
this.context = null;
|
|
14615
14851
|
this.cdpBrowser = null;
|
|
14616
14852
|
this.childChrome = null;
|
|
14617
14853
|
this.childChromeIdentity = null;
|
|
14854
|
+
this.childChromeProcessGroup = false;
|
|
14618
14855
|
this.launchedContext = false;
|
|
14619
14856
|
this.launchedProfileHolderIdentity = null;
|
|
14620
14857
|
const closeState = await closeProfileWithProof({
|
|
14621
14858
|
profileDir: this.profileDir,
|
|
14622
14859
|
identity,
|
|
14623
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.
|
|
14624
14867
|
if (page !== null)
|
|
14625
|
-
await page.close();
|
|
14868
|
+
await page.close().catch(() => undefined);
|
|
14626
14869
|
if (context !== null)
|
|
14627
|
-
await context.close();
|
|
14870
|
+
await context.close().catch(() => undefined);
|
|
14628
14871
|
if (cdpBrowser !== null)
|
|
14629
|
-
await cdpBrowser.close();
|
|
14630
|
-
if (childIdentity !== null) {
|
|
14631
|
-
signalProfileProcess(childIdentity, this.profileDir, "SIGTERM");
|
|
14632
|
-
}
|
|
14872
|
+
await cdpBrowser.close().catch(() => undefined);
|
|
14633
14873
|
},
|
|
14634
14874
|
forceClose: () => {
|
|
14635
|
-
if (identity !== null)
|
|
14636
|
-
|
|
14875
|
+
if (identity !== null) {
|
|
14876
|
+
signalOwnedChromeProcessTree(identity, treeProof?.processGroup ?? (childIdentity !== null ? childChromeProcessGroup : false), "SIGKILL", { ...(treeProof === null ? {} : { proof: treeProof }) });
|
|
14877
|
+
}
|
|
14637
14878
|
reapProfileHolderIfOwned(this.profileDir, identity);
|
|
14638
14879
|
},
|
|
14880
|
+
...(treeProof === null
|
|
14881
|
+
? {}
|
|
14882
|
+
: { identityState: () => ownedChromeProcessTreeState(treeProof) }),
|
|
14639
14883
|
});
|
|
14640
14884
|
// Self-launch path: disconnect the CDP browser and SIGKILL the Chrome we
|
|
14641
14885
|
// spawned. context.close() on a connectOverCDP context only disconnects —
|
|
14642
14886
|
// it does NOT necessarily exit the browser process, which would leak the
|
|
14643
14887
|
// SingletonLock and brick the next run (the reap below is the backstop, but
|
|
14644
14888
|
// killing our own child directly is cleaner and faster).
|
|
14645
|
-
if (
|
|
14646
|
-
|
|
14647
|
-
|
|
14648
|
-
|
|
14649
|
-
|
|
14650
|
-
|
|
14651
|
-
|
|
14652
|
-
|
|
14653
|
-
|
|
14654
|
-
|
|
14655
|
-
|
|
14656
|
-
|
|
14657
|
-
this.
|
|
14658
|
-
}
|
|
14659
|
-
|
|
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;
|
|
14660
14905
|
}
|
|
14661
14906
|
}
|
|
14662
14907
|
// Random integer in [min, max]. We use Math.random() (not crypto)
|