@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.
Files changed (60) hide show
  1. package/dist/api-client.d.ts +28 -0
  2. package/dist/api-client.d.ts.map +1 -1
  3. package/dist/api-client.js +11 -0
  4. package/dist/api-client.js.map +1 -1
  5. package/dist/bot/agent.d.ts +7 -1
  6. package/dist/bot/agent.d.ts.map +1 -1
  7. package/dist/bot/agent.js +631 -40
  8. package/dist/bot/agent.js.map +1 -1
  9. package/dist/bot/browser.d.ts +15 -0
  10. package/dist/bot/browser.d.ts.map +1 -1
  11. package/dist/bot/browser.js +858 -84
  12. package/dist/bot/browser.js.map +1 -1
  13. package/dist/bot/captcha-solver-2captcha.d.ts +18 -0
  14. package/dist/bot/captcha-solver-2captcha.d.ts.map +1 -1
  15. package/dist/bot/captcha-solver-2captcha.js +21 -0
  16. package/dist/bot/captcha-solver-2captcha.js.map +1 -1
  17. package/dist/bot/email-code-fetcher.d.ts +5 -0
  18. package/dist/bot/email-code-fetcher.d.ts.map +1 -0
  19. package/dist/bot/email-code-fetcher.js +33 -0
  20. package/dist/bot/email-code-fetcher.js.map +1 -0
  21. package/dist/bot/inbox-client.d.ts +1 -0
  22. package/dist/bot/inbox-client.d.ts.map +1 -1
  23. package/dist/bot/inbox-client.js +55 -15
  24. package/dist/bot/inbox-client.js.map +1 -1
  25. package/dist/bot/index.d.ts +2 -1
  26. package/dist/bot/index.d.ts.map +1 -1
  27. package/dist/bot/index.js +49 -19
  28. package/dist/bot/index.js.map +1 -1
  29. package/dist/bot/promote-to-skill.d.ts +3 -1
  30. package/dist/bot/promote-to-skill.d.ts.map +1 -1
  31. package/dist/bot/promote-to-skill.js +122 -7
  32. package/dist/bot/promote-to-skill.js.map +1 -1
  33. package/dist/bot/replay-skill.d.ts +18 -0
  34. package/dist/bot/replay-skill.d.ts.map +1 -1
  35. package/dist/bot/replay-skill.js +290 -12
  36. package/dist/bot/replay-skill.js.map +1 -1
  37. package/dist/bot/signup-lock.d.ts +17 -0
  38. package/dist/bot/signup-lock.d.ts.map +1 -0
  39. package/dist/bot/signup-lock.js +174 -0
  40. package/dist/bot/signup-lock.js.map +1 -0
  41. package/dist/tools/grant-app-access.d.ts +31 -0
  42. package/dist/tools/grant-app-access.d.ts.map +1 -0
  43. package/dist/tools/grant-app-access.js +59 -0
  44. package/dist/tools/grant-app-access.js.map +1 -0
  45. package/dist/tools/index.d.ts +2 -1
  46. package/dist/tools/index.d.ts.map +1 -1
  47. package/dist/tools/index.js +4 -1
  48. package/dist/tools/index.js.map +1 -1
  49. package/dist/tools/provision-any.d.ts.map +1 -1
  50. package/dist/tools/provision-any.js +25 -12
  51. package/dist/tools/provision-any.js.map +1 -1
  52. package/dist/tools/store-credential.d.ts +5 -0
  53. package/dist/tools/store-credential.d.ts.map +1 -1
  54. package/dist/tools/store-credential.js +13 -2
  55. package/dist/tools/store-credential.js.map +1 -1
  56. package/package.json +2 -2
  57. package/dist/bot/oauth-lock.d.ts +0 -2
  58. package/dist/bot/oauth-lock.d.ts.map +0 -1
  59. package/dist/bot/oauth-lock.js +0 -28
  60. package/dist/bot/oauth-lock.js.map +0 -1
@@ -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
- this.xvfb = await startXvfb({ width: 1280, height: 720 });
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
- const free = await waitForProfileFree(this.profileDir, {
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
- throw new ProfileBusyError("bot Chrome profile is held by another run (a login or signup); retry shortly");
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
- const context = await launchWithProfileGate(this.profileDir, () => launcher.launchPersistentContext(this.profileDir, {
457
- headless: chromeHeadless,
458
- ...(chromeEnv !== undefined ? { env: chromeEnv } : {}),
459
- // `channel:` selects a real installed browser over the bundled
460
- // binary (omitted when channel detection found nothing).
461
- ...(channel !== null ? { channel } : {}),
462
- // `proxy:` routes egress through a residential proxy — only for
463
- // datacenter-class egress (see resolveProxy()).
464
- ...(proxy !== null ? { proxy } : {}),
465
- args: [
466
- "--disable-blink-features=AutomationControlled",
467
- "--no-sandbox",
468
- "--disable-dev-shm-usage",
469
- // Enable software WebGL on the GPU-less Xvfb host. Without this,
470
- // Chrome 120+ disables WebGL entirely (getContext("webgl") null),
471
- // which MEASURED (2026-06-04) as the bot's one real fingerprint gap:
472
- // a browser with NO WebGL is itself an anti-bot tell (reCAPTCHA
473
- // Enterprise / device-fingerprinting weight it). SwiftShader gives a
474
- // real WebGL context. MEASURED 2026-06-04: with this on, WebGL reports
475
- // a Mesa/llvmpipe software renderer and the reCAPTCHA v3 score stays
476
- // 1.0 — a strict improvement over "no WebGL at all", which more
477
- // fingerprint libs treat as suspicious than a software renderer. The
478
- // rc.33 init-script below TRIES to spoof the renderer string to a real
479
- // Intel GPU, but it is INERT under patchright (hardened) — see its
480
- // comment. A clean GPU-string spoof under patchright needs binary-level
481
- // support; tracked as a follow-up, not blocking (score is already 1.0).
482
- "--enable-unsafe-swiftshader",
483
- "--ignore-gpu-blocklist",
484
- ],
485
- // `viewport: null` makes the page use the REAL OS window size
486
- // instead of a hardcoded value. The old fixed 1280×720 is exactly
487
- // Playwright's device-emulation default and is flagged by anti-bot
488
- // detectors as "default Playwright viewport"; the real window
489
- // (sized by the Xvfb display) reads as an ordinary browser.
490
- viewport: null,
491
- // No `userAgent` override: a real Chrome (channel) supplies a UA
492
- // that AGREES with navigator.userAgentData + the binary version.
493
- // The old hardcoded "Chrome/131" string mismatched the actual
494
- // binary (148) — a UA-vs-userAgentData inconsistency that is itself
495
- // a fingerprint tell. Let the browser report its own coherent UA.
496
- // locale stays en-US deliberately: matching it to the proxy
497
- // country would render signup pages in that language, and the
498
- // Claude vision form-planner expects English.
499
- locale: "en-US",
500
- // timezone + geolocation track the real egress (T3.1); a fixed
501
- // default when the probe failed.
502
- timezoneId: geo?.timezoneId ?? "America/New_York",
503
- // F10: `clipboard-read` is what makes `navigator.clipboard.readText()`
504
- // return the user's just-clicked Copy-button value, which is how
505
- // every modern API-key modal (OpenRouter, Anthropic, OpenAI,
506
- // Stripe) reveals the full secret the visible display is
507
- // masked / truncated and only the clipboard has the whole key.
508
- // `clipboard-write` is a freebie; some Copy buttons no-op without
509
- // it. Granting both at context-creation time so we don't have to
510
- // re-grant on every nav.
511
- permissions: [
512
- ...(geo?.geolocation !== undefined ? ["geolocation"] : []),
513
- "clipboard-read",
514
- "clipboard-write",
515
- ],
516
- ...(geo?.geolocation !== undefined ? { geolocation: geo.geolocation } : {}),
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
- const raw = process.env.UNIVERSAL_BOT_PROXY_URL;
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
- 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/i;
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 inputKind = await this.page
1340
+ const probe = await this.page
920
1341
  .$eval(selector, (el) => {
921
1342
  const t = el;
922
- return t.tagName === "INPUT" && (t.type === "radio" || t.type === "checkbox")
923
- ? t.type
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
- const count = await locator.count();
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
- const isChecked = await this.page.locator(selector).isChecked();
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
- const filteredCount = await filtered.count();
1729
- if (filteredCount > 0) {
1730
- await this.humanClickLocator(filtered.first());
1731
- await this.wait(0.5);
1732
- return;
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(options.first());
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
- const box = await el.boundingBox();
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
- (el.type === "hidden" || el.type === "password")) {
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) {