@trusty-squire/mcp 1.1.13-rc.9 → 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 +136 -64
- 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 +85 -80
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +1314 -1570
- 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 +18 -6
- package/dist/bot/operator-browser-watchdog.d.ts.map +1 -1
- package/dist/bot/operator-browser-watchdog.js +91 -37
- package/dist/bot/operator-browser-watchdog.js.map +1 -1
- 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 +7 -1
- package/dist/bot/pay-operator.d.ts.map +1 -1
- package/dist/bot/pay-operator.js +269 -103
- 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 +57 -186
- package/dist/bot/provision-session.d.ts.map +1 -1
- package/dist/bot/provision-session.js +1976 -1812
- 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 +151 -96
- package/dist/tools/operate-pay.js.map +1 -1
- package/dist/tools/provision-drive.d.ts +47 -39
- 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,22 +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 {
|
|
28
|
-
import { Socket, createServer } from "node:net";
|
|
26
|
+
import { Socket } from "node:net";
|
|
29
27
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
30
|
-
import {
|
|
28
|
+
import { readFile } from "node:fs/promises";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
import { spawn } from "node:child_process";
|
|
31
31
|
import { isSameRecipeDomain } from "@trusty-squire/recipe-schema";
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
import { createOperatorBrowserMarker, OPERATOR_BROWSER_MARKER_ENV, operatorBrowserProcessMatchesMarker, startGlobalOperatorBrowserProcessWatchdog, } from "./operator-browser-watchdog.js";
|
|
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";
|
|
36
35
|
// Lazy registration: installing the plugin mutates the chromium singleton
|
|
37
36
|
// from playwright-extra so we only do it once per process. We require()
|
|
38
37
|
// the CJS modules lazily (the stealth toolchain only ships CJS) and treat
|
|
39
38
|
// stealth as best-effort — a missing dep should never crash the bot.
|
|
40
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
|
+
}
|
|
41
102
|
export function contextInitScriptsFor(options) {
|
|
42
103
|
if (options.hardened)
|
|
43
104
|
return [];
|
|
@@ -236,12 +297,16 @@ export async function runCaptureConfirmedPaymentSubmit(options) {
|
|
|
236
297
|
catch (error) {
|
|
237
298
|
clickError = error;
|
|
238
299
|
}
|
|
300
|
+
if (clickError instanceof BrowserClickDispatchError &&
|
|
301
|
+
clickError.dispatchStatus === "not_dispatched") {
|
|
302
|
+
await options.clear();
|
|
303
|
+
throw clickError;
|
|
304
|
+
}
|
|
239
305
|
const evidence = await options.readEvidence();
|
|
240
306
|
await options.clear();
|
|
241
307
|
if (!evidence.dispatched) {
|
|
242
308
|
if (clickError !== undefined && !inputDispatchPossible)
|
|
243
309
|
throw clickError;
|
|
244
|
-
options.onSubmitDispatched?.();
|
|
245
310
|
throw new PaymentSubmitOutcomeUnknownError();
|
|
246
311
|
}
|
|
247
312
|
options.onSubmitDispatched?.();
|
|
@@ -329,17 +394,6 @@ function checkoutOutcomeBaselineFromDispatchSnapshot(snapshot) {
|
|
|
329
394
|
terminalUrlIdentity: identities?.terminal ?? null,
|
|
330
395
|
};
|
|
331
396
|
}
|
|
332
|
-
function parseCheckoutOutcomeDispatchSnapshot(value) {
|
|
333
|
-
if (value === null || typeof value !== "object")
|
|
334
|
-
return null;
|
|
335
|
-
const snapshot = value;
|
|
336
|
-
if (typeof snapshot.url !== "string" ||
|
|
337
|
-
!Array.isArray(snapshot.urls) ||
|
|
338
|
-
!snapshot.urls.every((url) => typeof url === "string")) {
|
|
339
|
-
return null;
|
|
340
|
-
}
|
|
341
|
-
return { url: snapshot.url, urls: snapshot.urls };
|
|
342
|
-
}
|
|
343
397
|
function checkoutUrlOrderIdentities(rawUrl) {
|
|
344
398
|
try {
|
|
345
399
|
const url = new URL(rawUrl);
|
|
@@ -451,6 +505,22 @@ function checkoutUrlOrderIdentities(rawUrl) {
|
|
|
451
505
|
return null;
|
|
452
506
|
}
|
|
453
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
|
+
}
|
|
454
524
|
// EbisuMart — a widely-deployed Japanese EC platform (the Hibiya Kadan
|
|
455
525
|
// checkout runs on it) — names its card fields CREDIT_NO / CREDIT_NAME /
|
|
456
526
|
// SECURITY_CD / CREDIT_LIMIT_MONTH / CREDIT_LIMIT_YEAR. PAN name/id and JP
|
|
@@ -569,36 +639,6 @@ const CHECKOUT_CARD_VALUE_FIELD_SELECTORS = [
|
|
|
569
639
|
CHECKOUT_CVV_FIELD_SELECTORS,
|
|
570
640
|
CHECKOUT_CARD_NAME_FIELD_SELECTORS,
|
|
571
641
|
].join(",");
|
|
572
|
-
// Defense-in-depth redaction set for operate_screenshot. The capture-scoped
|
|
573
|
-
// guard refuses nonempty secrets before pixels are read; these selectors also
|
|
574
|
-
// identify empty sensitive controls and provide rectangles that are composited
|
|
575
|
-
// over the captured bytes. The set mirrors the card fields
|
|
576
|
-
// fillCheckoutCardIntoFrames writes into, plus payment/type_secret markers and
|
|
577
|
-
// other secret-shaped inputs.
|
|
578
|
-
const SCREENSHOT_SECRET_FIELD_SELECTORS = [
|
|
579
|
-
'[data-ts-sealed-payment="1"]',
|
|
580
|
-
'input[type="password" i]',
|
|
581
|
-
'input[autocomplete~="one-time-code" i]',
|
|
582
|
-
'input[name*="otp" i]',
|
|
583
|
-
'input[id*="otp" i]',
|
|
584
|
-
'input[name*="pin" i]',
|
|
585
|
-
'input[id*="pin" i]',
|
|
586
|
-
].join(",");
|
|
587
|
-
const SCREENSHOT_REDACTION_SELECTORS = `${CHECKOUT_CARD_VALUE_FIELD_SELECTORS},${SCREENSHOT_SECRET_FIELD_SELECTORS}`;
|
|
588
|
-
function sealedElementSemanticKeys(descriptor) {
|
|
589
|
-
const clean = (value) => (value ?? "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
590
|
-
const tag = clean(descriptor.tag);
|
|
591
|
-
const type = clean(descriptor.type);
|
|
592
|
-
const landmark = clean(descriptor.landmark);
|
|
593
|
-
const label = clean(descriptor.labelText ?? descriptor.ariaLabel ?? descriptor.placeholder);
|
|
594
|
-
return Array.from(new Set([
|
|
595
|
-
clean(descriptor.testId) ? `test:${clean(descriptor.testId)}` : "",
|
|
596
|
-
clean(descriptor.id) ? `id:${clean(descriptor.id)}` : "",
|
|
597
|
-
clean(descriptor.name) ? `name:${tag}:${type}:${clean(descriptor.name)}` : "",
|
|
598
|
-
label ? `label:${landmark}:${tag}:${type}:${label}` : "",
|
|
599
|
-
`position:${landmark}:${tag}:${type}:${descriptor.ordinal}`,
|
|
600
|
-
].filter((key) => key.length > 0)));
|
|
601
|
-
}
|
|
602
642
|
// Charge-verb button labels — the click that may move money. Used by
|
|
603
643
|
// submitFilledCheckout to find the charge control, and by operate_act's
|
|
604
644
|
// pending-card-fill guard to recognize and cap caller-placed attempts while a
|
|
@@ -612,6 +652,112 @@ function sealedElementSemanticKeys(descriptor) {
|
|
|
612
652
|
// where a bare noun like 購入 would otherwise swallow navigation labels such
|
|
613
653
|
// as 購入手続きへ). 確定 (finalize) is deliberate — 確認 (review) must NOT match.
|
|
614
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
|
+
}
|
|
615
761
|
export function checkoutSubmitLabel(signals) {
|
|
616
762
|
return (signals.ariaLabel || signals.inputValue || signals.textContent || "").trim();
|
|
617
763
|
}
|
|
@@ -1737,19 +1883,13 @@ export function extractGoogleAccountEmail(pageText) {
|
|
|
1737
1883
|
}
|
|
1738
1884
|
// Map a cookie jar to the OAuth providers that have a LIVE logged-in session.
|
|
1739
1885
|
// The auth cookies that mean "signed in": GitHub → `user_session`; Google →
|
|
1740
|
-
//
|
|
1741
|
-
// 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
|
|
1742
1889
|
// google.com cookie can't pass for github. Cookie NAMES + presence only;
|
|
1743
1890
|
// values are checked for non-triviality, never logged. Exported for tests.
|
|
1744
1891
|
export function sessionProvidersFromCookies(cookies) {
|
|
1745
|
-
const SIGNATURES = [
|
|
1746
|
-
{ provider: "github", host: /(^|\.)github\.com$/i, names: ["user_session"] },
|
|
1747
|
-
{
|
|
1748
|
-
provider: "google",
|
|
1749
|
-
host: /(^|\.)google\.com$/i,
|
|
1750
|
-
names: GOOGLE_LOGIN_COOKIE_MARKERS,
|
|
1751
|
-
},
|
|
1752
|
-
];
|
|
1892
|
+
const SIGNATURES = [{ provider: "github", host: /(^|\.)github\.com$/i, names: ["user_session"] }];
|
|
1753
1893
|
const live = [];
|
|
1754
1894
|
for (const sig of SIGNATURES) {
|
|
1755
1895
|
const present = cookies.some((c) => sig.host.test(c.domain.replace(/^\./, "")) &&
|
|
@@ -1758,6 +1898,11 @@ export function sessionProvidersFromCookies(cookies) {
|
|
|
1758
1898
|
if (present)
|
|
1759
1899
|
live.push(sig.provider);
|
|
1760
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");
|
|
1761
1906
|
return live;
|
|
1762
1907
|
}
|
|
1763
1908
|
function isCaptchaVariant(v) {
|
|
@@ -1889,7 +2034,7 @@ export function resolveChannelBinary(channel) {
|
|
|
1889
2034
|
// Cloudflare Turnstile's interactive challenge FAILS a Playwright/patchright
|
|
1890
2035
|
// launchPersistentContext-driven Chrome and PASSES a Chrome the operator
|
|
1891
2036
|
// launches itself and then attaches to over CDP — every other variable held
|
|
1892
|
-
// constant (same box, same datacenter IP, same
|
|
2037
|
+
// constant (same box, same datacenter IP, same headed display, same Chrome 148
|
|
1893
2038
|
// binary, same software-WebGL, same humanized click). The discriminator
|
|
1894
2039
|
// matrix:
|
|
1895
2040
|
// launchPersistentContext + CDP click → "Verification failed"
|
|
@@ -2001,41 +2146,36 @@ export async function launchCancellablePersistentContext(opts) {
|
|
|
2001
2146
|
void lateCleanup;
|
|
2002
2147
|
return { status: "cancelled", closeState: "unknown" };
|
|
2003
2148
|
}
|
|
2004
|
-
|
|
2005
|
-
function
|
|
2006
|
-
|
|
2007
|
-
const srv = createServer();
|
|
2008
|
-
srv.on("error", reject);
|
|
2009
|
-
srv.listen(0, "127.0.0.1", () => {
|
|
2010
|
-
const addr = srv.address();
|
|
2011
|
-
const port = typeof addr === "object" && addr !== null ? addr.port : 0;
|
|
2012
|
-
srv.close(() => (port > 0 ? resolve(port) : reject(new Error("no port"))));
|
|
2013
|
-
});
|
|
2014
|
-
});
|
|
2015
|
-
}
|
|
2016
|
-
// Poll Chrome's DevTools HTTP endpoint until it answers (the browser is up
|
|
2017
|
-
// and accepting CDP), or the deadline passes. Returns the base endpoint URL
|
|
2018
|
-
// connectOverCDP accepts.
|
|
2019
|
-
async function waitForDevtools(port, deadlineMs, child) {
|
|
2020
|
-
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);
|
|
2021
2152
|
const deadline = Date.now() + deadlineMs;
|
|
2022
2153
|
let lastErr = "";
|
|
2023
2154
|
while (Date.now() < deadline) {
|
|
2024
|
-
if (
|
|
2025
|
-
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");
|
|
2026
2157
|
}
|
|
2027
2158
|
try {
|
|
2028
|
-
const
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
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}`;
|
|
2032
2169
|
}
|
|
2033
|
-
catch (
|
|
2034
|
-
lastErr =
|
|
2170
|
+
catch (error) {
|
|
2171
|
+
lastErr = error instanceof Error ? error.message : String(error);
|
|
2035
2172
|
}
|
|
2036
|
-
await new Promise((
|
|
2173
|
+
await new Promise((resolveWait) => {
|
|
2174
|
+
const timer = setTimeout(resolveWait, 200);
|
|
2175
|
+
timer.unref();
|
|
2176
|
+
});
|
|
2037
2177
|
}
|
|
2038
|
-
throw new Error(`Chrome DevTools endpoint
|
|
2178
|
+
throw new Error(`Owned Chrome DevTools endpoint was not published (${lastErr})`);
|
|
2039
2179
|
}
|
|
2040
2180
|
export async function withChromeStartupLock(fn, opts = {}) {
|
|
2041
2181
|
const lockDir = opts.lockDir ?? "/tmp/trusty-squire-chrome-start.lock";
|
|
@@ -2077,11 +2217,10 @@ const selfManagedChromes = new Map();
|
|
|
2077
2217
|
const ownedChromeProcessTrees = new Set();
|
|
2078
2218
|
let selfManagedCleanupInstalled = false;
|
|
2079
2219
|
let selfManagedTerminationSignalExitEnabled = true;
|
|
2080
|
-
let orphanVerifierReapRan = false;
|
|
2081
|
-
const orphanOperatorProfilesReaped = new Set();
|
|
2082
2220
|
function cleanupSelfManagedChromes() {
|
|
2083
2221
|
for (const proof of ownedChromeProcessTrees) {
|
|
2084
2222
|
signalOwnedChromeProcessTree(proof.identity, proof.processGroup, "SIGKILL", { proof });
|
|
2223
|
+
untrackOwnerProcess(proof.identity);
|
|
2085
2224
|
}
|
|
2086
2225
|
selfManagedChromes.clear();
|
|
2087
2226
|
}
|
|
@@ -2092,7 +2231,20 @@ const exitForSelfManagedSignal = (code) => {
|
|
|
2092
2231
|
const onSelfManagedSigint = () => exitForSelfManagedSignal(2);
|
|
2093
2232
|
const onSelfManagedSigterm = () => exitForSelfManagedSignal(15);
|
|
2094
2233
|
const onSelfManagedSighup = () => exitForSelfManagedSignal(1);
|
|
2095
|
-
|
|
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.
|
|
2096
2248
|
// False means another shutdown owner (the MCP server's disconnect coordinator,
|
|
2097
2249
|
// or an in-flight interactive login) holds process-exit responsibility.
|
|
2098
2250
|
export function isSelfManagedChromeTerminationSignalExitEnabled() {
|
|
@@ -2104,14 +2256,7 @@ export function setSelfManagedChromeTerminationSignalExitEnabled(enabled) {
|
|
|
2104
2256
|
selfManagedTerminationSignalExitEnabled = enabled;
|
|
2105
2257
|
if (!selfManagedCleanupInstalled)
|
|
2106
2258
|
return;
|
|
2107
|
-
|
|
2108
|
-
process.once("SIGINT", onSelfManagedSigint);
|
|
2109
|
-
process.once("SIGTERM", onSelfManagedSigterm);
|
|
2110
|
-
}
|
|
2111
|
-
else {
|
|
2112
|
-
process.removeListener("SIGINT", onSelfManagedSigint);
|
|
2113
|
-
process.removeListener("SIGTERM", onSelfManagedSigterm);
|
|
2114
|
-
}
|
|
2259
|
+
synchronizeSelfManagedChromeTerminationSignalHandlers(enabled);
|
|
2115
2260
|
}
|
|
2116
2261
|
function installSelfManagedChromeCleanup() {
|
|
2117
2262
|
if (selfManagedCleanupInstalled)
|
|
@@ -2119,18 +2264,22 @@ function installSelfManagedChromeCleanup() {
|
|
|
2119
2264
|
selfManagedCleanupInstalled = true;
|
|
2120
2265
|
process.once("exit", cleanupSelfManagedChromes);
|
|
2121
2266
|
if (selfManagedTerminationSignalExitEnabled) {
|
|
2122
|
-
|
|
2123
|
-
process.once("SIGTERM", onSelfManagedSigterm);
|
|
2267
|
+
synchronizeSelfManagedChromeTerminationSignalHandlers(true);
|
|
2124
2268
|
}
|
|
2125
|
-
process.once("SIGHUP", onSelfManagedSighup);
|
|
2126
2269
|
}
|
|
2127
2270
|
function registerSelfManagedChrome(child, profileDir, processGroup = false) {
|
|
2128
2271
|
installSelfManagedChromeCleanup();
|
|
2129
2272
|
const identity = child.pid === undefined ? null : profileProcessIdentity(child.pid, profileDir);
|
|
2130
2273
|
if (identity !== null) {
|
|
2131
2274
|
const proof = trackOwnedChromeProcessTree(identity, processGroup);
|
|
2132
|
-
if (proof !== null)
|
|
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
|
+
}
|
|
2133
2281
|
selfManagedChromes.set(identity.pid, { identity, processGroup, proof });
|
|
2282
|
+
}
|
|
2134
2283
|
}
|
|
2135
2284
|
child.once("exit", () => {
|
|
2136
2285
|
if (child.pid === undefined)
|
|
@@ -2249,16 +2398,20 @@ export function captureOwnedChromeProcessTreeProof(identity, processGroup, optio
|
|
|
2249
2398
|
}
|
|
2250
2399
|
function trackOwnedChromeProcessTree(identity, processGroup) {
|
|
2251
2400
|
installSelfManagedChromeCleanup();
|
|
2252
|
-
const
|
|
2401
|
+
const marker = operatorBrowserProcessMarker(identity.pid);
|
|
2402
|
+
const trackedIdentity = marker === null ? identity : { ...identity, process_marker: marker };
|
|
2403
|
+
const proof = captureOwnedChromeProcessTreeProof(trackedIdentity, processGroup);
|
|
2253
2404
|
if (proof === null)
|
|
2254
2405
|
return null;
|
|
2255
2406
|
ownedChromeProcessTrees.add(proof);
|
|
2407
|
+
trackOwnerProcess(proof.identity);
|
|
2256
2408
|
return proof;
|
|
2257
2409
|
}
|
|
2258
2410
|
function releaseOwnedChromeProcessTree(proof) {
|
|
2259
2411
|
if (proof === null)
|
|
2260
2412
|
return;
|
|
2261
2413
|
ownedChromeProcessTrees.delete(proof);
|
|
2414
|
+
untrackOwnerProcess(proof.identity);
|
|
2262
2415
|
}
|
|
2263
2416
|
export function ownedChromeProcessTreeState(proof, options = {}) {
|
|
2264
2417
|
const memberState = options.memberState ?? processBirthIdentityState;
|
|
@@ -2373,97 +2526,6 @@ export async function terminateTrackedProfileChild(child, profileDir, options =
|
|
|
2373
2526
|
}
|
|
2374
2527
|
return identity;
|
|
2375
2528
|
}
|
|
2376
|
-
// Stale verifier/operator browsers are the expensive leak mode: if the MCP process dies
|
|
2377
|
-
// mid-verify, self-launched Chrome survives as PPID=1 with a
|
|
2378
|
-
// ~/.trusty-squire/profiles/verify-* or ~/.trusty-squire/chrome-profile
|
|
2379
|
-
// user-data-dir. It keeps the profile lock,
|
|
2380
|
-
// burns memory, and may leave defunct helper children. A live verifier should
|
|
2381
|
-
// never be parented to init, so these are safe to reap at the next browser
|
|
2382
|
-
// startup. Best-effort and Linux-only; failure must not block signups.
|
|
2383
|
-
export function matchesReapableBrowserArgs(args, profileDirs, includeVerifier) {
|
|
2384
|
-
return reapableBrowserProfile(args, profileDirs, includeVerifier) !== null;
|
|
2385
|
-
}
|
|
2386
|
-
function reapableBrowserProfile(args, profileDirs, includeVerifier) {
|
|
2387
|
-
if (!/(?:chrome|chromium)/i.test(args))
|
|
2388
|
-
return null;
|
|
2389
|
-
const configuredProfiles = new Map(profileDirs.map((profileDir) => [profileDir, profilePathIdentity(profileDir)]));
|
|
2390
|
-
const match = /--user-data-dir=(?:"([^"]+)"|'([^']+)'|([^\s]+))(?=\s|$)/.exec(args);
|
|
2391
|
-
const candidate = match?.[1] ?? match?.[2] ?? match?.[3];
|
|
2392
|
-
if (candidate !== undefined) {
|
|
2393
|
-
const identity = profilePathIdentity(candidate);
|
|
2394
|
-
if ([...configuredProfiles.values()].includes(identity))
|
|
2395
|
-
return identity;
|
|
2396
|
-
if (includeVerifier && /[/\\]\.trusty-squire[/\\]profiles[/\\]verify-[^/\\]+$/.test(identity)) {
|
|
2397
|
-
return identity;
|
|
2398
|
-
}
|
|
2399
|
-
}
|
|
2400
|
-
for (const [profileDir, identity] of configuredProfiles) {
|
|
2401
|
-
for (const exactPath of new Set([profileDir, identity])) {
|
|
2402
|
-
const variants = [exactPath, `"${exactPath}"`, `'${exactPath}'`];
|
|
2403
|
-
if (variants.some((variant) => {
|
|
2404
|
-
const option = `--user-data-dir=${variant}`;
|
|
2405
|
-
const offset = args.indexOf(option);
|
|
2406
|
-
return (offset >= 0 &&
|
|
2407
|
-
/\s|$/.test(args.slice(offset + option.length, offset + option.length + 1)));
|
|
2408
|
-
})) {
|
|
2409
|
-
return identity;
|
|
2410
|
-
}
|
|
2411
|
-
}
|
|
2412
|
-
}
|
|
2413
|
-
return null;
|
|
2414
|
-
}
|
|
2415
|
-
export function claimOrphanBrowserReapScope(profileDir) {
|
|
2416
|
-
const includeVerifier = !orphanVerifierReapRan;
|
|
2417
|
-
const profileDirs = [...new Set([CHROME_PROFILE_DIR, profileDir])].filter((candidate) => !orphanOperatorProfilesReaped.has(candidate));
|
|
2418
|
-
if (!includeVerifier && profileDirs.length === 0)
|
|
2419
|
-
return null;
|
|
2420
|
-
orphanVerifierReapRan = true;
|
|
2421
|
-
for (const candidate of profileDirs)
|
|
2422
|
-
orphanOperatorProfilesReaped.add(candidate);
|
|
2423
|
-
return { includeVerifier, profileDirs };
|
|
2424
|
-
}
|
|
2425
|
-
function reapOrphanedBrowsersOnce(profileDir) {
|
|
2426
|
-
if (process.platform !== "linux")
|
|
2427
|
-
return;
|
|
2428
|
-
const scope = claimOrphanBrowserReapScope(profileDir);
|
|
2429
|
-
if (scope === null)
|
|
2430
|
-
return;
|
|
2431
|
-
let rows = "";
|
|
2432
|
-
try {
|
|
2433
|
-
rows = execFileSync("ps", ["-eo", "pid=,ppid=,args="], {
|
|
2434
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
2435
|
-
}).toString("utf8");
|
|
2436
|
-
}
|
|
2437
|
-
catch {
|
|
2438
|
-
return;
|
|
2439
|
-
}
|
|
2440
|
-
const identities = [];
|
|
2441
|
-
for (const line of rows.split("\n")) {
|
|
2442
|
-
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
|
2443
|
-
if (match === null)
|
|
2444
|
-
continue;
|
|
2445
|
-
const pid = Number(match[1]);
|
|
2446
|
-
const ppid = Number(match[2]);
|
|
2447
|
-
const args = match[3] ?? "";
|
|
2448
|
-
const expectedProfile = reapableBrowserProfile(args, scope.profileDirs, scope.includeVerifier);
|
|
2449
|
-
if (Number.isFinite(pid) && ppid === 1 && expectedProfile !== null) {
|
|
2450
|
-
const identity = profileProcessIdentity(pid, expectedProfile);
|
|
2451
|
-
if (identity !== null)
|
|
2452
|
-
identities.push(identity);
|
|
2453
|
-
}
|
|
2454
|
-
}
|
|
2455
|
-
if (identities.length === 0)
|
|
2456
|
-
return;
|
|
2457
|
-
console.error(`[operator] reaping ${identities.length} orphaned Chrome process(es)`);
|
|
2458
|
-
for (const identity of identities) {
|
|
2459
|
-
signalProfileProcess(identity, identity.user_data_dir, "SIGTERM");
|
|
2460
|
-
}
|
|
2461
|
-
setTimeout(() => {
|
|
2462
|
-
for (const identity of identities) {
|
|
2463
|
-
signalProfileProcess(identity, identity.user_data_dir, "SIGKILL");
|
|
2464
|
-
}
|
|
2465
|
-
}, 2_000).unref();
|
|
2466
|
-
}
|
|
2467
2529
|
// Classify an anti-bot interstitial page from its (title + body) text.
|
|
2468
2530
|
// `onInterstitial` matches the static Cloudflare/Turnstile challenge copy.
|
|
2469
2531
|
// `verificationPassed` is the signal the challenge SUCCEEDED — but
|
|
@@ -2510,124 +2572,45 @@ export function stripCloudflareChallengeParams(rawUrl) {
|
|
|
2510
2572
|
export function childProcessIsRunning(child) {
|
|
2511
2573
|
return child !== null && child.exitCode === null && child.signalCode === null;
|
|
2512
2574
|
}
|
|
2513
|
-
export async function attachSelfManagedLoginContext(endpoint, child, profileDir, identity, options = {}) {
|
|
2514
|
-
let browser = null;
|
|
2515
|
-
try {
|
|
2516
|
-
browser = await (options.launcher ?? getChromium()).connectOverCDP(endpoint);
|
|
2517
|
-
const context = browser.contexts()[0];
|
|
2518
|
-
if (context === undefined) {
|
|
2519
|
-
throw new Error("self-launched login Chrome exposed no default browser context");
|
|
2520
|
-
}
|
|
2521
|
-
return { browser, context };
|
|
2522
|
-
}
|
|
2523
|
-
catch (error) {
|
|
2524
|
-
await browser?.close().catch(() => undefined);
|
|
2525
|
-
const terminateChild = options.terminateChild ??
|
|
2526
|
-
((ownedChild, ownedProfileDir, ownedIdentity) => terminateTrackedProfileChild(ownedChild, ownedProfileDir, {
|
|
2527
|
-
identity: ownedIdentity,
|
|
2528
|
-
}));
|
|
2529
|
-
await terminateChild(child, profileDir, identity);
|
|
2530
|
-
throw error;
|
|
2531
|
-
}
|
|
2532
|
-
}
|
|
2533
2575
|
function profileCollisionFromStderr(stderr) {
|
|
2534
2576
|
return /ProcessSingleton|SingletonLock|profile.*in use/i.test(stderr)
|
|
2535
2577
|
? new ProfileBusyError(PROFILE_BUSY_MESSAGE)
|
|
2536
2578
|
: null;
|
|
2537
2579
|
}
|
|
2538
|
-
//
|
|
2539
|
-
// `mcp login`), instead of Playwright's launchPersistentContext.
|
|
2540
|
-
//
|
|
2541
|
-
// This is the STATE.md 2026-06-12 finding — the same launcher tell that fails
|
|
2542
|
-
// Cloudflare Turnstile from a launchPersistentContext-driven Chrome and passes
|
|
2543
|
-
// from a self-launched one — ported to the login path. BrowserController (the
|
|
2544
|
-
// signup path) already migrated; the connect login was the last consumer of
|
|
2545
|
-
// the detectable launcher. Kept STANDALONE (not a BrowserController method) so
|
|
2546
|
-
// the working signup path is untouched.
|
|
2580
|
+
// The signal that quits the plain login browser.
|
|
2547
2581
|
//
|
|
2548
|
-
//
|
|
2549
|
-
//
|
|
2550
|
-
//
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
let chromeStderr = "";
|
|
2581
|
-
spawned.stderr?.on("data", (chunk) => {
|
|
2582
|
-
chromeStderr = (chromeStderr + chunk.toString("utf8")).slice(-4_000);
|
|
2583
|
-
});
|
|
2584
|
-
try {
|
|
2585
|
-
const endpoint = await waitForDevtools(port, 30_000, spawned);
|
|
2586
|
-
childIdentity = await resolveAttachedProfileChildIdentity(spawned, params.profileDir, childIdentity);
|
|
2587
|
-
if (process.platform === "linux" && childIdentity === null) {
|
|
2588
|
-
throw new Error("self-launched login Chrome exited before identity was proven");
|
|
2589
|
-
}
|
|
2590
|
-
return endpoint;
|
|
2591
|
-
}
|
|
2592
|
-
catch (err) {
|
|
2593
|
-
childIdentity = await terminateTrackedProfileChild(spawned, params.profileDir, {
|
|
2594
|
-
identity: childIdentity,
|
|
2595
|
-
});
|
|
2596
|
-
const detail = chromeStderr.trim();
|
|
2597
|
-
const collision = profileCollisionFromStderr(detail);
|
|
2598
|
-
if (collision !== null)
|
|
2599
|
-
throw collision;
|
|
2600
|
-
throw new Error(`${err instanceof Error ? err.message : String(err)}` +
|
|
2601
|
-
`${detail.length > 0 ? `; Chrome stderr: ${detail}` : ""}`);
|
|
2602
|
-
}
|
|
2603
|
-
}, { deadlineMs: 0 });
|
|
2604
|
-
if (child === null) {
|
|
2605
|
-
throw new Error("self-launched login Chrome lost its process handle");
|
|
2606
|
-
}
|
|
2607
|
-
const { browser, context } = await attachSelfManagedLoginContext(endpoint, child, params.profileDir, childIdentity);
|
|
2608
|
-
let torn = false;
|
|
2609
|
-
const teardown = async () => {
|
|
2610
|
-
if (torn)
|
|
2611
|
-
return;
|
|
2612
|
-
torn = true;
|
|
2613
|
-
// Disconnect the CDP browser first; over connectOverCDP this leaves the
|
|
2614
|
-
// real Chrome running, so kill the child explicitly (SIGTERM, then a
|
|
2615
|
-
// hard SIGKILL after a short grace) to avoid the zombie-chrome leak.
|
|
2616
|
-
await browser.close();
|
|
2617
|
-
if (child !== null && childIdentity !== null) {
|
|
2618
|
-
signalProfileProcess(childIdentity, params.profileDir, "SIGTERM");
|
|
2619
|
-
}
|
|
2620
|
-
};
|
|
2621
|
-
const forceTeardown = () => {
|
|
2622
|
-
if (childIdentity !== null) {
|
|
2623
|
-
const tracked = selfManagedChromes.get(childIdentity.pid);
|
|
2624
|
-
signalOwnedChromeProcessTree(childIdentity, false, "SIGKILL", {
|
|
2625
|
-
...(tracked === undefined ? {} : { proof: tracked.proof }),
|
|
2626
|
-
});
|
|
2627
|
-
}
|
|
2628
|
-
reapProfileHolderIfOwned(params.profileDir, childIdentity);
|
|
2629
|
-
};
|
|
2630
|
-
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();
|
|
2631
2614
|
}
|
|
2632
2615
|
// Launch a TRULY PLAIN Chrome for the interactive connect claim — NO
|
|
2633
2616
|
// `--remote-debugging-port`, NO `connectOverCDP`, NO Playwright attach at all.
|
|
@@ -2640,106 +2623,146 @@ export async function launchSelfManagedLoginContext(params) {
|
|
|
2640
2623
|
// though the same CDP browser passes a DIRECT accounts.google.com sign-in. The
|
|
2641
2624
|
// tell is the CDP attachment itself (NOT the launcher, NOT the flags, NOT
|
|
2642
2625
|
// `navigator.webdriver` — all separately ruled out). The connect claim doesn't
|
|
2643
|
-
// need to drive the browser: the USER signs in
|
|
2644
|
-
// from the API (`installPoll`)
|
|
2645
|
-
//
|
|
2646
|
-
// 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.
|
|
2647
2629
|
//
|
|
2648
2630
|
// Persistent profile is preserved (--user-data-dir=profileDir) so the Google/
|
|
2649
2631
|
// GitHub session still lands in the bot's profile for later signups.
|
|
2650
2632
|
export async function launchPlainLoginBrowser(params) {
|
|
2651
2633
|
let child = null;
|
|
2652
2634
|
let childIdentity = null;
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
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}` : ""}`);
|
|
2696
2711
|
}
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
`${detail.length > 0 ? `; Chrome stderr: ${detail}` : ""}`);
|
|
2709
|
-
}
|
|
2710
|
-
if (process.platform === "linux" && childIdentity === null) {
|
|
2711
|
-
throw new Error("plain login Chrome identity could not be proven");
|
|
2712
|
-
}
|
|
2713
|
-
}, { deadlineMs: 0 });
|
|
2714
|
-
let torn = false;
|
|
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;
|
|
2715
2723
|
const forceTeardown = () => {
|
|
2724
|
+
markLocalBrowserLaunchTerminal(child);
|
|
2716
2725
|
if (childIdentity !== null) {
|
|
2717
2726
|
const tracked = selfManagedChromes.get(childIdentity.pid);
|
|
2718
2727
|
signalOwnedChromeProcessTree(childIdentity, false, "SIGKILL", {
|
|
2719
2728
|
...(tracked === undefined ? {} : { proof: tracked.proof }),
|
|
2720
2729
|
});
|
|
2721
2730
|
}
|
|
2731
|
+
else if (childProcessIsRunning(child)) {
|
|
2732
|
+
child?.kill("SIGKILL");
|
|
2733
|
+
}
|
|
2722
2734
|
reapProfileHolderIfOwned(params.profileDir, childIdentity);
|
|
2723
2735
|
};
|
|
2724
|
-
const teardown =
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
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;
|
|
2731
2755
|
};
|
|
2732
2756
|
return {
|
|
2733
2757
|
teardown,
|
|
2734
2758
|
forceTeardown,
|
|
2735
2759
|
isRunning: () => childProcessIsRunning(child),
|
|
2736
2760
|
identity: childIdentity,
|
|
2761
|
+
marker: launchMarker,
|
|
2737
2762
|
};
|
|
2738
2763
|
}
|
|
2739
2764
|
export class BrowserController {
|
|
2740
|
-
//
|
|
2741
|
-
// rather than an ephemeral context so the profile carries the user's
|
|
2742
|
-
// Google session across runs — see profile.ts / google-login.ts.
|
|
2765
|
+
// A persistent browser context backed by the user's real Chrome profile.
|
|
2743
2766
|
context = null;
|
|
2744
2767
|
page = null;
|
|
2745
2768
|
checkoutCardGroupScope;
|
|
@@ -2747,30 +2770,36 @@ export class BrowserController {
|
|
|
2747
2770
|
paymentInstrumentExpectation;
|
|
2748
2771
|
observedPaymentInstrumentMismatch;
|
|
2749
2772
|
checkoutSubmitSequence = 0;
|
|
2750
|
-
checkoutSubmitDispatchBindingName = `__trustySquirePaymentSubmitDispatch_${randomUUID().replaceAll("-", "")}`;
|
|
2751
|
-
checkoutSubmitDispatchBindingPages = new WeakSet();
|
|
2752
|
-
checkoutSubmitDispatchWaiters = new Map();
|
|
2753
2773
|
clickDispatchSequence = 0;
|
|
2754
|
-
|
|
2755
|
-
|
|
2774
|
+
mainDocumentSequence = 0;
|
|
2775
|
+
mainDocumentIdentities = new WeakMap();
|
|
2776
|
+
trackedMainDocumentPages = new WeakSet();
|
|
2756
2777
|
// The page start() configured with the controller's navigation/captcha
|
|
2757
|
-
// handlers. OAuth may temporarily switch `this.page` to a popup, but
|
|
2778
|
+
// handlers. OAuth may temporarily switch `this.page` to a popup, but session
|
|
2758
2779
|
// reuse must always restore this original page rather than adopting a popup
|
|
2759
2780
|
// whose lifecycle handlers were never installed.
|
|
2760
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 = [];
|
|
2761
2789
|
// Self-launch path (Turnstile-safe; see selfLaunchEnabled). When we spawn
|
|
2762
2790
|
// Chrome ourselves and attach over CDP, these hold the child process and
|
|
2763
2791
|
// the connected Browser so close() can tear both down.
|
|
2764
2792
|
childChrome = null;
|
|
2765
2793
|
childChromeIdentity = null;
|
|
2766
2794
|
childChromeProcessGroup = false;
|
|
2795
|
+
ownedDisplayRig = null;
|
|
2767
2796
|
ownedChromeProcessTreeProof = null;
|
|
2768
2797
|
operatorProcessMarker = null;
|
|
2798
|
+
ownerLaunchTracked = false;
|
|
2769
2799
|
cdpBrowser = null;
|
|
2770
2800
|
// True once a local browser context launched this session.
|
|
2771
2801
|
launchedContext = false;
|
|
2772
2802
|
launchedProfileHolderIdentity = null;
|
|
2773
|
-
profileOperationLease = null;
|
|
2774
2803
|
startPromise = null;
|
|
2775
2804
|
closePromise = null;
|
|
2776
2805
|
startCancellationRequested = false;
|
|
@@ -2779,7 +2808,6 @@ export class BrowserController {
|
|
|
2779
2808
|
persistentFallbackLaunchInFlight = false;
|
|
2780
2809
|
persistentFallbackOwnershipMonitor = null;
|
|
2781
2810
|
persistentFallbackCancellationState = null;
|
|
2782
|
-
profilePoolReusable = true;
|
|
2783
2811
|
resolveStartCancellation = null;
|
|
2784
2812
|
startCancellation = new Promise((resolveCancellation) => {
|
|
2785
2813
|
this.resolveStartCancellation = resolveCancellation;
|
|
@@ -2807,6 +2835,7 @@ export class BrowserController {
|
|
|
2807
2835
|
// siblings and fails-fast on genuinely out-of-scope in-page API calls. When
|
|
2808
2836
|
// null/never set (harness replay, non-session use) the guard is inert.
|
|
2809
2837
|
hostScopeAllowedHostsProvider = null;
|
|
2838
|
+
operationScopedAllowedHosts = new Map();
|
|
2810
2839
|
hostScopeGuardInstallation = null;
|
|
2811
2840
|
// Feed the current session's allowed hosts to the request-scope guard. Read
|
|
2812
2841
|
// lazily per request, so allow_host / auto-widen updates take effect without
|
|
@@ -2815,7 +2844,7 @@ export class BrowserController {
|
|
|
2815
2844
|
// sessions, never for harness/replay or non-session browsers.
|
|
2816
2845
|
async setHostScopeAllowedHosts(provider, siblingDomainProvider = provider) {
|
|
2817
2846
|
this.hostScopeAllowedHostsProvider = () => ({
|
|
2818
|
-
allowedHosts: provider(),
|
|
2847
|
+
allowedHosts: [...provider(), ...this.operationScopedAllowedHosts.keys()],
|
|
2819
2848
|
siblingDomainHosts: siblingDomainProvider(),
|
|
2820
2849
|
});
|
|
2821
2850
|
this.hostScopeGuardInstallation ??= this.installHostScopeGuard().catch((error) => {
|
|
@@ -2824,6 +2853,24 @@ export class BrowserController {
|
|
|
2824
2853
|
});
|
|
2825
2854
|
await this.hostScopeGuardInstallation;
|
|
2826
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
|
+
}
|
|
2827
2874
|
// Defect-A fail-fast request-scope guard. Only XHR/fetch subresource API
|
|
2828
2875
|
// calls are scope-guarded; page-load resources (scripts/styles/images/frames)
|
|
2829
2876
|
// always continue, so a legitimate render is never broken by the guard. A
|
|
@@ -2866,61 +2913,98 @@ export class BrowserController {
|
|
|
2866
2913
|
oauthProductPage = null;
|
|
2867
2914
|
oauthProviderPage = null;
|
|
2868
2915
|
oauthProviderPageClosed = false;
|
|
2869
|
-
//
|
|
2870
|
-
//
|
|
2871
|
-
// Stytch SSO callback fails to persist a session (cookie not set, FAPI
|
|
2872
|
-
// rejection, etc.) without guessing. Off by default; zero cost when unset.
|
|
2873
|
-
oauthNetLog = [];
|
|
2874
|
-
oauthNetListenerAttached = false;
|
|
2875
|
-
// F13 — on-demand Xvfb. Set when start() determined the host has no
|
|
2876
|
-
// display surface but Xvfb is available, so Chrome can run with
|
|
2877
|
-
// `headless: false` against a virtual display (Cloudflare/Stytch et
|
|
2878
|
-
// al. detect Chromium-headless and block their signup forms). Torn
|
|
2879
|
-
// down by close().
|
|
2880
|
-
xvfb = null;
|
|
2881
|
-
// F13 — which launch path start() took. Surfaced via .launchMode so
|
|
2882
|
-
// the agent can push it into the run's step trail and we can see
|
|
2883
|
-
// (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.
|
|
2884
2918
|
launchedMode = "unknown";
|
|
2885
2919
|
get launchMode() {
|
|
2886
2920
|
return this.launchedMode;
|
|
2887
2921
|
}
|
|
2888
2922
|
constructor(opts = {}) {
|
|
2889
2923
|
this.humanize = opts.humanize ?? true;
|
|
2890
|
-
this.profileDir = opts.profileDir ??
|
|
2891
|
-
this.profileOperationLease = opts.profileOperationLease ?? null;
|
|
2924
|
+
this.profileDir = opts.profileDir ?? "";
|
|
2892
2925
|
this.proxyOverride =
|
|
2893
2926
|
opts.proxyUrl !== undefined && opts.proxyUrl.trim().length > 0 ? opts.proxyUrl.trim() : null;
|
|
2894
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
|
+
}
|
|
2895
2954
|
/** Attach normal controller behavior to a harness-owned Playwright page. */
|
|
2896
2955
|
static fromHarnessPage(page) {
|
|
2897
2956
|
const controller = new BrowserController({ humanize: false });
|
|
2898
2957
|
controller.context = page.context();
|
|
2899
2958
|
controller.page = page;
|
|
2900
2959
|
controller.primaryPage = page;
|
|
2960
|
+
controller.trackMainDocument(page);
|
|
2961
|
+
controller.trackOpenedTabs(page.context());
|
|
2901
2962
|
controller.harnessAttachedPage = true;
|
|
2902
2963
|
controller.launchedMode = "headless";
|
|
2903
2964
|
return controller;
|
|
2904
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
|
+
}
|
|
2905
2980
|
// Per-launch egress override. null means direct egress. Explicit overrides
|
|
2906
2981
|
// are never subject to host-network classification.
|
|
2907
2982
|
proxyOverride;
|
|
2908
|
-
// Warm reuse is deliberately narrow: a browser belongs to exactly one
|
|
2909
|
-
// persistent profile and one explicit egress override. Undefined/blank proxy
|
|
2910
|
-
// values both mean direct egress.
|
|
2911
|
-
matchesLaunchOptions(opts) {
|
|
2912
|
-
const profileDir = opts.profileDir ?? CHROME_PROFILE_DIR;
|
|
2913
|
-
const proxyUrl = opts.proxyUrl !== undefined && opts.proxyUrl.trim().length > 0 ? opts.proxyUrl.trim() : null;
|
|
2914
|
-
return this.profileDir === profileDir && this.proxyOverride === proxyUrl;
|
|
2915
|
-
}
|
|
2916
|
-
/** Identity persisted by the operator profile lease for owner-safe crash recovery. */
|
|
2917
|
-
operatorWorkerIdentity() {
|
|
2918
|
-
return this.childChromeIdentity ?? this.launchedProfileHolderIdentity;
|
|
2919
|
-
}
|
|
2920
2983
|
operatorBrowserMarker() {
|
|
2921
2984
|
this.operatorProcessMarker ??= createOperatorBrowserMarker();
|
|
2922
2985
|
return this.operatorProcessMarker;
|
|
2923
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
|
+
}
|
|
2924
3008
|
adoptOwnedChromeProcessTree(identity, processGroup) {
|
|
2925
3009
|
if (this.ownedChromeProcessTreeProof?.identity.pid === identity.pid &&
|
|
2926
3010
|
this.ownedChromeProcessTreeProof.identity.start_time === identity.start_time) {
|
|
@@ -2930,6 +3014,12 @@ export class BrowserController {
|
|
|
2930
3014
|
const proof = tracked?.identity.start_time === identity.start_time
|
|
2931
3015
|
? tracked.proof
|
|
2932
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
|
+
}
|
|
2933
3023
|
if (proof !== null)
|
|
2934
3024
|
this.ownedChromeProcessTreeProof = proof;
|
|
2935
3025
|
return proof;
|
|
@@ -2941,7 +3031,7 @@ export class BrowserController {
|
|
|
2941
3031
|
: { proof: this.ownedChromeProcessTreeProof }),
|
|
2942
3032
|
});
|
|
2943
3033
|
}
|
|
2944
|
-
// Required health gate for a
|
|
3034
|
+
// Required health gate for a live session browser. BrowserContext alone is not a
|
|
2945
3035
|
// sufficient signal: a dead CDP transport can leave stale JS objects behind.
|
|
2946
3036
|
isConnected() {
|
|
2947
3037
|
const browser = this.cdpBrowser ?? this.context?.browser() ?? null;
|
|
@@ -3008,13 +3098,12 @@ export class BrowserController {
|
|
|
3008
3098
|
}
|
|
3009
3099
|
return ctx;
|
|
3010
3100
|
}
|
|
3011
|
-
const endpoint = await
|
|
3012
|
-
this.throwIfStartCancelled();
|
|
3013
|
-
const port = await findFreePort();
|
|
3101
|
+
const endpoint = await (async () => {
|
|
3014
3102
|
this.throwIfStartCancelled();
|
|
3015
3103
|
clearStaleSingletonLock(this.profileDir);
|
|
3104
|
+
rmSync(join(this.profileDir, DEVTOOLS_ACTIVE_PORT_FILE), { force: true });
|
|
3016
3105
|
const argv = [
|
|
3017
|
-
|
|
3106
|
+
"--remote-debugging-port=0",
|
|
3018
3107
|
"--remote-debugging-address=127.0.0.1",
|
|
3019
3108
|
`--user-data-dir=${this.profileDir}`,
|
|
3020
3109
|
"--no-first-run",
|
|
@@ -3025,16 +3114,16 @@ export class BrowserController {
|
|
|
3025
3114
|
"--lang=en-US",
|
|
3026
3115
|
...params.args,
|
|
3027
3116
|
...(params.proxy !== null ? [`--proxy-server=${params.proxy.server}`] : []),
|
|
3028
|
-
...(params.headless ? ["--headless=new"] : []),
|
|
3029
3117
|
"about:blank",
|
|
3030
3118
|
];
|
|
3031
3119
|
this.commitProfileLaunch();
|
|
3032
|
-
const child =
|
|
3120
|
+
const child = spawnLocalBrowser(params.binary, argv, this.profileDir, {
|
|
3033
3121
|
env: params.env,
|
|
3034
3122
|
stdio: ["ignore", "ignore", "pipe"],
|
|
3035
3123
|
// A dedicated process group gives the session a single, identity-
|
|
3036
3124
|
// proven teardown target for Chrome plus every renderer/GPU helper.
|
|
3037
3125
|
detached: process.platform !== "win32",
|
|
3126
|
+
marker: this.operatorBrowserMarker(),
|
|
3038
3127
|
});
|
|
3039
3128
|
this.childChrome = child;
|
|
3040
3129
|
this.childChromeProcessGroup = process.platform !== "win32";
|
|
@@ -3055,7 +3144,7 @@ export class BrowserController {
|
|
|
3055
3144
|
throw new Error("BrowserController start cancelled");
|
|
3056
3145
|
}
|
|
3057
3146
|
try {
|
|
3058
|
-
const endpoint = await
|
|
3147
|
+
const endpoint = await waitForOwnedDevtoolsEndpoint(this.profileDir, 30_000, child);
|
|
3059
3148
|
this.childChromeIdentity = await resolveAttachedProfileChildIdentity(child, this.profileDir, this.childChromeIdentity, { processGroup: this.childChromeProcessGroup });
|
|
3060
3149
|
if (process.platform === "linux" && this.childChromeIdentity === null) {
|
|
3061
3150
|
throw new Error("self-launched Chrome exited before identity was proven");
|
|
@@ -3088,7 +3177,7 @@ export class BrowserController {
|
|
|
3088
3177
|
throw new Error(`${err instanceof Error ? err.message : String(err)}; Chrome pid=${child.pid ?? "unknown"} alive=${alive ? 1 : 0}` +
|
|
3089
3178
|
`${chromeExit}${detail.length > 0 ? `; Chrome stderr: ${detail}` : ""}`);
|
|
3090
3179
|
}
|
|
3091
|
-
}
|
|
3180
|
+
})();
|
|
3092
3181
|
// Use the patchright launcher's connectOverCDP — it's the exact path the
|
|
3093
3182
|
// falsification experiment validated (its connect avoids Runtime.enable,
|
|
3094
3183
|
// which a plain attach would emit). The anti-detection that matters here
|
|
@@ -3194,6 +3283,9 @@ export class BrowserController {
|
|
|
3194
3283
|
console.error("[operator] resource blocking ON (image/media/font + analytics aborted; captcha/CSS/JS allowed)");
|
|
3195
3284
|
}
|
|
3196
3285
|
async start() {
|
|
3286
|
+
if (this.profileDir.length === 0) {
|
|
3287
|
+
throw new Error("BrowserController.start requires a per-session profile directory");
|
|
3288
|
+
}
|
|
3197
3289
|
if (this.closePromise !== null)
|
|
3198
3290
|
throw new Error("BrowserController is already closing");
|
|
3199
3291
|
this.startPromise ??= this.startOnce();
|
|
@@ -3203,24 +3295,17 @@ export class BrowserController {
|
|
|
3203
3295
|
const remoteMode = (process.env.BOT_CDP_ENDPOINT ?? "").trim().length > 0;
|
|
3204
3296
|
if (!remoteMode)
|
|
3205
3297
|
startGlobalOperatorBrowserProcessWatchdog();
|
|
3206
|
-
const lease = this.profileOperationLease ??
|
|
3207
|
-
(remoteMode ? null : await acquireFreeProfileOperationGuard(this.profileDir));
|
|
3208
|
-
this.profileOperationLease = lease;
|
|
3209
3298
|
try {
|
|
3210
|
-
await this.
|
|
3299
|
+
await this.startBrowser();
|
|
3211
3300
|
if (this.startCancellationRequested) {
|
|
3212
|
-
await this.
|
|
3301
|
+
await this.closeBrowser();
|
|
3213
3302
|
throw new Error("BrowserController start cancelled");
|
|
3214
3303
|
}
|
|
3215
3304
|
}
|
|
3216
3305
|
catch (err) {
|
|
3306
|
+
await this.teardownOwnedDisplay().catch(() => undefined);
|
|
3217
3307
|
if (this.startCancellationRequested && this.persistentFallbackCancellationState === null) {
|
|
3218
|
-
await this.
|
|
3219
|
-
}
|
|
3220
|
-
if (this.persistentFallbackCancellationState === null ||
|
|
3221
|
-
this.persistentFallbackCancellationState === "closed") {
|
|
3222
|
-
this.profileOperationLease = null;
|
|
3223
|
-
lease?.release();
|
|
3308
|
+
await this.closeBrowser().catch(() => undefined);
|
|
3224
3309
|
}
|
|
3225
3310
|
throw err;
|
|
3226
3311
|
}
|
|
@@ -3236,9 +3321,8 @@ export class BrowserController {
|
|
|
3236
3321
|
this.throwIfStartCancelled();
|
|
3237
3322
|
this.startLaunchCommitted = true;
|
|
3238
3323
|
}
|
|
3239
|
-
async
|
|
3324
|
+
async startBrowser() {
|
|
3240
3325
|
this.throwIfStartCancelled();
|
|
3241
|
-
reapOrphanedBrowsersOnce(this.profileDir);
|
|
3242
3326
|
const channel = await detectChromiumChannel();
|
|
3243
3327
|
this.throwIfStartCancelled();
|
|
3244
3328
|
this.launchedChannel = channel;
|
|
@@ -3253,7 +3337,7 @@ export class BrowserController {
|
|
|
3253
3337
|
// (e.g. a Mac with a real GPU + residential egress) and we attach over CDP
|
|
3254
3338
|
// across Tailscale. The remote machine IS a real device, so we spoof
|
|
3255
3339
|
// NOTHING — no WebGL/device fingerprint patch (a fake-Intel string over a
|
|
3256
|
-
// 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
|
|
3257
3341
|
// egress-geo override (the remote host's real timezone + residential IP are
|
|
3258
3342
|
// authentic). software-WebGL output is exactly what the toughest anti-bot
|
|
3259
3343
|
// (hCaptcha Enterprise) scores; only real hardware fixes the pixel
|
|
@@ -3261,15 +3345,20 @@ export class BrowserController {
|
|
|
3261
3345
|
const remoteMode = (process.env.BOT_CDP_ENDPOINT ?? "").trim().length > 0;
|
|
3262
3346
|
if (remoteMode) {
|
|
3263
3347
|
console.error(`[operator] REMOTE-CDP mode — attaching to ${(process.env.BOT_CDP_ENDPOINT ?? "").trim()} ` +
|
|
3264
|
-
`(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;
|
|
3265
3353
|
}
|
|
3354
|
+
const browserEnv = remoteMode ? process.env : await this.ownedHeadedBrowserEnvironment();
|
|
3266
3355
|
// T3.1: probe where this run's traffic actually exits so the
|
|
3267
3356
|
// browser's declared timezone matches its egress IP (a US-timezone
|
|
3268
3357
|
// browser on a foreign proxy IP is itself an anti-bot signal).
|
|
3269
3358
|
// Done before the real launch: launchPersistentContext bakes the
|
|
3270
3359
|
// timezone in at creation, with no way to set it afterward. Skipped in
|
|
3271
3360
|
// remote mode — the remote host's own clock/IP are the authentic truth.
|
|
3272
|
-
const geo = remoteMode ? null : await this.probeEgressGeo(channel, proxy);
|
|
3361
|
+
const geo = remoteMode ? null : await this.probeEgressGeo(channel, proxy, browserEnv);
|
|
3273
3362
|
this.throwIfStartCancelled();
|
|
3274
3363
|
if (geo !== null) {
|
|
3275
3364
|
console.error(`[operator] egress geo: timezone=${geo.timezoneId}` +
|
|
@@ -3277,84 +3366,12 @@ export class BrowserController {
|
|
|
3277
3366
|
? ` loc=${geo.geolocation.latitude},${geo.geolocation.longitude}`
|
|
3278
3367
|
: ""));
|
|
3279
3368
|
}
|
|
3280
|
-
//
|
|
3281
|
-
//
|
|
3282
|
-
|
|
3283
|
-
//
|
|
3284
|
-
//
|
|
3285
|
-
//
|
|
3286
|
-
// The decision matrix:
|
|
3287
|
-
// - UNIVERSAL_BOT_HEADLESS=true (explicit opt-in): keep true
|
|
3288
|
-
// headless. CI / Codespaces that lack Xvfb.
|
|
3289
|
-
// - UNIVERSAL_BOT_HEADLESS=false (explicit opt-out): the
|
|
3290
|
-
// current pre-F13 behavior — DISPLAY must exist already.
|
|
3291
|
-
// - default + DISPLAY set: run headed against the existing
|
|
3292
|
-
// display (laptop/desktop install).
|
|
3293
|
-
// - default + no DISPLAY + Xvfb on PATH: spawn Xvfb, run
|
|
3294
|
-
// headed against it (the headless-server install — what
|
|
3295
|
-
// Cloudflare needed).
|
|
3296
|
-
// - default + no DISPLAY + no Xvfb: fall back to true
|
|
3297
|
-
// headless with a clear stderr warning.
|
|
3298
|
-
let chromeEnv;
|
|
3299
|
-
let chromeHeadless;
|
|
3300
|
-
const explicitHeadless = process.env.UNIVERSAL_BOT_HEADLESS;
|
|
3301
|
-
const hostHasDisplay = process.platform === "darwin" ||
|
|
3302
|
-
process.platform === "win32" ||
|
|
3303
|
-
(typeof process.env.DISPLAY === "string" && process.env.DISPLAY.length > 0);
|
|
3304
|
-
if (explicitHeadless === "true") {
|
|
3305
|
-
chromeHeadless = true;
|
|
3306
|
-
this.launchedMode = "headless";
|
|
3307
|
-
}
|
|
3308
|
-
else if (explicitHeadless === "false") {
|
|
3309
|
-
chromeHeadless = false;
|
|
3310
|
-
this.launchedMode = "display";
|
|
3311
|
-
}
|
|
3312
|
-
else if (hostHasDisplay) {
|
|
3313
|
-
chromeHeadless = false;
|
|
3314
|
-
this.launchedMode = "display";
|
|
3315
|
-
}
|
|
3316
|
-
else if (xvfbAvailable()) {
|
|
3317
|
-
try {
|
|
3318
|
-
// 1920×1080 — the most common real desktop resolution. The old
|
|
3319
|
-
// 1280×720 here was exactly Playwright's emulated-device viewport
|
|
3320
|
-
// default (the code's own comments flag that as an anti-bot tell),
|
|
3321
|
-
// and with viewport:null the page read it straight back. A 720p
|
|
3322
|
-
// screen whose availHeight==height (no taskbar) is a headless
|
|
3323
|
-
// signature strict Turnstiles (exa/cartesia) score against.
|
|
3324
|
-
const xvfb = await startXvfb({ width: 1920, height: 1080 });
|
|
3325
|
-
if (this.startCancellationRequested) {
|
|
3326
|
-
xvfb.stop();
|
|
3327
|
-
this.throwIfStartCancelled();
|
|
3328
|
-
}
|
|
3329
|
-
this.xvfb = xvfb;
|
|
3330
|
-
chromeEnv = { ...process.env, DISPLAY: xvfb.display };
|
|
3331
|
-
chromeHeadless = false;
|
|
3332
|
-
this.launchedMode = "xvfb";
|
|
3333
|
-
console.error(`[operator] no DISPLAY — spawned Xvfb at ${this.xvfb.display} for headed Chrome`);
|
|
3334
|
-
}
|
|
3335
|
-
catch (err) {
|
|
3336
|
-
console.error(`[operator] Xvfb failed (${err instanceof Error ? err.message : String(err)}) — ` +
|
|
3337
|
-
`falling back to true headless; Cloudflare/Stytch-class signups may fail`);
|
|
3338
|
-
chromeHeadless = true;
|
|
3339
|
-
this.launchedMode = "headless";
|
|
3340
|
-
}
|
|
3341
|
-
}
|
|
3342
|
-
else {
|
|
3343
|
-
console.error(`[operator] no DISPLAY and Xvfb not installed — running true headless. ` +
|
|
3344
|
-
`For Cloudflare/Stytch-class signups install xvfb: apt-get install -y xvfb`);
|
|
3345
|
-
chromeHeadless = true;
|
|
3346
|
-
this.launchedMode = "headless";
|
|
3347
|
-
}
|
|
3348
|
-
const free = await waitForProfileFree(this.profileDir, { deadlineMs: 0 });
|
|
3349
|
-
this.throwIfStartCancelled();
|
|
3350
|
-
if (!free) {
|
|
3351
|
-
throw new ProfileBusyError(PROFILE_BUSY_MESSAGE);
|
|
3352
|
-
}
|
|
3353
|
-
// T3: a PERSISTENT context. The profile dir carries the user's
|
|
3354
|
-
// Google session (established by `mcp login` — see google-login.ts),
|
|
3355
|
-
// so the OAuth-first signup path reuses it instead of starting
|
|
3356
|
-
// logged-out. launchPersistentContext takes launch + context
|
|
3357
|
-
// 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.
|
|
3358
3375
|
// Resolve the launcher first so activeStealthProfile is set before we
|
|
3359
3376
|
// decide on executablePath below.
|
|
3360
3377
|
const launcher = getChromium();
|
|
@@ -3368,7 +3385,7 @@ export class BrowserController {
|
|
|
3368
3385
|
this.launchedChannel = channel;
|
|
3369
3386
|
// Launch args shared by BOTH paths (launchPersistentContext and the
|
|
3370
3387
|
// self-launch). See the per-flag rationale: swiftshader gives a real
|
|
3371
|
-
// (software) WebGL context on
|
|
3388
|
+
// (software) WebGL context on GPU-less hosts; the others are the
|
|
3372
3389
|
// standard headless/sandbox flags. The three background-throttling disables
|
|
3373
3390
|
// are payment correctness controls: a backgrounded CardinalCommerce ACS
|
|
3374
3391
|
// frame must keep running its timers long enough to finish the issuer's OOB
|
|
@@ -3403,25 +3420,23 @@ export class BrowserController {
|
|
|
3403
3420
|
this.throwIfStartCancelled();
|
|
3404
3421
|
if (useSelfLaunch && selfLaunchBinary !== null) {
|
|
3405
3422
|
console.error(`[operator] self-launch + connectOverCDP (Turnstile-safe launch) binary=${selfLaunchBinary}`);
|
|
3406
|
-
const window =
|
|
3407
|
-
? { width: 1920, height: 1080 }
|
|
3408
|
-
: { width: 1280, height: 1024 };
|
|
3423
|
+
const window = { width: 1280, height: 1024 };
|
|
3409
3424
|
const selfEnv = {
|
|
3410
|
-
...
|
|
3425
|
+
...browserEnv,
|
|
3411
3426
|
TZ: geo?.timezoneId ?? "America/New_York",
|
|
3412
3427
|
[OPERATOR_BROWSER_MARKER_ENV]: this.operatorBrowserMarker(),
|
|
3413
3428
|
};
|
|
3414
|
-
|
|
3429
|
+
const launch = () => {
|
|
3415
3430
|
this.throwIfStartCancelled();
|
|
3416
3431
|
return this.launchSelfManagedContext({
|
|
3417
3432
|
binary: selfLaunchBinary,
|
|
3418
|
-
headless: chromeHeadless,
|
|
3419
3433
|
args: launchArgs,
|
|
3420
3434
|
proxy,
|
|
3421
3435
|
env: selfEnv,
|
|
3422
3436
|
window,
|
|
3423
3437
|
});
|
|
3424
|
-
}
|
|
3438
|
+
};
|
|
3439
|
+
context = await launch();
|
|
3425
3440
|
try {
|
|
3426
3441
|
await context.grantPermissions(grantedPermissions);
|
|
3427
3442
|
if (geo?.geolocation !== undefined) {
|
|
@@ -3433,7 +3448,6 @@ export class BrowserController {
|
|
|
3433
3448
|
}
|
|
3434
3449
|
}
|
|
3435
3450
|
else {
|
|
3436
|
-
this.profilePoolReusable = false;
|
|
3437
3451
|
this.persistentFallbackLaunchInFlight = true;
|
|
3438
3452
|
this.startPersistentFallbackOwnershipMonitor();
|
|
3439
3453
|
const cleanupProfileHolder = async () => {
|
|
@@ -3450,7 +3464,7 @@ export class BrowserController {
|
|
|
3450
3464
|
return (await this.waitForOwnedProfileExit(identity, treeProof)) ? "closed" : "unknown";
|
|
3451
3465
|
};
|
|
3452
3466
|
const cleanupCancelled = async (lateContext) => {
|
|
3453
|
-
const proof = await this.waitForPersistentFallbackIdentity();
|
|
3467
|
+
const proof = await this.waitForPersistentFallbackIdentity().catch(() => ({ state: "unknown" }));
|
|
3454
3468
|
if (proof.state !== "owned") {
|
|
3455
3469
|
await lateContext.close().catch(() => undefined);
|
|
3456
3470
|
return proof.state === "absent" ? "closed" : "unknown";
|
|
@@ -3478,11 +3492,11 @@ export class BrowserController {
|
|
|
3478
3492
|
const outcome = await (async () => {
|
|
3479
3493
|
try {
|
|
3480
3494
|
return await launchCancellablePersistentContext({
|
|
3481
|
-
launch: (options) =>
|
|
3495
|
+
launch: (options) => launcher.launchPersistentContext(this.profileDir, options),
|
|
3482
3496
|
options: {
|
|
3483
|
-
headless:
|
|
3497
|
+
headless: OPERATOR_BROWSER_HEADLESS,
|
|
3484
3498
|
env: {
|
|
3485
|
-
...
|
|
3499
|
+
...browserEnv,
|
|
3486
3500
|
[OPERATOR_BROWSER_MARKER_ENV]: this.operatorBrowserMarker(),
|
|
3487
3501
|
},
|
|
3488
3502
|
...(channel !== null ? { channel } : {}),
|
|
@@ -3522,12 +3536,23 @@ export class BrowserController {
|
|
|
3522
3536
|
}
|
|
3523
3537
|
this.context = context;
|
|
3524
3538
|
this.launchedContext = true;
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
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
|
+
});
|
|
3531
3556
|
this.commitProfileLaunch();
|
|
3532
3557
|
this.persistentFallbackLaunchInFlight = false;
|
|
3533
3558
|
}
|
|
@@ -3544,7 +3569,7 @@ export class BrowserController {
|
|
|
3544
3569
|
}
|
|
3545
3570
|
}
|
|
3546
3571
|
if (this.startCancellationRequested) {
|
|
3547
|
-
await this.
|
|
3572
|
+
await this.closeBrowser();
|
|
3548
3573
|
throw new Error("BrowserController start cancelled");
|
|
3549
3574
|
}
|
|
3550
3575
|
// Speed: optionally abort heavy/irrelevant requests before any navigation.
|
|
@@ -3647,7 +3672,7 @@ export class BrowserController {
|
|
|
3647
3672
|
get: () => 8,
|
|
3648
3673
|
configurable: true,
|
|
3649
3674
|
});
|
|
3650
|
-
// Screen availHeight tell: a
|
|
3675
|
+
// Screen availHeight tell: a virtual screen reports
|
|
3651
3676
|
// availHeight == height (no OS taskbar), whereas a real Windows
|
|
3652
3677
|
// desktop reserves ~40px for the taskbar (availHeight = height-40,
|
|
3653
3678
|
// availWidth = width). Reinstate that gap so the screen reads like
|
|
@@ -3678,7 +3703,11 @@ export class BrowserController {
|
|
|
3678
3703
|
if (contextInitScripts.includes("webgl-spoof")) {
|
|
3679
3704
|
await context.addInitScript({ content: installWebglSpoofScript });
|
|
3680
3705
|
}
|
|
3706
|
+
for (const page of context.pages())
|
|
3707
|
+
this.trackMainDocument(page);
|
|
3708
|
+
this.trackOpenedTabs(context);
|
|
3681
3709
|
this.page = context.pages()[0] ?? (await context.newPage());
|
|
3710
|
+
this.trackMainDocument(this.page);
|
|
3682
3711
|
this.primaryPage = this.page;
|
|
3683
3712
|
// In baseline mode addInitScript covers document-start page JS, but
|
|
3684
3713
|
// Playwright's page.evaluate utility execution can run in a separate realm.
|
|
@@ -3805,7 +3834,7 @@ export class BrowserController {
|
|
|
3805
3834
|
// timezone has to be known before it is. The throwaway inherits the
|
|
3806
3835
|
// same channel + proxy so it reports the real egress. Best-effort —
|
|
3807
3836
|
// any failure returns null and start() keeps a default timezone.
|
|
3808
|
-
async probeEgressGeo(channel, proxy) {
|
|
3837
|
+
async probeEgressGeo(channel, proxy, browserEnv) {
|
|
3809
3838
|
if (proxy === null) {
|
|
3810
3839
|
try {
|
|
3811
3840
|
const resp = await fetch("https://ipinfo.io/json", { signal: AbortSignal.timeout(10_000) });
|
|
@@ -3822,7 +3851,8 @@ export class BrowserController {
|
|
|
3822
3851
|
let probe;
|
|
3823
3852
|
try {
|
|
3824
3853
|
probe = await getChromium().launch({
|
|
3825
|
-
headless:
|
|
3854
|
+
headless: OPERATOR_BROWSER_HEADLESS,
|
|
3855
|
+
env: browserEnv,
|
|
3826
3856
|
...(channel !== null ? { channel } : {}),
|
|
3827
3857
|
...(proxy !== null ? { proxy } : {}),
|
|
3828
3858
|
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
|
@@ -4113,7 +4143,28 @@ export class BrowserController {
|
|
|
4113
4143
|
async type(selector, text, sealed = false) {
|
|
4114
4144
|
if (!this.page)
|
|
4115
4145
|
throw new Error("Browser not started");
|
|
4116
|
-
|
|
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);
|
|
4117
4168
|
}
|
|
4118
4169
|
async typeInner(selector, text, sealed = false) {
|
|
4119
4170
|
if (!this.page)
|
|
@@ -4121,16 +4172,16 @@ export class BrowserController {
|
|
|
4121
4172
|
// Wait for element to be visible and enabled before typing.
|
|
4122
4173
|
await this.page.waitForSelector(selector, { state: "visible", timeout: 10000 });
|
|
4123
4174
|
const locator = this.page.locator(selector);
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
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.
|
|
4127
4178
|
if (sealed) {
|
|
4128
4179
|
await locator.evaluate((el) => el.setAttribute("data-ts-sealed-payment", "1"));
|
|
4129
4180
|
}
|
|
4130
4181
|
if (!this.humanize) {
|
|
4131
4182
|
// Fast path for tests / non-humanized runs.
|
|
4132
4183
|
await this.page.fill(selector, text);
|
|
4133
|
-
return
|
|
4184
|
+
return;
|
|
4134
4185
|
}
|
|
4135
4186
|
// Humanized typing:
|
|
4136
4187
|
// - Click into the field first (moves mouse, generates focus event)
|
|
@@ -4161,7 +4212,6 @@ export class BrowserController {
|
|
|
4161
4212
|
// hook, and over-engineering it added zero observable behavior-
|
|
4162
4213
|
// score improvement.
|
|
4163
4214
|
await locator.pressSequentially(text, { delay: rand(40, 110) });
|
|
4164
|
-
return sealedFieldKeys;
|
|
4165
4215
|
}
|
|
4166
4216
|
// Best-effort scan for the SPECIFIC unfilled required field(s) blocking a
|
|
4167
4217
|
// disabled submit. Returns a " Unfilled required field(s) — …" suffix for the
|
|
@@ -5149,20 +5199,16 @@ export class BrowserController {
|
|
|
5149
5199
|
const ownerFrame = await handle.ownerFrame();
|
|
5150
5200
|
if (ownerFrame === null)
|
|
5151
5201
|
throw new Error("locator target has no owning frame");
|
|
5152
|
-
const sealedFieldKeys = sealed
|
|
5153
|
-
? await this.operatorScreenshotIdentityKeys(handle, ownerFrame)
|
|
5154
|
-
: [];
|
|
5155
5202
|
if (sealed) {
|
|
5156
5203
|
await handle.evaluate((el) => el.setAttribute("data-ts-sealed-payment", "1"));
|
|
5157
5204
|
}
|
|
5158
5205
|
if (!this.humanize) {
|
|
5159
5206
|
await handle.fill(text);
|
|
5160
|
-
return
|
|
5207
|
+
return;
|
|
5161
5208
|
}
|
|
5162
5209
|
await handle.click({ timeout: 8000 }).catch(() => undefined);
|
|
5163
5210
|
await handle.fill("").catch(() => undefined);
|
|
5164
5211
|
await handle.type(text, { delay: rand(40, 110) });
|
|
5165
|
-
return sealedFieldKeys;
|
|
5166
5212
|
}
|
|
5167
5213
|
// Dispatch a DOM .click() in the page context. Some React copy buttons fire
|
|
5168
5214
|
// their onClick (and thus navigator.clipboard.writeText) on the synthetic
|
|
@@ -6761,7 +6807,7 @@ export class BrowserController {
|
|
|
6761
6807
|
// device memory, screen, languages, webdriver flag. Turnstile
|
|
6762
6808
|
// error 600010 ("internal client execution error") usually points
|
|
6763
6809
|
// at one of these returning something the challenge JS can't
|
|
6764
|
-
// handle (e.g. SwiftShader/llvmpipe renderer
|
|
6810
|
+
// handle (e.g. a SwiftShader/llvmpipe renderer).
|
|
6765
6811
|
if (process.env.UNIVERSAL_BOT_CAPTCHA_TRACE === "1") {
|
|
6766
6812
|
try {
|
|
6767
6813
|
const fp = await this.page.evaluate(() => {
|
|
@@ -7642,10 +7688,6 @@ export class BrowserController {
|
|
|
7642
7688
|
hasHcaptchaGlobal: win.hcaptcha !== undefined,
|
|
7643
7689
|
};
|
|
7644
7690
|
}, { tok: token, key: responseKey });
|
|
7645
|
-
if (process.env.UNIVERSAL_BOT_OAUTH_DEBUG) {
|
|
7646
|
-
console.error(`[captcha] hCaptcha inject diag: ok=${diag.ok} textareas=${diag.textareas} ` +
|
|
7647
|
-
`widgets=${diag.widgets} callbackFired=${diag.callbackFired} hcaptchaGlobal=${diag.hasHcaptchaGlobal}`);
|
|
7648
|
-
}
|
|
7649
7691
|
return diag.ok;
|
|
7650
7692
|
}
|
|
7651
7693
|
catch {
|
|
@@ -7864,582 +7906,78 @@ export class BrowserController {
|
|
|
7864
7906
|
}
|
|
7865
7907
|
return null;
|
|
7866
7908
|
}
|
|
7867
|
-
|
|
7868
|
-
|
|
7869
|
-
|
|
7870
|
-
|
|
7871
|
-
|
|
7872
|
-
|
|
7873
|
-
|
|
7874
|
-
const strictFrames = new Set();
|
|
7875
|
-
const visit = (frame) => {
|
|
7876
|
-
strictFrames.add(frame);
|
|
7877
|
-
for (const child of frame.childFrames())
|
|
7878
|
-
visit(child);
|
|
7879
|
-
};
|
|
7880
|
-
visit(targetFrame);
|
|
7881
|
-
const targetHandle = await targetFrame.frameElement();
|
|
7882
|
-
const clip = await targetHandle.boundingBox();
|
|
7883
|
-
await targetHandle.dispose().catch(() => undefined);
|
|
7884
|
-
if (clip === null)
|
|
7885
|
-
throw new Error("screenshot_redaction_unresolved");
|
|
7886
|
-
const intersects = (box) => box.x < clip.x + clip.width &&
|
|
7887
|
-
box.x + box.width > clip.x &&
|
|
7888
|
-
box.y < clip.y + clip.height &&
|
|
7889
|
-
box.y + box.height > clip.y;
|
|
7890
|
-
const frames = [];
|
|
7891
|
-
for (const frame of this.page.frames()) {
|
|
7892
|
-
if (frame === this.page.mainFrame() || strictFrames.has(frame)) {
|
|
7893
|
-
frames.push(frame);
|
|
7894
|
-
continue;
|
|
7895
|
-
}
|
|
7896
|
-
const handle = await frame.frameElement();
|
|
7897
|
-
try {
|
|
7898
|
-
const box = await handle.boundingBox();
|
|
7899
|
-
if (box !== null && intersects(box))
|
|
7900
|
-
frames.push(frame);
|
|
7901
|
-
}
|
|
7902
|
-
finally {
|
|
7903
|
-
await handle.dispose().catch(() => undefined);
|
|
7904
|
-
}
|
|
7905
|
-
}
|
|
7906
|
-
return { frames, strictFrames, clip };
|
|
7907
|
-
}
|
|
7908
|
-
async sealedDocumentIdentity(frame) {
|
|
7909
|
-
const current = this.sealedDocuments.get(frame);
|
|
7910
|
-
if (current !== undefined &&
|
|
7911
|
-
!frame.isDetached() &&
|
|
7912
|
-
(await frame.evaluate((expected) => document === expected, current.handle).catch(() => false))) {
|
|
7913
|
-
return current.identity;
|
|
7914
|
-
}
|
|
7915
|
-
await current?.handle.dispose().catch(() => undefined);
|
|
7916
|
-
const handle = await frame.evaluateHandle(() => document);
|
|
7917
|
-
const identity = `document-${this.sealedDocumentSequence++}`;
|
|
7918
|
-
this.sealedDocuments.set(frame, { handle, identity });
|
|
7919
|
-
return identity;
|
|
7920
|
-
}
|
|
7921
|
-
async operatorScreenshotIdentityKeys(target, frame) {
|
|
7922
|
-
const descriptor = await target.evaluate((el) => {
|
|
7923
|
-
const clean = (value) => {
|
|
7924
|
-
const normalized = (value ?? "").replace(/\s+/g, " ").trim();
|
|
7925
|
-
return normalized.length > 0 ? normalized : null;
|
|
7926
|
-
};
|
|
7927
|
-
const labelFor = (element) => {
|
|
7928
|
-
const id = element.getAttribute("id");
|
|
7929
|
-
if (id !== null && id.length > 0) {
|
|
7930
|
-
const label = document.querySelector(`label[for="${CSS.escape(id)}"]`);
|
|
7931
|
-
if (label !== null)
|
|
7932
|
-
return clean(label.textContent);
|
|
7933
|
-
}
|
|
7934
|
-
const labelledBy = element.getAttribute("aria-labelledby");
|
|
7935
|
-
if (labelledBy !== null) {
|
|
7936
|
-
const text = labelledBy
|
|
7937
|
-
.split(/\s+/)
|
|
7938
|
-
.map((part) => clean(document.getElementById(part)?.textContent))
|
|
7939
|
-
.filter((part) => part !== null)
|
|
7940
|
-
.join(" ");
|
|
7941
|
-
if (text.length > 0)
|
|
7942
|
-
return text;
|
|
7943
|
-
}
|
|
7944
|
-
return clean(element.closest("label")?.textContent);
|
|
7945
|
-
};
|
|
7946
|
-
const controls = Array.from(el.getRootNode().querySelectorAll("input,textarea,select,[contenteditable='true']"));
|
|
7947
|
-
return {
|
|
7948
|
-
tag: el.tagName.toLowerCase(),
|
|
7949
|
-
type: el.getAttribute("type"),
|
|
7950
|
-
id: el.getAttribute("id"),
|
|
7951
|
-
name: el.getAttribute("name"),
|
|
7952
|
-
testId: el.getAttribute("data-testid") ??
|
|
7953
|
-
el.getAttribute("data-test-id") ??
|
|
7954
|
-
el.getAttribute("data-test") ??
|
|
7955
|
-
el.getAttribute("data-cy") ??
|
|
7956
|
-
el.getAttribute("data-qa"),
|
|
7957
|
-
labelText: labelFor(el),
|
|
7958
|
-
ariaLabel: el.getAttribute("aria-label"),
|
|
7959
|
-
placeholder: el.getAttribute("placeholder"),
|
|
7960
|
-
landmark: el.closest("header,main,footer,nav,aside,article,section")?.tagName.toLowerCase() ?? null,
|
|
7961
|
-
ordinal: controls.indexOf(el),
|
|
7962
|
-
};
|
|
7963
|
-
});
|
|
7964
|
-
const documentIdentity = await this.sealedDocumentIdentity(frame);
|
|
7965
|
-
return sealedElementSemanticKeys(descriptor).map((key) => `${documentIdentity}:${key}`);
|
|
7966
|
-
}
|
|
7967
|
-
async resolveOperatorScreenshotSealedLocators(frames, sealedFieldKeys) {
|
|
7968
|
-
const byFrame = new Map();
|
|
7969
|
-
for (const frame of frames) {
|
|
7970
|
-
const matches = [];
|
|
7971
|
-
const candidates = await frame
|
|
7972
|
-
.locator("input,textarea,select,[contenteditable='true']")
|
|
7973
|
-
.all();
|
|
7974
|
-
for (const candidate of candidates) {
|
|
7975
|
-
const keys = await this.operatorScreenshotIdentityKeys(candidate, frame);
|
|
7976
|
-
if (keys.some((key) => sealedFieldKeys.has(key)))
|
|
7977
|
-
matches.push(candidate);
|
|
7978
|
-
}
|
|
7979
|
-
byFrame.set(frame, matches);
|
|
7980
|
-
}
|
|
7981
|
-
return byFrame;
|
|
7982
|
-
}
|
|
7983
|
-
// Resolve every card-shaped/sealed field in the captured frames to a
|
|
7984
|
-
// screenshot redaction rectangles — text-masking (presentFieldValue in
|
|
7985
|
-
// provision-session.ts) only covers the JSON observation, not a rendered
|
|
7986
|
-
// image. Beyond the attribute-based
|
|
7987
|
-
// selector set, every renderable input/textarea whose CURRENT value contains
|
|
7988
|
-
// a Luhn-valid PAN span is masked too (containsLuhnPanSpan — the same
|
|
7989
|
-
// detection the payment paths use), so a card number sitting in a field the
|
|
7990
|
-
// fixed selectors don't recognize still never reaches the image. Fail-closed
|
|
7991
|
-
// throughout: a selector that cannot be queried, a value that cannot be
|
|
7992
|
-
// read, or a matched element whose geometry cannot be resolved (boundingBox
|
|
7993
|
-
// returns null rather than throwing) aborts the whole capture rather than
|
|
7994
|
-
// shrinking the redaction. The per-frame count signature lets the caller
|
|
7995
|
-
// verify the redaction set stayed stable across the capture window.
|
|
7996
|
-
async collectOperatorScreenshotMask(frames, extraRedactionSelectors, sealedLocators = new Map(), captureClip = null, strictFrames = new Set(frames)) {
|
|
7997
|
-
const selector = [SCREENSHOT_REDACTION_SELECTORS, ...extraRedactionSelectors].join(",");
|
|
7998
|
-
const rectangles = [];
|
|
7999
|
-
const handles = [];
|
|
8000
|
-
const signatureParts = [];
|
|
8001
|
-
try {
|
|
8002
|
-
for (const [frameIndex, frame] of frames.entries()) {
|
|
8003
|
-
let frameCount = 0;
|
|
8004
|
-
const frameHandles = await frame.locator(selector).elementHandles();
|
|
8005
|
-
for (const locator of sealedLocators.get(frame) ?? []) {
|
|
8006
|
-
const handle = await locator.elementHandle({ timeout: 5_000 });
|
|
8007
|
-
if (handle === null)
|
|
8008
|
-
throw new Error("screenshot_redaction_unresolved");
|
|
8009
|
-
if (!(await handle.evaluate((el, matchSelector) => el.matches(matchSelector), selector))) {
|
|
8010
|
-
frameHandles.push(handle);
|
|
8011
|
-
}
|
|
8012
|
-
else {
|
|
8013
|
-
await handle.dispose();
|
|
8014
|
-
}
|
|
8015
|
-
}
|
|
8016
|
-
const valueCandidates = await frame
|
|
8017
|
-
.locator('input:not([type="hidden" i]),textarea')
|
|
8018
|
-
.elementHandles();
|
|
8019
|
-
for (const candidate of valueCandidates) {
|
|
8020
|
-
const value = await candidate.inputValue({ timeout: 5_000 });
|
|
8021
|
-
if (!containsLuhnPanSpan(value)) {
|
|
8022
|
-
await candidate.dispose();
|
|
8023
|
-
continue;
|
|
8024
|
-
}
|
|
8025
|
-
const duplicate = await Promise.all(frameHandles.map(async (existing) => await candidate.evaluate((el, other) => el === other, existing)));
|
|
8026
|
-
if (duplicate.includes(true)) {
|
|
8027
|
-
await candidate.dispose();
|
|
8028
|
-
continue;
|
|
8029
|
-
}
|
|
8030
|
-
frameHandles.push(candidate);
|
|
8031
|
-
}
|
|
8032
|
-
for (const handle of frameHandles) {
|
|
8033
|
-
if (!(await handle.evaluate((el) => el.isConnected))) {
|
|
8034
|
-
throw new Error("screenshot_redaction_unresolved");
|
|
8035
|
-
}
|
|
8036
|
-
const box = await handle.boundingBox();
|
|
8037
|
-
if (captureClip !== null &&
|
|
8038
|
-
!strictFrames.has(frame) &&
|
|
8039
|
-
(box === null ||
|
|
8040
|
-
box.x >= captureClip.x + captureClip.width ||
|
|
8041
|
-
box.x + box.width <= captureClip.x ||
|
|
8042
|
-
box.y >= captureClip.y + captureClip.height ||
|
|
8043
|
-
box.y + box.height <= captureClip.y)) {
|
|
8044
|
-
await handle.dispose();
|
|
8045
|
-
continue;
|
|
8046
|
-
}
|
|
8047
|
-
handles.push(handle);
|
|
8048
|
-
if (box !== null)
|
|
8049
|
-
rectangles.push(box);
|
|
8050
|
-
signatureParts.push(`${frameIndex}:${box === null ? "hidden" : [box.x, box.y, box.width, box.height].join(":")}`);
|
|
8051
|
-
frameCount += 1;
|
|
8052
|
-
}
|
|
8053
|
-
signatureParts.push(`count:${frameIndex}:${frameCount}`);
|
|
8054
|
-
}
|
|
8055
|
-
}
|
|
8056
|
-
catch {
|
|
8057
|
-
await Promise.all(handles.map(async (handle) => await handle.dispose().catch(() => undefined)));
|
|
8058
|
-
throw new Error("screenshot_redaction_unresolved");
|
|
8059
|
-
}
|
|
8060
|
-
return {
|
|
8061
|
-
rectangles,
|
|
8062
|
-
redactedCount: handles.length,
|
|
8063
|
-
signature: signatureParts.join("|"),
|
|
8064
|
-
handles,
|
|
8065
|
-
};
|
|
8066
|
-
}
|
|
8067
|
-
async redactOperatorScreenshot(buffer, rectangles, origin, captureSize) {
|
|
8068
|
-
const metadata = await sharp(buffer).metadata();
|
|
8069
|
-
if (metadata.width === undefined || metadata.height === undefined) {
|
|
8070
|
-
throw new Error("screenshot_redaction_unresolved");
|
|
8071
|
-
}
|
|
8072
|
-
const scaleX = metadata.width / captureSize.width;
|
|
8073
|
-
const scaleY = metadata.height / captureSize.height;
|
|
8074
|
-
const rects = rectangles
|
|
8075
|
-
.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"/>`)
|
|
8076
|
-
.join("");
|
|
8077
|
-
const overlay = Buffer.from(`<svg width="${metadata.width}" height="${metadata.height}" xmlns="http://www.w3.org/2000/svg">${rects}</svg>`);
|
|
8078
|
-
return (await sharp(buffer)
|
|
8079
|
-
.composite([{ input: overlay, blend: "over" }])
|
|
8080
|
-
.jpeg({ quality: 80 })
|
|
8081
|
-
.toBuffer()).toString("base64");
|
|
8082
|
-
}
|
|
8083
|
-
// Verify the capture set before pixels are read. This is deliberately
|
|
8084
|
-
// narrower than the capture-time mask: empty checkout controls are harmless,
|
|
8085
|
-
// but a nonempty type_secret target, password, payment-sealed node, or
|
|
8086
|
-
// Luhn-valid PAN means the requested image could contain a secret. Every
|
|
8087
|
-
// frame included by the image must be readable; a detached or navigating
|
|
8088
|
-
// frame is not evidence that it is safe to capture.
|
|
8089
|
-
async assertOperatorScreenshotFramesNoSealedValues(frames, extraRedactionSelectors, sealedLocators = new Map(), captureClip = null, strictFrames = new Set(frames)) {
|
|
8090
|
-
const sealedSelector = [SCREENSHOT_REDACTION_SELECTORS, ...extraRedactionSelectors].join(",");
|
|
8091
|
-
try {
|
|
8092
|
-
for (const frame of frames) {
|
|
8093
|
-
if (frame.isDetached())
|
|
8094
|
-
throw new Error("frame detached");
|
|
8095
|
-
const sealedMatches = [
|
|
8096
|
-
...(await frame.locator(sealedSelector).all()),
|
|
8097
|
-
...(sealedLocators.get(frame) ?? []),
|
|
8098
|
-
];
|
|
8099
|
-
for (const match of sealedMatches) {
|
|
8100
|
-
const box = strictFrames.has(frame) ? null : await match.boundingBox();
|
|
8101
|
-
if (captureClip !== null &&
|
|
8102
|
-
!strictFrames.has(frame) &&
|
|
8103
|
-
(box === null ||
|
|
8104
|
-
box.x >= captureClip.x + captureClip.width ||
|
|
8105
|
-
box.x + box.width <= captureClip.x ||
|
|
8106
|
-
box.y >= captureClip.y + captureClip.height ||
|
|
8107
|
-
box.y + box.height <= captureClip.y)) {
|
|
8108
|
-
continue;
|
|
8109
|
-
}
|
|
8110
|
-
const hasValue = await match.evaluate((el) => {
|
|
8111
|
-
if (el instanceof HTMLSelectElement) {
|
|
8112
|
-
const optionText = el.options[el.selectedIndex]?.textContent ?? "";
|
|
8113
|
-
const normalizedOption = optionText.replace(/\s+/g, " ").trim();
|
|
8114
|
-
const placeholder = /^(?:select|choose)?\s*(?:a\s+)?(?:month|year|mm|yy)?\s*(?:\.\.\.|[-–—]*)$/i;
|
|
8115
|
-
return (el.value.trim().length > 0 ||
|
|
8116
|
-
(normalizedOption.length > 0 && !placeholder.test(normalizedOption)));
|
|
8117
|
-
}
|
|
8118
|
-
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
|
8119
|
-
return el.value.trim().length > 0;
|
|
8120
|
-
}
|
|
8121
|
-
return (el.textContent ?? "").trim().length > 0;
|
|
8122
|
-
});
|
|
8123
|
-
if (hasValue)
|
|
8124
|
-
throw new Error("sealed value");
|
|
8125
|
-
}
|
|
8126
|
-
const candidates = await frame.locator('input:not([type="hidden" i]),textarea').all();
|
|
8127
|
-
for (const candidate of candidates) {
|
|
8128
|
-
const box = strictFrames.has(frame) ? null : await candidate.boundingBox();
|
|
8129
|
-
if (captureClip !== null &&
|
|
8130
|
-
!strictFrames.has(frame) &&
|
|
8131
|
-
(box === null ||
|
|
8132
|
-
box.x >= captureClip.x + captureClip.width ||
|
|
8133
|
-
box.x + box.width <= captureClip.x ||
|
|
8134
|
-
box.y >= captureClip.y + captureClip.height ||
|
|
8135
|
-
box.y + box.height <= captureClip.y)) {
|
|
8136
|
-
continue;
|
|
8137
|
-
}
|
|
8138
|
-
const hasPan = await candidate.evaluate((el) => {
|
|
8139
|
-
const text = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement ? el.value : "";
|
|
8140
|
-
const digits = Array.from(text.matchAll(/\d/g), (match) => match.index);
|
|
8141
|
-
const luhn = (value) => {
|
|
8142
|
-
let sum = 0;
|
|
8143
|
-
let double = false;
|
|
8144
|
-
for (let index = value.length - 1; index >= 0; index -= 1) {
|
|
8145
|
-
let digit = Number(value[index]);
|
|
8146
|
-
if (double) {
|
|
8147
|
-
digit *= 2;
|
|
8148
|
-
if (digit > 9)
|
|
8149
|
-
digit -= 9;
|
|
8150
|
-
}
|
|
8151
|
-
sum += digit;
|
|
8152
|
-
double = !double;
|
|
8153
|
-
}
|
|
8154
|
-
return sum % 10 === 0;
|
|
8155
|
-
};
|
|
8156
|
-
for (let start = 0; start + 13 <= digits.length; start += 1) {
|
|
8157
|
-
const maxLength = Math.min(19, digits.length - start);
|
|
8158
|
-
for (let length = 13; length <= maxLength; length += 1) {
|
|
8159
|
-
const positions = digits.slice(start, start + length);
|
|
8160
|
-
if (positions[positions.length - 1] - positions[0] + 1 >
|
|
8161
|
-
PAYMENT_PAN_MAX_SPAN_CHARS) {
|
|
8162
|
-
break;
|
|
8163
|
-
}
|
|
8164
|
-
if (luhn(positions.map((position) => text[position]).join("")))
|
|
8165
|
-
return true;
|
|
8166
|
-
}
|
|
8167
|
-
}
|
|
8168
|
-
return false;
|
|
8169
|
-
});
|
|
8170
|
-
if (hasPan)
|
|
8171
|
-
throw new Error("card value");
|
|
8172
|
-
}
|
|
8173
|
-
if (strictFrames.has(frame) || captureClip === null || frame !== this.page?.mainFrame()) {
|
|
8174
|
-
const renderedText = await frame.evaluate(() => document.body?.innerText ?? document.documentElement?.innerText ?? "");
|
|
8175
|
-
if (containsLuhnPanSpan(renderedText))
|
|
8176
|
-
throw new Error("rendered card value");
|
|
8177
|
-
}
|
|
8178
|
-
else {
|
|
8179
|
-
const renderedTextInClip = await frame.evaluate((clip) => {
|
|
8180
|
-
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
8181
|
-
const texts = [];
|
|
8182
|
-
let node = walker.nextNode();
|
|
8183
|
-
while (node !== null) {
|
|
8184
|
-
const text = node.textContent ?? "";
|
|
8185
|
-
if (/\d/.test(text)) {
|
|
8186
|
-
const range = document.createRange();
|
|
8187
|
-
range.selectNodeContents(node);
|
|
8188
|
-
const overlaps = Array.from(range.getClientRects()).some((rect) => rect.left < clip.x + clip.width &&
|
|
8189
|
-
rect.right > clip.x &&
|
|
8190
|
-
rect.top < clip.y + clip.height &&
|
|
8191
|
-
rect.bottom > clip.y);
|
|
8192
|
-
if (overlaps)
|
|
8193
|
-
texts.push(text);
|
|
8194
|
-
}
|
|
8195
|
-
node = walker.nextNode();
|
|
8196
|
-
}
|
|
8197
|
-
return texts.join(" ");
|
|
8198
|
-
}, captureClip);
|
|
8199
|
-
if (containsLuhnPanSpan(renderedTextInClip))
|
|
8200
|
-
throw new Error("rendered card value");
|
|
8201
|
-
}
|
|
8202
|
-
}
|
|
8203
|
-
}
|
|
8204
|
-
catch {
|
|
8205
|
-
throw new Error("screenshot_unavailable_sealed_context");
|
|
8206
|
-
}
|
|
8207
|
-
}
|
|
8208
|
-
async captureOperatorScreenshot(opts = {}, sealedFieldKeys = []) {
|
|
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);
|
|
7914
|
+
}
|
|
7915
|
+
async screenshotForOperator(opts = {}) {
|
|
8209
7916
|
if (!this.page)
|
|
8210
7917
|
throw new Error("Browser not started");
|
|
7918
|
+
const page = this.page;
|
|
8211
7919
|
const targetFrame = this.resolveOperatorScreenshotFrame(opts);
|
|
8212
|
-
const
|
|
8213
|
-
const { frames } = scope;
|
|
8214
|
-
const documents = [];
|
|
7920
|
+
const cdp = await page.context().newCDPSession(page);
|
|
8215
7921
|
try {
|
|
8216
|
-
|
|
8217
|
-
|
|
8218
|
-
|
|
8219
|
-
|
|
8220
|
-
|
|
8221
|
-
const sealedLocators = await this.resolveOperatorScreenshotSealedLocators(frames, new Set(sealedFieldKeys));
|
|
8222
|
-
await this.assertOperatorScreenshotFramesNoSealedValues(frames, [], sealedLocators, scope.clip, scope.strictFrames);
|
|
8223
|
-
const documentsStillCurrent = async () => {
|
|
8224
|
-
if (documents.length !== frames.length)
|
|
8225
|
-
return false;
|
|
8226
|
-
for (let index = 0; index < frames.length; index += 1) {
|
|
8227
|
-
const frame = frames[index];
|
|
8228
|
-
if (frame.isDetached())
|
|
8229
|
-
return false;
|
|
8230
|
-
if (!(await frame.evaluate((expected) => document === expected, documents[index]))) {
|
|
8231
|
-
return false;
|
|
8232
|
-
}
|
|
8233
|
-
}
|
|
8234
|
-
return true;
|
|
8235
|
-
};
|
|
8236
|
-
if (!(await documentsStillCurrent())) {
|
|
8237
|
-
throw new Error("screenshot_unavailable_sealed_context");
|
|
8238
|
-
}
|
|
8239
|
-
let captured;
|
|
8240
|
-
let attemptScope = scope;
|
|
8241
|
-
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
8242
|
-
if (attempt > 0) {
|
|
8243
|
-
attemptScope = await this.operatorScreenshotCaptureScope(targetFrame);
|
|
8244
|
-
if (attemptScope.frames.length !== frames.length ||
|
|
8245
|
-
!frames.every((frame, index) => attemptScope.frames[index] === frame)) {
|
|
8246
|
-
throw new Error("screenshot_unavailable_sealed_context");
|
|
8247
|
-
}
|
|
8248
|
-
}
|
|
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();
|
|
8249
7927
|
try {
|
|
8250
|
-
|
|
8251
|
-
|
|
8252
|
-
|
|
8253
|
-
|
|
8254
|
-
await this.assertOperatorScreenshotFramesNoSealedValues(frames, [], sealedLocators, attemptScope.clip, attemptScope.strictFrames);
|
|
8255
|
-
if (!(await documentsStillCurrent())) {
|
|
8256
|
-
throw new Error("screenshot_unavailable_sealed_context");
|
|
8257
|
-
}
|
|
8258
|
-
});
|
|
8259
|
-
break;
|
|
8260
|
-
}
|
|
8261
|
-
catch (error) {
|
|
8262
|
-
if (attempt === 0 &&
|
|
8263
|
-
error instanceof Error &&
|
|
8264
|
-
error.message === "screenshot_redaction_unstable") {
|
|
8265
|
-
continue;
|
|
8266
|
-
}
|
|
8267
|
-
throw error;
|
|
8268
|
-
}
|
|
8269
|
-
}
|
|
8270
|
-
if (captured === undefined)
|
|
8271
|
-
throw new Error("screenshot_unavailable_sealed_context");
|
|
8272
|
-
if (!(await documentsStillCurrent())) {
|
|
8273
|
-
throw new Error("screenshot_unavailable_sealed_context");
|
|
8274
|
-
}
|
|
8275
|
-
await this.assertOperatorScreenshotFramesNoSealedValues(frames, [], sealedLocators, attemptScope.clip, attemptScope.strictFrames);
|
|
8276
|
-
return captured;
|
|
8277
|
-
}
|
|
8278
|
-
catch (error) {
|
|
8279
|
-
if (error instanceof Error && error.message === "screenshot_frame_not_found")
|
|
8280
|
-
throw error;
|
|
8281
|
-
if (error instanceof Error && error.message === "screenshot_unavailable_sealed_context") {
|
|
8282
|
-
throw error;
|
|
8283
|
-
}
|
|
8284
|
-
throw new Error("screenshot_unavailable_sealed_context");
|
|
8285
|
-
}
|
|
8286
|
-
finally {
|
|
8287
|
-
await Promise.all(documents.map(async (document) => await document.dispose().catch(() => undefined)));
|
|
8288
|
-
}
|
|
8289
|
-
}
|
|
8290
|
-
// Low-level read-only capture used by captureOperatorScreenshot after its
|
|
8291
|
-
// capture-scoped sealed-value checks, and directly by redaction tests. It
|
|
8292
|
-
// never navigates, clicks, types, focuses, or mutates the DOM. Redaction
|
|
8293
|
-
// covers every frame included in the image: the whole page composites its
|
|
8294
|
-
// visible frames, while an isolated frame includes only that frame.
|
|
8295
|
-
// `extraRedactionSelectors` is retained for callers that already resolved
|
|
8296
|
-
// additional sensitive elements; production capture passes durable locators.
|
|
8297
|
-
async screenshotForOperator(opts = {}) {
|
|
8298
|
-
if (!this.page)
|
|
8299
|
-
throw new Error("Browser not started");
|
|
8300
|
-
const targetFrame = this.resolveOperatorScreenshotFrame(opts);
|
|
8301
|
-
const scope = await this.operatorScreenshotCaptureScope(targetFrame);
|
|
8302
|
-
return await this.screenshotForOperatorResolved(opts, targetFrame, scope.frames, new Map(), scope);
|
|
8303
|
-
}
|
|
8304
|
-
async screenshotForOperatorResolved(opts, targetFrame, framesBefore, sealedLocators = new Map(), scope = { frames: [...framesBefore], strictFrames: new Set(framesBefore), clip: null }, beforeCapture) {
|
|
8305
|
-
if (!this.page)
|
|
8306
|
-
throw new Error("Browser not started");
|
|
8307
|
-
const page = this.page;
|
|
8308
|
-
const extraRedactionSelectors = opts.extraRedactionSelectors ?? [];
|
|
8309
|
-
const before = await this.collectOperatorScreenshotMask(framesBefore, extraRedactionSelectors, sealedLocators, scope.clip, scope.strictFrames);
|
|
8310
|
-
const { rectangles, redactedCount, signature } = before;
|
|
8311
|
-
let recheck;
|
|
8312
|
-
try {
|
|
8313
|
-
// caret:"initial" skips Playwright's default caret-hiding pass, which
|
|
8314
|
-
// writes (and restores) caret-color on every editable element's inline
|
|
8315
|
-
// style — this capture must leave element styles untouched.
|
|
8316
|
-
let buffer;
|
|
8317
|
-
let origin = { x: 0, y: 0 };
|
|
8318
|
-
let captureSize;
|
|
8319
|
-
const cdp = await page.context().newCDPSession(page);
|
|
8320
|
-
try {
|
|
8321
|
-
if (targetFrame !== null && targetFrame !== page.mainFrame()) {
|
|
8322
|
-
const handle = await targetFrame.frameElement();
|
|
8323
|
-
try {
|
|
8324
|
-
const box = await handle.boundingBox();
|
|
8325
|
-
if (box === null)
|
|
8326
|
-
throw new Error("screenshot_redaction_unresolved");
|
|
8327
|
-
if (scope.clip === null ||
|
|
8328
|
-
box.x !== scope.clip.x ||
|
|
8329
|
-
box.y !== scope.clip.y ||
|
|
8330
|
-
box.width !== scope.clip.width ||
|
|
8331
|
-
box.height !== scope.clip.height) {
|
|
8332
|
-
throw new Error("screenshot_redaction_unresolved");
|
|
8333
|
-
}
|
|
8334
|
-
const scroll = await page.evaluate(() => ({ x: window.scrollX, y: window.scrollY }));
|
|
8335
|
-
origin = { x: box.x, y: box.y };
|
|
8336
|
-
captureSize = { width: box.width, height: box.height };
|
|
8337
|
-
await beforeCapture?.();
|
|
8338
|
-
const result = await cdp.send("Page.captureScreenshot", {
|
|
8339
|
-
format: "jpeg",
|
|
8340
|
-
quality: 80,
|
|
8341
|
-
fromSurface: true,
|
|
8342
|
-
captureBeyondViewport: true,
|
|
8343
|
-
clip: {
|
|
8344
|
-
x: box.x + scroll.x,
|
|
8345
|
-
y: box.y + scroll.y,
|
|
8346
|
-
width: box.width,
|
|
8347
|
-
height: box.height,
|
|
8348
|
-
scale: 1,
|
|
8349
|
-
},
|
|
8350
|
-
});
|
|
8351
|
-
buffer = Buffer.from(result.data, "base64");
|
|
8352
|
-
}
|
|
8353
|
-
finally {
|
|
8354
|
-
await handle.dispose().catch(() => undefined);
|
|
8355
|
-
}
|
|
8356
|
-
}
|
|
8357
|
-
else if (opts.fullPage === true) {
|
|
8358
|
-
const dimensions = await page.evaluate(() => ({
|
|
8359
|
-
origin: { x: -window.scrollX, y: -window.scrollY },
|
|
8360
|
-
size: {
|
|
8361
|
-
width: document.documentElement.scrollWidth,
|
|
8362
|
-
height: document.documentElement.scrollHeight,
|
|
8363
|
-
},
|
|
8364
|
-
}));
|
|
8365
|
-
origin = dimensions.origin;
|
|
8366
|
-
captureSize = dimensions.size;
|
|
8367
|
-
await beforeCapture?.();
|
|
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 }));
|
|
8368
7932
|
const result = await cdp.send("Page.captureScreenshot", {
|
|
8369
7933
|
format: "jpeg",
|
|
8370
7934
|
quality: 80,
|
|
8371
7935
|
fromSurface: true,
|
|
8372
7936
|
captureBeyondViewport: true,
|
|
8373
7937
|
clip: {
|
|
8374
|
-
x:
|
|
8375
|
-
y:
|
|
8376
|
-
width:
|
|
8377
|
-
height:
|
|
7938
|
+
x: box.x + scroll.x,
|
|
7939
|
+
y: box.y + scroll.y,
|
|
7940
|
+
width: box.width,
|
|
7941
|
+
height: box.height,
|
|
8378
7942
|
scale: 1,
|
|
8379
7943
|
},
|
|
8380
7944
|
});
|
|
8381
|
-
|
|
7945
|
+
base64 = result.data;
|
|
8382
7946
|
}
|
|
8383
|
-
|
|
8384
|
-
|
|
8385
|
-
if (viewport === null)
|
|
8386
|
-
throw new Error("screenshot_redaction_unresolved");
|
|
8387
|
-
captureSize = viewport;
|
|
8388
|
-
await beforeCapture?.();
|
|
8389
|
-
const result = await cdp.send("Page.captureScreenshot", {
|
|
8390
|
-
format: "jpeg",
|
|
8391
|
-
quality: 80,
|
|
8392
|
-
fromSurface: true,
|
|
8393
|
-
});
|
|
8394
|
-
buffer = Buffer.from(result.data, "base64");
|
|
7947
|
+
finally {
|
|
7948
|
+
await handle.dispose().catch(() => undefined);
|
|
8395
7949
|
}
|
|
8396
7950
|
}
|
|
8397
|
-
|
|
8398
|
-
await
|
|
8399
|
-
|
|
8400
|
-
|
|
8401
|
-
|
|
8402
|
-
|
|
8403
|
-
|
|
8404
|
-
|
|
8405
|
-
|
|
8406
|
-
|
|
8407
|
-
|
|
8408
|
-
|
|
8409
|
-
|
|
8410
|
-
|
|
8411
|
-
|
|
8412
|
-
|
|
8413
|
-
|
|
8414
|
-
|
|
8415
|
-
|
|
8416
|
-
|
|
8417
|
-
|
|
8418
|
-
stable = false;
|
|
8419
|
-
break;
|
|
8420
|
-
}
|
|
8421
|
-
}
|
|
8422
|
-
}
|
|
8423
|
-
}
|
|
8424
|
-
catch {
|
|
8425
|
-
stable = false;
|
|
8426
|
-
}
|
|
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;
|
|
8427
7972
|
}
|
|
8428
|
-
if (!stable)
|
|
8429
|
-
throw new Error("screenshot_redaction_unstable");
|
|
8430
|
-
const base64 = await this.redactOperatorScreenshot(buffer, rectangles, origin, captureSize);
|
|
8431
7973
|
return {
|
|
8432
7974
|
base64,
|
|
8433
7975
|
frameUrl: targetFrame?.url() ?? null,
|
|
8434
7976
|
frameCount: page.frames().length,
|
|
8435
|
-
redactedCount,
|
|
8436
7977
|
};
|
|
8437
7978
|
}
|
|
8438
7979
|
finally {
|
|
8439
|
-
await
|
|
8440
|
-
...before.handles.map(async (handle) => await handle.dispose().catch(() => undefined)),
|
|
8441
|
-
...(recheck?.handles ?? []).map(async (handle) => await handle.dispose().catch(() => undefined)),
|
|
8442
|
-
]);
|
|
7980
|
+
await cdp.detach().catch(() => undefined);
|
|
8443
7981
|
}
|
|
8444
7982
|
}
|
|
8445
7983
|
async getState() {
|
|
@@ -8489,6 +8027,32 @@ export class BrowserController {
|
|
|
8489
8027
|
throw new Error("Browser not started");
|
|
8490
8028
|
return await this.page.evaluate(extractObservationVisibleText);
|
|
8491
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
|
+
}
|
|
8492
8056
|
async readCheckoutSummary(fallbackCurrency) {
|
|
8493
8057
|
if (!this.page)
|
|
8494
8058
|
throw new Error("Browser not started");
|
|
@@ -8783,6 +8347,28 @@ export class BrowserController {
|
|
|
8783
8347
|
});
|
|
8784
8348
|
return includeDetails ? items : items.map(({ title, quantity }) => ({ title, quantity }));
|
|
8785
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
|
+
}
|
|
8786
8372
|
async readSettledCheckoutReviewSummary(fallbackCurrency, timeoutMs = 12_000) {
|
|
8787
8373
|
if (!this.page)
|
|
8788
8374
|
throw new Error("Browser not started");
|
|
@@ -8843,10 +8429,10 @@ export class BrowserController {
|
|
|
8843
8429
|
}
|
|
8844
8430
|
}
|
|
8845
8431
|
// The first frame that actually renders a visible card (PAN) input, or null.
|
|
8846
|
-
async panFieldFrame() {
|
|
8432
|
+
async panFieldFrame(frames) {
|
|
8847
8433
|
if (!this.page)
|
|
8848
8434
|
return null;
|
|
8849
|
-
for (const frame of this.page.frames()) {
|
|
8435
|
+
for (const frame of frames ?? this.page.frames()) {
|
|
8850
8436
|
const locator = frame.locator(CHECKOUT_PAN_FIELD_SELECTORS);
|
|
8851
8437
|
const count = await locator.count().catch(() => 0);
|
|
8852
8438
|
for (let i = 0; i < count; i += 1) {
|
|
@@ -8859,7 +8445,7 @@ export class BrowserController {
|
|
|
8859
8445
|
}
|
|
8860
8446
|
return null;
|
|
8861
8447
|
}
|
|
8862
|
-
// Bounded wait for a PAN field to appear
|
|
8448
|
+
// Bounded wait for a PAN field to appear in caller-eligible frames. A
|
|
8863
8449
|
// single-page checkout's card entry can live in a cross-origin PCI iframe
|
|
8864
8450
|
// (e.g. Shopify's checkout.pci.shopifyinc.com) that mounts only after the
|
|
8865
8451
|
// payment section itself renders — later than the total becomes readable,
|
|
@@ -8867,7 +8453,7 @@ export class BrowserController {
|
|
|
8867
8453
|
// fillAndSubmitCheckout/fillCheckoutCardFields used to take one frames()
|
|
8868
8454
|
// snapshot at call time; a frame that hadn't mounted YET at that exact
|
|
8869
8455
|
// instant made a genuinely fillable checkout fail closed.
|
|
8870
|
-
async waitForPanField(timeoutMs) {
|
|
8456
|
+
async waitForPanField(timeoutMs, frameAllowed = () => true) {
|
|
8871
8457
|
if (!this.page)
|
|
8872
8458
|
return;
|
|
8873
8459
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -8877,14 +8463,108 @@ export class BrowserController {
|
|
|
8877
8463
|
// CHECKOUT_PAN_FIELD_SELECTORS once stamped — without this,
|
|
8878
8464
|
// panFieldFrame() below can never match and every call here burns its
|
|
8879
8465
|
// full timeoutMs even though the field was on the page from the start.
|
|
8880
|
-
|
|
8881
|
-
|
|
8466
|
+
const frames = this.page.frames().filter(frameAllowed);
|
|
8467
|
+
await this.stampJapaneseCardLabelFields(frames);
|
|
8468
|
+
if ((await this.panFieldFrame(frames)) !== null)
|
|
8882
8469
|
return;
|
|
8883
8470
|
if (Date.now() >= deadline)
|
|
8884
8471
|
return;
|
|
8885
8472
|
await this.page.waitForTimeout(200).catch(() => undefined);
|
|
8886
8473
|
}
|
|
8887
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
|
+
}
|
|
8888
8568
|
// Normalizes conservative PAN name/id conventions and scans
|
|
8889
8569
|
// dt/th/label/"table-label" elements for カード番号 / カード名義 /
|
|
8890
8570
|
// セキュリティコード and stamps the associated single text input with
|
|
@@ -9966,7 +9646,7 @@ export class BrowserController {
|
|
|
9966
9646
|
this.rememberPaymentInstrumentExpectation(card);
|
|
9967
9647
|
primary = {
|
|
9968
9648
|
kind: "outcome",
|
|
9969
|
-
value: await this.submitFilledCheckoutInScope(cardGroup, options.onSubmitDispatched),
|
|
9649
|
+
value: await this.submitFilledCheckoutInScope(cardGroup, options.onSubmitDispatched, options.beforeSubmitDispatch),
|
|
9970
9650
|
};
|
|
9971
9651
|
}
|
|
9972
9652
|
catch (error) {
|
|
@@ -10000,7 +9680,7 @@ export class BrowserController {
|
|
|
10000
9680
|
// left behind. On success the filled values STAY in the page (the site
|
|
10001
9681
|
// needs them at its confirm step) marked data-ts-sealed-payment, which
|
|
10002
9682
|
// extractInteractiveElements reports as sealed so observations mask them.
|
|
10003
|
-
async fillCheckoutCardFields(card) {
|
|
9683
|
+
async fillCheckoutCardFields(card, options = {}) {
|
|
10004
9684
|
if (!this.page)
|
|
10005
9685
|
throw new Error("Browser not started");
|
|
10006
9686
|
this.checkoutCardGroupScope = undefined;
|
|
@@ -10009,11 +9689,10 @@ export class BrowserController {
|
|
|
10009
9689
|
if (!recognizedPaymentProviderFrame(pageUrl, pageUrl)) {
|
|
10010
9690
|
throw new Error("payment_checkout_https_required");
|
|
10011
9691
|
}
|
|
10012
|
-
|
|
10013
|
-
|
|
10014
|
-
|
|
10015
|
-
|
|
10016
|
-
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
|
+
}
|
|
10017
9696
|
const allowed = page
|
|
10018
9697
|
.frames()
|
|
10019
9698
|
.filter((frame) => frame === page.mainFrame() || recognizedPaymentProviderFrame(frame.url(), pageUrl));
|
|
@@ -10206,30 +9885,7 @@ export class BrowserController {
|
|
|
10206
9885
|
async submitFilledCheckout() {
|
|
10207
9886
|
return await this.submitFilledCheckoutInScope(this.checkoutCardGroupScope);
|
|
10208
9887
|
}
|
|
10209
|
-
async
|
|
10210
|
-
if (this.checkoutSubmitDispatchBindingPages.has(page))
|
|
10211
|
-
return;
|
|
10212
|
-
await page.exposeBinding(this.checkoutSubmitDispatchBindingName, (source, payload) => {
|
|
10213
|
-
if (payload === null || typeof payload !== "object")
|
|
10214
|
-
return;
|
|
10215
|
-
const candidate = payload;
|
|
10216
|
-
if (typeof candidate.token !== "string" || typeof candidate.nonce !== "string")
|
|
10217
|
-
return;
|
|
10218
|
-
const waiter = this.checkoutSubmitDispatchWaiters.get(candidate.token);
|
|
10219
|
-
if (waiter === undefined ||
|
|
10220
|
-
waiter.frame !== source.frame ||
|
|
10221
|
-
waiter.nonce !== candidate.nonce) {
|
|
10222
|
-
return;
|
|
10223
|
-
}
|
|
10224
|
-
const snapshot = parseCheckoutOutcomeDispatchSnapshot(candidate.snapshot);
|
|
10225
|
-
if (snapshot === null)
|
|
10226
|
-
return;
|
|
10227
|
-
waiter.resolve(snapshot);
|
|
10228
|
-
waiter.report();
|
|
10229
|
-
});
|
|
10230
|
-
this.checkoutSubmitDispatchBindingPages.add(page);
|
|
10231
|
-
}
|
|
10232
|
-
async submitFilledCheckoutInScope(cardGroup, onSubmitDispatched) {
|
|
9888
|
+
async submitFilledCheckoutInScope(cardGroup, onSubmitDispatched, beforeSubmitDispatch) {
|
|
10233
9889
|
if (!this.page)
|
|
10234
9890
|
throw new Error("Browser not started");
|
|
10235
9891
|
const savedCardSelection = await this.resolveCompetingSavedCardSelection();
|
|
@@ -10239,6 +9895,7 @@ export class BrowserController {
|
|
|
10239
9895
|
let outcomeBaseline;
|
|
10240
9896
|
this.checkoutOutcomeBaseline = undefined;
|
|
10241
9897
|
let submitted = false;
|
|
9898
|
+
let clearSubmittedDispatchTracking = null;
|
|
10242
9899
|
for (const frame of this.page.frames()) {
|
|
10243
9900
|
const matches = frame.locator('button,input[type="submit"],[role="button"]');
|
|
10244
9901
|
const count = Math.min(await matches.count().catch(() => 0), 100);
|
|
@@ -10288,10 +9945,11 @@ export class BrowserController {
|
|
|
10288
9945
|
if (!CHECKOUT_SUBMIT_LABEL_RE.test(label))
|
|
10289
9946
|
continue;
|
|
10290
9947
|
const dispatchToken = `ts-payment-submit-${this.checkoutSubmitSequence++}`;
|
|
10291
|
-
const dispatchNonce = randomUUID();
|
|
10292
|
-
const dispatchBaselineStorageKey = `__trusty_squire_payment_baseline_${dispatchToken}`;
|
|
10293
9948
|
const preDispatchFrameUrls = this.page.frames().map((pageFrame) => pageFrame.url());
|
|
10294
|
-
|
|
9949
|
+
const clickOnlyOutcomeBaseline = checkoutOutcomeBaselineFromDispatchSnapshot({
|
|
9950
|
+
url: this.page.url(),
|
|
9951
|
+
urls: preDispatchFrameUrls,
|
|
9952
|
+
});
|
|
10295
9953
|
let submitDispatchedReported = false;
|
|
10296
9954
|
const reportSubmitDispatched = () => {
|
|
10297
9955
|
if (submitDispatchedReported)
|
|
@@ -10299,152 +9957,135 @@ export class BrowserController {
|
|
|
10299
9957
|
onSubmitDispatched?.();
|
|
10300
9958
|
submitDispatchedReported = true;
|
|
10301
9959
|
};
|
|
10302
|
-
let resolveBoundDispatch = (_snapshot) => undefined;
|
|
10303
|
-
const boundDispatch = new Promise((resolve) => {
|
|
10304
|
-
resolveBoundDispatch = resolve;
|
|
10305
|
-
});
|
|
10306
|
-
this.checkoutSubmitDispatchWaiters.set(dispatchToken, {
|
|
10307
|
-
frame,
|
|
10308
|
-
nonce: dispatchNonce,
|
|
10309
|
-
resolve: resolveBoundDispatch,
|
|
10310
|
-
report: reportSubmitDispatched,
|
|
10311
|
-
});
|
|
10312
9960
|
const dispatchTrackingInstalled = await candidate
|
|
10313
|
-
.evaluate((element,
|
|
10314
|
-
const { baselineStorageKey, bindingName, frameUrls, nonce, pageUrl, token } = options;
|
|
9961
|
+
.evaluate((element, token) => {
|
|
10315
9962
|
const stateWindow = window;
|
|
10316
9963
|
const tracked = element;
|
|
10317
|
-
|
|
10318
|
-
|
|
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);
|
|
9968
|
+
}
|
|
10319
9969
|
}
|
|
10320
9970
|
stateWindow.__trustySquirePaymentSubmitDispatch = {
|
|
10321
9971
|
token,
|
|
10322
|
-
|
|
9972
|
+
validationBlocked: false,
|
|
10323
9973
|
};
|
|
10324
|
-
const
|
|
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 = () => {
|
|
10325
9980
|
const state = stateWindow.__trustySquirePaymentSubmitDispatch;
|
|
10326
|
-
if (state?.token
|
|
10327
|
-
|
|
10328
|
-
try {
|
|
10329
|
-
const merchantWindow = window.top;
|
|
10330
|
-
if (merchantWindow === null)
|
|
10331
|
-
return;
|
|
10332
|
-
const urls = new Set(frameUrls);
|
|
10333
|
-
const collectFrameUrls = (currentWindow) => {
|
|
10334
|
-
try {
|
|
10335
|
-
urls.add(currentWindow.location.href);
|
|
10336
|
-
for (let index = 0; index < currentWindow.frames.length; index += 1) {
|
|
10337
|
-
collectFrameUrls(currentWindow.frames[index]);
|
|
10338
|
-
}
|
|
10339
|
-
}
|
|
10340
|
-
catch (error) {
|
|
10341
|
-
void error;
|
|
10342
|
-
}
|
|
10343
|
-
};
|
|
10344
|
-
collectFrameUrls(merchantWindow);
|
|
10345
|
-
const snapshot = {
|
|
10346
|
-
url: pageUrl,
|
|
10347
|
-
urls: [...urls],
|
|
10348
|
-
};
|
|
10349
|
-
const dispatchBinding = window[bindingName];
|
|
10350
|
-
if (typeof dispatchBinding === "function") {
|
|
10351
|
-
void dispatchBinding({ token, nonce, snapshot });
|
|
10352
|
-
}
|
|
10353
|
-
const baselineWindow = merchantWindow;
|
|
10354
|
-
baselineWindow.__trustySquirePaymentDispatchBaselines ??= {};
|
|
10355
|
-
baselineWindow.__trustySquirePaymentDispatchBaselines[token] = snapshot;
|
|
10356
|
-
try {
|
|
10357
|
-
merchantWindow.sessionStorage.setItem(baselineStorageKey, JSON.stringify(snapshot));
|
|
10358
|
-
}
|
|
10359
|
-
catch (error) {
|
|
10360
|
-
void error;
|
|
10361
|
-
}
|
|
10362
|
-
}
|
|
10363
|
-
finally {
|
|
10364
|
-
state.dispatched = true;
|
|
10365
|
-
}
|
|
9981
|
+
if (state?.token === token)
|
|
9982
|
+
state.validationBlocked = true;
|
|
10366
9983
|
};
|
|
10367
|
-
|
|
10368
|
-
|
|
10369
|
-
|
|
10370
|
-
|
|
10371
|
-
|
|
10372
|
-
|
|
10373
|
-
|
|
10374
|
-
|
|
10375
|
-
|
|
10376
|
-
|
|
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)
|
|
10377
9998
|
.then(() => true)
|
|
10378
9999
|
.catch(() => false);
|
|
10379
10000
|
if (!dispatchTrackingInstalled) {
|
|
10380
|
-
this.checkoutSubmitDispatchWaiters.delete(dispatchToken);
|
|
10381
10001
|
continue;
|
|
10382
10002
|
}
|
|
10383
|
-
|
|
10384
|
-
|
|
10385
|
-
|
|
10386
|
-
|
|
10387
|
-
|
|
10388
|
-
|
|
10389
|
-
|
|
10390
|
-
|
|
10391
|
-
|
|
10392
|
-
|
|
10393
|
-
|
|
10394
|
-
|
|
10395
|
-
|
|
10396
|
-
|
|
10397
|
-
|
|
10398
|
-
|
|
10399
|
-
|
|
10400
|
-
|
|
10401
|
-
|
|
10402
|
-
|
|
10403
|
-
|
|
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;
|
|
10404
10043
|
}
|
|
10405
|
-
|
|
10406
|
-
|
|
10044
|
+
const challenge = await this.detectThreeDsChallenge().catch(() => undefined);
|
|
10045
|
+
if (challenge?.three_ds_required === true) {
|
|
10046
|
+
navigationThreeDsObserved = true;
|
|
10047
|
+
resolveNavigationOutcome();
|
|
10407
10048
|
}
|
|
10408
|
-
|
|
10409
|
-
}, { baselineStorageKey: dispatchBaselineStorageKey, token: dispatchToken })
|
|
10410
|
-
.catch(() => null);
|
|
10411
|
-
const parsedSnapshot = parseCheckoutOutcomeDispatchSnapshot(snapshot);
|
|
10412
|
-
return parsedSnapshot === null
|
|
10413
|
-
? null
|
|
10414
|
-
: checkoutOutcomeBaselineFromDispatchSnapshot(parsedSnapshot);
|
|
10049
|
+
})();
|
|
10415
10050
|
};
|
|
10416
|
-
|
|
10051
|
+
this.page.on("request", paymentRequestListener);
|
|
10052
|
+
this.page.on("framenavigated", navigationListener);
|
|
10053
|
+
const waitForDispatchEvidence = async () => {
|
|
10417
10054
|
let timer;
|
|
10418
|
-
|
|
10419
|
-
|
|
10055
|
+
await Promise.race([
|
|
10056
|
+
concretePaymentRequest,
|
|
10057
|
+
navigationOutcome,
|
|
10420
10058
|
new Promise((resolve) => {
|
|
10421
|
-
timer = setTimeout(
|
|
10059
|
+
timer = setTimeout(resolve, CHECKOUT_PAYMENT_REQUEST_OBSERVATION_MS);
|
|
10422
10060
|
}),
|
|
10423
10061
|
]);
|
|
10424
10062
|
if (timer !== undefined)
|
|
10425
10063
|
clearTimeout(timer);
|
|
10426
|
-
return snapshot === null ? null : checkoutOutcomeBaselineFromDispatchSnapshot(snapshot);
|
|
10427
10064
|
};
|
|
10428
10065
|
const readDispatchState = async () => await frame
|
|
10429
10066
|
.evaluate((token) => {
|
|
10430
10067
|
const stateWindow = window;
|
|
10431
10068
|
const state = stateWindow.__trustySquirePaymentSubmitDispatch;
|
|
10432
10069
|
return {
|
|
10433
|
-
|
|
10434
|
-
dispatched: state?.token === token && state.dispatched,
|
|
10070
|
+
validationBlocked: state?.token === token && state.validationBlocked,
|
|
10435
10071
|
};
|
|
10436
10072
|
}, dispatchToken)
|
|
10437
10073
|
.catch(() => null);
|
|
10438
10074
|
const clearDispatchTracking = async () => {
|
|
10439
|
-
|
|
10075
|
+
paymentRequestTrackingArmed = false;
|
|
10076
|
+
this.page?.off("request", paymentRequestListener);
|
|
10077
|
+
this.page?.off("framenavigated", navigationListener);
|
|
10440
10078
|
await candidate
|
|
10441
10079
|
.evaluate((element) => {
|
|
10442
10080
|
const tracked = element;
|
|
10443
|
-
|
|
10444
|
-
|
|
10445
|
-
|
|
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;
|
|
10446
10087
|
}
|
|
10447
|
-
})
|
|
10088
|
+
}, undefined, { timeout: 250 })
|
|
10448
10089
|
.catch(() => undefined);
|
|
10449
10090
|
await frame
|
|
10450
10091
|
.evaluate((token) => {
|
|
@@ -10454,7 +10095,6 @@ export class BrowserController {
|
|
|
10454
10095
|
}
|
|
10455
10096
|
}, dispatchToken)
|
|
10456
10097
|
.catch(() => undefined);
|
|
10457
|
-
await readDispatchOutcomeBaseline();
|
|
10458
10098
|
};
|
|
10459
10099
|
// Money-fence boundary: the async pay-button scan above (visibility/
|
|
10460
10100
|
// enabled/ownership/label checks, dispatch-tracking install) is a real
|
|
@@ -10473,26 +10113,56 @@ export class BrowserController {
|
|
|
10473
10113
|
}
|
|
10474
10114
|
capturedBaseline = await runCaptureConfirmedPaymentSubmit({
|
|
10475
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;
|
|
10476
10124
|
markInputDispatchPossible();
|
|
10477
|
-
await candidate.click({
|
|
10125
|
+
await candidate.click({
|
|
10126
|
+
noWaitAfter: true,
|
|
10127
|
+
...(typeof remainingMs === "number"
|
|
10128
|
+
? { timeout: Math.max(1, Math.ceil(remainingMs)) }
|
|
10129
|
+
: {}),
|
|
10130
|
+
});
|
|
10478
10131
|
},
|
|
10479
10132
|
readEvidence: async () => {
|
|
10480
|
-
|
|
10481
|
-
const
|
|
10482
|
-
const
|
|
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;
|
|
10483
10144
|
return {
|
|
10484
|
-
baseline,
|
|
10485
|
-
dispatched:
|
|
10145
|
+
baseline: clickOnlyDispatchObserved ? clickOnlyOutcomeBaseline : null,
|
|
10146
|
+
dispatched: clickOnlyDispatchObserved,
|
|
10486
10147
|
};
|
|
10487
10148
|
},
|
|
10488
10149
|
clear: async () => undefined,
|
|
10489
10150
|
onSubmitDispatched: reportSubmitDispatched,
|
|
10490
10151
|
});
|
|
10152
|
+
clearSubmittedDispatchTracking = clearDispatchTracking;
|
|
10491
10153
|
}
|
|
10492
|
-
|
|
10154
|
+
catch (error) {
|
|
10493
10155
|
await clearDispatchTracking();
|
|
10156
|
+
throw error;
|
|
10157
|
+
}
|
|
10158
|
+
try {
|
|
10159
|
+
outcomeBaseline = capturedBaseline ?? (await this.captureCheckoutOutcomeBaseline());
|
|
10160
|
+
}
|
|
10161
|
+
catch (error) {
|
|
10162
|
+
await clearSubmittedDispatchTracking?.();
|
|
10163
|
+
clearSubmittedDispatchTracking = null;
|
|
10164
|
+
throw error;
|
|
10494
10165
|
}
|
|
10495
|
-
outcomeBaseline = capturedBaseline ?? (await this.captureCheckoutOutcomeBaseline());
|
|
10496
10166
|
this.checkoutOutcomeBaseline = outcomeBaseline;
|
|
10497
10167
|
submitted = true;
|
|
10498
10168
|
break;
|
|
@@ -10500,8 +10170,10 @@ export class BrowserController {
|
|
|
10500
10170
|
if (submitted)
|
|
10501
10171
|
break;
|
|
10502
10172
|
}
|
|
10503
|
-
if (!submitted || outcomeBaseline === undefined)
|
|
10173
|
+
if (!submitted || outcomeBaseline === undefined) {
|
|
10174
|
+
await clearSubmittedDispatchTracking?.();
|
|
10504
10175
|
throw new Error("payment_submit_not_found");
|
|
10176
|
+
}
|
|
10505
10177
|
try {
|
|
10506
10178
|
const challengeDeadline = Date.now() + 15_000;
|
|
10507
10179
|
while (Date.now() < challengeDeadline) {
|
|
@@ -10509,17 +10181,23 @@ export class BrowserController {
|
|
|
10509
10181
|
return { three_ds_required: false, order_confirmed: true };
|
|
10510
10182
|
}
|
|
10511
10183
|
const challenge = await this.detectThreeDsChallenge();
|
|
10512
|
-
if (challenge.three_ds_required)
|
|
10184
|
+
if (challenge.three_ds_required) {
|
|
10513
10185
|
return challenge;
|
|
10186
|
+
}
|
|
10514
10187
|
await this.page.waitForTimeout(250).catch(() => undefined);
|
|
10515
10188
|
}
|
|
10516
10189
|
return { three_ds_required: false, order_confirmed: false };
|
|
10517
10190
|
}
|
|
10518
10191
|
catch (error) {
|
|
10519
|
-
if (error instanceof PaymentSubmitOutcomeUnknownError
|
|
10192
|
+
if (error instanceof PaymentSubmitOutcomeUnknownError ||
|
|
10193
|
+
error instanceof BrowserClickDispatchError) {
|
|
10520
10194
|
throw error;
|
|
10195
|
+
}
|
|
10521
10196
|
throw new PaymentSubmitOutcomeUnknownError();
|
|
10522
10197
|
}
|
|
10198
|
+
finally {
|
|
10199
|
+
await clearSubmittedDispatchTracking?.();
|
|
10200
|
+
}
|
|
10523
10201
|
}
|
|
10524
10202
|
async clearSealedPaymentFields() {
|
|
10525
10203
|
this.checkoutCardGroupScope = undefined;
|
|
@@ -10981,9 +10659,23 @@ export class BrowserController {
|
|
|
10981
10659
|
catch {
|
|
10982
10660
|
sameCheckoutOrigin = current.url === baseline.url;
|
|
10983
10661
|
}
|
|
10984
|
-
|
|
10662
|
+
const newTerminalOrderIdentity = sameCheckoutOrigin &&
|
|
10985
10663
|
current.terminalUrlIdentity !== null &&
|
|
10986
|
-
!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
|
+
});
|
|
10987
10679
|
}
|
|
10988
10680
|
// Let the browser complete the challenge natively (including out-of-band
|
|
10989
10681
|
// bank-app 3DS): just poll for the same terminal-order signal a plain
|
|
@@ -10996,11 +10688,14 @@ export class BrowserController {
|
|
|
10996
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;
|
|
10997
10689
|
const deadline = Date.now() + Math.max(timeoutMs, 0);
|
|
10998
10690
|
const mismatchAtEntry = this.observedPaymentInstrumentMismatch;
|
|
10691
|
+
let challengeObserved = false;
|
|
10999
10692
|
while (true) {
|
|
11000
10693
|
await this.page.bringToFront().catch(() => undefined);
|
|
11001
|
-
await this.detectThreeDsChallenge().catch(() => undefined);
|
|
10694
|
+
const challenge = await this.detectThreeDsChallenge().catch(() => undefined);
|
|
10695
|
+
if (challenge?.three_ds_required === true)
|
|
10696
|
+
challengeObserved = true;
|
|
11002
10697
|
if (mismatchAtEntry === undefined && this.observedPaymentInstrumentMismatch !== undefined) {
|
|
11003
|
-
return "timeout";
|
|
10698
|
+
return challengeObserved ? "challenge_pending" : "timeout";
|
|
11004
10699
|
}
|
|
11005
10700
|
if (await this.hasConfirmedCheckoutOutcome(outcomeBaseline))
|
|
11006
10701
|
return "succeeded";
|
|
@@ -11012,7 +10707,7 @@ export class BrowserController {
|
|
|
11012
10707
|
return "failed";
|
|
11013
10708
|
const remainingMs = deadline - Date.now();
|
|
11014
10709
|
if (remainingMs <= 0)
|
|
11015
|
-
return "timeout";
|
|
10710
|
+
return challengeObserved ? "challenge_pending" : "timeout";
|
|
11016
10711
|
await this.page.waitForTimeout(Math.min(1_000, remainingMs)).catch(() => undefined);
|
|
11017
10712
|
}
|
|
11018
10713
|
}
|
|
@@ -12774,6 +12469,18 @@ export class BrowserController {
|
|
|
12774
12469
|
el instanceof HTMLTextAreaElement ||
|
|
12775
12470
|
el instanceof HTMLSelectElement;
|
|
12776
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
|
+
};
|
|
12777
12484
|
const regionName = (region) => {
|
|
12778
12485
|
if (region === null)
|
|
12779
12486
|
return null;
|
|
@@ -13061,8 +12768,11 @@ export class BrowserController {
|
|
|
13061
12768
|
// matches it and the OAuth-first scan picks it up.
|
|
13062
12769
|
const isGoogleGSIIframe = el instanceof HTMLIFrameElement &&
|
|
13063
12770
|
(el.getAttribute("src") ?? "").includes("accounts.google.com/gsi/button");
|
|
13064
|
-
const
|
|
12771
|
+
const region = regionFor(el);
|
|
12772
|
+
const container = regionName(region);
|
|
12773
|
+
const containerId = regionId(region);
|
|
13065
12774
|
const inDialog = nearestModalRegion(el) !== null;
|
|
12775
|
+
const formId = regionId(el.closest("form"));
|
|
13066
12776
|
const status = topmostStatus(el);
|
|
13067
12777
|
const pathLabel = isGoogleGSIIframe
|
|
13068
12778
|
? "Continue with Google"
|
|
@@ -13128,6 +12838,8 @@ export class BrowserController {
|
|
|
13128
12838
|
checked: el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio")
|
|
13129
12839
|
? el.checked
|
|
13130
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,
|
|
13131
12843
|
// For <select>: the currently-selected option's visible text
|
|
13132
12844
|
// and a short list of available option labels. The combination
|
|
13133
12845
|
// is how the planner detects the "React-defaulted dropdown"
|
|
@@ -13151,12 +12863,12 @@ export class BrowserController {
|
|
|
13151
12863
|
? clean(el.options[el.selectedIndex]?.textContent ?? null)
|
|
13152
12864
|
: null,
|
|
13153
12865
|
interactedThisRun: el.getAttribute("data-ts-touched") === "1",
|
|
13154
|
-
sealed: el.getAttribute("data-ts-sealed-payment") === "1",
|
|
13155
|
-
sealedOrdinal: Array.from(el.getRootNode().querySelectorAll("input,textarea,select,[contenteditable='true']")).indexOf(el),
|
|
13156
12866
|
screenPath: `${container ?? "body:root"} > ${elementKind(el)}:` +
|
|
13157
12867
|
slug(pathLabel, `${elementKind(el)}-${out.length}`),
|
|
13158
12868
|
container,
|
|
13159
12869
|
inDialog,
|
|
12870
|
+
containerId,
|
|
12871
|
+
formId,
|
|
13160
12872
|
topmost: status.topmost,
|
|
13161
12873
|
occludedBy: status.occludedBy,
|
|
13162
12874
|
});
|
|
@@ -13496,20 +13208,16 @@ export class BrowserController {
|
|
|
13496
13208
|
const frame = await handle.ownerFrame();
|
|
13497
13209
|
if (frame === null)
|
|
13498
13210
|
throw new Error("type target has no owning frame");
|
|
13499
|
-
const sealedFieldKeys = sealed
|
|
13500
|
-
? await this.operatorScreenshotIdentityKeys(handle, frame)
|
|
13501
|
-
: [];
|
|
13502
13211
|
if (sealed) {
|
|
13503
13212
|
await handle.evaluate((el) => el.setAttribute("data-ts-sealed-payment", "1"));
|
|
13504
13213
|
}
|
|
13505
13214
|
if (!this.humanize) {
|
|
13506
13215
|
await handle.fill(text);
|
|
13507
|
-
return
|
|
13216
|
+
return;
|
|
13508
13217
|
}
|
|
13509
13218
|
await handle.click({ timeout: 8000 }).catch(() => undefined);
|
|
13510
13219
|
await handle.fill("").catch(() => undefined);
|
|
13511
13220
|
await handle.type(text, { delay: rand(40, 110) });
|
|
13512
|
-
return sealedFieldKeys;
|
|
13513
13221
|
}
|
|
13514
13222
|
finally {
|
|
13515
13223
|
await handle.dispose().catch(() => undefined);
|
|
@@ -13587,21 +13295,8 @@ export class BrowserController {
|
|
|
13587
13295
|
const page = this.page;
|
|
13588
13296
|
const mainRaw = await this.extractElementsFromContext(page);
|
|
13589
13297
|
const mainGroups = assignCardRadioGroups(mainRaw.clusterMeta);
|
|
13590
|
-
const mainDocumentIdentity = await this.sealedDocumentIdentity(page.mainFrame());
|
|
13591
13298
|
const mainElements = mainRaw.out.map((e, i) => ({
|
|
13592
13299
|
...e,
|
|
13593
|
-
sealedIdentityKeys: sealedElementSemanticKeys({
|
|
13594
|
-
tag: e.tag,
|
|
13595
|
-
type: e.type,
|
|
13596
|
-
id: e.id,
|
|
13597
|
-
name: e.name,
|
|
13598
|
-
testId: e.testId,
|
|
13599
|
-
labelText: e.labelText,
|
|
13600
|
-
ariaLabel: e.ariaLabel,
|
|
13601
|
-
placeholder: e.placeholder,
|
|
13602
|
-
landmark: e.landmark,
|
|
13603
|
-
ordinal: e.sealedOrdinal,
|
|
13604
|
-
}).map((key) => `${mainDocumentIdentity}:${key}`),
|
|
13605
13300
|
cardRadioGroup: mainGroups[i] ?? null,
|
|
13606
13301
|
frameOrigin: null,
|
|
13607
13302
|
frameUrl: null,
|
|
@@ -13633,23 +13328,10 @@ export class BrowserController {
|
|
|
13633
13328
|
const raw = await this.extractElementsFromContext(frame);
|
|
13634
13329
|
const security = await this.frameSecurity(frame);
|
|
13635
13330
|
const frameOrigin = security.origin;
|
|
13636
|
-
const frameDocumentIdentity = await this.sealedDocumentIdentity(frame);
|
|
13637
13331
|
const groups = assignCardRadioGroups(raw.clusterMeta);
|
|
13638
13332
|
for (const [i, e] of raw.out.entries()) {
|
|
13639
13333
|
framedElements.push({
|
|
13640
13334
|
...e,
|
|
13641
|
-
sealedIdentityKeys: sealedElementSemanticKeys({
|
|
13642
|
-
tag: e.tag,
|
|
13643
|
-
type: e.type,
|
|
13644
|
-
id: e.id,
|
|
13645
|
-
name: e.name,
|
|
13646
|
-
testId: e.testId,
|
|
13647
|
-
labelText: e.labelText,
|
|
13648
|
-
ariaLabel: e.ariaLabel,
|
|
13649
|
-
placeholder: e.placeholder,
|
|
13650
|
-
landmark: e.landmark,
|
|
13651
|
-
ordinal: e.sealedOrdinal,
|
|
13652
|
-
}).map((key) => `${frameDocumentIdentity}:${key}`),
|
|
13653
13335
|
cardRadioGroup: groups[i] ?? null,
|
|
13654
13336
|
frameOrigin,
|
|
13655
13337
|
frameUrl,
|
|
@@ -13725,7 +13407,6 @@ export class BrowserController {
|
|
|
13725
13407
|
async startOAuth(selector) {
|
|
13726
13408
|
if (!this.page || !this.context)
|
|
13727
13409
|
throw new Error("Browser not started");
|
|
13728
|
-
this.maybeAttachOAuthNetListener();
|
|
13729
13410
|
if (!/accounts\.google\.com|github\.com\/login|login\.microsoftonline\.com/i.test(this.page.url())) {
|
|
13730
13411
|
this.oauthProductPage = this.page;
|
|
13731
13412
|
}
|
|
@@ -13762,30 +13443,46 @@ export class BrowserController {
|
|
|
13762
13443
|
// clicks the product's already-observed OAuth control and waits for the
|
|
13763
13444
|
// provider-owned window to finish; no credentials, frames, or consent scopes
|
|
13764
13445
|
// are read or bypassed here.
|
|
13765
|
-
async loginWithOAuth(selector, settleTimeoutMs =
|
|
13446
|
+
async loginWithOAuth(selector, settleTimeoutMs = 30_000, consentProvider, expectedGoogleAccountEmail) {
|
|
13766
13447
|
const product = this.page;
|
|
13767
13448
|
const context = this.context;
|
|
13768
13449
|
if (product === null || product.isClosed() || context === null) {
|
|
13769
13450
|
throw new Error("OAuth login cannot start because the product page is unavailable");
|
|
13770
13451
|
}
|
|
13771
|
-
this.maybeAttachOAuthNetListener();
|
|
13772
13452
|
this.oauthProductPage = product;
|
|
13773
13453
|
this.oauthProviderPage = null;
|
|
13774
13454
|
this.oauthProviderPageClosed = false;
|
|
13455
|
+
const oauthBudgetMs = Math.max(1, settleTimeoutMs);
|
|
13775
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.`);
|
|
13776
13464
|
let recovery = null;
|
|
13777
13465
|
let providerPage = null;
|
|
13778
13466
|
let productDeparted = false;
|
|
13467
|
+
let resolveProductDeparture = () => undefined;
|
|
13468
|
+
const productDeparturePromise = new Promise((resolve) => {
|
|
13469
|
+
resolveProductDeparture = resolve;
|
|
13470
|
+
});
|
|
13779
13471
|
const onProductNavigation = (frame) => {
|
|
13780
13472
|
if (frame !== product.mainFrame())
|
|
13781
13473
|
return;
|
|
13782
|
-
if (!this.isOAuthProductUrl(frame.url(), productUrl))
|
|
13474
|
+
if (!this.isOAuthProductUrl(frame.url(), productUrl)) {
|
|
13783
13475
|
productDeparted = true;
|
|
13476
|
+
resolveProductDeparture();
|
|
13477
|
+
}
|
|
13784
13478
|
};
|
|
13785
13479
|
product.on("framenavigated", onProductNavigation);
|
|
13786
13480
|
try {
|
|
13787
13481
|
recovery = await context.newPage();
|
|
13788
|
-
await recovery.goto(productUrl, {
|
|
13482
|
+
await recovery.goto(productUrl, {
|
|
13483
|
+
waitUntil: "domcontentloaded",
|
|
13484
|
+
timeout: remainingBudgetMs(),
|
|
13485
|
+
});
|
|
13789
13486
|
let resolvePopup = () => undefined;
|
|
13790
13487
|
const popupPromise = new Promise((resolve) => {
|
|
13791
13488
|
resolvePopup = resolve;
|
|
@@ -13796,6 +13493,8 @@ export class BrowserController {
|
|
|
13796
13493
|
};
|
|
13797
13494
|
context.on("page", onPopup);
|
|
13798
13495
|
try {
|
|
13496
|
+
if (Date.now() >= oauthDeadline)
|
|
13497
|
+
throw deadlineError();
|
|
13799
13498
|
try {
|
|
13800
13499
|
await this.click(selector);
|
|
13801
13500
|
}
|
|
@@ -13805,25 +13504,56 @@ export class BrowserController {
|
|
|
13805
13504
|
}
|
|
13806
13505
|
providerPage = await Promise.race([
|
|
13807
13506
|
popupPromise,
|
|
13808
|
-
|
|
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),
|
|
13809
13512
|
]);
|
|
13810
13513
|
}
|
|
13811
13514
|
finally {
|
|
13812
13515
|
context.off("page", onPopup);
|
|
13813
13516
|
resolvePopup(null);
|
|
13517
|
+
resolveProductDeparture();
|
|
13814
13518
|
}
|
|
13815
13519
|
const transient = providerPage ?? product;
|
|
13520
|
+
productDeparted = productDeparted || !this.isOAuthProductUrl(transient.url(), productUrl);
|
|
13816
13521
|
const durableProduct = providerPage === null ? recovery : product;
|
|
13817
13522
|
this.oauthProductPage = durableProduct;
|
|
13818
13523
|
this.oauthProviderPage = transient;
|
|
13819
13524
|
this.oauthProviderPageClosed = transient.isClosed();
|
|
13820
13525
|
this.restoreProductPageWhenOAuthPageCloses(transient, durableProduct);
|
|
13821
|
-
|
|
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
|
+
}
|
|
13822
13547
|
if (settled === null) {
|
|
13823
|
-
throw
|
|
13548
|
+
throw deadlineError();
|
|
13824
13549
|
}
|
|
13825
13550
|
if (providerPage === null && product.isClosed()) {
|
|
13826
|
-
|
|
13551
|
+
if (Date.now() >= oauthDeadline)
|
|
13552
|
+
throw deadlineError();
|
|
13553
|
+
await recovery.reload({
|
|
13554
|
+
waitUntil: "domcontentloaded",
|
|
13555
|
+
timeout: remainingBudgetMs(),
|
|
13556
|
+
});
|
|
13827
13557
|
}
|
|
13828
13558
|
}
|
|
13829
13559
|
finally {
|
|
@@ -13842,7 +13572,9 @@ export class BrowserController {
|
|
|
13842
13572
|
if (this.page !== null && !this.page.isClosed()) {
|
|
13843
13573
|
await this.page.bringToFront().catch(() => undefined);
|
|
13844
13574
|
await this.page
|
|
13845
|
-
.waitForLoadState("domcontentloaded", {
|
|
13575
|
+
.waitForLoadState("domcontentloaded", {
|
|
13576
|
+
timeout: remainingBudgetMs(),
|
|
13577
|
+
})
|
|
13846
13578
|
.catch(() => undefined);
|
|
13847
13579
|
}
|
|
13848
13580
|
}
|
|
@@ -13882,12 +13614,23 @@ export class BrowserController {
|
|
|
13882
13614
|
const url = page.url();
|
|
13883
13615
|
const isProduct = this.isOAuthProductUrl(url, productUrl);
|
|
13884
13616
|
if (startsOnProduct && departed && isProduct) {
|
|
13885
|
-
|
|
13886
|
-
|
|
13887
|
-
|
|
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()) })
|
|
13888
13623
|
.then(() => true)
|
|
13889
13624
|
.catch(() => false);
|
|
13890
|
-
|
|
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)
|
|
13891
13634
|
? "returned"
|
|
13892
13635
|
: null;
|
|
13893
13636
|
}
|
|
@@ -13897,118 +13640,6 @@ export class BrowserController {
|
|
|
13897
13640
|
}
|
|
13898
13641
|
return page.isClosed() ? "closed" : null;
|
|
13899
13642
|
}
|
|
13900
|
-
// ── OAuth/SSO debug instrumentation (UNIVERSAL_BOT_OAUTH_DEBUG) ──
|
|
13901
|
-
// Attach a context-level response recorder ONCE. Records SSO-relevant
|
|
13902
|
-
// responses (the service host + Clerk/Stytch/WorkOS FAPI hosts) with their
|
|
13903
|
-
// status + whether they set a cookie — the signal for "did the callback's
|
|
13904
|
-
// session-establish request succeed and set the session cookie?"
|
|
13905
|
-
maybeAttachOAuthNetListener() {
|
|
13906
|
-
if (this.oauthNetListenerAttached)
|
|
13907
|
-
return;
|
|
13908
|
-
if (!/^(1|true|on)$/i.test(process.env.UNIVERSAL_BOT_OAUTH_DEBUG ?? ""))
|
|
13909
|
-
return;
|
|
13910
|
-
if (!this.context)
|
|
13911
|
-
return;
|
|
13912
|
-
this.oauthNetListenerAttached = true;
|
|
13913
|
-
const RELEVANT = /clerk|stytch|workos|accounts\.|\/sso|\/oauth|\/session|\/sign|callback|\/v1\/client/i;
|
|
13914
|
-
this.context.on("response", (res) => {
|
|
13915
|
-
void (async () => {
|
|
13916
|
-
try {
|
|
13917
|
-
const url = res.url();
|
|
13918
|
-
if (!RELEVANT.test(url))
|
|
13919
|
-
return;
|
|
13920
|
-
const headers = res.headers();
|
|
13921
|
-
if (this.oauthNetLog.length >= 200)
|
|
13922
|
-
return;
|
|
13923
|
-
const entry = {
|
|
13924
|
-
url: url.slice(0, 200),
|
|
13925
|
-
status: res.status(),
|
|
13926
|
-
setCookie: "set-cookie" in headers || "Set-Cookie" in headers,
|
|
13927
|
-
ct: (headers["content-type"] ?? "").slice(0, 40),
|
|
13928
|
-
};
|
|
13929
|
-
// Capture the body of a Clerk/Stytch/WorkOS sign-in/up/callback error
|
|
13930
|
-
// (>=400) — its error code is the definitive tell (captcha_invalid vs
|
|
13931
|
-
// transfer vs identifier_*). JSON only, bounded.
|
|
13932
|
-
if (res.status() >= 400 &&
|
|
13933
|
-
/\/v1\/client\/(sign_ins|sign_ups)|oauth_callback|\/session/i.test(url)) {
|
|
13934
|
-
entry.body = (await res.text().catch(() => "")).slice(0, 800);
|
|
13935
|
-
}
|
|
13936
|
-
this.oauthNetLog.push(entry);
|
|
13937
|
-
}
|
|
13938
|
-
catch {
|
|
13939
|
-
// best-effort observability — never perturb the run
|
|
13940
|
-
}
|
|
13941
|
-
})();
|
|
13942
|
-
});
|
|
13943
|
-
}
|
|
13944
|
-
// Dump cookies + the SSO network log to a file for post-mortem. Called at the
|
|
13945
|
-
// oauth_session_not_persisted decision point when UNIVERSAL_BOT_OAUTH_DEBUG.
|
|
13946
|
-
async dumpOAuthDebug(service, label) {
|
|
13947
|
-
if (!/^(1|true|on)$/i.test(process.env.UNIVERSAL_BOT_OAUTH_DEBUG ?? ""))
|
|
13948
|
-
return;
|
|
13949
|
-
if (!this.context)
|
|
13950
|
-
return;
|
|
13951
|
-
try {
|
|
13952
|
-
const cookies = await this.context.cookies();
|
|
13953
|
-
const cookieSummary = cookies.map((c) => ({
|
|
13954
|
-
name: c.name,
|
|
13955
|
-
domain: c.domain,
|
|
13956
|
-
path: c.path,
|
|
13957
|
-
len: c.value.length,
|
|
13958
|
-
httpOnly: c.httpOnly,
|
|
13959
|
-
secure: c.secure,
|
|
13960
|
-
sameSite: c.sameSite,
|
|
13961
|
-
}));
|
|
13962
|
-
const url = this.page ? this.page.url() : "(no page)";
|
|
13963
|
-
// Capture the live Clerk SDK state — the definitive read on whether a
|
|
13964
|
-
// sign-up transfer is available (the new-user-OAuth fix hinges on this).
|
|
13965
|
-
const clerkState = this.page
|
|
13966
|
-
? await this.page
|
|
13967
|
-
.evaluate(() => {
|
|
13968
|
-
const w = window;
|
|
13969
|
-
const c = w.Clerk;
|
|
13970
|
-
if (c === undefined)
|
|
13971
|
-
return { present: false };
|
|
13972
|
-
const client = c.client;
|
|
13973
|
-
const si = client?.["signIn"];
|
|
13974
|
-
const su = client?.["signUp"];
|
|
13975
|
-
const ffv = si?.["firstFactorVerification"];
|
|
13976
|
-
return {
|
|
13977
|
-
present: true,
|
|
13978
|
-
loaded: c.loaded ?? null,
|
|
13979
|
-
signInStatus: si?.["status"] ?? null,
|
|
13980
|
-
signInFFVStatus: ffv?.["status"] ?? null,
|
|
13981
|
-
signInFFVError: ffv?.["error"] ?? null,
|
|
13982
|
-
signUpStatus: su?.["status"] ?? null,
|
|
13983
|
-
signUpMissingFields: su?.["missingFields"] ?? null,
|
|
13984
|
-
hasSignUpCreate: typeof su?.create === "function",
|
|
13985
|
-
};
|
|
13986
|
-
})
|
|
13987
|
-
.catch((e) => ({ present: "evalError", err: String(e).slice(0, 120) }))
|
|
13988
|
-
: { present: false };
|
|
13989
|
-
const consoleText = await this.extractText().catch(() => "");
|
|
13990
|
-
const { writeFileSync, mkdirSync } = await import("node:fs");
|
|
13991
|
-
const { join } = await import("node:path");
|
|
13992
|
-
const { homedir } = await import("node:os");
|
|
13993
|
-
const dir = join(homedir(), ".trusty-squire", "oauth-debug");
|
|
13994
|
-
mkdirSync(dir, { recursive: true });
|
|
13995
|
-
const ts = process.env.OAUTH_DEBUG_TS ?? String(this.oauthNetLog.length);
|
|
13996
|
-
const path = join(dir, `${service}-${label}-${ts}.json`);
|
|
13997
|
-
writeFileSync(path, JSON.stringify({
|
|
13998
|
-
service,
|
|
13999
|
-
label,
|
|
14000
|
-
finalUrl: url,
|
|
14001
|
-
clerkState,
|
|
14002
|
-
cookies: cookieSummary,
|
|
14003
|
-
netLog: this.oauthNetLog,
|
|
14004
|
-
pageText: consoleText.slice(0, 600),
|
|
14005
|
-
}, null, 2));
|
|
14006
|
-
console.error(`[oauth-debug] wrote ${path} (${cookieSummary.length} cookies, ${this.oauthNetLog.length} net entries)`);
|
|
14007
|
-
}
|
|
14008
|
-
catch (err) {
|
|
14009
|
-
console.error(`[oauth-debug] dump failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
14010
|
-
}
|
|
14011
|
-
}
|
|
14012
13643
|
// Read the page's Rails/OmniAuth CSRF token (<meta name="csrf-token">).
|
|
14013
13644
|
// Needed to recover OmniAuth 2.0 POST-only OAuth when the provider button is
|
|
14014
13645
|
// a GET <a> that page-JS upgrades to a POST.
|
|
@@ -14257,6 +13888,67 @@ export class BrowserController {
|
|
|
14257
13888
|
recoverActivePage() {
|
|
14258
13889
|
return this.adoptLivePage();
|
|
14259
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
|
+
}
|
|
14260
13952
|
adoptLivePage() {
|
|
14261
13953
|
if (this.page !== null && !this.page.isClosed())
|
|
14262
13954
|
return true;
|
|
@@ -14494,14 +14186,7 @@ export class BrowserController {
|
|
|
14494
14186
|
return false;
|
|
14495
14187
|
}
|
|
14496
14188
|
}
|
|
14497
|
-
// Which OAuth providers have a
|
|
14498
|
-
// The logged-in-providers.json marker is a memo that drifts out of sync
|
|
14499
|
-
// (a --force-relogin clears it, a misclassified run clears it, a parallel
|
|
14500
|
-
// run overwrites it) — so a session that is genuinely live in the cookies
|
|
14501
|
-
// can go invisible to provider selection, which is exactly how a warm
|
|
14502
|
-
// GitHub session got skipped in favour of a broken Google path. The cookie
|
|
14503
|
-
// jar is the ground truth: read it directly. Cookie NAMES + presence only;
|
|
14504
|
-
// 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.
|
|
14505
14190
|
async detectSessionProviders() {
|
|
14506
14191
|
if (this.context === null)
|
|
14507
14192
|
return [];
|
|
@@ -14512,13 +14197,18 @@ export class BrowserController {
|
|
|
14512
14197
|
return [];
|
|
14513
14198
|
}
|
|
14514
14199
|
}
|
|
14515
|
-
async detectGoogleAccountEmail() {
|
|
14200
|
+
async detectGoogleAccountEmail(expectedGoogleAccountEmail) {
|
|
14516
14201
|
if (this.context === null)
|
|
14517
14202
|
return null;
|
|
14518
14203
|
let identityPage = null;
|
|
14519
14204
|
try {
|
|
14520
14205
|
identityPage = await this.context.newPage();
|
|
14521
|
-
|
|
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, {
|
|
14522
14212
|
waitUntil: "domcontentloaded",
|
|
14523
14213
|
timeout: 20_000,
|
|
14524
14214
|
});
|
|
@@ -14542,13 +14232,18 @@ export class BrowserController {
|
|
|
14542
14232
|
await identityPage?.close().catch(() => undefined);
|
|
14543
14233
|
}
|
|
14544
14234
|
}
|
|
14545
|
-
// Advance a provider's consent / account-chooser screen by one click
|
|
14546
|
-
//
|
|
14235
|
+
// Advance a provider's consent / account-chooser screen by one click.
|
|
14236
|
+
// Returns false when no
|
|
14547
14237
|
// approve control is present — the agent then aborts rather than
|
|
14548
14238
|
// hang. Clicks only; never types (the critical guarantee holds here).
|
|
14549
|
-
async advanceOAuthConsent(provider) {
|
|
14239
|
+
async advanceOAuthConsent(provider, timeoutMs = 8_000, expectedGoogleAccountEmail) {
|
|
14550
14240
|
if (!this.page)
|
|
14551
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;
|
|
14552
14247
|
if (provider === "github") {
|
|
14553
14248
|
// GitHub App install flow can include an account target chooser before
|
|
14554
14249
|
// the Install/Authorize screen:
|
|
@@ -14558,8 +14253,12 @@ export class BrowserController {
|
|
|
14558
14253
|
// consent loop re-classify the next GitHub page.
|
|
14559
14254
|
if (/\/apps\/[^/]+\/installations\/select_target\b/.test(new URL(this.page.url()).pathname)) {
|
|
14560
14255
|
const startUrl = this.page.url();
|
|
14256
|
+
if (!hasBudget())
|
|
14257
|
+
return false;
|
|
14561
14258
|
const clicked = await this.page
|
|
14562
|
-
.evaluate(() => {
|
|
14259
|
+
.evaluate((expiresAt) => {
|
|
14260
|
+
if (Date.now() >= expiresAt)
|
|
14261
|
+
return false;
|
|
14563
14262
|
const visible = (el) => {
|
|
14564
14263
|
const r = el.getBoundingClientRect();
|
|
14565
14264
|
const s = window.getComputedStyle(el);
|
|
@@ -14584,13 +14283,17 @@ export class BrowserController {
|
|
|
14584
14283
|
});
|
|
14585
14284
|
if (target === undefined)
|
|
14586
14285
|
return false;
|
|
14286
|
+
if (Date.now() >= expiresAt)
|
|
14287
|
+
return false;
|
|
14587
14288
|
target.click();
|
|
14588
14289
|
return true;
|
|
14589
|
-
})
|
|
14290
|
+
}, deadline)
|
|
14590
14291
|
.catch(() => false);
|
|
14591
14292
|
if (clicked) {
|
|
14592
14293
|
const advanced = await this.page
|
|
14593
|
-
.waitForFunction((s) => window.location.href !== s, startUrl, {
|
|
14294
|
+
.waitForFunction((s) => window.location.href !== s, startUrl, {
|
|
14295
|
+
timeout: boundedTimeout(8_000),
|
|
14296
|
+
})
|
|
14594
14297
|
.then(() => true)
|
|
14595
14298
|
.catch(() => false);
|
|
14596
14299
|
if (advanced)
|
|
@@ -14625,7 +14328,7 @@ export class BrowserController {
|
|
|
14625
14328
|
// (MEASURED 2026-06-11: defang's "Authorize DefangLabs"). Poll up to
|
|
14626
14329
|
// 12s for it to enable before clicking.
|
|
14627
14330
|
{
|
|
14628
|
-
const deadline = Date.now() + 12_000;
|
|
14331
|
+
const deadline = Date.now() + boundedTimeout(12_000);
|
|
14629
14332
|
while (Date.now() < deadline) {
|
|
14630
14333
|
const disabled = await btn
|
|
14631
14334
|
.evaluate((el) => {
|
|
@@ -14642,8 +14345,10 @@ export class BrowserController {
|
|
|
14642
14345
|
await this.sleep(400);
|
|
14643
14346
|
}
|
|
14644
14347
|
}
|
|
14348
|
+
if (!hasBudget())
|
|
14349
|
+
return false;
|
|
14645
14350
|
try {
|
|
14646
|
-
await btn.click({ timeout:
|
|
14351
|
+
await btn.click({ timeout: boundedTimeout(8_000) });
|
|
14647
14352
|
}
|
|
14648
14353
|
catch {
|
|
14649
14354
|
continue;
|
|
@@ -14653,7 +14358,9 @@ export class BrowserController {
|
|
|
14653
14358
|
// click silently failed (wrong element, or button disabled
|
|
14654
14359
|
// behind a hidden iframe). Return false so the caller knows.
|
|
14655
14360
|
const advanced = await this.page
|
|
14656
|
-
.waitForFunction((s) => window.location.href !== s, startUrl, {
|
|
14361
|
+
.waitForFunction((s) => window.location.href !== s, startUrl, {
|
|
14362
|
+
timeout: boundedTimeout(4_000),
|
|
14363
|
+
})
|
|
14657
14364
|
.then(() => true)
|
|
14658
14365
|
.catch(() => false);
|
|
14659
14366
|
if (advanced)
|
|
@@ -14686,10 +14393,89 @@ export class BrowserController {
|
|
|
14686
14393
|
}
|
|
14687
14394
|
// Google. Account chooser: Google renders each account with a
|
|
14688
14395
|
// stable data-identifier attribute (the account email).
|
|
14689
|
-
const
|
|
14690
|
-
|
|
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;
|
|
14691
14477
|
try {
|
|
14692
|
-
await
|
|
14478
|
+
await accountRows.nth(accountRowIndex).click({ timeout: boundedTimeout(1_000) });
|
|
14693
14479
|
return true;
|
|
14694
14480
|
}
|
|
14695
14481
|
catch {
|
|
@@ -14707,14 +14493,16 @@ export class BrowserController {
|
|
|
14707
14493
|
const APPROVE_NAME = /^(?:continue|allow|accept|agree)\b/i;
|
|
14708
14494
|
const approve = this.page.getByRole("button", { name: APPROVE_NAME }).first();
|
|
14709
14495
|
try {
|
|
14710
|
-
await approve.waitFor({ state: "visible", timeout:
|
|
14496
|
+
await approve.waitFor({ state: "visible", timeout: boundedTimeout(1_000) });
|
|
14711
14497
|
}
|
|
14712
14498
|
catch {
|
|
14713
14499
|
// not visible within the window — fall through to the DOM-scan path
|
|
14714
14500
|
}
|
|
14715
14501
|
if ((await approve.count().catch(() => 0)) > 0) {
|
|
14502
|
+
if (!hasBudget())
|
|
14503
|
+
return false;
|
|
14716
14504
|
try {
|
|
14717
|
-
await approve.click({ timeout:
|
|
14505
|
+
await approve.click({ timeout: boundedTimeout(1_000) });
|
|
14718
14506
|
return true;
|
|
14719
14507
|
}
|
|
14720
14508
|
catch {
|
|
@@ -14726,8 +14514,12 @@ export class BrowserController {
|
|
|
14726
14514
|
// <div role>/<span> or an <input type=submit value="Allow access">).
|
|
14727
14515
|
// Click the first visible candidate whose text is an approve verb and
|
|
14728
14516
|
// is NOT a cancel/deny/back. Log what was visible on failure.
|
|
14517
|
+
if (!hasBudget())
|
|
14518
|
+
return false;
|
|
14729
14519
|
const clicked = await this.page
|
|
14730
|
-
.evaluate(() => {
|
|
14520
|
+
.evaluate((expiresAt) => {
|
|
14521
|
+
if (Date.now() >= expiresAt)
|
|
14522
|
+
return null;
|
|
14731
14523
|
const APPROVE = /^(?:continue|allow|accept|agree)\b/i;
|
|
14732
14524
|
const DENY = /\b(?:cancel|deny|back|no\b|not now|reject)\b/i;
|
|
14733
14525
|
const els = Array.from(document.querySelectorAll('button, input[type="submit"], [role="button"], a[href]'));
|
|
@@ -14741,12 +14533,14 @@ export class BrowserController {
|
|
|
14741
14533
|
if (DENY.test(t))
|
|
14742
14534
|
continue;
|
|
14743
14535
|
if (APPROVE.test(t)) {
|
|
14536
|
+
if (Date.now() >= expiresAt)
|
|
14537
|
+
return null;
|
|
14744
14538
|
el.click();
|
|
14745
14539
|
return t.slice(0, 40);
|
|
14746
14540
|
}
|
|
14747
14541
|
}
|
|
14748
14542
|
return null;
|
|
14749
|
-
})
|
|
14543
|
+
}, deadline)
|
|
14750
14544
|
.catch(() => null);
|
|
14751
14545
|
if (clicked !== null)
|
|
14752
14546
|
return true;
|
|
@@ -14826,51 +14620,6 @@ export class BrowserController {
|
|
|
14826
14620
|
this.oauthProviderPageClosed = false;
|
|
14827
14621
|
}
|
|
14828
14622
|
}
|
|
14829
|
-
// End one sequential task without tearing down Chrome. The original,
|
|
14830
|
-
// handler-bound page survives; OAuth popups and any other incidental tabs are
|
|
14831
|
-
// closed. about:blank replaces the task document while the persistent profile
|
|
14832
|
-
// (cookies, OAuth identity, local profile data) intentionally remains intact.
|
|
14833
|
-
async resetPageForReuse() {
|
|
14834
|
-
if (!this.profilePoolReusable)
|
|
14835
|
-
throw new Error("browser profile is not pool-reusable");
|
|
14836
|
-
const context = this.context;
|
|
14837
|
-
const primaryPage = this.primaryPage;
|
|
14838
|
-
if (!this.isConnected() || context === null || primaryPage === null || primaryPage.isClosed()) {
|
|
14839
|
-
throw new Error("warm browser page is not reusable");
|
|
14840
|
-
}
|
|
14841
|
-
this.page = primaryPage;
|
|
14842
|
-
this.oauthProductPage = null;
|
|
14843
|
-
this.oauthProviderPage = null;
|
|
14844
|
-
this.oauthProviderPageClosed = false;
|
|
14845
|
-
this.oauthNetLog = [];
|
|
14846
|
-
this.mouseX = 100;
|
|
14847
|
-
this.mouseY = 100;
|
|
14848
|
-
for (const page of context.pages()) {
|
|
14849
|
-
if (page !== primaryPage && !page.isClosed()) {
|
|
14850
|
-
let timer;
|
|
14851
|
-
try {
|
|
14852
|
-
await Promise.race([
|
|
14853
|
-
page.close(),
|
|
14854
|
-
new Promise((_, reject) => {
|
|
14855
|
-
timer = setTimeout(() => reject(new Error("timed out closing warm browser popup")), 5_000);
|
|
14856
|
-
timer.unref();
|
|
14857
|
-
}),
|
|
14858
|
-
]);
|
|
14859
|
-
}
|
|
14860
|
-
finally {
|
|
14861
|
-
if (timer !== undefined)
|
|
14862
|
-
clearTimeout(timer);
|
|
14863
|
-
}
|
|
14864
|
-
}
|
|
14865
|
-
}
|
|
14866
|
-
if (context.pages().some((page) => page !== primaryPage && !page.isClosed())) {
|
|
14867
|
-
throw new Error("warm browser reset left a non-primary page open");
|
|
14868
|
-
}
|
|
14869
|
-
await primaryPage.goto("about:blank", {
|
|
14870
|
-
waitUntil: "domcontentloaded",
|
|
14871
|
-
timeout: 30_000,
|
|
14872
|
-
});
|
|
14873
|
-
}
|
|
14874
14623
|
async close(options = {}) {
|
|
14875
14624
|
if (options.cancelStart === true) {
|
|
14876
14625
|
this.startCancellationRequested = true;
|
|
@@ -14880,10 +14629,22 @@ export class BrowserController {
|
|
|
14880
14629
|
this.closePromise ??= this.closeAfterStart();
|
|
14881
14630
|
return await this.closePromise;
|
|
14882
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
|
+
}
|
|
14883
14641
|
async forceCloseOwnedProcessTree() {
|
|
14884
14642
|
this.startCancellationRequested = true;
|
|
14885
14643
|
this.resolveStartCancellation?.();
|
|
14886
14644
|
this.resolveStartCancellation = null;
|
|
14645
|
+
const marker = this.operatorBrowserMarker();
|
|
14646
|
+
if (this.ownerLaunchTracked)
|
|
14647
|
+
markOwnerBrowserLaunchTerminal(marker);
|
|
14887
14648
|
const proof = this.ownedChromeProcessTreeProof;
|
|
14888
14649
|
const identity = proof?.identity ?? this.currentOwnedProfileIdentity();
|
|
14889
14650
|
if (identity !== null) {
|
|
@@ -14903,43 +14664,24 @@ export class BrowserController {
|
|
|
14903
14664
|
if (this.ownedChromeProcessTreeProof === proof)
|
|
14904
14665
|
this.ownedChromeProcessTreeProof = null;
|
|
14905
14666
|
}
|
|
14906
|
-
const
|
|
14907
|
-
this.
|
|
14908
|
-
|
|
14909
|
-
|
|
14910
|
-
try {
|
|
14911
|
-
this.xvfb.stop();
|
|
14912
|
-
}
|
|
14913
|
-
catch { }
|
|
14914
|
-
this.xvfb = null;
|
|
14667
|
+
const markerClosed = !this.ownerLaunchTracked || (await terminateOwnerBrowserLaunch(marker, this.profileDir));
|
|
14668
|
+
if (closed && markerClosed && this.ownerLaunchTracked) {
|
|
14669
|
+
untrackOwnerBrowserLaunch(marker);
|
|
14670
|
+
this.ownerLaunchTracked = false;
|
|
14915
14671
|
}
|
|
14916
|
-
|
|
14672
|
+
await this.teardownOwnedDisplay().catch(() => undefined);
|
|
14673
|
+
return closed && markerClosed ? "closed" : "unknown";
|
|
14917
14674
|
}
|
|
14918
14675
|
async closeCancelledStart() {
|
|
14919
14676
|
void this.reapCancelledStartProcess().catch(() => undefined);
|
|
14920
14677
|
if (this.persistentFallbackCancellationState !== null) {
|
|
14921
|
-
|
|
14922
|
-
if (closeState === "closed") {
|
|
14923
|
-
const lease = this.profileOperationLease;
|
|
14924
|
-
this.profileOperationLease = null;
|
|
14925
|
-
lease?.release();
|
|
14926
|
-
}
|
|
14927
|
-
return closeState;
|
|
14678
|
+
return this.persistentFallbackCancellationState;
|
|
14928
14679
|
}
|
|
14929
14680
|
if (!this.startLaunchCommitted) {
|
|
14930
|
-
const closeState = await this.
|
|
14931
|
-
const lease = this.profileOperationLease;
|
|
14932
|
-
this.profileOperationLease = null;
|
|
14933
|
-
lease?.release();
|
|
14681
|
+
const closeState = await this.closeBrowser();
|
|
14934
14682
|
return this.startSettled ? closeState : "unknown";
|
|
14935
14683
|
}
|
|
14936
|
-
|
|
14937
|
-
if (this.startSettled) {
|
|
14938
|
-
const lease = this.profileOperationLease;
|
|
14939
|
-
this.profileOperationLease = null;
|
|
14940
|
-
lease?.release();
|
|
14941
|
-
}
|
|
14942
|
-
return closeState;
|
|
14684
|
+
return await this.closeBrowser();
|
|
14943
14685
|
}
|
|
14944
14686
|
async reapCancelledStartProcess() {
|
|
14945
14687
|
this.cancelledStartReaper ??= this.monitorCancelledStartProcess();
|
|
@@ -14970,7 +14712,7 @@ export class BrowserController {
|
|
|
14970
14712
|
const identity = profileProcessIdentity(holderPid, this.profileDir);
|
|
14971
14713
|
if (identity === null)
|
|
14972
14714
|
return null;
|
|
14973
|
-
if (!this.startCancellationRequested
|
|
14715
|
+
if (!this.startCancellationRequested)
|
|
14974
14716
|
return identity;
|
|
14975
14717
|
return operatorBrowserProcessMatchesMarker(identity.pid, this.operatorBrowserMarker())
|
|
14976
14718
|
? identity
|
|
@@ -14983,7 +14725,6 @@ export class BrowserController {
|
|
|
14983
14725
|
const proof = await resolvePersistentFallbackIdentity({ profileDir: this.profileDir });
|
|
14984
14726
|
if (proof.state === "owned" &&
|
|
14985
14727
|
this.startCancellationRequested &&
|
|
14986
|
-
this.profileOperationLease === null &&
|
|
14987
14728
|
!operatorBrowserProcessMatchesMarker(proof.identity.pid, this.operatorBrowserMarker())) {
|
|
14988
14729
|
return { state: "unknown" };
|
|
14989
14730
|
}
|
|
@@ -14991,6 +14732,20 @@ export class BrowserController {
|
|
|
14991
14732
|
this.adoptOwnedChromeProcessTree(proof.identity, false);
|
|
14992
14733
|
return proof;
|
|
14993
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
|
+
}
|
|
14994
14749
|
startPersistentFallbackOwnershipMonitor() {
|
|
14995
14750
|
if (this.persistentFallbackOwnershipMonitor !== null)
|
|
14996
14751
|
return;
|
|
@@ -15000,11 +14755,13 @@ export class BrowserController {
|
|
|
15000
14755
|
const identity = holderPid === null ? null : profileProcessIdentity(holderPid, this.profileDir);
|
|
15001
14756
|
const controllerOwnsIdentity = identity !== null &&
|
|
15002
14757
|
(!this.startCancellationRequested ||
|
|
15003
|
-
this.profileOperationLease !== null ||
|
|
15004
14758
|
operatorBrowserProcessMatchesMarker(identity.pid, this.operatorBrowserMarker()));
|
|
15005
14759
|
if (identity !== null && controllerOwnsIdentity) {
|
|
15006
14760
|
this.launchedProfileHolderIdentity = identity;
|
|
15007
|
-
|
|
14761
|
+
try {
|
|
14762
|
+
this.adoptOwnedChromeProcessTree(identity, false);
|
|
14763
|
+
}
|
|
14764
|
+
catch { }
|
|
15008
14765
|
return;
|
|
15009
14766
|
}
|
|
15010
14767
|
await new Promise((resolveWait) => {
|
|
@@ -15047,31 +14804,25 @@ export class BrowserController {
|
|
|
15047
14804
|
}
|
|
15048
14805
|
if (this.startCancellationRequested)
|
|
15049
14806
|
return await this.closeCancelledStart();
|
|
15050
|
-
|
|
15051
|
-
return await this.closeWithProfileGuard();
|
|
15052
|
-
}
|
|
15053
|
-
finally {
|
|
15054
|
-
const lease = this.profileOperationLease;
|
|
15055
|
-
this.profileOperationLease = null;
|
|
15056
|
-
lease?.release();
|
|
15057
|
-
}
|
|
14807
|
+
return await this.closeBrowser();
|
|
15058
14808
|
}
|
|
15059
|
-
async
|
|
14809
|
+
async closeBrowser() {
|
|
15060
14810
|
if (this.harnessAttachedPage) {
|
|
15061
14811
|
this.page = null;
|
|
15062
14812
|
this.primaryPage = null;
|
|
15063
14813
|
this.oauthProductPage = null;
|
|
15064
14814
|
this.oauthProviderPage = null;
|
|
15065
14815
|
this.oauthProviderPageClosed = false;
|
|
15066
|
-
this.oauthNetLog = [];
|
|
15067
14816
|
this.context = null;
|
|
15068
14817
|
return "closed";
|
|
15069
14818
|
}
|
|
14819
|
+
const marker = this.operatorBrowserMarker();
|
|
14820
|
+
if (this.ownerLaunchTracked)
|
|
14821
|
+
markOwnerBrowserLaunchTerminal(marker);
|
|
15070
14822
|
// Each step is best-effort and independent: a throw closing the page
|
|
15071
|
-
// or context must NOT skip the
|
|
15072
|
-
//
|
|
15073
|
-
//
|
|
15074
|
-
// 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`).
|
|
15075
14826
|
//
|
|
15076
14827
|
// EVERY close call is timeout-capped. On a wedged headed Chrome (e.g. a
|
|
15077
14828
|
// run that crashed mid-captcha-click), BOTH page.close() AND
|
|
@@ -15096,7 +14847,6 @@ export class BrowserController {
|
|
|
15096
14847
|
this.oauthProductPage = null;
|
|
15097
14848
|
this.oauthProviderPage = null;
|
|
15098
14849
|
this.oauthProviderPageClosed = false;
|
|
15099
|
-
this.oauthNetLog = [];
|
|
15100
14850
|
this.context = null;
|
|
15101
14851
|
this.cdpBrowser = null;
|
|
15102
14852
|
this.childChrome = null;
|
|
@@ -15113,7 +14863,7 @@ export class BrowserController {
|
|
|
15113
14863
|
}
|
|
15114
14864
|
// A process-tree SIGTERM can close the CDP target before Playwright
|
|
15115
14865
|
// observes it. That is successful teardown, not a reason to skip the
|
|
15116
|
-
// proof/reap path or
|
|
14866
|
+
// proof/reap path or retain a cleanly closed ephemeral profile.
|
|
15117
14867
|
if (page !== null)
|
|
15118
14868
|
await page.close().catch(() => undefined);
|
|
15119
14869
|
if (context !== null)
|
|
@@ -15145,19 +14895,13 @@ export class BrowserController {
|
|
|
15145
14895
|
this.ownedChromeProcessTreeProof = null;
|
|
15146
14896
|
}
|
|
15147
14897
|
}
|
|
15148
|
-
|
|
15149
|
-
|
|
15150
|
-
|
|
15151
|
-
|
|
15152
|
-
try {
|
|
15153
|
-
this.xvfb.stop();
|
|
15154
|
-
}
|
|
15155
|
-
catch {
|
|
15156
|
-
/* best-effort */
|
|
15157
|
-
}
|
|
15158
|
-
this.xvfb = null;
|
|
14898
|
+
const markerClosed = !this.ownerLaunchTracked || (await terminateOwnerBrowserLaunch(marker, this.profileDir));
|
|
14899
|
+
if (markerClosed && this.ownerLaunchTracked) {
|
|
14900
|
+
untrackOwnerBrowserLaunch(marker);
|
|
14901
|
+
this.ownerLaunchTracked = false;
|
|
15159
14902
|
}
|
|
15160
|
-
|
|
14903
|
+
await this.teardownOwnedDisplay().catch(() => undefined);
|
|
14904
|
+
return closeState === "closed" && !markerClosed ? "force_closed_unproven" : closeState;
|
|
15161
14905
|
}
|
|
15162
14906
|
}
|
|
15163
14907
|
// Random integer in [min, max]. We use Math.random() (not crypto)
|