@trusty-squire/mcp 0.9.17-rc.3 → 0.9.17

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 (117) hide show
  1. package/dist/bot/agent.d.ts +61 -10
  2. package/dist/bot/agent.d.ts.map +1 -1
  3. package/dist/bot/agent.js +2577 -1626
  4. package/dist/bot/agent.js.map +1 -1
  5. package/dist/bot/browser.d.ts +34 -1
  6. package/dist/bot/browser.d.ts.map +1 -1
  7. package/dist/bot/browser.js +1313 -229
  8. package/dist/bot/browser.js.map +1 -1
  9. package/dist/bot/captcha-solver-2captcha.d.ts +21 -0
  10. package/dist/bot/captcha-solver-2captcha.d.ts.map +1 -1
  11. package/dist/bot/captcha-solver-2captcha.js +77 -0
  12. package/dist/bot/captcha-solver-2captcha.js.map +1 -1
  13. package/dist/bot/credential-extraction-flow.d.ts +66 -0
  14. package/dist/bot/credential-extraction-flow.d.ts.map +1 -0
  15. package/dist/bot/credential-extraction-flow.js +422 -0
  16. package/dist/bot/credential-extraction-flow.js.map +1 -0
  17. package/dist/bot/failure-stage.d.ts.map +1 -1
  18. package/dist/bot/failure-stage.js +8 -3
  19. package/dist/bot/failure-stage.js.map +1 -1
  20. package/dist/bot/flow-engine.d.ts +7 -0
  21. package/dist/bot/flow-engine.d.ts.map +1 -0
  22. package/dist/bot/flow-engine.js +13 -0
  23. package/dist/bot/flow-engine.js.map +1 -0
  24. package/dist/bot/form-fill.d.ts +31 -4
  25. package/dist/bot/form-fill.d.ts.map +1 -1
  26. package/dist/bot/form-fill.js +109 -8
  27. package/dist/bot/form-fill.js.map +1 -1
  28. package/dist/bot/google-login.d.ts.map +1 -1
  29. package/dist/bot/google-login.js +10 -2
  30. package/dist/bot/google-login.js.map +1 -1
  31. package/dist/bot/index.d.ts.map +1 -1
  32. package/dist/bot/index.js +9 -33
  33. package/dist/bot/index.js.map +1 -1
  34. package/dist/bot/nav-search.d.ts +34 -0
  35. package/dist/bot/nav-search.d.ts.map +1 -1
  36. package/dist/bot/nav-search.js +283 -31
  37. package/dist/bot/nav-search.js.map +1 -1
  38. package/dist/bot/observation-frame.d.ts +16 -0
  39. package/dist/bot/observation-frame.d.ts.map +1 -0
  40. package/dist/bot/observation-frame.js +22 -0
  41. package/dist/bot/observation-frame.js.map +1 -0
  42. package/dist/bot/onboarding-capture.d.ts +10 -2
  43. package/dist/bot/onboarding-capture.d.ts.map +1 -1
  44. package/dist/bot/onboarding-capture.js +131 -22
  45. package/dist/bot/onboarding-capture.js.map +1 -1
  46. package/dist/bot/post-signup-action-executor.d.ts +53 -0
  47. package/dist/bot/post-signup-action-executor.d.ts.map +1 -0
  48. package/dist/bot/post-signup-action-executor.js +104 -0
  49. package/dist/bot/post-signup-action-executor.js.map +1 -0
  50. package/dist/bot/post-signup-flow.d.ts +51 -0
  51. package/dist/bot/post-signup-flow.d.ts.map +1 -0
  52. package/dist/bot/post-signup-flow.js +134 -0
  53. package/dist/bot/post-signup-flow.js.map +1 -0
  54. package/dist/bot/post-signup-login-flow.d.ts +28 -0
  55. package/dist/bot/post-signup-login-flow.d.ts.map +1 -0
  56. package/dist/bot/post-signup-login-flow.js +35 -0
  57. package/dist/bot/post-signup-login-flow.js.map +1 -0
  58. package/dist/bot/post-signup-recovery-state.d.ts +145 -0
  59. package/dist/bot/post-signup-recovery-state.d.ts.map +1 -0
  60. package/dist/bot/post-signup-recovery-state.js +239 -0
  61. package/dist/bot/post-signup-recovery-state.js.map +1 -0
  62. package/dist/bot/post-signup-synthetic-capture.d.ts +43 -0
  63. package/dist/bot/post-signup-synthetic-capture.d.ts.map +1 -0
  64. package/dist/bot/post-signup-synthetic-capture.js +84 -0
  65. package/dist/bot/post-signup-synthetic-capture.js.map +1 -0
  66. package/dist/bot/promote-to-skill.d.ts +18 -1
  67. package/dist/bot/promote-to-skill.d.ts.map +1 -1
  68. package/dist/bot/promote-to-skill.js +310 -23
  69. package/dist/bot/promote-to-skill.js.map +1 -1
  70. package/dist/bot/read-otp.d.ts +1 -0
  71. package/dist/bot/read-otp.d.ts.map +1 -1
  72. package/dist/bot/read-otp.js +70 -18
  73. package/dist/bot/read-otp.js.map +1 -1
  74. package/dist/bot/redact.d.ts.map +1 -1
  75. package/dist/bot/redact.js +1 -0
  76. package/dist/bot/redact.js.map +1 -1
  77. package/dist/bot/replay-skill.d.ts +18 -0
  78. package/dist/bot/replay-skill.d.ts.map +1 -1
  79. package/dist/bot/replay-skill.js +1290 -71
  80. package/dist/bot/replay-skill.js.map +1 -1
  81. package/dist/bot/semantic-transition.d.ts +66 -0
  82. package/dist/bot/semantic-transition.d.ts.map +1 -0
  83. package/dist/bot/semantic-transition.js +361 -0
  84. package/dist/bot/semantic-transition.js.map +1 -0
  85. package/dist/bot/signup-oauth-flow.d.ts +16 -0
  86. package/dist/bot/signup-oauth-flow.d.ts.map +1 -0
  87. package/dist/bot/signup-oauth-flow.js +31 -0
  88. package/dist/bot/signup-oauth-flow.js.map +1 -0
  89. package/dist/bot/state-classifier.d.ts +18 -0
  90. package/dist/bot/state-classifier.d.ts.map +1 -0
  91. package/dist/bot/state-classifier.js +127 -0
  92. package/dist/bot/state-classifier.js.map +1 -0
  93. package/dist/bot/terminal-gate.d.ts +20 -0
  94. package/dist/bot/terminal-gate.d.ts.map +1 -0
  95. package/dist/bot/terminal-gate.js +165 -0
  96. package/dist/bot/terminal-gate.js.map +1 -0
  97. package/dist/bot/xvfb.d.ts +3 -0
  98. package/dist/bot/xvfb.d.ts.map +1 -1
  99. package/dist/bot/xvfb.js +106 -40
  100. package/dist/bot/xvfb.js.map +1 -1
  101. package/dist/provision-gate.d.ts +16 -0
  102. package/dist/provision-gate.d.ts.map +1 -0
  103. package/dist/provision-gate.js +66 -0
  104. package/dist/provision-gate.js.map +1 -0
  105. package/dist/provision-run.d.ts +45 -0
  106. package/dist/provision-run.d.ts.map +1 -0
  107. package/dist/provision-run.js +99 -0
  108. package/dist/provision-run.js.map +1 -0
  109. package/dist/skill-registry-client.d.ts +9 -0
  110. package/dist/skill-registry-client.d.ts.map +1 -1
  111. package/dist/skill-registry-client.js +31 -6
  112. package/dist/skill-registry-client.js.map +1 -1
  113. package/dist/tools/provision-any.d.ts +21 -0
  114. package/dist/tools/provision-any.d.ts.map +1 -1
  115. package/dist/tools/provision-any.js +127 -36
  116. package/dist/tools/provision-any.js.map +1 -1
  117. package/package.json +1 -1
@@ -24,10 +24,10 @@
24
24
  import { chromium as baseChromium } from "playwright";
25
25
  import { createRequire } from "node:module";
26
26
  import { Socket, createServer } from "node:net";
27
- import { existsSync } from "node:fs";
28
- import { spawn } from "node:child_process";
27
+ import { existsSync, mkdirSync, rmSync, statSync } from "node:fs";
28
+ import { execFileSync, spawn } from "node:child_process";
29
29
  import { detectAsn } from "./asn.js";
30
- import { CHROME_PROFILE_DIR, launchWithProfileGate, ProfileBusyError, reapLeakedProfileHolder, waitForProfileFree } from "./profile.js";
30
+ import { CHROME_PROFILE_DIR, clearStaleSingletonLock, launchWithProfileGate, ProfileBusyError, reapLeakedProfileHolder, waitForProfileFree, } from "./profile.js";
31
31
  import { startXvfb, xvfbAvailable } from "./xvfb.js";
32
32
  // Lazy registration: installing the plugin mutates the chromium singleton
33
33
  // from playwright-extra so we only do it once per process. We require()
@@ -109,6 +109,69 @@ function getChromium() {
109
109
  }
110
110
  return cachedChromium;
111
111
  }
112
+ const HCAPTCHA_UUID_RE = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
113
+ export function extractHcaptchaSitekeyFromHtml(html) {
114
+ if (!/hcaptcha\.com|h-captcha|hcaptcha/i.test(html))
115
+ return null;
116
+ const normalized = html
117
+ .replace(/"/g, '"')
118
+ .replace(/"/g, '"')
119
+ .replace(/&/g, "&");
120
+ for (const src of normalized.matchAll(/<iframe[^>]+src=["']([^"']*hcaptcha[^"']*)["']/gi)) {
121
+ const raw = src[1];
122
+ if (raw === undefined)
123
+ continue;
124
+ try {
125
+ const url = new URL(raw, "https://example.invalid");
126
+ const direct = url.searchParams.get("sitekey");
127
+ if (direct !== null && direct.length > 10)
128
+ return direct;
129
+ const hash = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash;
130
+ const fromHash = new URLSearchParams(hash).get("sitekey");
131
+ if (fromHash !== null && fromHash.length > 10)
132
+ return fromHash;
133
+ }
134
+ catch {
135
+ const match = raw.match(new RegExp(`[?#&]sitekey=(${HCAPTCHA_UUID_RE})`, "i"));
136
+ if (match?.[1] !== undefined)
137
+ return match[1];
138
+ }
139
+ }
140
+ const patterns = [
141
+ // Standard hCaptcha/SDK naming.
142
+ new RegExp(`(?:sitekey|site_key|site-key|hcaptcha_key|captchaApiKey|data-(?:hcaptcha-)?sitekey)["'\\s]*[:=]\\s*["'](${HCAPTCHA_UUID_RE})["']`, "i"),
143
+ // Stripe and similar app config JSON often names keys
144
+ // `express_hcaptcha_site_key` or `hcaptcha_login_main_site_key`.
145
+ new RegExp(`(?:hcaptcha[^"'<>]{0,80}site[_-]?key|express_hcaptcha_site_key)["'\\s]*[:=]\\s*["'](${HCAPTCHA_UUID_RE})["']`, "i"),
146
+ ];
147
+ for (const pattern of patterns) {
148
+ const match = normalized.match(pattern);
149
+ if (match?.[1] !== undefined)
150
+ return match[1];
151
+ }
152
+ return null;
153
+ }
154
+ export function extractHcaptchaResponseKeyFromToken(token) {
155
+ const parts = token.split(".");
156
+ if (parts.length < 2)
157
+ return null;
158
+ const payload = parts[1];
159
+ if (payload === undefined || payload.length === 0)
160
+ return null;
161
+ try {
162
+ const json = Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
163
+ const parsed = JSON.parse(json);
164
+ for (const key of ["ekey", "eKey", "respKey", "responseKey", "key", "kr"]) {
165
+ const value = parsed[key];
166
+ if (typeof value === "string" && value.trim().length > 0)
167
+ return value;
168
+ }
169
+ }
170
+ catch {
171
+ return null;
172
+ }
173
+ return null;
174
+ }
112
175
  // Map a cookie jar to the OAuth providers that have a LIVE logged-in session.
113
176
  // The auth cookies that mean "signed in": GitHub → `user_session`; Google →
114
177
  // any of the *SID session cookies (NID / CONSENT / 1P_JAR are set even when
@@ -141,6 +204,17 @@ function isCaptchaVariant(v) {
141
204
  v === "hcaptcha" ||
142
205
  v === "unknown");
143
206
  }
207
+ function pngDimensions(buf) {
208
+ if (buf.length < 24)
209
+ return null;
210
+ if (buf[0] !== 0x89 ||
211
+ buf[1] !== 0x50 ||
212
+ buf[2] !== 0x4e ||
213
+ buf[3] !== 0x47) {
214
+ return null;
215
+ }
216
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
217
+ }
144
218
  // Real-Chromium-family browser channels we'll prefer over the bundled
145
219
  // Chromium binary when available. Chromium ships without Widevine,
146
220
  // without proprietary codecs, with an empty navigator.plugins array,
@@ -309,6 +383,123 @@ async function waitForDevtools(port, deadlineMs) {
309
383
  }
310
384
  throw new Error(`Chrome DevTools endpoint never came up on ${base} (${lastErr})`);
311
385
  }
386
+ async function withChromeStartupLock(fn) {
387
+ const lockDir = "/tmp/trusty-squire-chrome-start.lock";
388
+ const deadline = Date.now() + 60_000;
389
+ for (;;) {
390
+ try {
391
+ mkdirSync(lockDir);
392
+ break;
393
+ }
394
+ catch (err) {
395
+ try {
396
+ const ageMs = Date.now() - statSync(lockDir).mtimeMs;
397
+ if (ageMs > 120_000) {
398
+ rmSync(lockDir, { recursive: true, force: true });
399
+ continue;
400
+ }
401
+ }
402
+ catch {
403
+ rmSync(lockDir, { recursive: true, force: true });
404
+ continue;
405
+ }
406
+ if (Date.now() >= deadline) {
407
+ throw new Error(`Timed out waiting for Chrome startup lock at ${lockDir}: ${err instanceof Error ? err.message : String(err)}`);
408
+ }
409
+ await new Promise((resolve) => setTimeout(resolve, 100));
410
+ }
411
+ }
412
+ try {
413
+ return await fn();
414
+ }
415
+ finally {
416
+ rmSync(lockDir, { recursive: true, force: true });
417
+ }
418
+ }
419
+ const selfManagedChromePids = new Set();
420
+ let selfManagedCleanupInstalled = false;
421
+ let orphanVerifyReapRan = false;
422
+ function killPid(pid, signal) {
423
+ try {
424
+ process.kill(pid, signal);
425
+ }
426
+ catch {
427
+ // already gone / not ours
428
+ }
429
+ }
430
+ function cleanupSelfManagedChromes() {
431
+ for (const pid of selfManagedChromePids)
432
+ killPid(pid, "SIGKILL");
433
+ selfManagedChromePids.clear();
434
+ }
435
+ function installSelfManagedChromeCleanup() {
436
+ if (selfManagedCleanupInstalled)
437
+ return;
438
+ selfManagedCleanupInstalled = true;
439
+ process.once("exit", cleanupSelfManagedChromes);
440
+ const exitForSignal = (code) => {
441
+ cleanupSelfManagedChromes();
442
+ process.exit(128 + code);
443
+ };
444
+ process.once("SIGINT", () => exitForSignal(2));
445
+ process.once("SIGTERM", () => exitForSignal(15));
446
+ process.once("SIGHUP", () => exitForSignal(1));
447
+ }
448
+ function registerSelfManagedChrome(child) {
449
+ installSelfManagedChromeCleanup();
450
+ if (child.pid !== undefined)
451
+ selfManagedChromePids.add(child.pid);
452
+ child.once("exit", () => {
453
+ if (child.pid !== undefined)
454
+ selfManagedChromePids.delete(child.pid);
455
+ });
456
+ }
457
+ // Stale verifier browsers are the expensive leak mode: if the MCP process dies
458
+ // mid-verify, self-launched Chrome survives as PPID=1 with a
459
+ // ~/.trusty-squire/profiles/verify-* user-data-dir. It keeps the profile lock,
460
+ // burns memory, and may leave defunct helper children. A live verifier should
461
+ // never be parented to init, so these are safe to reap at the next browser
462
+ // startup. Best-effort and Linux-only; failure must not block signups.
463
+ function reapOrphanedVerifyBrowsersOnce() {
464
+ if (orphanVerifyReapRan)
465
+ return;
466
+ orphanVerifyReapRan = true;
467
+ if (process.platform !== "linux")
468
+ return;
469
+ let rows = "";
470
+ try {
471
+ rows = execFileSync("ps", ["-eo", "pid=,ppid=,args="], {
472
+ stdio: ["ignore", "pipe", "ignore"],
473
+ }).toString("utf8");
474
+ }
475
+ catch {
476
+ return;
477
+ }
478
+ const pids = [];
479
+ for (const line of rows.split("\n")) {
480
+ const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
481
+ if (match === null)
482
+ continue;
483
+ const pid = Number(match[1]);
484
+ const ppid = Number(match[2]);
485
+ const args = match[3] ?? "";
486
+ if (Number.isFinite(pid) &&
487
+ ppid === 1 &&
488
+ /(?:chrome|chromium)/i.test(args) &&
489
+ /--user-data-dir=.*\.trusty-squire\/profiles\/verify-[^/\s]+/.test(args)) {
490
+ pids.push(pid);
491
+ }
492
+ }
493
+ if (pids.length === 0)
494
+ return;
495
+ console.error(`[universal-bot] reaping ${pids.length} orphaned verify Chrome process(es)`);
496
+ for (const pid of pids)
497
+ killPid(pid, "SIGTERM");
498
+ setTimeout(() => {
499
+ for (const pid of pids)
500
+ killPid(pid, "SIGKILL");
501
+ }, 2_000).unref();
502
+ }
312
503
  // Classify an anti-bot interstitial page from its (title + body) text.
313
504
  // `onInterstitial` matches the static Cloudflare/Turnstile challenge copy.
314
505
  // `verificationPassed` is the signal the challenge SUCCEEDED — but
@@ -462,38 +653,82 @@ export class BrowserController {
462
653
  // an emulated viewport on the connected context)
463
654
  // • locale/geo/permissions → applied post-connect by start()
464
655
  async launchSelfManagedContext(params) {
465
- const port = await findFreePort();
466
- const argv = [
467
- `--remote-debugging-port=${port}`,
468
- "--remote-debugging-address=127.0.0.1",
469
- `--user-data-dir=${this.profileDir}`,
470
- "--no-first-run",
471
- "--no-default-browser-check",
472
- "--password-store=basic",
473
- "--window-position=0,0",
474
- `--window-size=${params.window.width},${params.window.height}`,
475
- "--lang=en-US",
476
- ...params.args,
477
- ...(params.proxy !== null ? [`--proxy-server=${params.proxy.server}`] : []),
478
- ...(params.headless ? ["--headless=new"] : []),
479
- "about:blank",
480
- ];
481
- const child = spawn(params.binary, argv, { env: params.env, stdio: "ignore" });
482
- this.childChrome = child;
483
- let endpoint;
484
- try {
485
- endpoint = await waitForDevtools(port, 30_000);
486
- }
487
- catch (err) {
656
+ // Remote-CDP attach: BOT_CDP_ENDPOINT points at a Chrome already running on
657
+ // another host (e.g. a real-GPU Mac), reachable over Tailscale. We do NOT
658
+ // spawn or own the binary — the remote host launched it with its own
659
+ // profile, real GPU, and (residential) egress. Just attach over CDP. This
660
+ // is the real-GPU path: software-WebGL output (llvmpipe) is what
661
+ // hCaptcha-Enterprise-class anti-bot scores, and only real hardware fixes
662
+ // the rendered-pixel fingerprint that JS spoofing can't.
663
+ const remoteEndpoint = (process.env.BOT_CDP_ENDPOINT ?? "").trim();
664
+ if (remoteEndpoint.length > 0) {
665
+ const launcher = getChromium();
666
+ const browser = await launcher.connectOverCDP(remoteEndpoint);
667
+ this.cdpBrowser = browser;
668
+ this.launchedMode = "remote";
669
+ const ctx = browser.contexts()[0];
670
+ if (ctx === undefined) {
671
+ throw new Error(`remote Chrome (BOT_CDP_ENDPOINT=${remoteEndpoint}) exposed no default browser context`);
672
+ }
673
+ return ctx;
674
+ }
675
+ const endpoint = await withChromeStartupLock(async () => {
676
+ const port = await findFreePort();
677
+ clearStaleSingletonLock(this.profileDir);
678
+ const argv = [
679
+ `--remote-debugging-port=${port}`,
680
+ "--remote-debugging-address=127.0.0.1",
681
+ `--user-data-dir=${this.profileDir}`,
682
+ "--no-first-run",
683
+ "--no-default-browser-check",
684
+ "--password-store=basic",
685
+ "--window-position=0,0",
686
+ `--window-size=${params.window.width},${params.window.height}`,
687
+ "--lang=en-US",
688
+ ...params.args,
689
+ ...(params.proxy !== null ? [`--proxy-server=${params.proxy.server}`] : []),
690
+ ...(params.headless ? ["--headless=new"] : []),
691
+ "about:blank",
692
+ ];
693
+ const child = spawn(params.binary, argv, { env: params.env, stdio: ["ignore", "ignore", "pipe"] });
694
+ this.childChrome = child;
695
+ registerSelfManagedChrome(child);
696
+ let chromeStderr = "";
697
+ let chromeExit = "";
698
+ child.stderr?.on("data", (chunk) => {
699
+ chromeStderr = (chromeStderr + chunk.toString("utf8")).slice(-4_000);
700
+ });
701
+ child.on("exit", (code, signal) => {
702
+ chromeExit = ` exit=${code ?? "null"} signal=${signal ?? "none"}`;
703
+ });
488
704
  try {
489
- child.kill("SIGKILL");
705
+ return await waitForDevtools(port, 30_000);
490
706
  }
491
- catch {
492
- /* already gone */
707
+ catch (err) {
708
+ const alive = child.pid !== undefined
709
+ ? (() => {
710
+ try {
711
+ process.kill(child.pid, 0);
712
+ return true;
713
+ }
714
+ catch {
715
+ return false;
716
+ }
717
+ })()
718
+ : false;
719
+ try {
720
+ child.kill("SIGKILL");
721
+ }
722
+ catch {
723
+ /* already gone */
724
+ }
725
+ reapLeakedProfileHolder(this.profileDir);
726
+ this.childChrome = null;
727
+ const detail = chromeStderr.trim();
728
+ throw new Error(`${err instanceof Error ? err.message : String(err)}; Chrome pid=${child.pid ?? "unknown"} alive=${alive ? 1 : 0}` +
729
+ `${chromeExit}${detail.length > 0 ? `; Chrome stderr: ${detail}` : ""}`);
493
730
  }
494
- this.childChrome = null;
495
- throw err;
496
- }
731
+ });
497
732
  // Use the patchright launcher's connectOverCDP — it's the exact path the
498
733
  // falsification experiment validated (its connect avoids Runtime.enable,
499
734
  // which a plain attach would emit). The anti-detection that matters here
@@ -562,6 +797,7 @@ export class BrowserController {
562
797
  console.error("[universal-bot] resource blocking ON (image/media/font + analytics aborted; captcha/CSS/JS allowed)");
563
798
  }
564
799
  async start() {
800
+ reapOrphanedVerifyBrowsersOnce();
565
801
  const channel = await detectChromiumChannel();
566
802
  this.launchedChannel = channel;
567
803
  const proxy = await this.resolveProxy();
@@ -570,12 +806,27 @@ export class BrowserController {
570
806
  // module's existing logging convention).
571
807
  console.error(`[universal-bot] launching browser channel=${channel ?? "bundled-chromium"} ` +
572
808
  `proxy=${proxy?.server ?? "direct"}`);
809
+ // Remote-CDP mode (BOT_CDP_ENDPOINT): the browser runs on a REMOTE host
810
+ // (e.g. a Mac with a real GPU + residential egress) and we attach over CDP
811
+ // across Tailscale. The remote machine IS a real device, so we spoof
812
+ // NOTHING — no WebGL/device fingerprint patch (a fake-Intel string over a
813
+ // real Apple-GPU output would be its own mismatch tell), no local Xvfb, no
814
+ // egress-geo override (the remote host's real timezone + residential IP are
815
+ // authentic). software-WebGL output is exactly what the toughest anti-bot
816
+ // (hCaptcha Enterprise) scores; only real hardware fixes the pixel
817
+ // fingerprint, which is the whole point of this path.
818
+ const remoteMode = (process.env.BOT_CDP_ENDPOINT ?? "").trim().length > 0;
819
+ if (remoteMode) {
820
+ console.error(`[universal-bot] REMOTE-CDP mode — attaching to ${(process.env.BOT_CDP_ENDPOINT ?? "").trim()} ` +
821
+ `(real-host GPU + egress; local fingerprint spoof + Xvfb DISABLED)`);
822
+ }
573
823
  // T3.1: probe where this run's traffic actually exits so the
574
824
  // browser's declared timezone matches its egress IP (a US-timezone
575
825
  // browser on a foreign proxy IP is itself an anti-bot signal).
576
826
  // Done before the real launch: launchPersistentContext bakes the
577
- // timezone in at creation, with no way to set it afterward.
578
- const geo = await this.probeEgressGeo(channel, proxy);
827
+ // timezone in at creation, with no way to set it afterward. Skipped in
828
+ // remote mode the remote host's own clock/IP are the authentic truth.
829
+ const geo = remoteMode ? null : await this.probeEgressGeo(channel, proxy);
579
830
  if (geo !== null) {
580
831
  console.error(`[universal-bot] egress geo: timezone=${geo.timezoneId}` +
581
832
  (geo.geolocation !== undefined
@@ -666,11 +917,22 @@ export class BrowserController {
666
917
  // have released within the 120s wait — so by here, reaping the LOCAL
667
918
  // holder (SIGKILL + clear singletons; no-ops on a remote-host holder)
668
919
  // and retrying once is safe and recovers the run instead of failing it.
669
- const reaped = reapLeakedProfileHolder(this.profileDir);
920
+ //
921
+ // That assumption is false for verifier/discovery concurrency: a live
922
+ // holder can be another legitimate slot still closing the same robot
923
+ // profile. In concurrent mode, never SIGKILL the holder; surface
924
+ // ProfileBusyError so the orchestrator can retry later without corrupting
925
+ // another run.
926
+ const concurrency = Number.parseInt(process.env.HOUSEKEEPER_CONCURRENCY ?? "1", 10) || 1;
927
+ const allowLiveReap = concurrency <= 1;
928
+ const reaped = allowLiveReap ? reapLeakedProfileHolder(this.profileDir) : false;
670
929
  if (reaped) {
671
930
  console.error("[universal-bot] reaped a leaked Chrome holding the profile (orphan from an externally-killed run) — retrying");
672
931
  free = await waitForProfileFree(this.profileDir, { deadlineMs: 10_000 });
673
932
  }
933
+ else if (!allowLiveReap) {
934
+ console.error("[universal-bot] profile still held after wait; not reaping because HOUSEKEEPER_CONCURRENCY>1");
935
+ }
674
936
  if (!free) {
675
937
  throw new ProfileBusyError("bot Chrome profile is held by another run (a login or signup); retry shortly");
676
938
  }
@@ -777,6 +1039,15 @@ export class BrowserController {
777
1039
  this.launchedContext = true;
778
1040
  // Speed: optionally abort heavy/irrelevant requests before any navigation.
779
1041
  await this.installResourceBlocking();
1042
+ // Dev-runtime guard: when the bot is run through `tsx`, esbuild may inject
1043
+ // calls to its `__name(fn, "name")` helper into functions passed to
1044
+ // page.evaluate/addInitScript. Those functions execute in the browser page,
1045
+ // where Node's helper does not exist, causing an immediate
1046
+ // `ReferenceError: __name is not defined` before the real signup even
1047
+ // starts. Define the same no-op helper in every document. Built `dist`
1048
+ // should not emit these calls, but the helper is harmless there too.
1049
+ const evaluateNameShimScript = 'Object.defineProperty(globalThis, "__name", { value: (fn) => fn, configurable: true });';
1050
+ await context.addInitScript({ content: evaluateNameShimScript });
780
1051
  // Patch navigator.webdriver — BASELINE ONLY. Measured against the
781
1052
  // rebrowser bot-detector, this manual `defineProperty` is
782
1053
  // COUNTERPRODUCTIVE under patchright: it re-adds `webdriver` as an own
@@ -803,103 +1074,168 @@ export class BrowserController {
803
1074
  // at 1.0. Idempotent via a marker so the per-nav re-apply is cheap, and
804
1075
  // getParameter.toString() is masked to the original native source so
805
1076
  // the patch itself isn't a tell. Only strings change, not rendering.
806
- const installWebglSpoof = () => {
807
- const VENDOR_WEBGL = 0x9245; // UNMASKED_VENDOR_WEBGL
808
- const RENDERER_WEBGL = 0x9246; // UNMASKED_RENDERER_WEBGL
809
- const spoof = (proto) => {
810
- // The marker lives on the prototype so re-application is a no-op; the
811
- // cast is the one typed-alternative-exhausted spot (adding an ad-hoc
812
- // brand to a DOM prototype).
813
- const marked = proto;
814
- if (marked.__tsWebglPatched === true)
815
- return;
816
- const orig = proto.getParameter;
817
- const native = orig.toString();
818
- proto.getParameter = function (p) {
819
- if (p === VENDOR_WEBGL)
820
- return "Google Inc. (Intel)";
821
- if (p === RENDERER_WEBGL) {
822
- return "ANGLE (Intel, Mesa Intel(R) UHD Graphics 620 (KBL GT2), OpenGL 4.6)";
823
- }
824
- return orig.call(this, p);
825
- };
826
- Object.defineProperty(proto.getParameter, "toString", {
827
- value: () => native,
828
- configurable: true,
829
- writable: true,
830
- });
831
- marked.__tsWebglPatched = true;
832
- };
833
- if (typeof WebGLRenderingContext !== "undefined") {
834
- spoof(WebGLRenderingContext.prototype);
835
- }
836
- if (typeof WebGL2RenderingContext !== "undefined") {
837
- spoof(WebGL2RenderingContext.prototype);
838
- }
839
- // Device-tell normalization. The headless harvester box reports 20
840
- // logical cores (navigator.hardwareConcurrency) — a consumer residential
841
- // device is 4-16. A 20-core Linux machine behind a "residential" IP is
842
- // an internal inconsistency Cloudflare Turnstile scores against
843
- // (MEASURED 2026-06-11: exa/cartesia Turnstile won't issue a token on a
844
- // clean-fingerprint click; hwConcurrency=20 + Linux is the standout
845
- // anomaly). Normalize to a common consumer profile. Same per-nav main-
846
- // world application as the WebGL spoof — patchright denies init-world
847
- // reach, and Turnstile reads these after the challenge script loads
848
- // (seconds in), so the framenavigated re-apply wins the race. Defined on
849
- // Navigator.prototype (where the native getters live) so there's no own-
850
- // property tell on the instance.
851
- const navProto = Navigator.prototype;
852
- if (navProto.__tsDevicePatched !== true) {
853
- try {
854
- Object.defineProperty(Navigator.prototype, "hardwareConcurrency", {
855
- get: () => 8,
856
- configurable: true,
857
- });
858
- Object.defineProperty(Navigator.prototype, "deviceMemory", {
859
- get: () => 8,
860
- configurable: true,
861
- });
862
- // Screen availHeight tell: a headless Xvfb screen reports
863
- // availHeight == height (no OS taskbar), whereas a real Windows
864
- // desktop reserves ~40px for the taskbar (availHeight = height-40,
865
- // availWidth = width). Reinstate that gap so the screen reads like
866
- // an ordinary desktop, not a bare framebuffer. Guarded so it only
867
- // applies when the two are currently equal (i.e. headless).
868
- try {
869
- if (screen.availHeight === screen.height) {
870
- Object.defineProperty(Screen.prototype, "availHeight", {
871
- get: () => screen.height - 40,
872
- configurable: true,
873
- });
874
- }
875
- }
876
- catch {
877
- // leave it
878
- }
879
- navProto.__tsDevicePatched = true;
880
- }
881
- catch {
882
- // descriptor already locked by something else — leave it.
883
- }
884
- }
1077
+ const installWebglSpoofScript = String.raw `(() => {
1078
+ const VENDOR_WEBGL = 0x9245; // UNMASKED_VENDOR_WEBGL
1079
+ const RENDERER_WEBGL = 0x9246; // UNMASKED_RENDERER_WEBGL
1080
+ const spoof = (proto) => {
1081
+ // The marker lives on the prototype so re-application is a no-op; the
1082
+ // cast is the one typed-alternative-exhausted spot (adding an ad-hoc
1083
+ // brand to a DOM prototype).
1084
+ if (proto.__tsWebglPatched === true) return;
1085
+ const orig = proto.getParameter;
1086
+ const native = orig.toString();
1087
+ proto.getParameter = function (p) {
1088
+ if (p === VENDOR_WEBGL) return "Google Inc. (Intel)";
1089
+ if (p === RENDERER_WEBGL) {
1090
+ return "ANGLE (Intel, Mesa Intel(R) UHD Graphics 620 (KBL GT2), OpenGL 4.6)";
1091
+ }
1092
+ return orig.call(this, p);
885
1093
  };
886
- await context.addInitScript(installWebglSpoof);
1094
+ Object.defineProperty(proto.getParameter, "toString", {
1095
+ value: () => native,
1096
+ configurable: true,
1097
+ writable: true,
1098
+ });
1099
+ proto.__tsWebglPatched = true;
1100
+ };
1101
+ if (typeof WebGLRenderingContext !== "undefined") {
1102
+ spoof(WebGLRenderingContext.prototype);
1103
+ }
1104
+ if (typeof WebGL2RenderingContext !== "undefined") {
1105
+ spoof(WebGL2RenderingContext.prototype);
1106
+ }
1107
+ // Device-tell normalization. The headless harvester box reports 20
1108
+ // logical cores (navigator.hardwareConcurrency) — a consumer residential
1109
+ // device is 4-16. A 20-core Linux machine behind a "residential" IP is
1110
+ // an internal inconsistency Cloudflare Turnstile scores against
1111
+ // (MEASURED 2026-06-11: exa/cartesia Turnstile won't issue a token on a
1112
+ // clean-fingerprint click; hwConcurrency=20 + Linux is the standout
1113
+ // anomaly). Normalize to a common consumer profile. Same per-nav main-
1114
+ // world application as the WebGL spoof — patchright denies init-world
1115
+ // reach, and Turnstile reads these after the challenge script loads
1116
+ // (seconds in), so the framenavigated re-apply wins the race. Defined on
1117
+ // Navigator.prototype (where the native getters live) so there's no own-
1118
+ // property tell on the instance.
1119
+ const navProto = Navigator.prototype;
1120
+ if (navProto.__tsDevicePatched !== true) {
1121
+ try {
1122
+ Object.defineProperty(Navigator.prototype, "hardwareConcurrency", {
1123
+ get: () => 8,
1124
+ configurable: true,
1125
+ });
1126
+ Object.defineProperty(Navigator.prototype, "deviceMemory", {
1127
+ get: () => 8,
1128
+ configurable: true,
1129
+ });
1130
+ // Screen availHeight tell: a headless Xvfb screen reports
1131
+ // availHeight == height (no OS taskbar), whereas a real Windows
1132
+ // desktop reserves ~40px for the taskbar (availHeight = height-40,
1133
+ // availWidth = width). Reinstate that gap so the screen reads like
1134
+ // an ordinary desktop, not a bare framebuffer. Guarded so it only
1135
+ // applies when the two are currently equal (i.e. headless).
1136
+ try {
1137
+ if (screen.availHeight === screen.height) {
1138
+ Object.defineProperty(Screen.prototype, "availHeight", {
1139
+ get: () => screen.height - 40,
1140
+ configurable: true,
1141
+ });
1142
+ }
1143
+ } catch {
1144
+ // leave it
1145
+ }
1146
+ navProto.__tsDevicePatched = true;
1147
+ } catch {
1148
+ // descriptor already locked by something else — leave it.
1149
+ }
1150
+ }
1151
+ })();`;
1152
+ if (!remoteMode)
1153
+ await context.addInitScript({ content: installWebglSpoofScript });
887
1154
  this.page = context.pages()[0] ?? (await context.newPage());
1155
+ // addInitScript covers document-start page JS, but Playwright's
1156
+ // page.evaluate utility execution can run in a separate realm. Install the
1157
+ // same no-op helper there with a STRING evaluate (tsx cannot wrap strings
1158
+ // with __name). This prevents dev-runtime source runs from crashing before
1159
+ // replay reaches the service page.
1160
+ await this.page.evaluate(evaluateNameShimScript).catch(() => undefined);
888
1161
  // Re-apply on every navigation — the main-world reach patchright's isolated
889
1162
  // init world denies us. framenavigated fires at navigation-commit (before
890
1163
  // most page JS), so a late WebGL query (reCAPTCHA scores seconds in) sees
891
1164
  // the spoofed strings; a document-start fingerprinter could still race it.
892
1165
  const reapplyWebglSpoof = () => {
1166
+ if (remoteMode)
1167
+ return; // real-GPU remote host: spoof nothing
893
1168
  const pg = this.page;
894
1169
  if (pg === null)
895
1170
  return;
896
- void pg.evaluate(installWebglSpoof).catch(() => {
897
- // mid-navigation / closed page — the next navigation re-applies.
898
- });
1171
+ void (async () => {
1172
+ await pg.evaluate(evaluateNameShimScript).catch(() => undefined);
1173
+ await pg.evaluate(installWebglSpoofScript).catch(() => {
1174
+ // mid-navigation / closed page — the next navigation re-applies.
1175
+ });
1176
+ })();
899
1177
  };
1178
+ // A CROSS-ORIGIN captcha iframe (hCaptcha / Turnstile / reCAPTCHA) is its own
1179
+ // realm: the main-frame page.evaluate above never reaches it, so the captcha's
1180
+ // OWN fingerprint read sees the real software-WebGL renderer (llvmpipe /
1181
+ // SwiftShader) + 20-core / high-memory / no-taskbar Linux profile — a
1182
+ // headless/VM tell. MEASURED 2026-06-23: Stripe's invisible hCaptcha
1183
+ // Enterprise flags the session before any token, identically on a datacenter
1184
+ // AND a residential exit IP (IP falsified) — the discriminator is this
1185
+ // unspoofed in-iframe fingerprint. Patch the iframe's own main world too.
1186
+ // frame.evaluate reaches a cross-origin frame's main world at the driver
1187
+ // level (same path that wins the main-frame race), re-applied at
1188
+ // navigation-commit before the captcha's scoring JS queries WebGL.
1189
+ const CAPTCHA_FRAME_RE = /(hcaptcha\.com|challenges\.cloudflare\.com|google\.com\/recaptcha|recaptcha\.net|arkoselabs\.com|funcaptcha\.com)/i;
1190
+ // String probe (no compiled-fn __name shim needed): the UNMASKED renderer
1191
+ // a captcha would read. Logged only under CAPTCHA_TRACE to prove the fix.
1192
+ const RENDERER_PROBE = String.raw `(() => { try { const c = document.createElement("canvas"); const gl = c.getContext("webgl") || c.getContext("webgl2"); if (!gl) return "no-gl"; const e = gl.getExtension("WEBGL_debug_renderer_info"); return e ? String(gl.getParameter(e.UNMASKED_RENDERER_WEBGL)) : "no-ext"; } catch (err) { return "err:" + (err && err.message); } })()`;
1193
+ const trace = process.env.UNIVERSAL_BOT_CAPTCHA_TRACE === "1";
900
1194
  this.page.on("framenavigated", (frame) => {
901
- if (this.page !== null && frame === this.page.mainFrame())
1195
+ if (remoteMode)
1196
+ return; // real-GPU remote host: no in-iframe spoof
1197
+ if (this.page === null)
1198
+ return;
1199
+ if (frame === this.page.mainFrame()) {
902
1200
  reapplyWebglSpoof();
1201
+ return;
1202
+ }
1203
+ if (!CAPTCHA_FRAME_RE.test(frame.url()))
1204
+ return;
1205
+ const cfHost = (() => {
1206
+ try {
1207
+ return new URL(frame.url()).host;
1208
+ }
1209
+ catch {
1210
+ return "captcha-frame";
1211
+ }
1212
+ })();
1213
+ void (async () => {
1214
+ if (trace) {
1215
+ const before = await frame.evaluate(RENDERER_PROBE).catch(() => "eval-fail");
1216
+ // eslint-disable-next-line no-console
1217
+ console.error(`[captcha-fp] ${cfHost} renderer BEFORE spoof: ${before}`);
1218
+ }
1219
+ // Retry until the spoof STICKS. The first framenavigated commonly
1220
+ // eval-fails (frame mid-commit, or a throwaway about:blank hCaptcha
1221
+ // replaces), and hCaptcha reads the fingerprint during its widget
1222
+ // lifecycle — a single best-effort apply loses the race. Re-apply on a
1223
+ // ~3s budget until the iframe's renderer reads Intel, so the spoof is in
1224
+ // place before the scoring read.
1225
+ let landed = false;
1226
+ for (let i = 0; i < 20 && !landed; i++) {
1227
+ await frame.evaluate(installWebglSpoofScript).catch(() => undefined);
1228
+ const r = await frame.evaluate(RENDERER_PROBE).catch(() => "eval-fail");
1229
+ if (typeof r === "string" && r.includes("Intel"))
1230
+ landed = true;
1231
+ else
1232
+ await new Promise((res) => setTimeout(res, 150));
1233
+ }
1234
+ if (trace) {
1235
+ // eslint-disable-next-line no-console
1236
+ console.error(`[captcha-fp] ${cfHost} renderer AFTER spoof: ${landed ? "Intel (landed)" : "FAILED to land in budget"}`);
1237
+ }
1238
+ })();
903
1239
  });
904
1240
  this.page.on("load", reapplyWebglSpoof);
905
1241
  // rc.33 — captcha tracing. When UNIVERSAL_BOT_CAPTCHA_TRACE=1 is
@@ -913,13 +1249,14 @@ export class BrowserController {
913
1249
  if (process.env.UNIVERSAL_BOT_CAPTCHA_TRACE === "1") {
914
1250
  this.page.on("response", async (resp) => {
915
1251
  const url = resp.url();
916
- if (!/challenges\.cloudflare\.com|google\.com\/recaptcha/.test(url)) {
1252
+ if (!/challenges\.cloudflare\.com|google\.com\/recaptcha|hcaptcha\.com|newassets\.hcaptcha\.com/.test(url)) {
917
1253
  return;
918
1254
  }
919
1255
  const status = resp.status();
920
1256
  const ct = resp.headers()["content-type"] ?? "";
921
1257
  let bodyPreview = "";
922
- if (/json|javascript|html|plain/.test(ct)) {
1258
+ if (/json|javascript|html|plain/.test(ct) ||
1259
+ /api\.hcaptcha\.com\/(?:checksiteconfig|getcaptcha|checkcaptcha)/.test(url)) {
923
1260
  try {
924
1261
  const body = await resp.text();
925
1262
  bodyPreview =
@@ -945,6 +1282,19 @@ export class BrowserController {
945
1282
  // same channel + proxy so it reports the real egress. Best-effort —
946
1283
  // any failure returns null and start() keeps a default timezone.
947
1284
  async probeEgressGeo(channel, proxy) {
1285
+ if (proxy === null) {
1286
+ try {
1287
+ const resp = await fetch("https://ipinfo.io/json", { signal: AbortSignal.timeout(10_000) });
1288
+ if (!resp.ok)
1289
+ throw new Error(`HTTP ${resp.status}`);
1290
+ return parseEgressGeo(await resp.text());
1291
+ }
1292
+ catch (err) {
1293
+ console.error(`[universal-bot] egress geo probe failed — using default ` +
1294
+ `timezone: ${err instanceof Error ? err.message : String(err)}`);
1295
+ return null;
1296
+ }
1297
+ }
948
1298
  let probe;
949
1299
  try {
950
1300
  probe = await getChromium().launch({
@@ -1058,6 +1408,28 @@ export class BrowserController {
1058
1408
  // settled page. Distinct from ERR_CONNECTION_ABORTED (a dropped socket).
1059
1409
  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;
1060
1410
  const MAX_GOTO_ATTEMPTS = 3;
1411
+ const sameOriginPathAndSearch = (a, b) => {
1412
+ try {
1413
+ const left = new URL(a);
1414
+ const right = new URL(b);
1415
+ return left.origin === right.origin && left.pathname === right.pathname && left.search === right.search;
1416
+ }
1417
+ catch {
1418
+ return false;
1419
+ }
1420
+ };
1421
+ const landedAuthGateForTarget = (landedRaw, targetRaw) => {
1422
+ try {
1423
+ const landed = new URL(landedRaw);
1424
+ const target = new URL(targetRaw);
1425
+ if (landed.origin !== target.origin)
1426
+ return false;
1427
+ return /\/(?:sign[_-]?in|login|log[_-]?in|auth)(?:\/|$)/i.test(landed.pathname);
1428
+ }
1429
+ catch {
1430
+ return false;
1431
+ }
1432
+ };
1061
1433
  for (let attempt = 1;; attempt++) {
1062
1434
  try {
1063
1435
  await this.page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
@@ -1081,6 +1453,25 @@ export class BrowserController {
1081
1453
  }
1082
1454
  catch (err) {
1083
1455
  const msg = err instanceof Error ? err.message : String(err);
1456
+ // Some client-routed apps commit the address bar to the requested SPA
1457
+ // route but never fire the lifecycle event Playwright is waiting for.
1458
+ // Treat that as a successful navigation: callers immediately inspect
1459
+ // the DOM and have their own element-level waits.
1460
+ if (/Timeout \d+ms exceeded/i.test(msg)) {
1461
+ await this.sleep(500);
1462
+ if (sameOriginPathAndSearch(this.page.url(), url))
1463
+ break;
1464
+ if (landedAuthGateForTarget(this.page.url(), url))
1465
+ break;
1466
+ await this.page
1467
+ .waitForURL((landed) => sameOriginPathAndSearch(landed.toString(), url), { timeout: 5000 })
1468
+ .then(() => undefined)
1469
+ .catch(() => undefined);
1470
+ if (sameOriginPathAndSearch(this.page.url(), url))
1471
+ break;
1472
+ if (landedAuthGateForTarget(this.page.url(), url))
1473
+ break;
1474
+ }
1084
1475
  if (attempt >= MAX_GOTO_ATTEMPTS || !TRANSIENT_NET.test(msg))
1085
1476
  throw err;
1086
1477
  // Linear backoff — give the tunnel a moment to recover a slot.
@@ -1483,6 +1874,13 @@ export class BrowserController {
1483
1874
  }
1484
1875
  await this.humanClick(selector);
1485
1876
  }
1877
+ async clickNth(selector, index) {
1878
+ if (!this.page)
1879
+ throw new Error("Browser not started");
1880
+ const safeIndex = Math.max(0, Math.floor(index));
1881
+ const locator = this.page.locator(selector).nth(safeIndex);
1882
+ await locator.click({ timeout: 8000 });
1883
+ }
1486
1884
  // Click a link/button by its visible text. Used for one-off
1487
1885
  // dismissibles where the bot knows the literal label text and
1488
1886
  // doesn't need full inventory ranking (e.g. GitHub's "skip 2FA
@@ -1608,38 +2006,52 @@ export class BrowserController {
1608
2006
  .scrollIntoViewIfNeeded({ timeout: 5000 })
1609
2007
  .catch(() => { });
1610
2008
  if (!this.humanize) {
1611
- await this.page.check(selector, { force: true });
1612
- return;
2009
+ await this.page.check(selector, { force: true }).catch(() => undefined);
2010
+ if (await this.ensureChecked(selector))
2011
+ return;
2012
+ throw new Error(`Unable to check selector "${selector}" after label and DOM fallbacks`);
1613
2013
  }
1614
2014
  // For visible checkboxes, move the mouse to it first (a real user
1615
2015
  // would). For force-checked invisible ones, fall back to the
1616
2016
  // Playwright API so we don't try to mouse-click an offscreen element.
1617
- try {
1618
- await this.humanClick(selector);
1619
- // Verify it actually became checked; some checkboxes need the
1620
- // explicit `check()` call to flip state (e.g., styled labels
1621
- // that swallow the click event).
1622
- let isChecked = await this.page.locator(selector).isChecked();
1623
- if (!isChecked) {
1624
- await this.page.check(selector, { force: true });
1625
- isChecked = await this.page.locator(selector).isChecked().catch(() => false);
2017
+ await this.humanClick(selector).catch(() => undefined);
2018
+ await this.page.check(selector, { force: true }).catch(() => undefined);
2019
+ if (await this.ensureChecked(selector))
2020
+ return;
2021
+ throw new Error(`Unable to check selector "${selector}" after click, label, and DOM fallbacks`);
2022
+ }
2023
+ async ensureChecked(selector) {
2024
+ if (!this.page)
2025
+ return false;
2026
+ if (await this.page.locator(selector).isChecked().catch(() => false))
2027
+ return true;
2028
+ await this.clickAssociatedLabel(selector).catch(() => false);
2029
+ if (await this.page.locator(selector).isChecked().catch(() => false))
2030
+ return true;
2031
+ const domChecked = await this.page
2032
+ .locator(selector)
2033
+ .first()
2034
+ .evaluate((el) => {
2035
+ if (!(el instanceof HTMLInputElement))
2036
+ return false;
2037
+ if (el.type !== "checkbox" && el.type !== "radio")
2038
+ return false;
2039
+ if (!el.checked) {
2040
+ el.click();
1626
2041
  }
1627
- // Mantine / Radix styled checkboxes: the hidden <input> can read
1628
- // checked in the DOM while the library's React onChange never fired —
1629
- // so the form's controlled state stays false and the gated submit
1630
- // stays disabled even though isChecked() is true (MEASURED 2026-06-11:
1631
- // friendliai's #agreedToServiceTerms cost a wasted round because the
1632
- // first check didn't register the form state). Clicking the ASSOCIATED
1633
- // LABEL fires the real onChange the library listens for. Best-effort.
1634
- if (!isChecked) {
1635
- const labelClicked = await this.clickAssociatedLabel(selector);
1636
- if (!labelClicked)
1637
- await this.page.check(selector, { force: true });
2042
+ if (!el.checked) {
2043
+ el.checked = true;
2044
+ el.setAttribute("checked", "");
2045
+ el.setAttribute("aria-checked", "true");
2046
+ el.dispatchEvent(new Event("input", { bubbles: true }));
2047
+ el.dispatchEvent(new Event("change", { bubbles: true }));
1638
2048
  }
1639
- }
1640
- catch {
1641
- await this.page.check(selector, { force: true });
1642
- }
2049
+ return el.checked;
2050
+ })
2051
+ .catch(() => false);
2052
+ if (!domChecked)
2053
+ return false;
2054
+ return await this.page.locator(selector).isChecked().catch(() => false);
1643
2055
  }
1644
2056
  // Click the <label> associated with a checkbox/radio input — either a
1645
2057
  // `<label for="<id>">` or the wrapping `<label>` ancestor. Mantine/Radix
@@ -1669,6 +2081,31 @@ export class BrowserController {
1669
2081
  await wrapping.click({ timeout: 4000 });
1670
2082
  return true;
1671
2083
  }
2084
+ // Some Radix/shadcn-style controls render the hidden input as a sibling
2085
+ // of the visible agreement label, with no `for=` and no wrapping label
2086
+ // (Mistral's terms checkbox). At this point direct check has already
2087
+ // failed/not toggled, so clicking the nearest agreement-shaped label in
2088
+ // the same form is the safest remaining human-equivalent action.
2089
+ const clickedAgreement = await this.page
2090
+ .locator(selector)
2091
+ .first()
2092
+ .evaluate((el) => {
2093
+ const agreementRe = /terms|tos\b|privacy|policy|i accept|i agree|agree to/i;
2094
+ const form = el.closest("form");
2095
+ const labels = [
2096
+ ...(form ? Array.from(form.querySelectorAll("label")) : []),
2097
+ ...Array.from(document.querySelectorAll("label")),
2098
+ ];
2099
+ const label = labels.find((candidate) => agreementRe.test(candidate.textContent ?? ""));
2100
+ if (!(label instanceof HTMLElement))
2101
+ return false;
2102
+ label.click();
2103
+ return true;
2104
+ })
2105
+ .catch(() => false);
2106
+ if (clickedAgreement) {
2107
+ return true;
2108
+ }
1672
2109
  }
1673
2110
  catch {
1674
2111
  // best-effort
@@ -1699,7 +2136,7 @@ export class BrowserController {
1699
2136
  // These two regexes MUST stay byte-identical with
1700
2137
  // AGREEMENT_TEXT_RE / MARKETING_TEXT_RE in this module — the
1701
2138
  // page realm can't import, so they're inlined here.
1702
- const agreementRe = /terms|tos\b|privacy|consent|policy|i agree|agree to|acknowledge|gdpr/i;
2139
+ const agreementRe = /terms|tos\b|privacy|consent|policy|i agree|agree to|acknowledge|gdpr|age|18\+|18 years|certif/i;
1703
2140
  const marketingRe = /newsletter|updates|offers|product tips|marketing|promotional|receive emails|opt[- ]?in to|subscribe/i;
1704
2141
  const checked = [];
1705
2142
  const boxes = Array.from(document.querySelectorAll('input[type="checkbox"]'));
@@ -1707,7 +2144,11 @@ export class BrowserController {
1707
2144
  if (box.checked || box.disabled)
1708
2145
  continue;
1709
2146
  const rect = box.getBoundingClientRect();
1710
- if (rect.width <= 0 || rect.height <= 0)
2147
+ const ancestorLabel = box.closest("label");
2148
+ const labelRect = ancestorLabel?.getBoundingClientRect();
2149
+ const visible = (rect.width > 0 && rect.height > 0) ||
2150
+ (labelRect !== undefined && labelRect.width > 0 && labelRect.height > 0);
2151
+ if (!visible)
1711
2152
  continue;
1712
2153
  // Associated text = attributes + a label[for=id] + nearest
1713
2154
  // ancestor <label> + the immediately following sibling text.
@@ -1722,7 +2163,6 @@ export class BrowserController {
1722
2163
  if (forLabel)
1723
2164
  parts.push(forLabel.textContent ?? "");
1724
2165
  }
1725
- const ancestorLabel = box.closest("label");
1726
2166
  if (ancestorLabel)
1727
2167
  parts.push(ancestorLabel.textContent ?? "");
1728
2168
  const sibling = box.nextSibling;
@@ -1736,12 +2176,15 @@ export class BrowserController {
1736
2176
  continue;
1737
2177
  // React/Vue controlled inputs ignore a bare `.checked = true`:
1738
2178
  // their state lives in the framework, updated only by the real
1739
- // event flow. Set the property AND dispatch input/change AND a
1740
- // synthetic click so the controlled binding observes the flip.
1741
- box.checked = true;
2179
+ // event flow. Click first (while unchecked) so the framework sees the
2180
+ // same transition a user would make, then force-ensure checked and
2181
+ // dispatch input/change for styled/hidden inputs whose click target
2182
+ // does not toggle the underlying control.
2183
+ box.click();
2184
+ if (!box.checked)
2185
+ box.checked = true;
1742
2186
  box.dispatchEvent(new Event("input", { bubbles: true }));
1743
2187
  box.dispatchEvent(new Event("change", { bubbles: true }));
1744
- box.click();
1745
2188
  const label = box.getAttribute("data-testid") ||
1746
2189
  box.getAttribute("name") ||
1747
2190
  box.id ||
@@ -1756,6 +2199,111 @@ export class BrowserController {
1756
2199
  return [];
1757
2200
  }
1758
2201
  }
2202
+ // Deterministic pre-submit guard for required signup category choices.
2203
+ //
2204
+ // Paddle-class forms ask a required "What do you sell?" question where one
2205
+ // product category must be selected before account creation, but the submit
2206
+ // button remains enabled. The planner can satisfy the agreement checkbox and
2207
+ // still skip the category, producing a rejected submit + no verification mail.
2208
+ //
2209
+ // Keep this conservative: only fire when the page text explicitly says a
2210
+ // product/category choice is required, never touch agreement/marketing boxes,
2211
+ // and prefer low-risk SaaS/software labels over restricted categories.
2212
+ async checkRequiredSignupChoiceBoxes() {
2213
+ if (!this.page)
2214
+ throw new Error("Browser not started");
2215
+ try {
2216
+ return await this.page.evaluate(() => {
2217
+ const choiceGateRe = /what do you sell|categories we support|select which types? of products|choose (?:a|your) (?:category|product|business type)|product category|business category/i;
2218
+ const safeChoiceRe = /digital products?|saas|software|developer tools?|apis?|mobile apps?|data|analytics/i;
2219
+ const riskyChoiceRe = /gambling|financial services?|physical products?|marketplace|human services?|adult|weapons?|medical|restricted|crypto|payments?|banking/i;
2220
+ const agreementRe = /terms|tos\b|privacy|consent|policy|i agree|agree to|acknowledge|gdpr/i;
2221
+ const marketingRe = /newsletter|updates|offers|product tips|marketing|promotional|receive emails|opt[- ]?in to|subscribe/i;
2222
+ const bodyText = document.body?.innerText ?? "";
2223
+ if (!choiceGateRe.test(bodyText))
2224
+ return [];
2225
+ const associatedText = (box) => {
2226
+ const parts = [
2227
+ box.getAttribute("data-testid") ?? "",
2228
+ box.getAttribute("name") ?? "",
2229
+ box.id,
2230
+ box.getAttribute("aria-label") ?? "",
2231
+ ];
2232
+ if (box.id) {
2233
+ const forLabel = document.querySelector(`label[for="${CSS.escape(box.id)}"]`);
2234
+ if (forLabel)
2235
+ parts.push(forLabel.textContent ?? "");
2236
+ }
2237
+ const ancestorLabel = box.closest("label");
2238
+ if (ancestorLabel)
2239
+ parts.push(ancestorLabel.textContent ?? "");
2240
+ if (box.nextElementSibling) {
2241
+ parts.push(box.nextElementSibling.textContent ?? "");
2242
+ }
2243
+ return parts.join(" ").replace(/\s+/g, " ").trim();
2244
+ };
2245
+ const boxes = Array.from(document.querySelectorAll('input[type="checkbox"], input[type="radio"]'));
2246
+ const visibleBoxes = boxes.filter((box) => {
2247
+ if (box.disabled)
2248
+ return false;
2249
+ const rect = box.getBoundingClientRect();
2250
+ return rect.width > 0 && rect.height > 0;
2251
+ });
2252
+ const alreadyChoseCategory = visibleBoxes.some((box) => {
2253
+ if (!box.checked)
2254
+ return false;
2255
+ const text = associatedText(box);
2256
+ return (!agreementRe.test(text) &&
2257
+ !marketingRe.test(text) &&
2258
+ !riskyChoiceRe.test(text));
2259
+ });
2260
+ if (alreadyChoseCategory)
2261
+ return [];
2262
+ const candidates = visibleBoxes
2263
+ .filter((box) => !box.checked)
2264
+ .map((box) => ({ box, text: associatedText(box) }))
2265
+ .filter(({ text }) => {
2266
+ if (!text)
2267
+ return false;
2268
+ if (agreementRe.test(text) || marketingRe.test(text))
2269
+ return false;
2270
+ if (riskyChoiceRe.test(text))
2271
+ return false;
2272
+ return safeChoiceRe.test(text);
2273
+ })
2274
+ .sort((a, b) => {
2275
+ const score = (text) => {
2276
+ if (/digital products?|saas|software/i.test(text))
2277
+ return 3;
2278
+ if (/developer tools?|apis?|data|analytics/i.test(text))
2279
+ return 2;
2280
+ if (/mobile apps?/i.test(text))
2281
+ return 1;
2282
+ return 0;
2283
+ };
2284
+ return score(b.text) - score(a.text);
2285
+ });
2286
+ const choice = candidates[0];
2287
+ if (!choice)
2288
+ return [];
2289
+ choice.box.checked = true;
2290
+ choice.box.dispatchEvent(new Event("input", { bubbles: true }));
2291
+ choice.box.dispatchEvent(new Event("change", { bubbles: true }));
2292
+ choice.box.click();
2293
+ return [
2294
+ choice.box.getAttribute("data-testid") ||
2295
+ choice.box.getAttribute("name") ||
2296
+ choice.box.id ||
2297
+ choice.box.getAttribute("aria-label") ||
2298
+ choice.text ||
2299
+ "signup-choice",
2300
+ ];
2301
+ });
2302
+ }
2303
+ catch {
2304
+ return [];
2305
+ }
2306
+ }
1759
2307
  // Scroll a Terms-of-Service style modal to the bottom so the gated
1760
2308
  // "Accept" button enables. Railway's signup is the canonical case:
1761
2309
  // a modal with a virtualized ToS list watches real `scroll` /
@@ -1979,6 +2527,39 @@ export class BrowserController {
1979
2527
  tagName = "select";
1980
2528
  }
1981
2529
  }
2530
+ else {
2531
+ const rowControl = await this.page
2532
+ .locator(activeSelector)
2533
+ .first()
2534
+ .evaluate((label) => {
2535
+ const root = label.closest(".n-form-group__row") ??
2536
+ label.closest("label")?.parentElement ??
2537
+ label.parentElement;
2538
+ const control = root?.querySelector('select,button[role="combobox"],input[role="combobox"],[role="combobox"]');
2539
+ if (control === null || control === undefined)
2540
+ return null;
2541
+ const id = control.getAttribute("id");
2542
+ if (id !== null && id.length > 0)
2543
+ return `#${CSS.escape(id)}`;
2544
+ const testId = control.getAttribute("data-qa") ??
2545
+ control.getAttribute("data-testid") ??
2546
+ control.getAttribute("data-test") ??
2547
+ control.getAttribute("data-cy");
2548
+ if (testId !== null && testId.length > 0) {
2549
+ return `[data-qa="${CSS.escape(testId)}"],[data-testid="${CSS.escape(testId)}"],[data-test="${CSS.escape(testId)}"],[data-cy="${CSS.escape(testId)}"]`;
2550
+ }
2551
+ return null;
2552
+ })
2553
+ .catch(() => null);
2554
+ if (rowControl !== null) {
2555
+ activeSelector = rowControl;
2556
+ tagName = await this.page
2557
+ .locator(activeSelector)
2558
+ .first()
2559
+ .evaluate((node) => node.tagName.toLowerCase())
2560
+ .catch(() => tagName);
2561
+ }
2562
+ }
1982
2563
  }
1983
2564
  if (tagName === "select") {
1984
2565
  // Native path. rc.15 — keep value="" options selectable. The
@@ -2094,6 +2675,8 @@ export class BrowserController {
2094
2675
  '[role="option"]:visible',
2095
2676
  '[role="menuitem"]:visible',
2096
2677
  '[role="menuitemradio"]:visible',
2678
+ 'mat-option:visible',
2679
+ '.mat-mdc-option:visible',
2097
2680
  '[id^="react-select-"][role*="menu"]:visible',
2098
2681
  '[role="listbox"]:visible li:visible',
2099
2682
  ];
@@ -2392,7 +2975,7 @@ export class BrowserController {
2392
2975
  // misreads "URL unchanged, not on provider" as "OAuth completed"
2393
2976
  // and the run falls apart.
2394
2977
  //
2395
- // Poll for up to 6s for the disabled state to clear. Both the
2978
+ // Poll for up to 15s for the disabled state to clear. Both the
2396
2979
  // HTML `disabled` attribute AND `aria-disabled="true"` are
2397
2980
  // honored — the latter covers ARIA-styled buttons (Radix, Headless
2398
2981
  // UI) that visually appear interactive but reject input.
@@ -2411,7 +2994,7 @@ export class BrowserController {
2411
2994
  // next round's reason includes "click failed: target is
2412
2995
  // aria-disabled" and the planner pivots to checking other fields.
2413
2996
  {
2414
- const deadline = Date.now() + 6000;
2997
+ const deadline = Date.now() + 15_000;
2415
2998
  let isDisabled = false;
2416
2999
  while (Date.now() < deadline) {
2417
3000
  isDisabled = await locator
@@ -2435,7 +3018,7 @@ export class BrowserController {
2435
3018
  // (meilisearch/zilliz: planner clicked a disabled Next 4+ times because
2436
3019
  // the generic hint didn't say WHICH field blocked it). Feedback only.
2437
3020
  const hint = await this.unfilledRequiredHint();
2438
- throw new Error("target is disabled (HTML disabled or aria-disabled=true) after 6s — " +
3021
+ throw new Error("target is disabled (HTML disabled or aria-disabled=true) after 15s — " +
2439
3022
  "the click would no-op. A required precondition is unmet: an empty " +
2440
3023
  "input, an unselected dropdown, an unchecked agreement checkbox, or " +
2441
3024
  "a missing preset/permission choice. Do NOT retry this click — pick a " +
@@ -2677,6 +3260,11 @@ export class BrowserController {
2677
3260
  return false;
2678
3261
  });
2679
3262
  if (solved) {
3263
+ if (widget.kind === "hcaptcha") {
3264
+ const settled = await this.waitForCaptchaChallengeToSettle(15_000, 10_000);
3265
+ if (!settled)
3266
+ return { found: true, solved: false, kind: widget.kind };
3267
+ }
2680
3268
  return { found: true, solved: true, kind: widget.kind };
2681
3269
  }
2682
3270
  }
@@ -2968,6 +3556,14 @@ export class BrowserController {
2968
3556
  input.dispatchEvent(new Event("input", { bubbles: true }));
2969
3557
  input.dispatchEvent(new Event("change", { bubbles: true }));
2970
3558
  }
3559
+ for (const el of Array.from(document.querySelectorAll("[data-hcaptcha-widget-id], .h-captcha"))) {
3560
+ el.setAttribute("data-hcaptcha-response", tok);
3561
+ el.dispatchEvent(new Event("input", { bubbles: true }));
3562
+ el.dispatchEvent(new Event("change", { bubbles: true }));
3563
+ }
3564
+ const form = inputs[0]?.closest("form");
3565
+ form?.dispatchEvent(new Event("input", { bubbles: true }));
3566
+ form?.dispatchEvent(new Event("change", { bubbles: true }));
2971
3567
  // 2. Fire the widget's onSuccess callback if registered. The
2972
3568
  // callbacks are stored on `___grecaptcha_cfg.clients`; the
2973
3569
  // exact tree is undocumented and shifts across versions
@@ -3180,7 +3776,7 @@ export class BrowserController {
3180
3776
  if (!this.page)
3181
3777
  throw new Error("Browser not started");
3182
3778
  try {
3183
- return await this.page.evaluate(() => {
3779
+ const fromDom = await this.page.evaluate(() => {
3184
3780
  const div = document.querySelector(".h-captcha[data-sitekey], [data-hcaptcha-sitekey]");
3185
3781
  if (div !== null) {
3186
3782
  const k = div.getAttribute("data-sitekey") ??
@@ -3190,69 +3786,361 @@ export class BrowserController {
3190
3786
  }
3191
3787
  const iframe = document.querySelector('iframe[src*="hcaptcha.com"]');
3192
3788
  if (iframe !== null) {
3193
- const k = new URL(iframe.src).searchParams.get("sitekey");
3789
+ const url = new URL(iframe.src);
3790
+ const k = url.searchParams.get("sitekey") ??
3791
+ new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash).get("sitekey");
3194
3792
  if (k !== null && k.length > 10)
3195
3793
  return k;
3196
3794
  }
3197
- // INVISIBLE hCaptcha (huggingface, 2026-06-17): no .h-captcha div, no
3198
- // rendered iframe — the sitekey lives in the page's JS config
3199
- // (`captchaApiKey:"<uuid>"`, `sitekey:"<uuid>"`). Scan the HTML for a
3200
- // UUID-shaped key next to a sitekey/captcha hint, but ONLY when an
3201
- // hCaptcha script is present (so an unrelated config UUID can't match).
3202
- // UUID shape excludes reCAPTCHA `6L…` and Turnstile `0x…` keys.
3203
- const html = document.documentElement.outerHTML;
3204
- if (/hcaptcha\.com|h-captcha|hcaptcha/i.test(html)) {
3205
- const m = html.match(/(?:sitekey|captchaApiKey|data-(?:hcaptcha-)?sitekey)["'\s]*[:=]\s*["']([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})["']/i);
3206
- if (m !== null && m[1] !== undefined)
3207
- return m[1];
3208
- }
3209
3795
  return null;
3210
3796
  });
3797
+ if (fromDom !== null)
3798
+ return fromDom;
3799
+ // INVISIBLE hCaptcha (Hugging Face, Stripe): no .h-captcha div, no
3800
+ // iframe `?sitekey=` param — the sitekey lives in the page's JS/JSON
3801
+ // config (`captchaApiKey`, `express_hcaptcha_site_key`,
3802
+ // `hcaptcha_login_main_site_key`, etc.). Scan the HTML for a UUID-shaped
3803
+ // key next to a sitekey/captcha hint, but only when an hCaptcha marker is
3804
+ // present so an unrelated config UUID cannot match.
3805
+ const html = await this.page.evaluate(() => document.documentElement.outerHTML);
3806
+ return extractHcaptchaSitekeyFromHtml(html);
3807
+ }
3808
+ catch {
3809
+ return null;
3810
+ }
3811
+ }
3812
+ async getBrowserUserAgent() {
3813
+ if (!this.page)
3814
+ throw new Error("Browser not started");
3815
+ try {
3816
+ return await this.page.evaluate(() => navigator.userAgent);
3211
3817
  }
3212
3818
  catch {
3213
3819
  return null;
3214
3820
  }
3215
3821
  }
3822
+ async getHcaptchaSolveContext() {
3823
+ if (!this.page)
3824
+ throw new Error("Browser not started");
3825
+ try {
3826
+ return await this.page.evaluate(() => {
3827
+ let invisible = false;
3828
+ let rqdata = null;
3829
+ const useRqdata = (value) => {
3830
+ if (rqdata === null && value !== null && value.trim().length > 0)
3831
+ rqdata = value;
3832
+ };
3833
+ for (const el of Array.from(document.querySelectorAll(".h-captcha, [data-hcaptcha-widget-id]"))) {
3834
+ const size = el.getAttribute("data-size") ?? el.getAttribute("size");
3835
+ if (size?.toLowerCase() === "invisible")
3836
+ invisible = true;
3837
+ useRqdata(el.getAttribute("data-rqdata"));
3838
+ }
3839
+ for (const iframe of Array.from(document.querySelectorAll('iframe[src*="hcaptcha.com"]'))) {
3840
+ try {
3841
+ const url = new URL(iframe.src);
3842
+ const hashParams = new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash);
3843
+ const size = url.searchParams.get("size") ?? hashParams.get("size");
3844
+ const frame = url.searchParams.get("frame") ?? hashParams.get("frame");
3845
+ useRqdata(url.searchParams.get("rqdata") ?? hashParams.get("rqdata"));
3846
+ const clientOptions = url.searchParams.get("clientOptions") ?? hashParams.get("clientOptions");
3847
+ if (clientOptions !== null) {
3848
+ try {
3849
+ const parsed = JSON.parse(clientOptions);
3850
+ if (typeof parsed.rqdata === "string")
3851
+ useRqdata(parsed.rqdata);
3852
+ }
3853
+ catch {
3854
+ // ignore non-JSON client options
3855
+ }
3856
+ }
3857
+ if (size?.toLowerCase() === "invisible" ||
3858
+ frame?.toLowerCase() === "checkbox-invisible") {
3859
+ invisible = true;
3860
+ }
3861
+ }
3862
+ catch {
3863
+ // ignore malformed extension/proxy iframe URLs
3864
+ }
3865
+ }
3866
+ return { invisible, userAgent: navigator.userAgent, rqdata };
3867
+ });
3868
+ }
3869
+ catch {
3870
+ return {
3871
+ invisible: false,
3872
+ userAgent: await this.getBrowserUserAgent().catch(() => null),
3873
+ rqdata: null,
3874
+ };
3875
+ }
3876
+ }
3216
3877
  // Inject a 2Captcha-resolved hCaptcha token into the page's
3217
- // h-captcha-response textarea(s) and fire the widget's data-callback
3218
- // if the page registered one. Mirrors injectRecaptchaToken; hCaptcha
3219
- // also mirrors the response token into a g-recaptcha-response textarea
3220
- // on some compat installs, so populate both names if present.
3878
+ // h-captcha-response textarea(s), update hCaptcha runtime response
3879
+ // accessors, and fire registered callbacks. Mirrors injectRecaptchaToken;
3880
+ // hCaptcha also mirrors the response token into a g-recaptcha-response
3881
+ // textarea on some compat installs, so populate both names if present.
3221
3882
  async injectHcaptchaToken(token) {
3222
3883
  if (!this.page)
3223
3884
  throw new Error("Browser not started");
3224
3885
  try {
3225
- return await this.page.evaluate((tok) => {
3886
+ const responseKey = extractHcaptchaResponseKeyFromToken(token);
3887
+ return await this.page.evaluate(({ tok, key }) => {
3888
+ const widgetIds = new Set();
3226
3889
  const inputs = Array.from(document.querySelectorAll('textarea[name="h-captcha-response"], textarea[id^="h-captcha-response"], textarea[name="g-recaptcha-response"]'));
3227
- if (inputs.length === 0)
3228
- return false;
3229
3890
  for (const input of inputs) {
3230
3891
  input.value = tok;
3231
3892
  input.dispatchEvent(new Event("input", { bubbles: true }));
3232
3893
  input.dispatchEvent(new Event("change", { bubbles: true }));
3233
3894
  }
3234
- // Fire the data-callback the page registered on the .h-captcha
3235
- // host (hCaptcha calls it by name on window). Best-effort — the
3236
- // populated textarea is what server-side validation reads.
3895
+ for (const host of Array.from(document.querySelectorAll(".h-captcha, [data-hcaptcha-widget-id], [data-hcaptcha-response]"))) {
3896
+ host.setAttribute("data-hcaptcha-response", tok);
3897
+ const id = host.getAttribute("data-hcaptcha-widget-id") ??
3898
+ host.getAttribute("data-hcaptcha-widget-id".toLowerCase());
3899
+ if (id !== null && id.length > 0)
3900
+ widgetIds.add(id);
3901
+ host.dispatchEvent(new Event("input", { bubbles: true }));
3902
+ host.dispatchEvent(new Event("change", { bubbles: true }));
3903
+ }
3904
+ for (const iframe of Array.from(document.querySelectorAll('iframe[src*="hcaptcha.com"]'))) {
3905
+ try {
3906
+ const url = new URL(iframe.src);
3907
+ const params = new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash);
3908
+ const id = params.get("id");
3909
+ if (id !== null && id.length > 0)
3910
+ widgetIds.add(id);
3911
+ }
3912
+ catch {
3913
+ // ignore malformed extension/proxy iframe URLs
3914
+ }
3915
+ }
3916
+ const win = window;
3917
+ const hcaptcha = win.hcaptcha;
3918
+ if (hcaptcha !== undefined) {
3919
+ const originalGetResponse = hcaptcha.getResponse?.bind(hcaptcha);
3920
+ const originalGetRespKey = hcaptcha.getRespKey?.bind(hcaptcha);
3921
+ hcaptcha.getResponse = (id) => {
3922
+ if (id === undefined || widgetIds.size === 0 || widgetIds.has(String(id)))
3923
+ return tok;
3924
+ return originalGetResponse?.(id) ?? tok;
3925
+ };
3926
+ hcaptcha.getRespKey = (id) => {
3927
+ if (id === undefined || widgetIds.size === 0 || widgetIds.has(String(id)))
3928
+ return key ?? "";
3929
+ return originalGetRespKey?.(id) ?? key ?? "";
3930
+ };
3931
+ }
3932
+ let callbackFired = false;
3933
+ const fire = (fn) => {
3934
+ if (typeof fn !== "function")
3935
+ return;
3936
+ callbackFired = true;
3937
+ try {
3938
+ fn(tok, key ?? undefined);
3939
+ }
3940
+ catch {
3941
+ // A page callback can be stale after React remounts a widget.
3942
+ }
3943
+ };
3944
+ // Fire callbacks registered by markup, e.g. data-callback="onSubmit".
3237
3945
  try {
3238
- const host = document.querySelector(".h-captcha[data-callback]");
3239
- const name = host?.getAttribute("data-callback");
3240
- if (name !== null && name !== undefined) {
3241
- const fn = window[name];
3242
- if (typeof fn === "function")
3243
- fn(tok);
3946
+ for (const host of Array.from(document.querySelectorAll(".h-captcha[data-callback]"))) {
3947
+ const name = host.getAttribute("data-callback");
3948
+ if (name !== null && name !== undefined)
3949
+ fire(win[name]);
3244
3950
  }
3245
3951
  }
3246
3952
  catch {
3247
- // no named callback DOM injection stands.
3953
+ // no named callback, continue to runtime config scan.
3248
3954
  }
3249
- return true;
3250
- }, token);
3955
+ // Programmatic hCaptcha integrations pass function callbacks to
3956
+ // hcaptcha.render(). The SDK keeps them in ___hcaptcha_cfg; crawl it
3957
+ // generically so React/Vue wrappers are handled like plain forms.
3958
+ const seen = new Set();
3959
+ const scan = (value, depth) => {
3960
+ if (value === null || value === undefined || depth > 7 || seen.has(value))
3961
+ return;
3962
+ seen.add(value);
3963
+ if (typeof value === "function")
3964
+ return;
3965
+ if (typeof value !== "object")
3966
+ return;
3967
+ for (const [key, child] of Object.entries(value)) {
3968
+ const normalized = key.toLowerCase();
3969
+ if (typeof child === "function" &&
3970
+ (normalized === "callback" ||
3971
+ normalized === "success-callback" ||
3972
+ normalized === "verify-callback" ||
3973
+ normalized === "onverify" ||
3974
+ normalized === "onsuccess")) {
3975
+ fire(child);
3976
+ continue;
3977
+ }
3978
+ if (typeof child === "object" && child !== null)
3979
+ scan(child, depth + 1);
3980
+ }
3981
+ };
3982
+ scan(win.___hcaptcha_cfg, 0);
3983
+ scan(win.hcaptcha, 0);
3984
+ return inputs.length > 0 || widgetIds.size > 0 || callbackFired;
3985
+ }, { tok: token, key: responseKey });
3251
3986
  }
3252
3987
  catch {
3253
3988
  return false;
3254
3989
  }
3255
3990
  }
3991
+ async solveVisibleHcaptchaChallengeWithCoordinates(solve) {
3992
+ if (!this.page)
3993
+ throw new Error("Browser not started");
3994
+ const challenge = await this.findVisibleHcaptchaChallengeFrame();
3995
+ if (challenge === null) {
3996
+ return { found: false, solved: false, reason: "no_visible_challenge" };
3997
+ }
3998
+ let shot;
3999
+ try {
4000
+ shot = await challenge.locator.screenshot({ type: "png", timeout: 8_000 });
4001
+ }
4002
+ catch (err) {
4003
+ return {
4004
+ found: true,
4005
+ solved: false,
4006
+ reason: `screenshot_failed:${err instanceof Error ? err.message : String(err)}`,
4007
+ clicks: 0,
4008
+ };
4009
+ }
4010
+ const dims = pngDimensions(shot);
4011
+ if (dims === null || dims.width <= 0 || dims.height <= 0) {
4012
+ return {
4013
+ found: true,
4014
+ solved: false,
4015
+ reason: "invalid_challenge_screenshot",
4016
+ clicks: 0,
4017
+ };
4018
+ }
4019
+ const solveRes = await solve({
4020
+ imageBase64: shot.toString("base64"),
4021
+ comment: "hCaptcha challenge screenshot. Click all matching image targets requested by the prompt. If a Verify or Submit button is visible, click it after selecting targets.",
4022
+ minClicks: 1,
4023
+ maxClicks: 12,
4024
+ });
4025
+ if (solveRes.kind !== "ok") {
4026
+ return {
4027
+ found: true,
4028
+ solved: false,
4029
+ reason: `2captcha_${solveRes.kind}` +
4030
+ ("reason" in solveRes ? `:${solveRes.reason}` : ""),
4031
+ clicks: 0,
4032
+ ...("durationMs" in solveRes ? { durationMs: solveRes.durationMs } : {}),
4033
+ };
4034
+ }
4035
+ let clicks = 0;
4036
+ for (const point of solveRes.coordinates) {
4037
+ const box = await challenge.locator.boundingBox({ timeout: 1_500 }).catch(() => null);
4038
+ if (box === null || box.width <= 0 || box.height <= 0)
4039
+ break;
4040
+ const x = box.x + (point.x / dims.width) * box.width;
4041
+ const y = box.y + (point.y / dims.height) * box.height;
4042
+ await this.bezierMouseTo(x, y);
4043
+ await this.sleep(rand(100, 260));
4044
+ await this.page.mouse.click(x, y);
4045
+ this.mouseX = x;
4046
+ this.mouseY = y;
4047
+ clicks += 1;
4048
+ }
4049
+ await this.sleep(650);
4050
+ let settled = await this.waitForCaptchaChallengeToSettle(2_500).catch(() => false);
4051
+ if (!settled && clicks > 0) {
4052
+ const box = await challenge.locator.boundingBox({ timeout: 1_500 }).catch(() => null);
4053
+ if (box !== null && box.width > 0 && box.height > 0) {
4054
+ const verifyX = box.x + Math.min(box.width - 32, Math.max(32, box.width * 0.84));
4055
+ const verifyY = box.y + Math.min(box.height - 24, Math.max(24, box.height * 0.92));
4056
+ await this.bezierMouseTo(verifyX, verifyY);
4057
+ await this.sleep(rand(120, 320));
4058
+ await this.page.mouse.click(verifyX, verifyY);
4059
+ this.mouseX = verifyX;
4060
+ this.mouseY = verifyY;
4061
+ }
4062
+ settled = await this.waitForCaptchaChallengeToSettle(10_000).catch(() => false);
4063
+ }
4064
+ const responsePresent = await this.page
4065
+ .evaluate(() => {
4066
+ const ta = document.querySelector('textarea[name="h-captcha-response"], textarea[id^="h-captcha-response"]');
4067
+ return ta !== null && ta.value.length > 0;
4068
+ })
4069
+ .catch(() => false);
4070
+ const out = {
4071
+ found: true,
4072
+ solved: settled || responsePresent,
4073
+ clicks,
4074
+ durationMs: solveRes.durationMs,
4075
+ };
4076
+ if (!out.solved)
4077
+ out.reason = "challenge_still_visible";
4078
+ return out;
4079
+ }
4080
+ async findVisibleHcaptchaChallengeFrame() {
4081
+ if (!this.page)
4082
+ throw new Error("Browser not started");
4083
+ const selectors = [
4084
+ 'iframe[src*="hcaptcha.com"][src*="frame=challenge"]',
4085
+ 'iframe[src*="newassets.hcaptcha.com"][src*="frame=challenge"]',
4086
+ 'iframe[src*="hcaptcha.com"][src*="/challenge"]',
4087
+ 'iframe[src*="newassets.hcaptcha.com"][src*="/challenge"]',
4088
+ ];
4089
+ const deadline = Date.now() + 5_000;
4090
+ while (Date.now() < deadline) {
4091
+ for (const selector of selectors) {
4092
+ const locator = this.page.locator(selector);
4093
+ const count = await locator.count().catch(() => 0);
4094
+ for (let i = 0; i < count; i++) {
4095
+ const el = locator.nth(i);
4096
+ const box = await el.boundingBox({ timeout: 1_000 }).catch(() => null);
4097
+ if (box === null)
4098
+ continue;
4099
+ if (box.width < 180 || box.height < 160)
4100
+ continue;
4101
+ return { locator: el, box };
4102
+ }
4103
+ }
4104
+ await this.sleep(250);
4105
+ }
4106
+ return null;
4107
+ }
4108
+ async waitForCaptchaChallengeToSettle(timeoutMs = 4000, stableClearMs = 2_500) {
4109
+ if (!this.page)
4110
+ throw new Error("Browser not started");
4111
+ const hasVisibleChallenge = async () => await this.page.evaluate(() => {
4112
+ const visible = (el) => {
4113
+ const style = window.getComputedStyle(el);
4114
+ if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") {
4115
+ return false;
4116
+ }
4117
+ const r = el.getBoundingClientRect();
4118
+ return r.width > 8 && r.height > 8;
4119
+ };
4120
+ const selectors = [
4121
+ 'iframe[src*="hcaptcha.com"][src*="frame=challenge"]',
4122
+ 'iframe[src*="newassets.hcaptcha.com"][src*="frame=challenge"]',
4123
+ 'iframe[src*="recaptcha/api2/bframe"]',
4124
+ 'iframe[src*="challenges.cloudflare.com"]',
4125
+ ];
4126
+ return selectors.some((sel) => Array.from(document.querySelectorAll(sel)).some((el) => visible(el)));
4127
+ });
4128
+ const deadline = Date.now() + timeoutMs;
4129
+ let clearSince = null;
4130
+ while (Date.now() < deadline) {
4131
+ const visible = await hasVisibleChallenge().catch(() => false);
4132
+ if (!visible) {
4133
+ clearSince ??= Date.now();
4134
+ if (Date.now() - clearSince >= stableClearMs)
4135
+ return true;
4136
+ }
4137
+ else {
4138
+ clearSince = null;
4139
+ }
4140
+ await this.sleep(250);
4141
+ }
4142
+ return false;
4143
+ }
3256
4144
  // Small mouse wiggle near the current position. Used during prewarm
3257
4145
  // so the page sees pointer events before we navigate away.
3258
4146
  async jitterMouse() {
@@ -3286,6 +4174,7 @@ export class BrowserController {
3286
4174
  fullPage: false,
3287
4175
  type: "jpeg",
3288
4176
  quality: 70,
4177
+ timeout: 8_000,
3289
4178
  });
3290
4179
  return buffer.toString("base64");
3291
4180
  }
@@ -3313,7 +4202,7 @@ export class BrowserController {
3313
4202
  url: this.page.url(),
3314
4203
  title: await this.page.title(),
3315
4204
  html: await this.page.content(),
3316
- screenshot: await this.screenshot(),
4205
+ screenshot: await this.screenshot().catch(() => ""),
3317
4206
  };
3318
4207
  }
3319
4208
  async extractText() {
@@ -3336,6 +4225,117 @@ export class BrowserController {
3336
4225
  throw new Error("Browser not started");
3337
4226
  return await this.page.evaluate(() => document.body?.innerText ?? "");
3338
4227
  }
4228
+ async extractScopedRouteCandidates(prefix) {
4229
+ if (!this.page)
4230
+ throw new Error("Browser not started");
4231
+ return await this.page.evaluate(async (rawPrefix) => {
4232
+ const prefix = String(rawPrefix ?? "").replace(/^\/+|\/+$/g, "").toLowerCase();
4233
+ const candidates = [];
4234
+ const seen = new Set();
4235
+ const add = (value) => {
4236
+ if (typeof value !== "string")
4237
+ return;
4238
+ const trimmed = value.trim();
4239
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{1,127}$/.test(trimmed))
4240
+ return;
4241
+ if (seen.has(trimmed))
4242
+ return;
4243
+ seen.add(trimmed);
4244
+ candidates.push(trimmed);
4245
+ };
4246
+ const pathSegments = (href) => {
4247
+ try {
4248
+ return new URL(href, location.origin).pathname.split("/").filter(Boolean);
4249
+ }
4250
+ catch {
4251
+ return [];
4252
+ }
4253
+ };
4254
+ for (const anchor of Array.from(document.querySelectorAll("a[href]"))) {
4255
+ const segs = pathSegments(anchor.getAttribute("href") ?? "");
4256
+ if ((segs[0] ?? "").toLowerCase() === prefix)
4257
+ add(segs[1]);
4258
+ }
4259
+ const walk = (value) => {
4260
+ if (Array.isArray(value)) {
4261
+ for (const item of value)
4262
+ walk(item);
4263
+ return;
4264
+ }
4265
+ if (value === null || typeof value !== "object")
4266
+ return;
4267
+ const record = value;
4268
+ const preferredKeys = prefix === "p" || prefix.startsWith("project")
4269
+ ? ["slug", "projectSlug", "currentProjectSlug", "lastViewedProjectSlug", "id"]
4270
+ : prefix.startsWith("org") || prefix.startsWith("organization")
4271
+ ? ["slug", "orgSlug", "organizationSlug", "id"]
4272
+ : prefix.startsWith("workspace")
4273
+ ? ["slug", "workspaceSlug", "id"]
4274
+ : ["slug", "id"];
4275
+ for (const key of preferredKeys)
4276
+ add(record[key]);
4277
+ for (const item of Object.values(record))
4278
+ walk(item);
4279
+ };
4280
+ const inspectJsonText = (text) => {
4281
+ try {
4282
+ walk(JSON.parse(text));
4283
+ }
4284
+ catch {
4285
+ // Ignore non-JSON storage/API payloads.
4286
+ }
4287
+ };
4288
+ try {
4289
+ for (let i = 0; i < localStorage.length; i += 1) {
4290
+ const key = localStorage.key(i);
4291
+ if (key !== null)
4292
+ inspectJsonText(localStorage.getItem(key) ?? "");
4293
+ }
4294
+ for (let i = 0; i < sessionStorage.length; i += 1) {
4295
+ const key = sessionStorage.key(i);
4296
+ if (key !== null)
4297
+ inspectJsonText(sessionStorage.getItem(key) ?? "");
4298
+ }
4299
+ }
4300
+ catch {
4301
+ // Storage can be blocked in hardened contexts; DOM/API probes are enough.
4302
+ }
4303
+ const likelyListApi = (url) => {
4304
+ const lower = url.toLowerCase();
4305
+ if (!lower.includes("api"))
4306
+ return false;
4307
+ if (prefix === "p" || prefix.startsWith("project"))
4308
+ return /projects?[\w.-]*list|list[\w.-]*projects?/.test(lower);
4309
+ if (prefix.startsWith("org") || prefix.startsWith("organization"))
4310
+ return /organi[sz]ations?[\w.-]*list|orgs?[\w.-]*list|list[\w.-]*(orgs?|organi[sz]ations?)/.test(lower);
4311
+ if (prefix.startsWith("workspace"))
4312
+ return /workspaces?[\w.-]*list|list[\w.-]*workspaces?/.test(lower);
4313
+ return /list/.test(lower);
4314
+ };
4315
+ const urls = Array.from(new Set(performance
4316
+ .getEntriesByType("resource")
4317
+ .map((entry) => entry.name)
4318
+ .filter(likelyListApi))).slice(-8);
4319
+ for (const url of urls) {
4320
+ try {
4321
+ const controller = new AbortController();
4322
+ const timeout = window.setTimeout(() => controller.abort(), 1_500);
4323
+ const res = await fetch(url, {
4324
+ credentials: "include",
4325
+ signal: controller.signal,
4326
+ });
4327
+ window.clearTimeout(timeout);
4328
+ if (!res.ok)
4329
+ continue;
4330
+ inspectJsonText(await res.text());
4331
+ }
4332
+ catch {
4333
+ // Best-effort only; resolver falls back to text/href matching.
4334
+ }
4335
+ }
4336
+ return candidates.slice(0, 20);
4337
+ }, prefix);
4338
+ }
3339
4339
  // Discrete strings an API key might occupy — for credential
3340
4340
  // extraction. Gathered so a key is read WHOLE and un-glued from its
3341
4341
  // neighbours: extractText() concatenates the whole <body>, which
@@ -3984,31 +4984,39 @@ export class BrowserController {
3984
4984
  async waitForAuthWidgetHydration(timeoutMs = 8_000) {
3985
4985
  if (!this.page)
3986
4986
  return;
4987
+ const authWidgetHydrationProbe = String.raw `(() => {
4988
+ const vis = (el) => {
4989
+ const r = el.getBoundingClientRect();
4990
+ return r.width > 0 && r.height > 0;
4991
+ };
4992
+ const anyVis = (sel) =>
4993
+ Array.from(document.querySelectorAll(sel)).some(vis);
4994
+ const hasAuthInput = anyVis(
4995
+ 'input[type="email"],input[type="password"],input[name="email" i],input[name="password" i]',
4996
+ );
4997
+ let hasAuthButton = false;
4998
+ const re = /\b(sign\s?up|continue with|log ?in with|with google|with github|with sso|create account)\b/i;
4999
+ for (const el of Array.from(
5000
+ document.querySelectorAll('button,a[href],[role="button"]'),
5001
+ )) {
5002
+ if (!vis(el)) continue;
5003
+ if (re.test((el.textContent ?? "").trim())) {
5004
+ hasAuthButton = true;
5005
+ break;
5006
+ }
5007
+ }
5008
+ const spinnerVisible = anyVis(
5009
+ '[role="progressbar"],[aria-busy="true"],[class*="spin" i],[class*="loading" i],[class*="loader" i],.ant-spin,.MuiCircularProgress-root',
5010
+ );
5011
+ return { hasAuth: hasAuthInput || hasAuthButton, spinnerVisible };
5012
+ })()`;
3987
5013
  const deadline = Date.now() + timeoutMs;
3988
5014
  while (Date.now() < deadline) {
3989
5015
  try {
3990
- const state = await this.page.evaluate(() => {
3991
- const vis = (el) => {
3992
- const r = el.getBoundingClientRect();
3993
- return r.width > 0 && r.height > 0;
3994
- };
3995
- const anyVis = (sel) => Array.from(document.querySelectorAll(sel)).some(vis);
3996
- // Auth signal: a real form input or a recognizable provider /
3997
- // signup affordance.
3998
- const hasAuthInput = anyVis('input[type="email"],input[type="password"],input[name="email" i],input[name="password" i]');
3999
- let hasAuthButton = false;
4000
- const re = /\b(sign\s?up|continue with|log ?in with|with google|with github|with sso|create account)\b/i;
4001
- for (const el of Array.from(document.querySelectorAll('button,a[href],[role="button"]'))) {
4002
- if (!vis(el))
4003
- continue;
4004
- if (re.test((el.textContent ?? "").trim())) {
4005
- hasAuthButton = true;
4006
- break;
4007
- }
4008
- }
4009
- const spinnerVisible = anyVis('[role="progressbar"],[aria-busy="true"],[class*="spin" i],[class*="loading" i],[class*="loader" i],.ant-spin,.MuiCircularProgress-root');
4010
- return { hasAuth: hasAuthInput || hasAuthButton, spinnerVisible };
4011
- });
5016
+ const state = (await Promise.race([
5017
+ this.page.evaluate(authWidgetHydrationProbe),
5018
+ new Promise((_, reject) => setTimeout(() => reject(new Error("auth widget probe timed out")), 1_500)),
5019
+ ]));
4012
5020
  // Done the moment an auth signal appears, or once nothing is
4013
5021
  // spinning anymore (no point waiting on a page that simply has
4014
5022
  // no auth widget — a true OAuth-less/blank page bails honestly).
@@ -4037,20 +5045,23 @@ export class BrowserController {
4037
5045
  const deadline = Date.now() + timeoutMs;
4038
5046
  while (Date.now() < deadline) {
4039
5047
  try {
4040
- const count = await this.page.evaluate((min) => {
4041
- const sels = 'input,textarea,select,button,a[href],[role="button"],[role="menuitem"],[role="option"]';
4042
- const nodes = Array.from(document.querySelectorAll(sels));
4043
- let visible = 0;
4044
- for (const n of nodes) {
4045
- const el = n;
4046
- const r = el.getBoundingClientRect();
4047
- if (r.width >= 2 && r.height >= 2)
4048
- visible++;
4049
- if (visible >= min)
4050
- return visible;
4051
- }
4052
- return visible;
4053
- }, minElements);
5048
+ const count = await Promise.race([
5049
+ this.page.evaluate((min) => {
5050
+ const sels = 'input,textarea,select,button,a[href],[role="button"],[role="menuitem"],[role="option"]';
5051
+ const nodes = Array.from(document.querySelectorAll(sels));
5052
+ let visible = 0;
5053
+ for (const n of nodes) {
5054
+ const el = n;
5055
+ const r = el.getBoundingClientRect();
5056
+ if (r.width >= 2 && r.height >= 2)
5057
+ visible++;
5058
+ if (visible >= min)
5059
+ return visible;
5060
+ }
5061
+ return visible;
5062
+ }, minElements),
5063
+ new Promise((_, reject) => setTimeout(() => reject(new Error("interactive DOM probe timed out")), 1_500)),
5064
+ ]);
4054
5065
  if (count >= minElements)
4055
5066
  return;
4056
5067
  }
@@ -4448,9 +5459,22 @@ export class BrowserController {
4448
5459
  const selectorFor = (el) => {
4449
5460
  const tag = el.tagName.toLowerCase();
4450
5461
  let base;
5462
+ const testId = el.getAttribute("data-testid") ??
5463
+ el.getAttribute("data-test-id") ??
5464
+ el.getAttribute("data-test") ??
5465
+ el.getAttribute("data-cy") ??
5466
+ el.getAttribute("data-qa");
4451
5467
  const id = el.getAttribute("id");
4452
5468
  const name = el.getAttribute("name");
4453
- if (id !== null && /^[A-Za-z][\w-]*$/.test(id)) {
5469
+ if (testId !== null && testId.length > 0) {
5470
+ const attr = el.hasAttribute("data-testid") ? "data-testid" :
5471
+ el.hasAttribute("data-test-id") ? "data-test-id" :
5472
+ el.hasAttribute("data-test") ? "data-test" :
5473
+ el.hasAttribute("data-cy") ? "data-cy" :
5474
+ "data-qa";
5475
+ base = `[${attr}="${CSS.escape(testId)}"]`;
5476
+ }
5477
+ else if (id !== null && /^[A-Za-z][\w-]*$/.test(id)) {
4454
5478
  base = `#${id}`;
4455
5479
  }
4456
5480
  else if (name !== null && name.length > 0) {
@@ -5210,6 +6234,53 @@ export class BrowserController {
5210
6234
  if (!this.page)
5211
6235
  throw new Error("Browser not started");
5212
6236
  if (provider === "github") {
6237
+ // GitHub App install flow can include an account target chooser before
6238
+ // the Install/Authorize screen:
6239
+ // /apps/<app>/installations/select_target
6240
+ // It renders account/org cards as links/buttons, not as an approve
6241
+ // button. Advance exactly one visible target and let the caller's
6242
+ // consent loop re-classify the next GitHub page.
6243
+ if (/\/apps\/[^/]+\/installations\/select_target\b/.test(new URL(this.page.url()).pathname)) {
6244
+ const startUrl = this.page.url();
6245
+ const clicked = await this.page
6246
+ .evaluate(() => {
6247
+ const visible = (el) => {
6248
+ const r = el.getBoundingClientRect();
6249
+ const s = window.getComputedStyle(el);
6250
+ return (r.width > 2 &&
6251
+ r.height > 2 &&
6252
+ s.display !== "none" &&
6253
+ s.visibility !== "hidden" &&
6254
+ parseFloat(s.opacity || "1") > 0.01);
6255
+ };
6256
+ const bad = /\b(settings|marketplace|learn more|cancel|skip|back|terms|privacy)\b/i;
6257
+ const candidates = Array.from(document.querySelectorAll('a[href], button, [role="button"], [role="link"]')).filter((el) => visible(el));
6258
+ const byHref = candidates.find((el) => {
6259
+ const href = el instanceof HTMLAnchorElement ? el.href : el.getAttribute("href") ?? "";
6260
+ return /\/installations\/(?:new|permissions)\b/.test(href);
6261
+ });
6262
+ const target = byHref ??
6263
+ candidates.find((el) => {
6264
+ const text = (el.textContent ?? "").replace(/\s+/g, " ").trim();
6265
+ if (text.length === 0 || text.length > 80 || bad.test(text))
6266
+ return false;
6267
+ return true;
6268
+ });
6269
+ if (target === undefined)
6270
+ return false;
6271
+ target.click();
6272
+ return true;
6273
+ })
6274
+ .catch(() => false);
6275
+ if (clicked) {
6276
+ const advanced = await this.page
6277
+ .waitForFunction((s) => window.location.href !== s, startUrl, { timeout: 8000 })
6278
+ .then(() => true)
6279
+ .catch(() => false);
6280
+ if (advanced)
6281
+ return true;
6282
+ }
6283
+ }
5213
6284
  // GitHub consent screen variants:
5214
6285
  // Classic OAuth: "Authorize <app>"
5215
6286
  // GitHub App (install + auth): "Authorize <app>", "Install",
@@ -5468,6 +6539,8 @@ export class BrowserController {
5468
6539
  catch {
5469
6540
  /* already gone */
5470
6541
  }
6542
+ if (this.childChrome.pid !== undefined)
6543
+ selfManagedChromePids.delete(this.childChrome.pid);
5471
6544
  this.childChrome = null;
5472
6545
  }
5473
6546
  // …and context.close() doesn't always kill the browser: headed Chrome
@@ -5563,8 +6636,10 @@ export function pickSubmitButtonIndex(texts) {
5563
6636
  // `checkRequiredAgreementBoxes`. The evaluate runs in the page realm and
5564
6637
  // can't import, so the same two regexes are inlined there verbatim —
5565
6638
  // keep them BYTE-IDENTICAL with these.
5566
- const AGREEMENT_TEXT_RE = /terms|tos\b|privacy|consent|policy|i agree|agree to|acknowledge|gdpr/i;
6639
+ const AGREEMENT_TEXT_RE = /terms|tos\b|privacy|consent|policy|i agree|agree to|acknowledge|gdpr|age|18\+|18 years|certif/i;
5567
6640
  const MARKETING_TEXT_RE = /newsletter|updates|offers|product tips|marketing|promotional|receive emails|opt[- ]?in to|subscribe/i;
6641
+ const SAFE_SIGNUP_CHOICE_TEXT_RE = /digital products?|saas|software|developer tools?|apis?|mobile apps?|data|analytics/i;
6642
+ const RISKY_SIGNUP_CHOICE_TEXT_RE = /gambling|financial services?|physical products?|marketplace|human services?|adult|weapons?|medical|restricted|crypto|payments?|banking/i;
5568
6643
  // True when a checkbox's associated text reads as a REQUIRED agreement
5569
6644
  // (terms/privacy/consent) and NOT as a marketing/newsletter opt-in.
5570
6645
  //
@@ -5578,6 +6653,15 @@ const MARKETING_TEXT_RE = /newsletter|updates|offers|product tips|marketing|prom
5578
6653
  export function isAgreementCheckboxText(text) {
5579
6654
  return AGREEMENT_TEXT_RE.test(text) && !MARKETING_TEXT_RE.test(text);
5580
6655
  }
6656
+ // True when a required signup-category choice is a low-risk default the bot can
6657
+ // select deterministically. Keep byte-identical with the in-page regexes in
6658
+ // `checkRequiredSignupChoiceBoxes`.
6659
+ export function isSafeSignupChoiceText(text) {
6660
+ return (SAFE_SIGNUP_CHOICE_TEXT_RE.test(text) &&
6661
+ !RISKY_SIGNUP_CHOICE_TEXT_RE.test(text) &&
6662
+ !AGREEMENT_TEXT_RE.test(text) &&
6663
+ !MARKETING_TEXT_RE.test(text));
6664
+ }
5581
6665
  // Parse a UNIVERSAL_BOT_PROXY_URL — e.g. "http://user:pass@host:8080" or
5582
6666
  // "socks5://host:1080" — into Playwright's proxy option shape. Playwright
5583
6667
  // wants credentials separate from `server`, so we split them out and