@trusty-squire/mcp 0.9.13 → 0.9.14-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api-client.d.ts +28 -0
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +11 -0
- package/dist/api-client.js.map +1 -1
- package/dist/bot/agent.d.ts +7 -1
- package/dist/bot/agent.d.ts.map +1 -1
- package/dist/bot/agent.js +631 -40
- package/dist/bot/agent.js.map +1 -1
- package/dist/bot/browser.d.ts +15 -0
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +858 -84
- package/dist/bot/browser.js.map +1 -1
- package/dist/bot/captcha-solver-2captcha.d.ts +18 -0
- package/dist/bot/captcha-solver-2captcha.d.ts.map +1 -1
- package/dist/bot/captcha-solver-2captcha.js +21 -0
- package/dist/bot/captcha-solver-2captcha.js.map +1 -1
- package/dist/bot/email-code-fetcher.d.ts +5 -0
- package/dist/bot/email-code-fetcher.d.ts.map +1 -0
- package/dist/bot/email-code-fetcher.js +33 -0
- package/dist/bot/email-code-fetcher.js.map +1 -0
- package/dist/bot/inbox-client.d.ts +1 -0
- package/dist/bot/inbox-client.d.ts.map +1 -1
- package/dist/bot/inbox-client.js +55 -15
- package/dist/bot/inbox-client.js.map +1 -1
- package/dist/bot/index.d.ts +2 -1
- package/dist/bot/index.d.ts.map +1 -1
- package/dist/bot/index.js +49 -19
- package/dist/bot/index.js.map +1 -1
- package/dist/bot/promote-to-skill.d.ts +3 -1
- package/dist/bot/promote-to-skill.d.ts.map +1 -1
- package/dist/bot/promote-to-skill.js +122 -7
- package/dist/bot/promote-to-skill.js.map +1 -1
- package/dist/bot/replay-skill.d.ts +18 -0
- package/dist/bot/replay-skill.d.ts.map +1 -1
- package/dist/bot/replay-skill.js +290 -12
- package/dist/bot/replay-skill.js.map +1 -1
- package/dist/bot/signup-lock.d.ts +17 -0
- package/dist/bot/signup-lock.d.ts.map +1 -0
- package/dist/bot/signup-lock.js +174 -0
- package/dist/bot/signup-lock.js.map +1 -0
- package/dist/tools/grant-app-access.d.ts +31 -0
- package/dist/tools/grant-app-access.d.ts.map +1 -0
- package/dist/tools/grant-app-access.js +59 -0
- package/dist/tools/grant-app-access.js.map +1 -0
- package/dist/tools/index.d.ts +2 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +4 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/provision-any.d.ts.map +1 -1
- package/dist/tools/provision-any.js +25 -12
- package/dist/tools/provision-any.js.map +1 -1
- package/dist/tools/store-credential.d.ts +5 -0
- package/dist/tools/store-credential.d.ts.map +1 -1
- package/dist/tools/store-credential.js +13 -2
- package/dist/tools/store-credential.js.map +1 -1
- package/package.json +2 -2
- package/dist/bot/oauth-lock.d.ts +0 -2
- package/dist/bot/oauth-lock.d.ts.map +0 -1
- package/dist/bot/oauth-lock.js +0 -28
- package/dist/bot/oauth-lock.js.map +0 -1
package/dist/bot/browser.js
CHANGED
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
// agent.ts.
|
|
24
24
|
import { chromium as baseChromium } from "playwright";
|
|
25
25
|
import { createRequire } from "node:module";
|
|
26
|
+
import { Socket, createServer } from "node:net";
|
|
27
|
+
import { existsSync } from "node:fs";
|
|
28
|
+
import { spawn } from "node:child_process";
|
|
26
29
|
import { detectAsn } from "./asn.js";
|
|
27
30
|
import { CHROME_PROFILE_DIR, launchWithProfileGate, ProfileBusyError, reapLeakedProfileHolder, waitForProfileFree } from "./profile.js";
|
|
28
31
|
import { startXvfb, xvfbAvailable } from "./xvfb.js";
|
|
@@ -225,6 +228,87 @@ async function detectChromiumChannel() {
|
|
|
225
228
|
}
|
|
226
229
|
return null;
|
|
227
230
|
}
|
|
231
|
+
// Resolve the on-disk Chrome binary for a detected channel, for the
|
|
232
|
+
// self-launch path (see launchSelfManagedContext). Playwright launches a
|
|
233
|
+
// channel by name; we have to spawn the binary ourselves, so we need the
|
|
234
|
+
// path. Returns null when the channel is unknown / not found on disk
|
|
235
|
+
// (caller falls back to launchPersistentContext).
|
|
236
|
+
export function resolveChannelBinary(channel) {
|
|
237
|
+
if (channel === null)
|
|
238
|
+
return null; // bundled Chromium — no self-launch
|
|
239
|
+
const explicit = process.env.UNIVERSAL_BOT_CHROME_BINARY;
|
|
240
|
+
if (explicit !== undefined && explicit.length > 0) {
|
|
241
|
+
return existsSync(explicit) ? explicit : null;
|
|
242
|
+
}
|
|
243
|
+
const candidates = CHANNEL_PATHS[channel] ?? [];
|
|
244
|
+
for (const c of candidates) {
|
|
245
|
+
try {
|
|
246
|
+
if (existsSync(c))
|
|
247
|
+
return c;
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
// skip unreadable candidate
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
// Whether to launch Chrome ourselves and attach over CDP, instead of
|
|
256
|
+
// Playwright's launchPersistentContext.
|
|
257
|
+
//
|
|
258
|
+
// WHY THIS EXISTS — the single decisive finding (2026-06-12, fully
|
|
259
|
+
// reproduced + falsifiable; see STATE.md "Cloudflare-Turnstile wall").
|
|
260
|
+
// Cloudflare Turnstile's interactive challenge FAILS a Playwright/patchright
|
|
261
|
+
// launchPersistentContext-driven Chrome and PASSES a Chrome the operator
|
|
262
|
+
// launches itself and then attaches to over CDP — every other variable held
|
|
263
|
+
// constant (same box, same datacenter IP, same Xvfb display, same Chrome 148
|
|
264
|
+
// binary, same software-WebGL, same humanized click). The discriminator
|
|
265
|
+
// matrix:
|
|
266
|
+
// launchPersistentContext + CDP click → "Verification failed"
|
|
267
|
+
// launchPersistentContext + OS click → "Verification failed"
|
|
268
|
+
// plain google-chrome + OS click → "Success!"
|
|
269
|
+
// plain google-chrome + connectOverCDP + page.mouse → token issued (len816)
|
|
270
|
+
// So the tell is NEITHER the live CDP attachment NOR the click mechanism —
|
|
271
|
+
// it is specifically the launch flags/instrumentation Playwright injects at
|
|
272
|
+
// launchPersistentContext time. Self-launching the binary (no
|
|
273
|
+
// --enable-automation et al.) and attaching with connectOverCDP avoids it.
|
|
274
|
+
// Default-ON; opt out with BOT_SELF_LAUNCH=0 for the old path. Exported for tests.
|
|
275
|
+
export function selfLaunchEnabled() {
|
|
276
|
+
const v = process.env.BOT_SELF_LAUNCH;
|
|
277
|
+
return v !== "0" && v !== "false" && v !== "off";
|
|
278
|
+
}
|
|
279
|
+
// Find an ephemeral TCP port for Chrome's --remote-debugging-port.
|
|
280
|
+
function findFreePort() {
|
|
281
|
+
return new Promise((resolve, reject) => {
|
|
282
|
+
const srv = createServer();
|
|
283
|
+
srv.on("error", reject);
|
|
284
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
285
|
+
const addr = srv.address();
|
|
286
|
+
const port = typeof addr === "object" && addr !== null ? addr.port : 0;
|
|
287
|
+
srv.close(() => (port > 0 ? resolve(port) : reject(new Error("no port"))));
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
// Poll Chrome's DevTools HTTP endpoint until it answers (the browser is up
|
|
292
|
+
// and accepting CDP), or the deadline passes. Returns the base endpoint URL
|
|
293
|
+
// connectOverCDP accepts.
|
|
294
|
+
async function waitForDevtools(port, deadlineMs) {
|
|
295
|
+
const base = `http://127.0.0.1:${port}`;
|
|
296
|
+
const deadline = Date.now() + deadlineMs;
|
|
297
|
+
let lastErr = "";
|
|
298
|
+
while (Date.now() < deadline) {
|
|
299
|
+
try {
|
|
300
|
+
const res = await fetch(`${base}/json/version`, { signal: AbortSignal.timeout(2_000) });
|
|
301
|
+
if (res.ok)
|
|
302
|
+
return base;
|
|
303
|
+
lastErr = `HTTP ${res.status}`;
|
|
304
|
+
}
|
|
305
|
+
catch (err) {
|
|
306
|
+
lastErr = err instanceof Error ? err.message : String(err);
|
|
307
|
+
}
|
|
308
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
309
|
+
}
|
|
310
|
+
throw new Error(`Chrome DevTools endpoint never came up on ${base} (${lastErr})`);
|
|
311
|
+
}
|
|
228
312
|
// Classify an anti-bot interstitial page from its (title + body) text.
|
|
229
313
|
// `onInterstitial` matches the static Cloudflare/Turnstile challenge copy.
|
|
230
314
|
// `verificationPassed` is the signal the challenge SUCCEEDED — but
|
|
@@ -274,6 +358,11 @@ export class BrowserController {
|
|
|
274
358
|
// Google session across runs — see profile.ts / google-login.ts.
|
|
275
359
|
context = null;
|
|
276
360
|
page = null;
|
|
361
|
+
// Self-launch path (Turnstile-safe; see selfLaunchEnabled). When we spawn
|
|
362
|
+
// Chrome ourselves and attach over CDP, these hold the child process and
|
|
363
|
+
// the connected Browser so close() can tear both down.
|
|
364
|
+
childChrome = null;
|
|
365
|
+
cdpBrowser = null;
|
|
277
366
|
// True once launchPersistentContext succeeded this session. close() only
|
|
278
367
|
// reaps a leaked Chrome when WE launched one — so a ProfileBusyError thrown
|
|
279
368
|
// BEFORE launch (while waiting on a genuine concurrent holder) never kills
|
|
@@ -317,7 +406,14 @@ export class BrowserController {
|
|
|
317
406
|
constructor(opts = {}) {
|
|
318
407
|
this.humanize = opts.humanize ?? true;
|
|
319
408
|
this.profileDir = opts.profileDir ?? CHROME_PROFILE_DIR;
|
|
409
|
+
this.proxyOverride =
|
|
410
|
+
opts.proxyUrl !== undefined && opts.proxyUrl.trim().length > 0
|
|
411
|
+
? opts.proxyUrl.trim()
|
|
412
|
+
: null;
|
|
320
413
|
}
|
|
414
|
+
// Per-launch egress override (verify-fleet identities each get their own IP).
|
|
415
|
+
// null → use the env-global proxy. See resolveProxy().
|
|
416
|
+
proxyOverride;
|
|
321
417
|
// Which browser channel the most recent .start() actually used.
|
|
322
418
|
// `null` means bundled Chromium; a string like "chrome" means a
|
|
323
419
|
// real installed browser of that channel. Throws if .start() hasn't
|
|
@@ -348,6 +444,63 @@ export class BrowserController {
|
|
|
348
444
|
}
|
|
349
445
|
return activeStealthProfileValue();
|
|
350
446
|
}
|
|
447
|
+
// Launch Chrome ourselves and attach over CDP — the Turnstile-safe launch
|
|
448
|
+
// (see selfLaunchEnabled for the proof). The profile dir is the SAME shared
|
|
449
|
+
// profile launchPersistentContext would use, so the OAuth session carries
|
|
450
|
+
// over. Options that launchPersistentContext takes at creation but a default
|
|
451
|
+
// (connectOverCDP) context can't are applied differently:
|
|
452
|
+
// • timezone → TZ env on the child (more authentic than a CDP override)
|
|
453
|
+
// • proxy → --proxy-server flag (auth-less only; the caller routes
|
|
454
|
+
// credentialed proxies to the old path)
|
|
455
|
+
// • viewport → --window-size (with viewport:null-equivalent: we never set
|
|
456
|
+
// an emulated viewport on the connected context)
|
|
457
|
+
// • locale/geo/permissions → applied post-connect by start()
|
|
458
|
+
async launchSelfManagedContext(params) {
|
|
459
|
+
const port = await findFreePort();
|
|
460
|
+
const argv = [
|
|
461
|
+
`--remote-debugging-port=${port}`,
|
|
462
|
+
"--remote-debugging-address=127.0.0.1",
|
|
463
|
+
`--user-data-dir=${this.profileDir}`,
|
|
464
|
+
"--no-first-run",
|
|
465
|
+
"--no-default-browser-check",
|
|
466
|
+
"--password-store=basic",
|
|
467
|
+
"--window-position=0,0",
|
|
468
|
+
`--window-size=${params.window.width},${params.window.height}`,
|
|
469
|
+
"--lang=en-US",
|
|
470
|
+
...params.args,
|
|
471
|
+
...(params.proxy !== null ? [`--proxy-server=${params.proxy.server}`] : []),
|
|
472
|
+
...(params.headless ? ["--headless=new"] : []),
|
|
473
|
+
"about:blank",
|
|
474
|
+
];
|
|
475
|
+
const child = spawn(params.binary, argv, { env: params.env, stdio: "ignore" });
|
|
476
|
+
this.childChrome = child;
|
|
477
|
+
let endpoint;
|
|
478
|
+
try {
|
|
479
|
+
endpoint = await waitForDevtools(port, 30_000);
|
|
480
|
+
}
|
|
481
|
+
catch (err) {
|
|
482
|
+
try {
|
|
483
|
+
child.kill("SIGKILL");
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
/* already gone */
|
|
487
|
+
}
|
|
488
|
+
this.childChrome = null;
|
|
489
|
+
throw err;
|
|
490
|
+
}
|
|
491
|
+
// Use the patchright launcher's connectOverCDP — it's the exact path the
|
|
492
|
+
// falsification experiment validated (its connect avoids Runtime.enable,
|
|
493
|
+
// which a plain attach would emit). The anti-detection that matters here
|
|
494
|
+
// is the LAUNCH (which we now own), not the connect.
|
|
495
|
+
const launcher = getChromium();
|
|
496
|
+
const browser = await launcher.connectOverCDP(endpoint);
|
|
497
|
+
this.cdpBrowser = browser;
|
|
498
|
+
const ctx = browser.contexts()[0];
|
|
499
|
+
if (ctx === undefined) {
|
|
500
|
+
throw new Error("self-launched Chrome exposed no default browser context");
|
|
501
|
+
}
|
|
502
|
+
return ctx;
|
|
503
|
+
}
|
|
351
504
|
async start() {
|
|
352
505
|
const channel = await detectChromiumChannel();
|
|
353
506
|
this.launchedChannel = channel;
|
|
@@ -407,7 +560,13 @@ export class BrowserController {
|
|
|
407
560
|
}
|
|
408
561
|
else if (xvfbAvailable()) {
|
|
409
562
|
try {
|
|
410
|
-
|
|
563
|
+
// 1920×1080 — the most common real desktop resolution. The old
|
|
564
|
+
// 1280×720 here was exactly Playwright's emulated-device viewport
|
|
565
|
+
// default (the code's own comments flag that as an anti-bot tell),
|
|
566
|
+
// and with viewport:null the page read it straight back. A 720p
|
|
567
|
+
// screen whose availHeight==height (no taskbar) is a headless
|
|
568
|
+
// signature strict Turnstiles (exa/cartesia) score against.
|
|
569
|
+
this.xvfb = await startXvfb({ width: 1920, height: 1080 });
|
|
411
570
|
chromeEnv = { ...process.env, DISPLAY: this.xvfb.display };
|
|
412
571
|
chromeHeadless = false;
|
|
413
572
|
this.launchedMode = "xvfb";
|
|
@@ -430,12 +589,31 @@ export class BrowserController {
|
|
|
430
589
|
// SingletonLock from a killed run, or wait our turn behind a live
|
|
431
590
|
// `mcp login` / another signup. Without this, launchPersistentContext
|
|
432
591
|
// aborts with "Failed to create a ProcessSingleton" and bricks the run.
|
|
433
|
-
|
|
592
|
+
let free = await waitForProfileFree(this.profileDir, {
|
|
434
593
|
deadlineMs: 120_000,
|
|
435
594
|
onWait: () => console.error("[universal-bot] bot Chrome profile is busy with another run — waiting…"),
|
|
436
595
|
});
|
|
437
596
|
if (!free) {
|
|
438
|
-
|
|
597
|
+
// A live-pid holder that never released within the deadline. The
|
|
598
|
+
// signup/discover loop is strictly serial (one run at a time), so a
|
|
599
|
+
// local holder that outlasts 120s is NOT a legitimate concurrent run —
|
|
600
|
+
// it's a leaked Chrome from a previously EXTERNALLY-killed run
|
|
601
|
+
// (run_timeout SIGKILL, OOM, reboot) whose JS `finally`/close() never
|
|
602
|
+
// executed, so reapLeakedProfileHolder never ran. waitForProfileFree
|
|
603
|
+
// only reclaims dead-pid / null locks, so this live orphan otherwise
|
|
604
|
+
// crashes every subsequent run with ProfileBusyError (MEASURED
|
|
605
|
+
// 2026-06-11: cyclic, railpack). A genuine concurrent `mcp login` would
|
|
606
|
+
// have released within the 120s wait — so by here, reaping the LOCAL
|
|
607
|
+
// holder (SIGKILL + clear singletons; no-ops on a remote-host holder)
|
|
608
|
+
// and retrying once is safe and recovers the run instead of failing it.
|
|
609
|
+
const reaped = reapLeakedProfileHolder(this.profileDir);
|
|
610
|
+
if (reaped) {
|
|
611
|
+
console.error("[universal-bot] reaped a leaked Chrome holding the profile (orphan from an externally-killed run) — retrying");
|
|
612
|
+
free = await waitForProfileFree(this.profileDir, { deadlineMs: 10_000 });
|
|
613
|
+
}
|
|
614
|
+
if (!free) {
|
|
615
|
+
throw new ProfileBusyError("bot Chrome profile is held by another run (a login or signup); retry shortly");
|
|
616
|
+
}
|
|
439
617
|
}
|
|
440
618
|
// T3: a PERSISTENT context. The profile dir carries the user's
|
|
441
619
|
// Google session (established by `mcp login` — see google-login.ts),
|
|
@@ -453,68 +631,87 @@ export class BrowserController {
|
|
|
453
631
|
// rebrowser fork required (the pin is what crashed the OAuth flow and
|
|
454
632
|
// confounded the A/B). One binary for both arms.
|
|
455
633
|
this.launchedChannel = channel;
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
}
|
|
634
|
+
// Launch args shared by BOTH paths (launchPersistentContext and the
|
|
635
|
+
// self-launch). See the per-flag rationale: swiftshader gives a real
|
|
636
|
+
// (software) WebGL context on the GPU-less Xvfb box; the others are the
|
|
637
|
+
// standard headless/sandbox flags. NOTE we deliberately do NOT include
|
|
638
|
+
// Playwright's automation flags (--enable-automation et al.) — on the
|
|
639
|
+
// self-launch path their ABSENCE is the whole fix.
|
|
640
|
+
const launchArgs = [
|
|
641
|
+
"--disable-blink-features=AutomationControlled",
|
|
642
|
+
"--no-sandbox",
|
|
643
|
+
"--disable-dev-shm-usage",
|
|
644
|
+
"--enable-unsafe-swiftshader",
|
|
645
|
+
"--ignore-gpu-blocklist",
|
|
646
|
+
];
|
|
647
|
+
// F10 clipboard + egress-matched geolocation permission, built once for
|
|
648
|
+
// either path. Typed as string[] (Playwright's grantPermissions /
|
|
649
|
+
// permissions option both accept it).
|
|
650
|
+
const grantedPermissions = [
|
|
651
|
+
...(geo?.geolocation !== undefined ? ["geolocation"] : []),
|
|
652
|
+
"clipboard-read",
|
|
653
|
+
"clipboard-write",
|
|
654
|
+
];
|
|
655
|
+
// Decide the launch path. Self-launch (Turnstile-safe) requires a real
|
|
656
|
+
// Chrome binary on disk AND an auth-less proxy (a credentialed proxy needs
|
|
657
|
+
// Playwright's native proxy auth, which only the launchPersistentContext
|
|
658
|
+
// path provides — so route those there).
|
|
659
|
+
const selfLaunchBinary = selfLaunchEnabled() ? resolveChannelBinary(channel) : null;
|
|
660
|
+
const proxyHasAuth = proxy !== null && typeof proxy.username === "string" && proxy.username.length > 0;
|
|
661
|
+
const useSelfLaunch = selfLaunchBinary !== null && !proxyHasAuth;
|
|
662
|
+
let context;
|
|
663
|
+
if (useSelfLaunch && selfLaunchBinary !== null) {
|
|
664
|
+
console.error(`[universal-bot] self-launch + connectOverCDP (Turnstile-safe launch) binary=${selfLaunchBinary}`);
|
|
665
|
+
// Window size matches the display surface so viewport reads as a real
|
|
666
|
+
// window (no emulated-viewport tell). TZ on the child makes Chrome
|
|
667
|
+
// report the egress timezone natively.
|
|
668
|
+
const window = this.launchedMode === "xvfb"
|
|
669
|
+
? { width: 1920, height: 1080 }
|
|
670
|
+
: { width: 1280, height: 1024 };
|
|
671
|
+
const selfEnv = {
|
|
672
|
+
...(chromeEnv ?? process.env),
|
|
673
|
+
TZ: geo?.timezoneId ?? "America/New_York",
|
|
674
|
+
};
|
|
675
|
+
context = await launchWithProfileGate(this.profileDir, () => this.launchSelfManagedContext({
|
|
676
|
+
binary: selfLaunchBinary,
|
|
677
|
+
headless: chromeHeadless,
|
|
678
|
+
args: launchArgs,
|
|
679
|
+
proxy,
|
|
680
|
+
env: selfEnv,
|
|
681
|
+
window,
|
|
682
|
+
}));
|
|
683
|
+
// Options the default (connectOverCDP) context can't take at creation —
|
|
684
|
+
// applied post-connect. Best-effort: a failure here is non-fatal (the
|
|
685
|
+
// signup proceeds; only clipboard-key-extraction / geo degrade).
|
|
686
|
+
try {
|
|
687
|
+
await context.grantPermissions(grantedPermissions);
|
|
688
|
+
if (geo?.geolocation !== undefined) {
|
|
689
|
+
await context.setGeolocation(geo.geolocation);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
catch (err) {
|
|
693
|
+
console.error(`[universal-bot] post-connect context setup partial: ${err instanceof Error ? err.message : String(err)}`);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
else {
|
|
697
|
+
if (selfLaunchEnabled() && selfLaunchBinary !== null && proxyHasAuth) {
|
|
698
|
+
console.error("[universal-bot] credentialed proxy → launchPersistentContext (self-launch can't carry proxy auth)");
|
|
699
|
+
}
|
|
700
|
+
// T3: a PERSISTENT context (the legacy path). The profile dir carries the
|
|
701
|
+
// user's Google session so the OAuth-first path reuses it.
|
|
702
|
+
context = await launchWithProfileGate(this.profileDir, () => launcher.launchPersistentContext(this.profileDir, {
|
|
703
|
+
headless: chromeHeadless,
|
|
704
|
+
...(chromeEnv !== undefined ? { env: chromeEnv } : {}),
|
|
705
|
+
...(channel !== null ? { channel } : {}),
|
|
706
|
+
...(proxy !== null ? { proxy } : {}),
|
|
707
|
+
args: [...launchArgs],
|
|
708
|
+
viewport: null,
|
|
709
|
+
locale: "en-US",
|
|
710
|
+
timezoneId: geo?.timezoneId ?? "America/New_York",
|
|
711
|
+
permissions: grantedPermissions,
|
|
712
|
+
...(geo?.geolocation !== undefined ? { geolocation: geo.geolocation } : {}),
|
|
713
|
+
}));
|
|
714
|
+
}
|
|
518
715
|
this.context = context;
|
|
519
716
|
// We own the profile now — close() may reap a leaked Chrome.
|
|
520
717
|
this.launchedContext = true;
|
|
@@ -577,6 +774,52 @@ export class BrowserController {
|
|
|
577
774
|
if (typeof WebGL2RenderingContext !== "undefined") {
|
|
578
775
|
spoof(WebGL2RenderingContext.prototype);
|
|
579
776
|
}
|
|
777
|
+
// Device-tell normalization. The headless harvester box reports 20
|
|
778
|
+
// logical cores (navigator.hardwareConcurrency) — a consumer residential
|
|
779
|
+
// device is 4-16. A 20-core Linux machine behind a "residential" IP is
|
|
780
|
+
// an internal inconsistency Cloudflare Turnstile scores against
|
|
781
|
+
// (MEASURED 2026-06-11: exa/cartesia Turnstile won't issue a token on a
|
|
782
|
+
// clean-fingerprint click; hwConcurrency=20 + Linux is the standout
|
|
783
|
+
// anomaly). Normalize to a common consumer profile. Same per-nav main-
|
|
784
|
+
// world application as the WebGL spoof — patchright denies init-world
|
|
785
|
+
// reach, and Turnstile reads these after the challenge script loads
|
|
786
|
+
// (seconds in), so the framenavigated re-apply wins the race. Defined on
|
|
787
|
+
// Navigator.prototype (where the native getters live) so there's no own-
|
|
788
|
+
// property tell on the instance.
|
|
789
|
+
const navProto = Navigator.prototype;
|
|
790
|
+
if (navProto.__tsDevicePatched !== true) {
|
|
791
|
+
try {
|
|
792
|
+
Object.defineProperty(Navigator.prototype, "hardwareConcurrency", {
|
|
793
|
+
get: () => 8,
|
|
794
|
+
configurable: true,
|
|
795
|
+
});
|
|
796
|
+
Object.defineProperty(Navigator.prototype, "deviceMemory", {
|
|
797
|
+
get: () => 8,
|
|
798
|
+
configurable: true,
|
|
799
|
+
});
|
|
800
|
+
// Screen availHeight tell: a headless Xvfb screen reports
|
|
801
|
+
// availHeight == height (no OS taskbar), whereas a real Windows
|
|
802
|
+
// desktop reserves ~40px for the taskbar (availHeight = height-40,
|
|
803
|
+
// availWidth = width). Reinstate that gap so the screen reads like
|
|
804
|
+
// an ordinary desktop, not a bare framebuffer. Guarded so it only
|
|
805
|
+
// applies when the two are currently equal (i.e. headless).
|
|
806
|
+
try {
|
|
807
|
+
if (screen.availHeight === screen.height) {
|
|
808
|
+
Object.defineProperty(Screen.prototype, "availHeight", {
|
|
809
|
+
get: () => screen.height - 40,
|
|
810
|
+
configurable: true,
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
catch {
|
|
815
|
+
// leave it
|
|
816
|
+
}
|
|
817
|
+
navProto.__tsDevicePatched = true;
|
|
818
|
+
}
|
|
819
|
+
catch {
|
|
820
|
+
// descriptor already locked by something else — leave it.
|
|
821
|
+
}
|
|
822
|
+
}
|
|
580
823
|
};
|
|
581
824
|
await context.addInitScript(installWebglSpoof);
|
|
582
825
|
this.page = context.pages()[0] ?? (await context.newPage());
|
|
@@ -681,7 +924,8 @@ export class BrowserController {
|
|
|
681
924
|
// that misclassify as "unknown". A malformed URL never aborts the
|
|
682
925
|
// run — we log and fall back to a direct connection.
|
|
683
926
|
async resolveProxy() {
|
|
684
|
-
|
|
927
|
+
// Per-launch override (verify fleet) wins over the env-global proxy.
|
|
928
|
+
const raw = this.proxyOverride ?? process.env.UNIVERSAL_BOT_PROXY_URL;
|
|
685
929
|
if (raw === undefined || raw.trim().length === 0)
|
|
686
930
|
return null;
|
|
687
931
|
let proxy;
|
|
@@ -698,6 +942,20 @@ export class BrowserController {
|
|
|
698
942
|
const asn = await detectAsn();
|
|
699
943
|
const asnClass = asn?.class ?? "unknown";
|
|
700
944
|
if (shouldRouteThroughProxy(asnClass, forceAlways)) {
|
|
945
|
+
// Proxy liveness probe. A dead proxy (gost crashed, Tailscale down) makes
|
|
946
|
+
// EVERY navigation time out for 60s and silently breaks the whole heal
|
|
947
|
+
// pass — MEASURED 2026-06-12: the Mac gost SOCKS5 went down and every
|
|
948
|
+
// discover died on page.goto Timeout. A cheap TCP connect to the SOCKS
|
|
949
|
+
// host tells us it's reachable; if not, fall back to DIRECT (the box's own
|
|
950
|
+
// datacenter egress) so the run still serves the services that don't block
|
|
951
|
+
// datacenter IPs, instead of dying entirely. Self-healing > silent stall.
|
|
952
|
+
const reachable = await isProxyReachable(proxy.server);
|
|
953
|
+
if (!reachable) {
|
|
954
|
+
console.error(`[universal-bot] proxy ${proxy.server} is UNREACHABLE — falling back to ` +
|
|
955
|
+
`DIRECT egress (datacenter IP; anti-bot services may block it, but far ` +
|
|
956
|
+
`better than every navigation timing out)`);
|
|
957
|
+
return null;
|
|
958
|
+
}
|
|
701
959
|
console.error(`[universal-bot] routing through residential proxy ` +
|
|
702
960
|
`(asn=${asnClass}${forceAlways ? ", forced" : ""})`);
|
|
703
961
|
return proxy;
|
|
@@ -731,11 +989,32 @@ export class BrowserController {
|
|
|
731
989
|
// The host is reachable on the next attempt — a single goto failure
|
|
732
990
|
// shouldn't fail the whole signup. Only retry these connection-level
|
|
733
991
|
// errors; HTTP statuses and selector/logic errors fall straight through.
|
|
734
|
-
|
|
992
|
+
// net::ERR_ABORTED — a navigation superseded by a redirect/JS-nav during
|
|
993
|
+
// the domcontentloaded wait. Usually transient (a redirect race on the
|
|
994
|
+
// first hit of an auth-gated portal — MEASURED 2026-06-11: defang's
|
|
995
|
+
// portal.defang.io aborted on the initial goto); a retry lands the
|
|
996
|
+
// settled page. Distinct from ERR_CONNECTION_ABORTED (a dropped socket).
|
|
997
|
+
const TRANSIENT_NET = /ERR_SOCKS_CONNECTION_FAILED|ERR_CONNECTION_(?:RESET|CLOSED|FAILED|ABORTED)|ERR_NETWORK_CHANGED|ERR_TIMED_OUT|ERR_NAME_NOT_RESOLVED|net::ERR_EMPTY_RESPONSE|net::ERR_ABORTED/i;
|
|
735
998
|
const MAX_GOTO_ATTEMPTS = 3;
|
|
736
999
|
for (let attempt = 1;; attempt++) {
|
|
737
1000
|
try {
|
|
738
1001
|
await this.page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
1002
|
+
// A SOCKS/connection drop does NOT always throw: Chrome resolves
|
|
1003
|
+
// domcontentloaded on its own `chrome-error://chromewebdata/`
|
|
1004
|
+
// interstitial and goto returns cleanly. The bot then ran the whole
|
|
1005
|
+
// planner on a dead error page and gave up after one round (MEASURED
|
|
1006
|
+
// 2026-06-11: galileo/lancedb landed on chrome-error with the app
|
|
1007
|
+
// host as the title, never retried). Treat a chrome-error landing as
|
|
1008
|
+
// the same transient class and retry it like a thrown net error.
|
|
1009
|
+
const landed = this.page.url();
|
|
1010
|
+
if (landed.startsWith("chrome-error://")) {
|
|
1011
|
+
if (attempt >= MAX_GOTO_ATTEMPTS) {
|
|
1012
|
+
throw new Error(`net::navigation landed on a Chrome error page for ${url} ` +
|
|
1013
|
+
`after ${attempt} attempts (transient proxy/host failure)`);
|
|
1014
|
+
}
|
|
1015
|
+
await this.sleep(1500 * attempt);
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
739
1018
|
break;
|
|
740
1019
|
}
|
|
741
1020
|
catch (err) {
|
|
@@ -904,6 +1183,148 @@ export class BrowserController {
|
|
|
904
1183
|
// score improvement.
|
|
905
1184
|
await locator.pressSequentially(text, { delay: rand(40, 110) });
|
|
906
1185
|
}
|
|
1186
|
+
// Best-effort scan for the SPECIFIC unfilled required field(s) blocking a
|
|
1187
|
+
// disabled submit. Returns a " Unfilled required field(s) — …" suffix for the
|
|
1188
|
+
// disabled-click error so the planner fills the right field instead of
|
|
1189
|
+
// re-clicking the dead button. Pure observation — never throws, never mutates.
|
|
1190
|
+
async unfilledRequiredHint() {
|
|
1191
|
+
if (!this.page)
|
|
1192
|
+
return "";
|
|
1193
|
+
try {
|
|
1194
|
+
const fields = await this.page.evaluate(() => {
|
|
1195
|
+
const out = [];
|
|
1196
|
+
const vis = (el) => {
|
|
1197
|
+
const r = el.getBoundingClientRect();
|
|
1198
|
+
return r.width > 0 && r.height > 0;
|
|
1199
|
+
};
|
|
1200
|
+
const label = (el) => {
|
|
1201
|
+
const al = el.getAttribute("aria-label");
|
|
1202
|
+
if (al && al.trim())
|
|
1203
|
+
return al.trim().slice(0, 40);
|
|
1204
|
+
const id = el.id;
|
|
1205
|
+
if (id) {
|
|
1206
|
+
const esc = window.CSS && CSS.escape ? CSS.escape(id) : id;
|
|
1207
|
+
const lab = document.querySelector(`label[for="${esc}"]`);
|
|
1208
|
+
if (lab && lab.textContent && lab.textContent.trim())
|
|
1209
|
+
return lab.textContent.trim().slice(0, 40);
|
|
1210
|
+
}
|
|
1211
|
+
const ph = el.getAttribute("placeholder");
|
|
1212
|
+
if (ph && ph.trim())
|
|
1213
|
+
return ph.trim().slice(0, 40);
|
|
1214
|
+
return (el.getAttribute("name") ?? el.tagName.toLowerCase()).slice(0, 40);
|
|
1215
|
+
};
|
|
1216
|
+
for (const el of Array.from(document.querySelectorAll("input[required],textarea[required],input[aria-required='true'],textarea[aria-required='true']"))) {
|
|
1217
|
+
if (!vis(el))
|
|
1218
|
+
continue;
|
|
1219
|
+
const inp = el;
|
|
1220
|
+
if (inp.type === "checkbox" || inp.type === "radio") {
|
|
1221
|
+
if (!inp.checked)
|
|
1222
|
+
out.push(`unchecked: ${label(el)}`);
|
|
1223
|
+
}
|
|
1224
|
+
else if (!inp.value || !inp.value.trim()) {
|
|
1225
|
+
out.push(`empty: ${label(el)}`);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
for (const el of Array.from(document.querySelectorAll("select"))) {
|
|
1229
|
+
if (vis(el) && !el.value)
|
|
1230
|
+
out.push(`unselected: ${label(el)}`);
|
|
1231
|
+
}
|
|
1232
|
+
for (const el of Array.from(document.querySelectorAll("[role='combobox'],[role='listbox']"))) {
|
|
1233
|
+
if (!vis(el))
|
|
1234
|
+
continue;
|
|
1235
|
+
const txt = (el.textContent ?? "").trim();
|
|
1236
|
+
if (txt.length === 0 || /^(select|choose|please|pick)\b/i.test(txt))
|
|
1237
|
+
out.push(`unselected: ${label(el)}`);
|
|
1238
|
+
}
|
|
1239
|
+
for (const grp of Array.from(document.querySelectorAll("[role='radiogroup']"))) {
|
|
1240
|
+
if (!vis(grp))
|
|
1241
|
+
continue;
|
|
1242
|
+
const chosen = grp.querySelector("[role='radio'][aria-checked='true'],input[type='radio']:checked");
|
|
1243
|
+
if (!chosen)
|
|
1244
|
+
out.push(`nothing chosen: ${label(grp)}`);
|
|
1245
|
+
}
|
|
1246
|
+
return Array.from(new Set(out)).slice(0, 5);
|
|
1247
|
+
});
|
|
1248
|
+
return fields.length > 0
|
|
1249
|
+
? ` Unfilled required field(s) — fill/select these first: ${fields.join("; ")}.`
|
|
1250
|
+
: "";
|
|
1251
|
+
}
|
|
1252
|
+
catch {
|
|
1253
|
+
return "";
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
// Read any visible transient toast / alert / notification text. Validation
|
|
1257
|
+
// errors, rate-limits, and "operation failed" messages frequently appear as a
|
|
1258
|
+
// toast that auto-dismisses BEFORE the next round's capture — so a failed
|
|
1259
|
+
// submit looks like a SILENT no-op to the planner. Surfacing it turns the
|
|
1260
|
+
// no-op into a diagnosable reason. MEASURED 2026-06-11 (deepseek Sign-up
|
|
1261
|
+
// no-ops; the error is a ds-toast the round-start capture never sees).
|
|
1262
|
+
// `settleMs` lets the caller reuse a wait it was already going to do.
|
|
1263
|
+
async captureTransientAlert(settleMs = 600) {
|
|
1264
|
+
if (!this.page)
|
|
1265
|
+
return "";
|
|
1266
|
+
if (settleMs > 0)
|
|
1267
|
+
await this.sleep(settleMs);
|
|
1268
|
+
try {
|
|
1269
|
+
return await this.page.evaluate(() => {
|
|
1270
|
+
const sels = [
|
|
1271
|
+
"[role='alert']",
|
|
1272
|
+
"[aria-live='assertive']",
|
|
1273
|
+
".ds-toast-container",
|
|
1274
|
+
".ds-notification-container",
|
|
1275
|
+
".Toastify__toast",
|
|
1276
|
+
".ant-message-notice",
|
|
1277
|
+
".ant-notification-notice",
|
|
1278
|
+
".sonner-toast",
|
|
1279
|
+
"[data-sonner-toast]",
|
|
1280
|
+
".toast",
|
|
1281
|
+
".Toaster",
|
|
1282
|
+
];
|
|
1283
|
+
const vis = (el) => {
|
|
1284
|
+
const r = el.getBoundingClientRect();
|
|
1285
|
+
return r.width > 0 && r.height > 0;
|
|
1286
|
+
};
|
|
1287
|
+
for (const sel of sels) {
|
|
1288
|
+
for (const el of Array.from(document.querySelectorAll(sel))) {
|
|
1289
|
+
if (!vis(el))
|
|
1290
|
+
continue;
|
|
1291
|
+
const t = (el.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
1292
|
+
if (t.length >= 2 && t.length <= 240)
|
|
1293
|
+
return t;
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
// Second pass: INLINE field-validation errors (not a transient
|
|
1297
|
+
// toast). Many SPAs render "Please enter the verification code" /
|
|
1298
|
+
// "Invalid code" as a small element with an error-ish class or an
|
|
1299
|
+
// aria-invalid node rather than a toast — so the first pass misses
|
|
1300
|
+
// them and a failed submit reads as a silent no-op.
|
|
1301
|
+
// MEASURED 2026-06-11 (deepseek post-OTP submit).
|
|
1302
|
+
const errSels = [
|
|
1303
|
+
"[class*='error' i]",
|
|
1304
|
+
"[class*='invalid' i]",
|
|
1305
|
+
"[class*='danger' i]",
|
|
1306
|
+
"[class*='explain' i]", // antd/ds-form-item-explain
|
|
1307
|
+
"[aria-invalid='true']",
|
|
1308
|
+
];
|
|
1309
|
+
for (const sel of errSels) {
|
|
1310
|
+
for (const el of Array.from(document.querySelectorAll(sel))) {
|
|
1311
|
+
if (!vis(el))
|
|
1312
|
+
continue;
|
|
1313
|
+
// Leaf-ish only — skip containers that wrap the whole form.
|
|
1314
|
+
if (el.querySelector("input, button, form"))
|
|
1315
|
+
continue;
|
|
1316
|
+
const t = (el.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
1317
|
+
if (t.length >= 3 && t.length <= 160)
|
|
1318
|
+
return t;
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
return "";
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
catch {
|
|
1325
|
+
return "";
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
907
1328
|
async click(selector) {
|
|
908
1329
|
if (!this.page)
|
|
909
1330
|
throw new Error("Browser not started");
|
|
@@ -916,14 +1337,39 @@ export class BrowserController {
|
|
|
916
1337
|
// dispatches input/change; `force` bypasses the visibility actionability
|
|
917
1338
|
// gate for the sr-only pattern. MEASURED 2026-06-09 (kinde tech-stack step).
|
|
918
1339
|
try {
|
|
919
|
-
const
|
|
1340
|
+
const probe = await this.page
|
|
920
1341
|
.$eval(selector, (el) => {
|
|
921
1342
|
const t = el;
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
1343
|
+
const inputKind = t.tagName === "INPUT" && (t.type === "radio" || t.type === "checkbox") ? t.type : "";
|
|
1344
|
+
return {
|
|
1345
|
+
inputKind,
|
|
1346
|
+
role: el.getAttribute("role") ?? "",
|
|
1347
|
+
text: (el.textContent ?? "").trim().slice(0, 80),
|
|
1348
|
+
};
|
|
925
1349
|
})
|
|
926
|
-
.catch(() => "");
|
|
1350
|
+
.catch(() => ({ inputKind: "", role: "", text: "" }));
|
|
1351
|
+
const inputKind = probe.inputKind;
|
|
1352
|
+
// Custom-combobox / listbox options (role=option|menuitem) — react-select,
|
|
1353
|
+
// Radix, downshift, MUI. Two failure modes the humanized RAW-COORDINATE
|
|
1354
|
+
// click hits: (1) the menu is a PORTAL that re-renders/repositions, so the
|
|
1355
|
+
// captured POSITIONAL selector (e.g. `div…>> nth=42`) resolves to the wrong
|
|
1356
|
+
// element at click time — nothing selects, planner loops (MEASURED
|
|
1357
|
+
// 2026-06-11, meilisearch Radix combobox); (2) options bind pointer/select
|
|
1358
|
+
// handlers a raw coordinate click misses. Fix: re-resolve by role+accessible
|
|
1359
|
+
// name (robust to portal/positional drift), and use the actionability-checked
|
|
1360
|
+
// locator click. Options are post-load, NOT the anti-bot-scored gate.
|
|
1361
|
+
if (probe.role === "option" || probe.role === "menuitem" || probe.role === "menuitemradio") {
|
|
1362
|
+
const role = probe.role;
|
|
1363
|
+
if (probe.text.length > 0) {
|
|
1364
|
+
const byName = this.page.getByRole(role, { name: probe.text, exact: false }).first();
|
|
1365
|
+
if ((await byName.count().catch(() => 0)) > 0) {
|
|
1366
|
+
await byName.click({ timeout: 8000 });
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
await this.page.locator(selector).first().click({ timeout: 8000 });
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
927
1373
|
if (inputKind === "radio" || inputKind === "checkbox") {
|
|
928
1374
|
// check() handles standard inputs; but a custom framework (kinde's kui)
|
|
929
1375
|
// binds its change handler via event delegation, and a force-check on an
|
|
@@ -1016,7 +1462,17 @@ export class BrowserController {
|
|
|
1016
1462
|
// fall through — click() below will produce the canonical error
|
|
1017
1463
|
}
|
|
1018
1464
|
const locator = this.page.locator(selector);
|
|
1019
|
-
|
|
1465
|
+
// The count can throw "Execution context was destroyed" when an
|
|
1466
|
+
// earlier fill already triggered a navigation/auto-submit (zilliz:
|
|
1467
|
+
// typing email+password redirects before we reach the submit click).
|
|
1468
|
+
// That race must NOT crash the whole signup — the page is already
|
|
1469
|
+
// moving on, so treat the submit as effectively done and let the
|
|
1470
|
+
// caller inspect the new page. MEASURED 2026-06-11 (zilliz /signup).
|
|
1471
|
+
const count = await locator.count().catch(() => -1);
|
|
1472
|
+
if (count < 0) {
|
|
1473
|
+
await this.page.waitForLoadState("domcontentloaded").catch(() => { });
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1020
1476
|
// A disabled submit means a required field or agreement checkbox
|
|
1021
1477
|
// wasn't satisfied — throw a distinct `submit_disabled` so the
|
|
1022
1478
|
// caller can re-plan to fix it, rather than wait out a generic
|
|
@@ -1080,15 +1536,62 @@ export class BrowserController {
|
|
|
1080
1536
|
// Verify it actually became checked; some checkboxes need the
|
|
1081
1537
|
// explicit `check()` call to flip state (e.g., styled labels
|
|
1082
1538
|
// that swallow the click event).
|
|
1083
|
-
|
|
1539
|
+
let isChecked = await this.page.locator(selector).isChecked();
|
|
1084
1540
|
if (!isChecked) {
|
|
1085
1541
|
await this.page.check(selector, { force: true });
|
|
1542
|
+
isChecked = await this.page.locator(selector).isChecked().catch(() => false);
|
|
1543
|
+
}
|
|
1544
|
+
// Mantine / Radix styled checkboxes: the hidden <input> can read
|
|
1545
|
+
// checked in the DOM while the library's React onChange never fired —
|
|
1546
|
+
// so the form's controlled state stays false and the gated submit
|
|
1547
|
+
// stays disabled even though isChecked() is true (MEASURED 2026-06-11:
|
|
1548
|
+
// friendliai's #agreedToServiceTerms cost a wasted round because the
|
|
1549
|
+
// first check didn't register the form state). Clicking the ASSOCIATED
|
|
1550
|
+
// LABEL fires the real onChange the library listens for. Best-effort.
|
|
1551
|
+
if (!isChecked) {
|
|
1552
|
+
const labelClicked = await this.clickAssociatedLabel(selector);
|
|
1553
|
+
if (!labelClicked)
|
|
1554
|
+
await this.page.check(selector, { force: true });
|
|
1086
1555
|
}
|
|
1087
1556
|
}
|
|
1088
1557
|
catch {
|
|
1089
1558
|
await this.page.check(selector, { force: true });
|
|
1090
1559
|
}
|
|
1091
1560
|
}
|
|
1561
|
+
// Click the <label> associated with a checkbox/radio input — either a
|
|
1562
|
+
// `<label for="<id>">` or the wrapping `<label>` ancestor. Mantine/Radix
|
|
1563
|
+
// render the real input visually-hidden inside a styled label; clicking the
|
|
1564
|
+
// label is what fires the library's onChange (a direct input check can
|
|
1565
|
+
// leave React's controlled state stale). Returns true if a label was
|
|
1566
|
+
// found + clicked. Best-effort — never throws.
|
|
1567
|
+
async clickAssociatedLabel(selector) {
|
|
1568
|
+
if (!this.page)
|
|
1569
|
+
return false;
|
|
1570
|
+
try {
|
|
1571
|
+
const id = await this.page
|
|
1572
|
+
.locator(selector)
|
|
1573
|
+
.first()
|
|
1574
|
+
.evaluate((el) => (el instanceof HTMLElement ? el.id : ""))
|
|
1575
|
+
.catch(() => "");
|
|
1576
|
+
if (id) {
|
|
1577
|
+
const forLabel = this.page.locator(`label[for="${id}"]`).first();
|
|
1578
|
+
if ((await forLabel.count()) > 0) {
|
|
1579
|
+
await forLabel.click({ timeout: 4000 });
|
|
1580
|
+
return true;
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
// No `for=` label — try the wrapping <label> ancestor.
|
|
1584
|
+
const wrapping = this.page.locator(selector).locator("xpath=ancestor::label[1]").first();
|
|
1585
|
+
if ((await wrapping.count()) > 0) {
|
|
1586
|
+
await wrapping.click({ timeout: 4000 });
|
|
1587
|
+
return true;
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
catch {
|
|
1591
|
+
// best-effort
|
|
1592
|
+
}
|
|
1593
|
+
return false;
|
|
1594
|
+
}
|
|
1092
1595
|
// Deterministic pre-submit guard: tick every visible, unchecked,
|
|
1093
1596
|
// non-disabled REQUIRED-AGREEMENT checkbox (terms/privacy/consent),
|
|
1094
1597
|
// while never touching marketing/newsletter opt-ins.
|
|
@@ -1723,16 +2226,42 @@ export class BrowserController {
|
|
|
1723
2226
|
// candidates. Matcher → filter by hasText (case-insensitive by
|
|
1724
2227
|
// default in Playwright). No matcher → first.
|
|
1725
2228
|
async pickComboboxOption(options, matcher) {
|
|
2229
|
+
let target = options.first();
|
|
1726
2230
|
if (matcher !== undefined) {
|
|
1727
2231
|
const filtered = options.filter({ hasText: matcher });
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
2232
|
+
if ((await filtered.count()) > 0)
|
|
2233
|
+
target = filtered.first();
|
|
2234
|
+
}
|
|
2235
|
+
// cmdk (the command-menu library) does NOT commit a selection from the
|
|
2236
|
+
// bot's humanized page.mouse.click(x, y): cmdk re-renders + re-orders its
|
|
2237
|
+
// list as the search filters, so the cached click coordinates land on the
|
|
2238
|
+
// wrong row (or empty space), and cmdk's onSelect — bound to a real
|
|
2239
|
+
// pointer/click event ON the item, or Enter on the highlighted item —
|
|
2240
|
+
// never fires. The trigger keeps its placeholder and the gated submit
|
|
2241
|
+
// stays disabled (MEASURED 2026-06-11: meilisearch's /welcome-informations
|
|
2242
|
+
// "reasons" + "SDK" comboboxes looped the whole run). Detect cmdk/Radix
|
|
2243
|
+
// option items and commit via a real, re-resolved actionable click (plus a
|
|
2244
|
+
// pointer-event sequence as backup) instead of raw mouse coordinates.
|
|
2245
|
+
const isCmdkItem = await target
|
|
2246
|
+
.evaluate((el) => el.hasAttribute("cmdk-item") ||
|
|
2247
|
+
el.closest("[cmdk-root],[cmdk-list],[cmdk-group]") !== null)
|
|
2248
|
+
.catch(() => false);
|
|
2249
|
+
if (isCmdkItem) {
|
|
2250
|
+
await target.scrollIntoViewIfNeeded().catch(() => { });
|
|
2251
|
+
// Playwright's locator.click() re-resolves geometry and dispatches the
|
|
2252
|
+
// full trusted pointer/mouse sequence at the element's center — what
|
|
2253
|
+
// cmdk's onSelect actually listens for.
|
|
2254
|
+
await target.click({ timeout: 5000 }).catch(async () => {
|
|
2255
|
+
// Backup: dispatch the pointer pair directly, then Enter (the cmdk
|
|
2256
|
+
// input is focused after type-to-filter and highlights this item).
|
|
2257
|
+
await target.dispatchEvent("pointerdown").catch(() => { });
|
|
2258
|
+
await target.dispatchEvent("pointerup").catch(() => { });
|
|
2259
|
+
await this.page?.keyboard.press("Enter").catch(() => { });
|
|
2260
|
+
});
|
|
2261
|
+
await this.wait(0.5);
|
|
2262
|
+
return;
|
|
1734
2263
|
}
|
|
1735
|
-
await this.humanClickLocator(
|
|
2264
|
+
await this.humanClickLocator(target);
|
|
1736
2265
|
await this.wait(0.5);
|
|
1737
2266
|
}
|
|
1738
2267
|
// ───────────── humanization internals ─────────────
|
|
@@ -1818,11 +2347,17 @@ export class BrowserController {
|
|
|
1818
2347
|
await this.sleep(150);
|
|
1819
2348
|
}
|
|
1820
2349
|
if (isDisabled) {
|
|
2350
|
+
// Name the SPECIFIC unfilled required field(s) so the planner fills the
|
|
2351
|
+
// right one instead of re-clicking the dead submit. MEASURED 2026-06-11
|
|
2352
|
+
// (meilisearch/zilliz: planner clicked a disabled Next 4+ times because
|
|
2353
|
+
// the generic hint didn't say WHICH field blocked it). Feedback only.
|
|
2354
|
+
const hint = await this.unfilledRequiredHint();
|
|
1821
2355
|
throw new Error("target is disabled (HTML disabled or aria-disabled=true) after 6s — " +
|
|
1822
2356
|
"the click would no-op. A required precondition is unmet: an empty " +
|
|
1823
2357
|
"input, an unselected dropdown, an unchecked agreement checkbox, or " +
|
|
1824
2358
|
"a missing preset/permission choice. Do NOT retry this click — pick a " +
|
|
1825
|
-
"different action that fills the missing field first."
|
|
2359
|
+
"different action that fills the missing field first." +
|
|
2360
|
+
hint);
|
|
1826
2361
|
}
|
|
1827
2362
|
}
|
|
1828
2363
|
// Scroll the element into the viewport BEFORE measuring it. A
|
|
@@ -2151,7 +2686,15 @@ export class BrowserController {
|
|
|
2151
2686
|
continue;
|
|
2152
2687
|
for (let i = 0; i < count; i++) {
|
|
2153
2688
|
const el = locator.nth(i);
|
|
2154
|
-
|
|
2689
|
+
// Bounded + best-effort. boundingBox() carries Playwright's default
|
|
2690
|
+
// 30s actionability wait; an invisible-mode Turnstile (the kind
|
|
2691
|
+
// patchright + a residential IP pass silently) never stabilises into
|
|
2692
|
+
// a visible box, so the unguarded call burned the full 30s and THREW
|
|
2693
|
+
// — and because the form-fill runCaptchaGate path didn't catch it,
|
|
2694
|
+
// it aborted the whole signup (measured: cartesia, cron-job.org).
|
|
2695
|
+
// A short timeout + catch turns "no clickable widget here" into a
|
|
2696
|
+
// skip, matching the Phase-2 host walk-up's `.catch(() => null)`.
|
|
2697
|
+
const box = await el.boundingBox({ timeout: 1500 }).catch(() => null);
|
|
2155
2698
|
if (box === null)
|
|
2156
2699
|
continue;
|
|
2157
2700
|
if (box.width < 50 || box.height < 30)
|
|
@@ -2383,6 +2926,83 @@ export class BrowserController {
|
|
|
2383
2926
|
return false;
|
|
2384
2927
|
}
|
|
2385
2928
|
}
|
|
2929
|
+
// Cloudflare Turnstile sitekey. On the `.cf-turnstile` widget's
|
|
2930
|
+
// data-sitekey, or as the `0x…` path segment in the challenge iframe src
|
|
2931
|
+
// (challenges.cloudflare.com/.../0x4AAAAA…/…). Returns null when absent.
|
|
2932
|
+
async extractTurnstileSitekey() {
|
|
2933
|
+
if (!this.page)
|
|
2934
|
+
throw new Error("Browser not started");
|
|
2935
|
+
try {
|
|
2936
|
+
return await this.page.evaluate(() => {
|
|
2937
|
+
// Turnstile sitekeys are `0x` + ~22 base64url chars (e.g.
|
|
2938
|
+
// 0x4AAAAAADSpJWQOnICEKAwx). A site-embedded WIDGET exposes it; a
|
|
2939
|
+
// Cloudflare-MANAGED interstitial does not (it's injected, not in the
|
|
2940
|
+
// DOM) — those return null and the caller can't Tier-3 solve them.
|
|
2941
|
+
const isKey = (k) => k != null && /^0x[A-Za-z0-9_-]{18,}$/.test(k);
|
|
2942
|
+
// 1. data-sitekey on any element.
|
|
2943
|
+
for (const el of Array.from(document.querySelectorAll("[data-sitekey]"))) {
|
|
2944
|
+
const k = el.getAttribute("data-sitekey");
|
|
2945
|
+
if (isKey(k))
|
|
2946
|
+
return k;
|
|
2947
|
+
}
|
|
2948
|
+
// 2. ANY iframe src carrying a 0x… sitekey (the challenge iframe path,
|
|
2949
|
+
// or a query param). Not just challenges.cloudflare.com — some
|
|
2950
|
+
// embeds proxy it.
|
|
2951
|
+
for (const ifr of Array.from(document.querySelectorAll("iframe"))) {
|
|
2952
|
+
const src = ifr.src || "";
|
|
2953
|
+
const path = src.match(/\/(0x[A-Za-z0-9_-]{18,})(?:\/|$)/);
|
|
2954
|
+
if (path !== null && isKey(path[1]))
|
|
2955
|
+
return path[1] ?? null;
|
|
2956
|
+
try {
|
|
2957
|
+
const q = new URL(src).searchParams.get("sitekey");
|
|
2958
|
+
if (isKey(q))
|
|
2959
|
+
return q;
|
|
2960
|
+
}
|
|
2961
|
+
catch {
|
|
2962
|
+
/* relative/blank src */
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
// 3. Inline HTML: `sitekey: '0x…'`, `data-sitekey="0x…"`,
|
|
2966
|
+
// `turnstile.render(el, { sitekey: '0x…' })`. Covers JS-config
|
|
2967
|
+
// widgets that never set a DOM attribute.
|
|
2968
|
+
const html = document.documentElement.outerHTML;
|
|
2969
|
+
const m = html.match(/data-sitekey=["'](0x[A-Za-z0-9_-]{18,})/i) ??
|
|
2970
|
+
html.match(/sitekey["'\s:=]{1,4}["'](0x[A-Za-z0-9_-]{18,})/i);
|
|
2971
|
+
if (m !== null && isKey(m[1]))
|
|
2972
|
+
return m[1] ?? null;
|
|
2973
|
+
return null;
|
|
2974
|
+
});
|
|
2975
|
+
}
|
|
2976
|
+
catch {
|
|
2977
|
+
return null;
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
// Inject a 2Captcha-resolved Turnstile token into the page's
|
|
2981
|
+
// cf-turnstile-response input(s) + dispatch input/change so the form's
|
|
2982
|
+
// submit handler sees it. Turnstile exposes no public callback-read API
|
|
2983
|
+
// (unlike grecaptcha), so DOM injection + events is the reliable path; the
|
|
2984
|
+
// server-side validation reads the input value. Returns true if an input
|
|
2985
|
+
// was populated.
|
|
2986
|
+
async injectTurnstileToken(token) {
|
|
2987
|
+
if (!this.page)
|
|
2988
|
+
throw new Error("Browser not started");
|
|
2989
|
+
try {
|
|
2990
|
+
return await this.page.evaluate((tok) => {
|
|
2991
|
+
const inputs = Array.from(document.querySelectorAll('[name="cf-turnstile-response"], [name^="cf-turnstile-response"], input[id^="cf-chl-widget"]'));
|
|
2992
|
+
if (inputs.length === 0)
|
|
2993
|
+
return false;
|
|
2994
|
+
for (const input of inputs) {
|
|
2995
|
+
input.value = tok;
|
|
2996
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
2997
|
+
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
2998
|
+
}
|
|
2999
|
+
return true;
|
|
3000
|
+
}, token);
|
|
3001
|
+
}
|
|
3002
|
+
catch {
|
|
3003
|
+
return false;
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
2386
3006
|
// Mint the score token for an INVISIBLE reCAPTCHA by calling
|
|
2387
3007
|
// grecaptcha.execute() ourselves, then wait for g-recaptcha-response to
|
|
2388
3008
|
// populate. MEASURED on amplitude (2026-06-04): an invisible reCAPTCHA's
|
|
@@ -3139,8 +3759,14 @@ export class BrowserController {
|
|
|
3139
3759
|
return r.width > 2 && r.height > 2;
|
|
3140
3760
|
};
|
|
3141
3761
|
document.querySelectorAll("input, textarea").forEach((el) => {
|
|
3762
|
+
// Only text-shaped inputs can RENDER a credential. A checkbox/
|
|
3763
|
+
// radio/button's `value` is a markup constant, not page content —
|
|
3764
|
+
// zilliz's CookieScript banner ships `<input type="checkbox"
|
|
3765
|
+
// value="personalization">` and those words sit earlier in DOM
|
|
3766
|
+
// order than the real key, so the validator-shaped scan tier was
|
|
3767
|
+
// returning them as the "credential".
|
|
3142
3768
|
if (el instanceof HTMLInputElement &&
|
|
3143
|
-
|
|
3769
|
+
!["text", "search", "url", "tel", "number", "email", ""].includes(el.type)) {
|
|
3144
3770
|
return;
|
|
3145
3771
|
}
|
|
3146
3772
|
const value = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement
|
|
@@ -3231,6 +3857,61 @@ export class BrowserController {
|
|
|
3231
3857
|
// No interactive element appeared in time — let the planner run
|
|
3232
3858
|
// anyway; it fails cleanly rather than hanging.
|
|
3233
3859
|
}
|
|
3860
|
+
// The generic wait above is satisfied by ANY interactive element —
|
|
3861
|
+
// on a signup page with marketing chrome (links, marketplace badges)
|
|
3862
|
+
// that fires while the actual auth widget is still an async spinner.
|
|
3863
|
+
// The bot then snapshots a form-less inventory and bails
|
|
3864
|
+
// `oauth_required` ("no email/password form"). MEASURED 2026-06-11
|
|
3865
|
+
// (zilliz /signup: right-panel spinner, marketing copy on the left).
|
|
3866
|
+
// So: if a loading spinner is visible AND no auth-form signal exists
|
|
3867
|
+
// yet, give the widget a bounded extra wait to hydrate.
|
|
3868
|
+
await this.waitForAuthWidgetHydration();
|
|
3869
|
+
}
|
|
3870
|
+
// Bounded poll for an auth-form signal when the page is still showing a
|
|
3871
|
+
// loading spinner. Strictly additive: returns immediately unless a
|
|
3872
|
+
// spinner is visible AND no auth signal (email/password input or a
|
|
3873
|
+
// provider/sign-up button) is present yet. Best-effort — never throws.
|
|
3874
|
+
async waitForAuthWidgetHydration(timeoutMs = 8_000) {
|
|
3875
|
+
if (!this.page)
|
|
3876
|
+
return;
|
|
3877
|
+
const deadline = Date.now() + timeoutMs;
|
|
3878
|
+
while (Date.now() < deadline) {
|
|
3879
|
+
try {
|
|
3880
|
+
const state = await this.page.evaluate(() => {
|
|
3881
|
+
const vis = (el) => {
|
|
3882
|
+
const r = el.getBoundingClientRect();
|
|
3883
|
+
return r.width > 0 && r.height > 0;
|
|
3884
|
+
};
|
|
3885
|
+
const anyVis = (sel) => Array.from(document.querySelectorAll(sel)).some(vis);
|
|
3886
|
+
// Auth signal: a real form input or a recognizable provider /
|
|
3887
|
+
// signup affordance.
|
|
3888
|
+
const hasAuthInput = anyVis('input[type="email"],input[type="password"],input[name="email" i],input[name="password" i]');
|
|
3889
|
+
let hasAuthButton = false;
|
|
3890
|
+
const re = /\b(sign\s?up|continue with|log ?in with|with google|with github|with sso|create account)\b/i;
|
|
3891
|
+
for (const el of Array.from(document.querySelectorAll('button,a[href],[role="button"]'))) {
|
|
3892
|
+
if (!vis(el))
|
|
3893
|
+
continue;
|
|
3894
|
+
if (re.test((el.textContent ?? "").trim())) {
|
|
3895
|
+
hasAuthButton = true;
|
|
3896
|
+
break;
|
|
3897
|
+
}
|
|
3898
|
+
}
|
|
3899
|
+
const spinnerVisible = anyVis('[role="progressbar"],[aria-busy="true"],[class*="spin" i],[class*="loading" i],[class*="loader" i],.ant-spin,.MuiCircularProgress-root');
|
|
3900
|
+
return { hasAuth: hasAuthInput || hasAuthButton, spinnerVisible };
|
|
3901
|
+
});
|
|
3902
|
+
// Done the moment an auth signal appears, or once nothing is
|
|
3903
|
+
// spinning anymore (no point waiting on a page that simply has
|
|
3904
|
+
// no auth widget — a true OAuth-less/blank page bails honestly).
|
|
3905
|
+
if (state.hasAuth)
|
|
3906
|
+
return;
|
|
3907
|
+
if (!state.spinnerVisible)
|
|
3908
|
+
return;
|
|
3909
|
+
}
|
|
3910
|
+
catch {
|
|
3911
|
+
return; // navigation / context teardown — let the caller proceed
|
|
3912
|
+
}
|
|
3913
|
+
await this.sleep(500);
|
|
3914
|
+
}
|
|
3234
3915
|
}
|
|
3235
3916
|
// rc.33 — wait for the DOM to grow past a minimum interactive-
|
|
3236
3917
|
// element count, polling every 500ms up to timeoutMs. The
|
|
@@ -3842,6 +4523,17 @@ export class BrowserController {
|
|
|
3842
4523
|
inConsentWidget: inConsent(el),
|
|
3843
4524
|
href: (el.getAttribute("href") ?? "").slice(0, 300) || null,
|
|
3844
4525
|
iconLabel: iconLabelFor(el),
|
|
4526
|
+
// The element's test-id, the GOLD-STANDARD stable anchor: authors set
|
|
4527
|
+
// data-testid/data-test/data-cy precisely so it survives refactors +
|
|
4528
|
+
// copy changes, which is exactly what text_match does not. Captured so
|
|
4529
|
+
// the synthesizer can prefer it over planner-gloss text. Common
|
|
4530
|
+
// variants folded to one field; first present wins.
|
|
4531
|
+
testId: el.getAttribute("data-testid") ??
|
|
4532
|
+
el.getAttribute("data-test-id") ??
|
|
4533
|
+
el.getAttribute("data-test") ??
|
|
4534
|
+
el.getAttribute("data-cy") ??
|
|
4535
|
+
el.getAttribute("data-qa") ??
|
|
4536
|
+
null,
|
|
3845
4537
|
title: clean(el.getAttribute("title")),
|
|
3846
4538
|
landmark: (() => {
|
|
3847
4539
|
// F15 — nearest HTML5 landmark ancestor. Used by the
|
|
@@ -4291,6 +4983,31 @@ export class BrowserController {
|
|
|
4291
4983
|
const count = await btn.count().catch(() => 0);
|
|
4292
4984
|
if (count === 0)
|
|
4293
4985
|
continue;
|
|
4986
|
+
// GitHub disables the Authorize button with a clickjacking-protection
|
|
4987
|
+
// COUNTDOWN (~3-8s) the first time you authorize an OAuth app that
|
|
4988
|
+
// requests org scopes (read:org). Clicking while disabled silently
|
|
4989
|
+
// no-ops and the URL never changes, so the whole consent bails
|
|
4990
|
+
// "no approve control" even though the button is right there
|
|
4991
|
+
// (MEASURED 2026-06-11: defang's "Authorize DefangLabs"). Poll up to
|
|
4992
|
+
// 12s for it to enable before clicking.
|
|
4993
|
+
{
|
|
4994
|
+
const deadline = Date.now() + 12_000;
|
|
4995
|
+
while (Date.now() < deadline) {
|
|
4996
|
+
const disabled = await btn
|
|
4997
|
+
.evaluate((el) => {
|
|
4998
|
+
if (el instanceof HTMLButtonElement || el instanceof HTMLInputElement) {
|
|
4999
|
+
if (el.disabled)
|
|
5000
|
+
return true;
|
|
5001
|
+
}
|
|
5002
|
+
const aria = el.getAttribute("aria-disabled");
|
|
5003
|
+
return aria === "true" || aria === "";
|
|
5004
|
+
})
|
|
5005
|
+
.catch(() => false);
|
|
5006
|
+
if (!disabled)
|
|
5007
|
+
break;
|
|
5008
|
+
await this.sleep(400);
|
|
5009
|
+
}
|
|
5010
|
+
}
|
|
4294
5011
|
try {
|
|
4295
5012
|
await btn.click({ timeout: 8000 });
|
|
4296
5013
|
}
|
|
@@ -4462,6 +5179,24 @@ export class BrowserController {
|
|
|
4462
5179
|
await capped(this.page.close(), 5_000);
|
|
4463
5180
|
if (this.context)
|
|
4464
5181
|
await capped(this.context.close(), 10_000);
|
|
5182
|
+
// Self-launch path: disconnect the CDP browser and SIGKILL the Chrome we
|
|
5183
|
+
// spawned. context.close() on a connectOverCDP context only disconnects —
|
|
5184
|
+
// it does NOT necessarily exit the browser process, which would leak the
|
|
5185
|
+
// SingletonLock and brick the next run (the reap below is the backstop, but
|
|
5186
|
+
// killing our own child directly is cleaner and faster).
|
|
5187
|
+
if (this.cdpBrowser) {
|
|
5188
|
+
await capped(this.cdpBrowser.close(), 5_000);
|
|
5189
|
+
this.cdpBrowser = null;
|
|
5190
|
+
}
|
|
5191
|
+
if (this.childChrome) {
|
|
5192
|
+
try {
|
|
5193
|
+
this.childChrome.kill("SIGKILL");
|
|
5194
|
+
}
|
|
5195
|
+
catch {
|
|
5196
|
+
/* already gone */
|
|
5197
|
+
}
|
|
5198
|
+
this.childChrome = null;
|
|
5199
|
+
}
|
|
4465
5200
|
// …and context.close() doesn't always kill the browser: headed Chrome
|
|
4466
5201
|
// under Xvfb / some patchright teardowns leave the main process alive
|
|
4467
5202
|
// holding the SingletonLock. A leaked browser makes the NEXT run wait
|
|
@@ -4581,6 +5316,45 @@ export function isAgreementCheckboxText(text) {
|
|
|
4581
5316
|
// and falls back to a direct connection.
|
|
4582
5317
|
//
|
|
4583
5318
|
// Exported for unit testing — URL parsing is the error-prone bit.
|
|
5319
|
+
// Cheap TCP liveness probe for a proxy `server` string ("socks5://host:port").
|
|
5320
|
+
// A SOCKS5 proxy listens on TCP; if a connect succeeds within the timeout the
|
|
5321
|
+
// proxy is up. Resolves false on connect error / timeout / a malformed server.
|
|
5322
|
+
// Pure (no class state) so resolveProxy can call it before launching Chrome.
|
|
5323
|
+
export async function isProxyReachable(server, timeoutMs = 4000) {
|
|
5324
|
+
let host;
|
|
5325
|
+
let port;
|
|
5326
|
+
try {
|
|
5327
|
+
const u = new URL(server);
|
|
5328
|
+
host = u.hostname;
|
|
5329
|
+
port = Number(u.port) || (u.protocol.startsWith("socks") ? 1080 : 8080);
|
|
5330
|
+
}
|
|
5331
|
+
catch {
|
|
5332
|
+
return false;
|
|
5333
|
+
}
|
|
5334
|
+
if (host.length === 0 || !Number.isFinite(port))
|
|
5335
|
+
return false;
|
|
5336
|
+
return await new Promise((resolve) => {
|
|
5337
|
+
const sock = new Socket();
|
|
5338
|
+
let settled = false;
|
|
5339
|
+
const finish = (ok) => {
|
|
5340
|
+
if (settled)
|
|
5341
|
+
return;
|
|
5342
|
+
settled = true;
|
|
5343
|
+
try {
|
|
5344
|
+
sock.destroy();
|
|
5345
|
+
}
|
|
5346
|
+
catch {
|
|
5347
|
+
// already closed
|
|
5348
|
+
}
|
|
5349
|
+
resolve(ok);
|
|
5350
|
+
};
|
|
5351
|
+
sock.setTimeout(timeoutMs);
|
|
5352
|
+
sock.once("connect", () => finish(true));
|
|
5353
|
+
sock.once("timeout", () => finish(false));
|
|
5354
|
+
sock.once("error", () => finish(false));
|
|
5355
|
+
sock.connect(port, host);
|
|
5356
|
+
});
|
|
5357
|
+
}
|
|
4584
5358
|
export function parseProxyUrl(raw) {
|
|
4585
5359
|
const u = new URL(raw.trim());
|
|
4586
5360
|
if (u.hostname.length === 0) {
|