@trusty-squire/mcp 0.6.15-rc.9 → 0.7.0

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 (48) hide show
  1. package/dist/bin.js +8 -0
  2. package/dist/bin.js.map +1 -1
  3. package/dist/bot/agent.d.ts +39 -0
  4. package/dist/bot/agent.d.ts.map +1 -1
  5. package/dist/bot/agent.js +1341 -20
  6. package/dist/bot/agent.js.map +1 -1
  7. package/dist/bot/browser.d.ts +13 -0
  8. package/dist/bot/browser.d.ts.map +1 -1
  9. package/dist/bot/browser.js +573 -31
  10. package/dist/bot/browser.js.map +1 -1
  11. package/dist/bot/captcha-solver-2captcha.d.ts +42 -0
  12. package/dist/bot/captcha-solver-2captcha.d.ts.map +1 -0
  13. package/dist/bot/captcha-solver-2captcha.js +144 -0
  14. package/dist/bot/captcha-solver-2captcha.js.map +1 -0
  15. package/dist/bot/index.d.ts +2 -0
  16. package/dist/bot/index.d.ts.map +1 -1
  17. package/dist/bot/index.js +2 -0
  18. package/dist/bot/index.js.map +1 -1
  19. package/dist/bot/llm-client.d.ts +2 -1
  20. package/dist/bot/llm-client.d.ts.map +1 -1
  21. package/dist/bot/llm-client.js +19 -2
  22. package/dist/bot/llm-client.js.map +1 -1
  23. package/dist/bot/notify-api.d.ts +2 -0
  24. package/dist/bot/notify-api.d.ts.map +1 -1
  25. package/dist/bot/notify-api.js +13 -5
  26. package/dist/bot/notify-api.js.map +1 -1
  27. package/dist/bot/promote-to-skill.d.ts +9 -0
  28. package/dist/bot/promote-to-skill.d.ts.map +1 -1
  29. package/dist/bot/promote-to-skill.js +98 -7
  30. package/dist/bot/promote-to-skill.js.map +1 -1
  31. package/dist/bot/read-otp.d.ts +14 -0
  32. package/dist/bot/read-otp.d.ts.map +1 -0
  33. package/dist/bot/read-otp.js +96 -0
  34. package/dist/bot/read-otp.js.map +1 -0
  35. package/dist/bot/redact.d.ts +2 -0
  36. package/dist/bot/redact.d.ts.map +1 -0
  37. package/dist/bot/redact.js +61 -0
  38. package/dist/bot/redact.js.map +1 -0
  39. package/dist/bot/telegram-notify.d.ts +8 -0
  40. package/dist/bot/telegram-notify.d.ts.map +1 -0
  41. package/dist/bot/telegram-notify.js +134 -0
  42. package/dist/bot/telegram-notify.js.map +1 -0
  43. package/dist/skill-cli/cli.js +14 -3
  44. package/dist/skill-cli/cli.js.map +1 -1
  45. package/dist/tools/provision-any.d.ts.map +1 -1
  46. package/dist/tools/provision-any.js +26 -1
  47. package/dist/tools/provision-any.js.map +1 -1
  48. package/package.json +5 -2
package/dist/bot/agent.js CHANGED
@@ -11,6 +11,10 @@ import { rankAndCapInventory, scoreSignupButton } from "./browser.js";
11
11
  import { OAUTH_PROVIDERS, extractOAuthScopes, isGitHubDismissible2faSetup, GITHUB_DISMISSIBLE_2FA_SKIP_TEXT, } from "./oauth-providers.js";
12
12
  import { extractGoogleNumberMatch, scrapeGoogleScopePhrases } from "./google-login.js";
13
13
  import { notifyHeightenedAuth } from "./notify-api.js";
14
+ import { sendTelegramHeightenedAuth } from "./telegram-notify.js";
15
+ import { TwoCaptchaSolver } from "./captcha-solver-2captcha.js";
16
+ import { redactCredentials } from "./redact.js";
17
+ import { readOperatorOtp, fromDomainFromUrl } from "./read-otp.js";
14
18
  import { loggedInProviders, clearProviderLoggedIn } from "./login-state.js";
15
19
  import { saveDebugSnapshot } from "./debug.js";
16
20
  import { captureOnboardingRound } from "./onboarding-capture.js";
@@ -75,8 +79,20 @@ const ONBOARDING_PAYWALL_PATTERNS = [
75
79
  /\benter\s+your\s+card\b/i,
76
80
  /\benter\s+your\s+payment\b/i,
77
81
  /\benter\s+payment\s+details\b/i,
82
+ /\bconnect\s+a(?:\s+valid)?\s+payment\s+method\b/i,
83
+ /\byour\s+(?:free\s+)?trial\s+(?:is\s+)?ending\b/i,
78
84
  /\bupgrade\s+your\s+plan\s+to\b/i,
79
85
  /\bstart\s+your\s+paid\s+plan\b/i,
86
+ // rc.39 — Koyeb-class. Cover the variants the post-verify planner
87
+ // produces in its `done` reason when it gives up on a billing wall:
88
+ // "requires credit card payment", "credit card verification wall",
89
+ // "payment wall", "Pro plan payment required", "complete billing".
90
+ /\brequir(?:es?|ing)\s+(?:a\s+)?credit\s+card\b/i,
91
+ /\b(?:credit\s+card|payment)\s+wall\b/i,
92
+ /\bcredit\s+card\s+verification\b/i,
93
+ /\b(?:plan\s+|account\s+)?payment\s+required\b/i,
94
+ /\bcomplet(?:e|ing)\s+(?:billing|payment)\b/i,
95
+ /\bbilling\s+setup\s+(?:is\s+)?required\b/i,
80
96
  ];
81
97
  // Negators that, if they appear in the ~30 characters immediately
82
98
  // before a paywall pattern match, flip its meaning from a demand
@@ -206,13 +222,22 @@ function extractJsonObject(raw) {
206
222
  // Tolerate models that wrap their reply in markdown fences.
207
223
  const fenced = trimmed.match(/```(?:json)?\s*([\s\S]+?)\s*```/);
208
224
  const candidate = fenced !== null && fenced[1] !== undefined ? fenced[1] : trimmed;
209
- const match = candidate.match(/\{[\s\S]*\}/);
210
- if (match === null) {
225
+ // F7 / 0.6.15-rc.11 — stack-based first-balanced-object extraction.
226
+ // The previous regex `\{[\s\S]*\}` was greedy and matched from the
227
+ // first `{` to the LAST `}` in the string. When an LLM emitted
228
+ // multiple JSON objects (e.g. {"kind":"fill",…}\n{"kind":"click",…}),
229
+ // the greedy match spanned both and JSON.parse failed with
230
+ // "Unexpected non-whitespace character after JSON at position N".
231
+ // The stack walker finds the first balanced `{…}` block respecting
232
+ // string-literal boundaries, so a single object always parses cleanly
233
+ // even when the model appends trailing prose or extra objects.
234
+ const objText = extractFirstBalancedObject(candidate);
235
+ if (objText === null) {
211
236
  throw new Error(`no JSON object in reply: ${raw.slice(0, 200)}`);
212
237
  }
213
238
  let parsed;
214
239
  try {
215
- parsed = JSON.parse(match[0]);
240
+ parsed = JSON.parse(objText);
216
241
  }
217
242
  catch (err) {
218
243
  throw new Error(`JSON.parse failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -228,6 +253,47 @@ function extractJsonObject(raw) {
228
253
  obj[k] = v;
229
254
  return obj;
230
255
  }
256
+ // Find the first balanced `{ … }` block in `s`, respecting string
257
+ // literals and escapes. Returns the substring (inclusive of braces) or
258
+ // null when no balanced block exists. Tolerates trailing text after
259
+ // the closing brace (which is the whole reason we need it — the
260
+ // previous greedy regex couldn't).
261
+ function extractFirstBalancedObject(s) {
262
+ const open = s.indexOf("{");
263
+ if (open < 0)
264
+ return null;
265
+ let depth = 0;
266
+ let inString = false;
267
+ let escape = false;
268
+ for (let i = open; i < s.length; i++) {
269
+ const c = s[i];
270
+ if (escape) {
271
+ escape = false;
272
+ continue;
273
+ }
274
+ if (inString) {
275
+ if (c === "\\") {
276
+ escape = true;
277
+ continue;
278
+ }
279
+ if (c === '"')
280
+ inString = false;
281
+ continue;
282
+ }
283
+ if (c === '"') {
284
+ inString = true;
285
+ continue;
286
+ }
287
+ if (c === "{")
288
+ depth++;
289
+ else if (c === "}") {
290
+ depth--;
291
+ if (depth === 0)
292
+ return s.slice(open, i + 1);
293
+ }
294
+ }
295
+ return null;
296
+ }
231
297
  // Narrow `unknown` to a non-null object map.
232
298
  function asObject(value, context) {
233
299
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
@@ -335,6 +401,31 @@ export function parseSignupPlan(raw, allowedSelectors) {
335
401
  // with "Email" both in the body CTA and the footer made the planner
336
402
  // pick the wrong one and loop. Single-occurrence text is rendered
337
403
  // without the landmark tag to keep the inventory terse.
404
+ //
405
+ // rc.17 — split keyboard-shortcut suffixes out of button labels.
406
+ // Resend / Linear / Notion-style buttons render the shortcut hint
407
+ // glued to the label without a separator: "AddCtrl↩", "CancelEsc",
408
+ // "Save⌘↩". The planner reads "AddCtrl↩" and gets confused — the
409
+ // Resend trace showed the planner clicking "All domains" thinking
410
+ // it was the Add button (because "AddCtrl↩" didn't pattern-match
411
+ // "Add" cleanly). Exported for unit testing.
412
+ export function splitKeyboardShortcut(text) {
413
+ // Trailing keyboard hint = optional modifier (Ctrl/⌘/Cmd/Shift/Alt/
414
+ // Opt/Option/Meta), optional `+`, then one of: arrow-return symbols,
415
+ // named keys (Enter/Esc/Tab/Space/Return), or a single letter / Fn key.
416
+ // The suffix MUST start at a word boundary OR a transition from
417
+ // lowercase to uppercase ("AddCtrl" — Add↑↓Ctrl). Anchored to end-
418
+ // of-string.
419
+ const re = /(?<=[a-z])(Ctrl|⌘|Cmd|Shift|Alt|Opt(?:ion)?|Meta)?\+?(?:[↩⏎⌫⌦⇧⌘⎋]|Enter|Esc|Tab|Space|Return|F\d{1,2})$/;
420
+ const m = re.exec(text);
421
+ if (m === null)
422
+ return { label: text, shortcut: null };
423
+ const label = text.slice(0, m.index).trim();
424
+ if (label.length < 2)
425
+ return { label: text, shortcut: null };
426
+ const shortcut = m[0];
427
+ return { label, shortcut };
428
+ }
338
429
  export function formatInventory(inventory) {
339
430
  if (inventory.length === 0)
340
431
  return "(no interactive elements found on the page)";
@@ -411,7 +502,10 @@ export function formatInventory(inventory) {
411
502
  e.tag !== "textarea" &&
412
503
  e.tag !== "select" &&
413
504
  e.visibleText !== null) {
414
- bits.push(`text=${JSON.stringify(e.visibleText)}`);
505
+ const { label, shortcut } = splitKeyboardShortcut(e.visibleText);
506
+ bits.push(`text=${JSON.stringify(label)}`);
507
+ if (shortcut !== null)
508
+ bits.push(`shortcut=${JSON.stringify(shortcut)}`);
415
509
  }
416
510
  if (e.inConsentWidget)
417
511
  bits.push("[cookie-consent — avoid]");
@@ -618,14 +712,28 @@ export function detectAlreadySignedIn(args) {
618
712
  let dashboardyPath = false;
619
713
  try {
620
714
  const parsed = new URL(url);
715
+ // rc.37 — widened the dashboard-path allowlist after the rc.35
716
+ // sweep showed Upstash's post-OAuth landing was /redis (the
717
+ // product-segment route, not a generic /dashboard). Added
718
+ // /redis, /kafka, /vector, /cluster, /databases?, /instances?,
719
+ // /apps?, /deployments?, /services? — all common product-name
720
+ // routes that almost always indicate authenticated state.
621
721
  dashboardyPath =
622
- /\/(?:new|dashboard|projects?|account|settings|workspace|home)(?:\/|$)/i.test(parsed.pathname) && !/\/(?:signup|sign-up|register|login|sign-in|signin)/i.test(parsed.pathname);
722
+ /\/(?:new|dashboard|projects?|account|settings|workspace|home|redis|kafka|vector|cluster|databases?|instances?|apps?|deployments?|services?)(?:\/|$)/i.test(parsed.pathname) && !/\/(?:signup|sign-up|register|login|sign-in|signin)/i.test(parsed.pathname);
623
723
  }
624
724
  catch {
625
725
  // Malformed URL — skip URL signal.
626
726
  }
627
727
  if (dashboardyPath) {
628
- const CREATION_CTA = /^\s*(?:\+\s*)?(?:new\s+(?:project|workspace|team|app|site|deployment|api\s*key)|create(?:\s+(?:new|a|project|workspace))?)/i;
728
+ // rc.37 widened the creation-CTA vocabulary to include the
729
+ // dashboard-y "Create <product-noun>" pattern. Upstash's
730
+ // dashboard CTA reads "Create Database"; Convex / Neon /
731
+ // PlanetScale / similar all use this shape ("Create cluster",
732
+ // "Create instance", "Create deployment"). Without this the
733
+ // bot's F17 already-signed-in path fell through to form-fill
734
+ // and the planner clicked the CTA thinking it was a signup
735
+ // submit button.
736
+ const CREATION_CTA = /^\s*(?:\+\s*)?(?:new\s+(?:project|workspace|team|app|site|deployment|api\s*key|database|cluster|instance|service)|create(?:\s+(?:new|a|project|workspace|database|cluster|instance|deployment|app|service|index|environment))?)/i;
629
737
  if (inventory.some((e) => {
630
738
  const t = e.visibleText ?? e.ariaLabel ?? "";
631
739
  return CREATION_CTA.test(t.trim());
@@ -635,6 +743,46 @@ export function detectAlreadySignedIn(args) {
635
743
  }
636
744
  return false;
637
745
  }
746
+ // rc.39 — companion to detectAlreadySignedIn for the form-fill stage.
747
+ // The URL-based dashboard check above conservatively excludes /sign-up,
748
+ // /signup, /register paths — but services like PlanetScale and Turso
749
+ // serve a logged-in create-database / billing-wall form at /sign-up
750
+ // when the user has an active session. The form-fill planner reliably
751
+ // describes what it sees: "create the database on this PS-5 plan",
752
+ // "Add credit card", "database name". Match those phrases in the
753
+ // planner's notes and action reasons to pivot to post-verify instead
754
+ // of fooling the form-fill loop into clicking a "create database"
755
+ // button it mistakes for a signup submit.
756
+ //
757
+ // Patterns are intentionally conservative — they must mention an
758
+ // authenticated-state product/billing noun, not just any verb. A
759
+ // regular signup form's planner output ("name field", "submit
760
+ // button") shouldn't match.
761
+ export function detectFormFillIsDashboard(plan) {
762
+ const haystack = [
763
+ plan.notes ?? "",
764
+ ...plan.actions.map((a) => a.reason ?? ""),
765
+ ]
766
+ .join(" ")
767
+ .toLowerCase();
768
+ // Billing / payment wall — the planner sees a credit-card / billing
769
+ // form, which is never a signup form.
770
+ const BILLING_WALL = /\b(?:add (?:a )?(?:credit card|payment method)|enter (?:your )?(?:credit card|payment)|billing (?:information|details)|payment information required)\b/;
771
+ if (BILLING_WALL.test(haystack))
772
+ return true;
773
+ // Product-creation form — the planner describes creating a
774
+ // database / cluster / instance / deployment / app / project /
775
+ // workspace / service, which is post-signup territory.
776
+ const PRODUCT_CREATION = /\b(?:create(?:s|d)?|creating|provision(?:s|ed|ing)?)\s+(?:(?:the|a|an|new|your|this)\s+){0,3}(?:database|cluster|instance|deployment|app|service|project|workspace|index|environment|tenant)\b/;
777
+ if (PRODUCT_CREATION.test(haystack))
778
+ return true;
779
+ // Explicit "not a signup" / "logged in" / "dashboard" statements
780
+ // from the planner.
781
+ const EXPLICIT = /\b(?:not\s+(?:a\s+)?(?:sign-?up|signup)|already\s+(?:signed[\s-]?in|logged[\s-]?in|authenticated)|logged[\s-]?in (?:dashboard|user))\b/;
782
+ if (EXPLICIT.test(haystack))
783
+ return true;
784
+ return false;
785
+ }
638
786
  // True when the page has no fillable text input AND no button that
639
787
  // reads as an email-signup option — a genuinely OAuth/SSO-only
640
788
  // service with no form to automate (F3 Issue 4).
@@ -686,6 +834,19 @@ export function isOauthOnlyChooser(inventory) {
686
834
  // or "Google's Privacy Policy" out.
687
835
  // Returns null when the page has no such affordance — the planner then
688
836
  // falls back to form-fill. Exported for unit testing.
837
+ //
838
+ // rc.12 — sanity-cap the element's own visible text. A real sign-in
839
+ // button is short ("Continue with Google" = 19 chars, "Sign in with
840
+ // GitHub" = 19). When the element's visibleText runs longer than the
841
+ // cap below, it is wrapping unrelated content — typically a marketing
842
+ // card with a small provider logo nested inside. The OpenRouter case:
843
+ // an <a> wrapping a model card whose textContent reads "anthropic/
844
+ // claude-opus-4.7Model routing visualization…" and whose descendant
845
+ // tree contains an <img alt="Google"> for a tiny G icon. The iconLabel
846
+ // path then fired against the wrong element. Capping at 60 chars also
847
+ // gates path 2 to truly icon-only elements (no own visible text) so a
848
+ // card wrapper with one stray <img alt> can never match.
849
+ const MAX_OAUTH_BUTTON_TEXT_CHARS = 60;
689
850
  export function findOAuthButton(inventory, provider) {
690
851
  const keyword = OAUTH_PROVIDERS[provider].buttonKeyword;
691
852
  const keywordRe = new RegExp(`\\b${keyword}\\b`);
@@ -699,17 +860,26 @@ export function findOAuthButton(inventory, provider) {
699
860
  e.type === "button";
700
861
  if (!isButtonish)
701
862
  continue;
863
+ const visibleText = (e.visibleText ?? "").trim();
864
+ if (visibleText.length > MAX_OAUTH_BUTTON_TEXT_CHARS)
865
+ continue;
702
866
  // 1. An <a> whose href routes through the provider's OAuth endpoint.
703
867
  const href = (e.href ?? "").toLowerCase();
704
868
  if (href.length > 0 && hrefRe.test(href))
705
869
  return e;
706
- // 2. Icon-only button — named only by a descendant img/svg.
707
- if (keywordRe.test((e.iconLabel ?? "").toLowerCase()))
870
+ // 2. Icon-only button — named only by a descendant img/svg. Require
871
+ // the element to be truly icon-only (no own visible text); a
872
+ // populated visibleText means the iconLabel signal is redundant
873
+ // with path 3 below, and accepting it here lets a card wrapper
874
+ // with a stray <img alt="Google"> inside match.
875
+ if (visibleText.length === 0 &&
876
+ keywordRe.test((e.iconLabel ?? "").toLowerCase())) {
708
877
  return e;
878
+ }
709
879
  // 3. Visible text / accessible label naming the provider + an
710
880
  // auth verb. The auth verb requirement rejects nav and policy
711
881
  // links that merely mention the provider.
712
- const text = `${e.visibleText ?? ""} ${e.ariaLabel ?? ""} ${e.labelText ?? ""}`
882
+ const text = `${visibleText} ${e.ariaLabel ?? ""} ${e.labelText ?? ""}`
713
883
  .toLowerCase()
714
884
  .replace(/\s+/g, " ")
715
885
  .trim();
@@ -718,9 +888,140 @@ export function findOAuthButton(inventory, provider) {
718
888
  if (/\b(sign|signup|signin|continue|log ?in|connect|auth)\b/.test(text)) {
719
889
  return e;
720
890
  }
891
+ // rc.39 — minimal-label OAuth buttons. Some auth UIs render the
892
+ // provider as a bare keyword button: just "GitHub" or just "Google"
893
+ // (Turso, several Stytch / Clerk / Auth0 templates). When the
894
+ // VISIBLE text is essentially nothing but the provider keyword,
895
+ // accept it — no auth-verb required. The keyword regex already
896
+ // ensured the provider name is present; the length cap MAX_OAUTH_
897
+ // BUTTON_TEXT_CHARS (60) ensures it's still buttonish, not a
898
+ // paragraph that happens to mention the provider.
899
+ const stripped = visibleText.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
900
+ if (stripped === keyword || stripped === `with ${keyword}`) {
901
+ return e;
902
+ }
721
903
  }
722
904
  return null;
723
905
  }
906
+ // rc.20 — true when the post-OAuth landing page LOOKS like the same
907
+ // login/auth UI the bot just OAuth'd from AND still surfaces an OAuth
908
+ // affordance for the same provider. Services like Groq complete the
909
+ // Google handshake server-side but bounce the user back to a
910
+ // /authenticate page that requires one more click of the provider
911
+ // button to actually finalize the session. Returns the OAuth button
912
+ // to click (so the caller can pass it to startOAuth) or null when the
913
+ // page is past that gate.
914
+ //
915
+ // Login-shaped path patterns: /login, /signin, /sign-in, /signup,
916
+ // /sign-up, /auth, /authenticate, /authorize. Excludes /callback
917
+ // (genuinely transient) and dashboard-shaped paths.
918
+ export function isLoginLoopState(url, inventory, provider) {
919
+ let path;
920
+ try {
921
+ path = new URL(url).pathname.toLowerCase();
922
+ }
923
+ catch {
924
+ return null;
925
+ }
926
+ const loginPath = /\/(?:login|signin|sign-in|signup|sign-up|auth|authenticate|authorize)(?:\/|$)/.test(path);
927
+ if (!loginPath)
928
+ return null;
929
+ // Defense in depth: if the page also shows authenticated-state
930
+ // markers (Sign out / Dashboard / billing widget) it isn't really
931
+ // a login loop — the OAuth-buttons are decoration. detectAlreadySignedIn
932
+ // returns false when ANY credential input is visible, but here we're
933
+ // checking the inverse — markers despite the login path.
934
+ if (detectAlreadySignedIn({ inventory, url }))
935
+ return null;
936
+ return findOAuthButton(inventory, provider);
937
+ }
938
+ // Path-only formatter for step trail entries. Same parse semantics as
939
+ // isLoginLoopState — best-effort, returns "(unparseable)" on failure.
940
+ export function pathOf(url) {
941
+ try {
942
+ return new URL(url).pathname;
943
+ }
944
+ catch {
945
+ return "(unparseable)";
946
+ }
947
+ }
948
+ // (a) Manual-login fallback. DigitalOcean + Hyperbolic completed the
949
+ // Google OAuth handshake on Google's side but the service didn't
950
+ // honour the callback — the bot lands back on a manual /login page
951
+ // with email + password inputs. Distinct from the rc.20 login-loop
952
+ // (which assumes the page still surfaces OAuth provider buttons).
953
+ export function detectManualLoginFallback(url, inventory) {
954
+ let path;
955
+ try {
956
+ path = new URL(url).pathname.toLowerCase();
957
+ }
958
+ catch {
959
+ return false;
960
+ }
961
+ const loginPath = /\/(?:login|signin|sign-in|signup|sign-up|authenticate)(?:\/|$)/.test(path);
962
+ if (!loginPath)
963
+ return false;
964
+ // Manual-login signal: email + password input pair on the page.
965
+ const hasEmail = inventory.some((e) => e.tag === "input" && e.type === "email");
966
+ const hasPassword = inventory.some((e) => e.tag === "input" && e.type === "password");
967
+ return hasEmail && hasPassword;
968
+ }
969
+ // (b) Email-OTP wall. Porter, Koyeb (both WorkOS-backed) send a 6-
970
+ // or 8-digit code to the operator's email and gate further access
971
+ // behind it. Signal: URL path or title literal "email-verification"
972
+ // + a single short-numeric input in the inventory.
973
+ export function detectEmailOtpGate(url, title, pageText) {
974
+ let path;
975
+ try {
976
+ path = new URL(url).pathname.toLowerCase();
977
+ }
978
+ catch {
979
+ path = "";
980
+ }
981
+ if (/email[-_]verification|verify[-_]email|email[-_]code|otp/.test(path)) {
982
+ return true;
983
+ }
984
+ const titleLower = title.toLowerCase();
985
+ if (titleLower.includes("verify your email") ||
986
+ titleLower.includes("email verification")) {
987
+ return true;
988
+ }
989
+ // Page-text fallback for services that route OTP gates through
990
+ // generic URLs (the bot has the rendered body anyway). Conservative
991
+ // phrasing — must include a "we sent … code … to" or "enter …
992
+ // code … sent" shape with a bounded gap.
993
+ const lower = pageText.toLowerCase();
994
+ return (/we sent[^.]{0,60}\bcode\b[^.]{0,40}to\b/.test(lower) ||
995
+ /enter[^.]{0,40}\bcode\b[^.]{0,40}\b(?:sent|email)/.test(lower));
996
+ }
997
+ // (c) SSO restriction (Fly.io class). Service rejects token-creation
998
+ // for accounts whose org enforces SSO/SAML. Signal: phrase fragments
999
+ // that explicitly name SSO/SAML/Single Sign-On as the blocker.
1000
+ export function detectSsoRestriction(pageText) {
1001
+ const lower = pageText.toLowerCase();
1002
+ // Common phrasings observed: "managed via SSO", "SSO-managed",
1003
+ // "Single Sign-On is required", "SSO organization membership".
1004
+ return /(?:managed\s+via\s+(?:sso|single\s+sign-?on)|sso[\s-]?managed|sso\s+organization|single\s+sign-?on\s+is\s+required|enforced\s+by\s+(?:sso|saml))/.test(lower);
1005
+ }
1006
+ // (d) Stuck-on-Google-OAuth-screens (Upstash class). After
1007
+ // settleAfterOAuth the URL is STILL on accounts.google.com — the
1008
+ // handshake didn't redirect through to the service. Most common
1009
+ // shape: Clerk-mediated OAuth (Upstash's auth.upstash.com → Google
1010
+ // account chooser) where the chooser uses a clickable card the
1011
+ // post-verify planner can't reliably target, and the bot loops
1012
+ // trying. Defining trait: hostname accounts.google.com (or
1013
+ // accounts.googleusercontent.com) at the post-OAuth gate.
1014
+ export function detectStuckOnGoogleOAuth(url) {
1015
+ try {
1016
+ const h = new URL(url).hostname.toLowerCase();
1017
+ return (h === "accounts.google.com" ||
1018
+ h === "accounts.googleusercontent.com" ||
1019
+ h.endsWith(".accounts.google.com"));
1020
+ }
1021
+ catch {
1022
+ return false;
1023
+ }
1024
+ }
724
1025
  // Scan the inventory for the first OAuth affordance among `providers`,
725
1026
  // in order — the auto-prefer decision passes every provider the
726
1027
  // profile has a session for. Returns the matched provider + element.
@@ -917,8 +1218,187 @@ export function extractQuotedTokenFromReason(reason, pageText) {
917
1218
  if (pageText.includes(candidate))
918
1219
  return candidate;
919
1220
  }
1221
+ // rc.36 — unquoted UUID fallback. Upstash's API key dialog shows a
1222
+ // bare UUID (`b7dd0ff0-2497-4dc8-a793-8261a38e0339`) and the
1223
+ // planner quotes it WITHOUT surrounding quote marks ("The full API
1224
+ // key b7dd0ff0-… is visible"). The verbatim-in-page check is the
1225
+ // safety net — random UUIDs sprinkled across dashboards (trace IDs,
1226
+ // project IDs) only false-positive if the SAME UUID appears in the
1227
+ // planner's reason. Keyword guard ('api key' / 'token' / 'secret'
1228
+ // / 'credential' within 50 chars of the UUID) keeps unrelated
1229
+ // UUIDs (project IDs, member IDs in a sidebar) from matching.
1230
+ const uuidRe = /\b([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/gi;
1231
+ const keywordRe = /\b(?:api[\s_-]?(?:key|token)|access[\s_-]?token|secret|credential)\b/i;
1232
+ for (const m of reason.matchAll(uuidRe)) {
1233
+ const candidate = m[1] ?? m[0];
1234
+ if (!pageText.includes(candidate))
1235
+ continue;
1236
+ const start = Math.max(0, m.index - 50);
1237
+ const end = Math.min(reason.length, m.index + candidate.length + 50);
1238
+ const window = reason.slice(start, end);
1239
+ if (keywordRe.test(window))
1240
+ return candidate;
1241
+ }
920
1242
  return null;
921
1243
  }
1244
+ // Phase E — multi-credential planner-prose parser. When a service
1245
+ // exposes several distinct credentials on the same page (Cloudinary:
1246
+ // cloud_name + api_key + api_secret; Algolia: application_id +
1247
+ // admin_api_key + search_api_key; Twilio: account_sid + auth_token;
1248
+ // Stripe: publishable_key + secret_key), the post-verify planner is
1249
+ // instructed (Phase E prompt update) to label each value explicitly
1250
+ // in its extract reason. This parser pulls those labels + values out
1251
+ // and returns them as { [label]: value }.
1252
+ //
1253
+ // The label vocabulary is whitelisted to known credential-shaped
1254
+ // names so the parser doesn't false-match prose like "the
1255
+ // dashboard_url is …" or "the project_name is …". Anything outside
1256
+ // the whitelist is dropped to keep credentials objects clean.
1257
+ //
1258
+ // Returns empty record when nothing parsed. Caller folds the result
1259
+ // into the credentials dict; falls back to single-cred extraction
1260
+ // when this returns empty.
1261
+ export function extractAllLabeledTokensFromReason(reason, pageText) {
1262
+ // Whitelist of credential labels we recognize. Snake_case canonical;
1263
+ // the matcher tolerates the LLM emitting hyphenated or PascalCase
1264
+ // variants. Each entry maps a normalized form back to the canonical
1265
+ // snake_case used in the credentials Record.
1266
+ const LABEL_ALIASES = {
1267
+ api_key: "api_key",
1268
+ apikey: "api_key",
1269
+ api_token: "api_key",
1270
+ apitoken: "api_key",
1271
+ access_token: "access_token",
1272
+ accesstoken: "access_token",
1273
+ api_secret: "api_secret",
1274
+ apisecret: "api_secret",
1275
+ secret_key: "secret_key",
1276
+ secretkey: "secret_key",
1277
+ publishable_key: "publishable_key",
1278
+ publishablekey: "publishable_key",
1279
+ client_id: "client_id",
1280
+ clientid: "client_id",
1281
+ client_secret: "client_secret",
1282
+ clientsecret: "client_secret",
1283
+ cloud_name: "cloud_name",
1284
+ cloudname: "cloud_name",
1285
+ application_id: "application_id",
1286
+ applicationid: "application_id",
1287
+ app_id: "application_id",
1288
+ appid: "application_id",
1289
+ admin_api_key: "admin_api_key",
1290
+ adminapikey: "admin_api_key",
1291
+ search_api_key: "search_api_key",
1292
+ searchapikey: "search_api_key",
1293
+ monitoring_api_key: "monitoring_api_key",
1294
+ account_sid: "account_sid",
1295
+ accountsid: "account_sid",
1296
+ auth_token: "auth_token",
1297
+ authtoken: "auth_token",
1298
+ sandbox_secret: "sandbox_secret",
1299
+ sandboxsecret: "sandbox_secret",
1300
+ org_id: "org_id",
1301
+ orgid: "org_id",
1302
+ organization_id: "org_id",
1303
+ consumer_key: "consumer_key",
1304
+ consumer_secret: "consumer_secret",
1305
+ access_token_secret: "access_token_secret",
1306
+ project_api_key: "project_api_key",
1307
+ personal_api_key: "personal_api_key",
1308
+ app_key: "app_key",
1309
+ appkey: "app_key",
1310
+ };
1311
+ const out = {};
1312
+ // Build the label-alternation from the whitelist keys. Restricting
1313
+ // the regex to KNOWN labels avoids the greedy-match-eats-real-label
1314
+ // bug (without this, "shows: application_id" would match as
1315
+ // label='shows' / value='application_id' and consume the real
1316
+ // 'application_id' that follows). Longer aliases first so the
1317
+ // regex prefers `admin_api_key` over `api_key` at the same start.
1318
+ const labelKeys = Object.keys(LABEL_ALIASES).sort((a, b) => b.length - a.length);
1319
+ const labelAlt = labelKeys.map(escapeRegex).join("|");
1320
+ // Hyphen variants — the LLM sometimes emits `cloud-name` instead of
1321
+ // `cloud_name`. Replace _ with [-_] inside each alternative.
1322
+ const labelAltLoose = labelAlt.replace(/_/g, "[-_]");
1323
+ // Two patterns:
1324
+ //
1325
+ // (A) Strict QUOTED form — `label='value'` / `label="value"` /
1326
+ // `label:'value'` etc. Trusts the value as credential-shape
1327
+ // because the planner was instructed (Phase E prompt) to quote.
1328
+ //
1329
+ // (B) Prose `label is value` form — required for natural-language
1330
+ // extracts but DANGEROUS. The Cloudinary trace produced
1331
+ // "api_secret is hidden behind asterisks" — the prose-pattern
1332
+ // greedily captured `hidden` as the value, then the
1333
+ // anti-hallucination check passed (the word "hidden" was in
1334
+ // pageText/reason). Mitigations: (1) require the value to LOOK
1335
+ // credential-shape (mixed alpha+digit, ≥16 chars, OR a known
1336
+ // credential prefix); (2) hard-reject a curated set of common
1337
+ // English status words that look label-like in extract prose.
1338
+ const quotedRe = new RegExp(`\\b(${labelAltLoose})\\b\\s*[=:]\\s*['"\`]([A-Za-z0-9_\\-]{4,80})['"\`]`, "gi");
1339
+ for (const m of reason.matchAll(quotedRe)) {
1340
+ const rawLabel = (m[1] ?? "").toLowerCase().replace(/-/g, "_");
1341
+ const normalized = rawLabel.replace(/_+/g, "_");
1342
+ const canonical = LABEL_ALIASES[normalized];
1343
+ const value = m[2];
1344
+ if (canonical === undefined || value === undefined)
1345
+ continue;
1346
+ if (!pageText.includes(value))
1347
+ continue;
1348
+ if (out[canonical] === undefined)
1349
+ out[canonical] = value;
1350
+ }
1351
+ // English status words that show up in planner prose alongside
1352
+ // a credential label but are NEVER the credential value itself.
1353
+ // Each is a literal lowercase comparison after value-lowercase.
1354
+ const PROSE_BLACKLIST = new Set([
1355
+ "hidden", "masked", "shown", "visible", "available", "missing",
1356
+ "unavailable", "redacted", "obscured", "concealed", "secret",
1357
+ "true", "false", "null", "none", "empty", "unset", "undefined",
1358
+ "displayed", "revealed", "asterisks", "bullets", "dots", "stars",
1359
+ "blurred", "encrypted",
1360
+ ]);
1361
+ const looksCredentialShape = (v) => {
1362
+ if (v.length >= 16)
1363
+ return true; // long-enough tokens are presumed real
1364
+ if (/^[A-Za-z]+$/.test(v))
1365
+ return false; // pure word → suspect
1366
+ if (/^\d{10,}$/.test(v))
1367
+ return true; // long all-digit (Cloudinary api_key)
1368
+ if (/[_\-]/.test(v) && /[a-z]/i.test(v) && /\d/.test(v))
1369
+ return true; // mixed
1370
+ if (/^[a-z]+_[A-Za-z0-9]/i.test(v))
1371
+ return true; // prefix_ style (sk_…, npm_…)
1372
+ if (/\d/.test(v) && /[A-Za-z]/.test(v))
1373
+ return true; // alphanumeric mix
1374
+ return false; // pure short word → reject as suspect
1375
+ };
1376
+ // Same separator vocab as quoted, plus optional quotes around the
1377
+ // value. The credential-shape + blacklist guards run on the
1378
+ // captured (possibly-unquoted) value.
1379
+ const proseRe = new RegExp(`\\b(${labelAltLoose})\\b\\s*(?:[=:]|\\b(?:is|are)\\b)\\s*['"\`]?([A-Za-z0-9_\\-]{4,80})['"\`]?`, "gi");
1380
+ for (const m of reason.matchAll(proseRe)) {
1381
+ const rawLabel = (m[1] ?? "").toLowerCase().replace(/-/g, "_");
1382
+ const normalized = rawLabel.replace(/_+/g, "_");
1383
+ const canonical = LABEL_ALIASES[normalized];
1384
+ const value = m[2];
1385
+ if (canonical === undefined || value === undefined)
1386
+ continue;
1387
+ if (out[canonical] !== undefined)
1388
+ continue; // quoted-form already won
1389
+ if (PROSE_BLACKLIST.has(value.toLowerCase()))
1390
+ continue;
1391
+ if (!looksCredentialShape(value))
1392
+ continue;
1393
+ if (!pageText.includes(value))
1394
+ continue;
1395
+ out[canonical] = value;
1396
+ }
1397
+ return out;
1398
+ }
1399
+ function escapeRegex(s) {
1400
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1401
+ }
922
1402
  export function extractApiKeyFromText(text) {
923
1403
  const prefixed = [
924
1404
  /\bre_[a-zA-Z0-9_]{20,}\b/, // Resend (key body contains underscores)
@@ -934,6 +1414,57 @@ export function extractApiKeyFromText(text) {
934
1414
  /\bSG\.[a-zA-Z0-9_\-]{20,}\.[a-zA-Z0-9_\-]{20,}\b/, // SendGrid
935
1415
  /\brnd_[a-zA-Z0-9]{20,}\b/, // Render
936
1416
  /\bsntry[su]_[A-Za-z0-9_=\-]{20,}/, // Sentry org/user auth token
1417
+ // Neon serverless Postgres. Modal renders `napi_<48-char-alnum>` and
1418
+ // also shows a truncated `napi_xxx…` in the visible text below the
1419
+ // input field. Without the prefix here, the bot saw the truncated
1420
+ // display, isTruncatedCapture rejected the partial value, every
1421
+ // pass returned null, and the planner gave up despite the full key
1422
+ // being in the input field's `value` attribute. rc.14 — surfaced
1423
+ // during the harvester rc.13 pass on Neon.
1424
+ /\bnapi_[a-zA-Z0-9]{30,80}\b/, // Neon
1425
+ // Replicate API tokens. `r8_<40-char alnum>` per their docs. Shown
1426
+ // in the table row after Create. The post-verify loop iterates,
1427
+ // adds rows, but extractCredentials returned null every round
1428
+ // until rc.20 because no regex matched. Added defensively after
1429
+ // the rc.13 verification pass showed Replicate burning the full
1430
+ // 12-round budget filling-creating tokens nobody could extract.
1431
+ /\br8_[a-zA-Z0-9]{30,60}\b/, // Replicate
1432
+ // rc.23 — added after the post-rc.22 registry-snapshot review of
1433
+ // 200 failed signups. Each pattern matches a token shape the
1434
+ // bot's planner had already QUOTED in its `reason` field (i.e.
1435
+ // the credential was visible on the page, just not in a shape
1436
+ // any prior regex recognised). The redact.{ts,mjs} pattern set
1437
+ // stays in lockstep with these.
1438
+ /\bpscale_tkn_[A-Za-z0-9]{30,60}\b/, // PlanetScale Service Token
1439
+ /\bsbp_[a-zA-Z0-9]{30,80}\b/, // Supabase Personal Access Token
1440
+ // Baseten: `<6-12 alnum>.<30+ alnum>`. The dot separator + length
1441
+ // bounds on both sides distinguish it from version strings (too
1442
+ // short on either side). rc.35 — relaxed the prefix to mixed-case
1443
+ // after the rc.33 broad sweep showed a Baseten key whose prefix
1444
+ // had uppercase letters: `HP9tFTtm.txDl4vv7ayYsTwx9dQea47ylRdN4Brk3`.
1445
+ /\b[A-Za-z0-9]{6,12}\.[A-Za-z0-9]{30,50}\b/, // Baseten
1446
+ // Qdrant Cloud: `<UUID>|<55-char opaque>` — a literal pipe between
1447
+ // a key id and the secret body. Unique enough that no false-
1448
+ // positive guard is needed.
1449
+ //
1450
+ // rc.34/rc.36 — extended the secret-body character class to
1451
+ // include underscore + hyphen. rc.35 broad sweep surfaced
1452
+ // another Qdrant shape with mid-body hyphens:
1453
+ // `<UUID>|e8L7oyi-5fHa327u7x-IQN6WivtPlpIVjT-giIsrXDZW7P-8i2G9Pw`.
1454
+ // [A-Za-z0-9_-] covers both observed shapes; the {30,80} length
1455
+ // bound + UUID prefix keep false-positive risk near zero.
1456
+ /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\|[A-Za-z0-9_\-]{30,80}\b/i, // Qdrant
1457
+ // JWT (eyJ...eyJ...sig) — Convex's API "token" is a JWT. Other
1458
+ // services may emit JWTs as bearer secrets too. Three-segment
1459
+ // base64url with literal dots. Conservative bounds — under 20
1460
+ // chars per segment is almost never a real JWT.
1461
+ /\beyJ[A-Za-z0-9_\-]{20,}\.eyJ[A-Za-z0-9_\-]{20,}\.[A-Za-z0-9_\-]{20,}\b/, // JWT
1462
+ // Zeabur's API key — `sk-<28-40 lowercase alnum>`. Shorter than
1463
+ // OpenAI legacy (which is 40+ mixed-case). The lowercase-only
1464
+ // character class differentiates from OpenAI legacy so this
1465
+ // pattern only fires on Zeabur-style keys. Surfaced from the
1466
+ // rc.23 snapshot review.
1467
+ /\bsk-[a-z0-9]{28,38}\b/, // Zeabur
937
1468
  // OpenRouter, Anthropic, OpenAI — these are the dominant
938
1469
  // OAuth-completed-then-copy-needed services. Specific-prefix
939
1470
  // patterns first so a labeled-pattern fallback isn't load-
@@ -1072,11 +1603,49 @@ export class SignupAgent {
1072
1603
  steps.push(`${label} captcha gate skipped — session already captcha-blocked (${kind}).`);
1073
1604
  return { found: true, solved: false, blocked: true, kind };
1074
1605
  }
1075
- const result = await this.browser.solveVisibleCaptcha();
1606
+ let result = await this.browser.solveVisibleCaptcha();
1076
1607
  if (!result.found) {
1077
1608
  return { found: false, solved: false, blocked: false, kind: "turnstile" };
1078
1609
  }
1079
1610
  steps.push(`${label} captcha (${result.kind}): ${result.solved ? "solved" : "NOT solved (timeout)"}`);
1611
+ // Tier 3 — when Tier 2 click-and-wait times out on a reCAPTCHA v2
1612
+ // image challenge AND TWOCAPTCHA_API_KEY is configured, fall
1613
+ // through to the third-party solver. Reads sitekey from the
1614
+ // page, submits to 2Captcha, polls for the token (~30-90s),
1615
+ // injects into the hidden g-recaptcha-response textarea + fires
1616
+ // the widget's onSuccess callback. Turnstile is intentionally
1617
+ // skipped — Cloudflare's challenge scores at the IP layer; a
1618
+ // solver-issued token gets rejected anyway.
1619
+ if (!result.solved &&
1620
+ result.kind === "recaptcha" &&
1621
+ this.captchaSolver?.isAvailable() === true) {
1622
+ const sitekey = await this.browser.extractRecaptchaSitekey();
1623
+ if (sitekey !== null) {
1624
+ const pageUrl = (await this.browser.getState().catch(() => null))?.url;
1625
+ if (pageUrl !== undefined) {
1626
+ steps.push(`${label} captcha: Tier 3 — submitting sitekey to 2Captcha (${sitekey.slice(0, 10)}…)`);
1627
+ const solveRes = await this.captchaSolver.solveRecaptchaV2({
1628
+ sitekey,
1629
+ pageUrl,
1630
+ });
1631
+ if (solveRes.kind === "ok") {
1632
+ const injected = await this.browser.injectRecaptchaToken(solveRes.token);
1633
+ if (injected) {
1634
+ steps.push(`${label} captcha: Tier 3 solved in ${Math.round(solveRes.durationMs / 1000)}s via 2Captcha`);
1635
+ result = { ...result, solved: true };
1636
+ }
1637
+ else {
1638
+ steps.push(`${label} captcha: Tier 3 token arrived but page injection failed — captcha stays blocked`);
1639
+ }
1640
+ }
1641
+ else {
1642
+ steps.push(`${label} captcha: Tier 3 ${solveRes.kind}` +
1643
+ ("reason" in solveRes ? `: ${solveRes.reason}` : "") +
1644
+ ("durationMs" in solveRes ? ` (${Math.round(solveRes.durationMs / 1000)}s)` : ""));
1645
+ }
1646
+ }
1647
+ }
1648
+ }
1080
1649
  // rc.32 — forensic snapshot after the captcha attempt. Without
1081
1650
  // this, the only snapshot near the captcha is the pre-fill one
1082
1651
  // taken BEFORE the click, so when a Turnstile fails to solve we
@@ -1329,6 +1898,19 @@ export class SignupAgent {
1329
1898
  }
1330
1899
  steps.push(`Plan: ${plan.actions.length} action(s), confidence=${plan.confidence}` +
1331
1900
  (plan.notes !== undefined ? ` — ${plan.notes}` : ""));
1901
+ // rc.39 — PlanetScale-class detection. The form-fill planner
1902
+ // sometimes lands on a logged-in product page (PlanetScale's
1903
+ // /sign-up redirects authenticated users to a create-database
1904
+ // form; similar for Turso, Cockroach, etc.). detectAlreadySignedIn
1905
+ // missed the URL signal because the path is /sign-up. But the
1906
+ // planner's notes / action reasons describe the page accurately:
1907
+ // "create the database on this PS-5 plan", "Add credit card",
1908
+ // "database name field". Pivot to post-verify navigation rather
1909
+ // than blindly filling the create-product form.
1910
+ if (detectFormFillIsDashboard(plan)) {
1911
+ steps.push("Form-fill planner described a logged-in product/billing page (not a signup form) — pivoting to post-verify navigation");
1912
+ return { kind: "already_oauth" };
1913
+ }
1332
1914
  // F14 — stuck-detection: if the plan picks ONLY click selectors
1333
1915
  // we already tried in the previous round without page progress,
1334
1916
  // it's a planner loop. Fail planning_failed with the offending
@@ -1561,6 +2143,45 @@ export class SignupAgent {
1561
2143
  // clear `needs_login` the host agent can act on, vs. a silent 45-second
1562
2144
  // form-fill timeout. Better failure mode wins; the original gate was
1563
2145
  // protecting the bot from the wrong loss.
2146
+ // rc.28 — click the first plausible account card on a Google
2147
+ // account-chooser page. Returns true on a successful click, false
2148
+ // when no card was identified. Used by the rc.25 stuck-on-chooser
2149
+ // post-OAuth gate to forward the flow off accounts.google.com
2150
+ // instead of immediately aborting.
2151
+ //
2152
+ // Google's chooser markup is consistent across surfaces: each
2153
+ // account renders as a clickable container with a data-identifier
2154
+ // attribute equal to the account's email, plus role="link" or
2155
+ // jsaction. The fallback is any element whose visible text
2156
+ // contains an @ — accounts always show their email.
2157
+ async tryClickGoogleChooserCard() {
2158
+ try {
2159
+ const page = this.browser.page;
2160
+ if (page === null || page === undefined)
2161
+ return false;
2162
+ // First-choice selector: data-identifier on an interactive element.
2163
+ const candidates = [
2164
+ '[data-identifier]:visible',
2165
+ '[role="link"]:has-text("@")',
2166
+ 'div[jsaction]:has-text("@")',
2167
+ ];
2168
+ for (const sel of candidates) {
2169
+ const loc = page.locator(sel).first();
2170
+ try {
2171
+ await loc.waitFor({ state: "visible", timeout: 2_000 });
2172
+ await loc.click({ timeout: 3_000 });
2173
+ return true;
2174
+ }
2175
+ catch {
2176
+ continue;
2177
+ }
2178
+ }
2179
+ return false;
2180
+ }
2181
+ catch {
2182
+ return false;
2183
+ }
2184
+ }
1564
2185
  async resolveOAuthCandidates(task, steps) {
1565
2186
  if (task.oauthProvider !== undefined) {
1566
2187
  return [task.oauthProvider];
@@ -1637,6 +2258,36 @@ export class SignupAgent {
1637
2258
  // Set per-task in signup(). Lets the uploader know which service
1638
2259
  // was being provisioned without threading it through every call.
1639
2260
  currentService = "";
2261
+ // rc.27 — set when the email_otp_required gate handler successfully
2262
+ // fetched a code from the operator's gmail. Consumed by the next
2263
+ // post-verify round's planner prompt as a hint so the planner can
2264
+ // fill the verification input without burning rounds discovering
2265
+ // it. Cleared once the loop emits a step that targets the OTP
2266
+ // input, so the hint doesn't echo into later unrelated rounds.
2267
+ pendingOtpCode = null;
2268
+ // rc.39 — when postVerifyLoop exits because the planner returned
2269
+ // `done`, capture the planner's stated reason so the caller can
2270
+ // factor it into paywall classification. Koyeb (and similar)
2271
+ // shows a billing wall whose page text doesn't match the
2272
+ // ONBOARDING_PAYWALL_PATTERNS regex set, but the planner accurately
2273
+ // reasons about it in the done reason ("requires credit card payment
2274
+ // to access the platform"). Mixing that into the paywall check
2275
+ // upgrades these from oauth_onboarding_failed → onboarding_blocked.
2276
+ lastPostVerifyDoneReason = null;
2277
+ // Stashed at signup() entry so deep postVerifyLoop code (the
2278
+ // heightened-auth detector below) can fire notifyHeightenedAuth
2279
+ // without threading task through every method. Mirrors the
2280
+ // currentService pattern.
2281
+ currentMachineToken = undefined;
2282
+ currentApiBase = undefined;
2283
+ // Once per signup — fire the heightened-auth notifier at most one
2284
+ // time even if the planner returns done with a challenge phrasing
2285
+ // multiple times before the loop fully exits.
2286
+ heightenedAuthFired = false;
2287
+ // Tier 3 captcha solver (2Captcha). Constructed eagerly so the
2288
+ // isAvailable() check in runCaptchaGate is cheap; opt-in via the
2289
+ // TWOCAPTCHA_API_KEY env var read at construction.
2290
+ captchaSolver;
1640
2291
  constructor(browser, llm, opts = {}) {
1641
2292
  this.browser = browser;
1642
2293
  if (llm === undefined) {
@@ -1658,6 +2309,7 @@ export class SignupAgent {
1658
2309
  if (opts.roundUploader !== undefined) {
1659
2310
  this.roundUploader = opts.roundUploader;
1660
2311
  }
2312
+ this.captchaSolver = opts.captchaSolver ?? new TwoCaptchaSolver();
1661
2313
  }
1662
2314
  // Read-only view of how many calls landed on which backend. Exported
1663
2315
  // through SignupResult.llm_backends so tests and ops can verify the
@@ -1773,6 +2425,14 @@ export class SignupAgent {
1773
2425
  // deep inside postVerifyLoop after a failed extract) can label
1774
2426
  // the snapshot without us threading task through every method.
1775
2427
  this.currentService = task.service;
2428
+ this.lastPostVerifyDoneReason = null;
2429
+ // Stash for the post-verify heightened-auth notifier — the
2430
+ // detection point is deep inside postVerifyLoop where it sees
2431
+ // the planner's `done` reason naming a Google challenge. Same
2432
+ // path runOAuthFlow uses for the in-handshake case.
2433
+ this.currentMachineToken = task.machineToken;
2434
+ this.currentApiBase = task.apiBase;
2435
+ this.heightenedAuthFired = false;
1776
2436
  const rawTimeout = Number(process.env.UNIVERSAL_BOT_RUN_TIMEOUT_MS);
1777
2437
  const timeoutMs = Number.isFinite(rawTimeout) && rawTimeout > 0 ? rawTimeout : 600_000;
1778
2438
  let timer;
@@ -2232,6 +2892,16 @@ export class SignupAgent {
2232
2892
  service: task.service,
2233
2893
  digit: String(matchNum),
2234
2894
  windowSeconds: 120,
2895
+ machineToken: task.machineToken,
2896
+ apiBase: task.apiBase,
2897
+ });
2898
+ // rc.18 — opt-in Telegram fallback. Bypasses the email
2899
+ // path (which collapses to Sent only when GMAIL_USER ==
2900
+ // account.email). No-op without TELEGRAM_BOT_TOKEN env.
2901
+ void sendTelegramHeightenedAuth({
2902
+ service: task.service,
2903
+ digit: String(matchNum),
2904
+ windowSeconds: 120,
2235
2905
  });
2236
2906
  }
2237
2907
  else {
@@ -2247,6 +2917,13 @@ export class SignupAgent {
2247
2917
  service: task.service,
2248
2918
  digit: null,
2249
2919
  windowSeconds: 120,
2920
+ machineToken: task.machineToken,
2921
+ apiBase: task.apiBase,
2922
+ });
2923
+ void sendTelegramHeightenedAuth({
2924
+ service: task.service,
2925
+ digit: null,
2926
+ windowSeconds: 120,
2250
2927
  });
2251
2928
  }
2252
2929
  // Either way (number found or not), the user can still
@@ -2404,6 +3081,190 @@ export class SignupAgent {
2404
3081
  await this.browser.wait(2);
2405
3082
  await saveDebugSnapshot(this.browser, "oauth-post-consent");
2406
3083
  steps.push(`OAuth: signed in via ${provider.label} — driving post-OAuth onboarding to the API key`);
3084
+ // rc.20 — login-loop detection. Services like Groq complete the
3085
+ // Google OAuth handshake server-side but redirect back to a
3086
+ // login-looking page (/authenticate) where the user has to click
3087
+ // "Continue with Google" ONE MORE TIME to actually finalize the
3088
+ // session. The post-verify planner sees the same OAuth buttons it
3089
+ // saw on the original signup page, picks `click` on the provider
3090
+ // affordance, and the click triggers a popup-based re-OAuth that
3091
+ // the planner-driven post-verify loop doesn't follow — so each
3092
+ // iteration sees the same page text and the bot burns the round
3093
+ // budget.
3094
+ //
3095
+ // Detect: post-OAuth URL path matches a known login-shaped pattern
3096
+ // (/login, /signin, /authenticate, ...) AND the inventory still
3097
+ // carries an OAuth affordance for the SAME provider we just used.
3098
+ // Recovery: re-invoke runOAuthFlow ONCE with the new selector —
3099
+ // the second handshake completes the session and lands on the
3100
+ // dashboard. Bounded to one retry so a service that genuinely
3101
+ // never finalizes can't trap us in a loop.
3102
+ const postOAuthState = await this.browser.getState();
3103
+ const postOAuthInv = await this.buildInventory(steps, [provider.id]);
3104
+ const loopBtn = isLoginLoopState(postOAuthState.url, postOAuthInv, provider.id);
3105
+ if (loopBtn !== null) {
3106
+ steps.push(`Post-OAuth: landed on a login-like page (${pathOf(postOAuthState.url)}) ` +
3107
+ `with a ${provider.label} sign-in button still visible — service requires a ` +
3108
+ `second click to finalize the session. Re-triggering OAuth once.`);
3109
+ try {
3110
+ await this.browser.startOAuth(loopBtn.selector);
3111
+ await this.browser.wait(3);
3112
+ await saveDebugSnapshot(this.browser, "oauth-loop-retry");
3113
+ await this.browser.settleAfterOAuth();
3114
+ await this.browser.wait(2);
3115
+ steps.push(`Post-OAuth: re-OAuth completed (url=${pathOf(this.browser.currentUrl())}).`);
3116
+ }
3117
+ catch (err) {
3118
+ steps.push(`Post-OAuth: re-OAuth retry threw (${err instanceof Error ? err.message : String(err)}) — ` +
3119
+ `continuing to post-verify loop anyway.`);
3120
+ }
3121
+ // After the retry, if we're STILL on a login-like page with the
3122
+ // same provider button visible, the service has trapped us. Abort
3123
+ // with a specific error rather than re-running the loop.
3124
+ const retryState = await this.browser.getState();
3125
+ const retryInv = await this.browser.extractInteractiveElements();
3126
+ if (isLoginLoopState(retryState.url, retryInv, provider.id) !== null) {
3127
+ return {
3128
+ success: false,
3129
+ error: `oauth_loop_detected: signed in via ${provider.label} twice but ${task.service} ` +
3130
+ `keeps redirecting to a login page (${pathOf(retryState.url)}). The service may ` +
3131
+ `require manual completion of an onboarding step before its OAuth session ` +
3132
+ `finalizes — finish the signup manually.`,
3133
+ steps,
3134
+ ...this.resultTail(),
3135
+ };
3136
+ }
3137
+ }
3138
+ // rc.24 — three additional post-OAuth gates surfaced from the
3139
+ // registry-snapshot review. Each is a state the bot definitively
3140
+ // cannot pass; emit a precise terminal error rather than burn the
3141
+ // post-verify budget.
3142
+ {
3143
+ const gateState = await this.browser.getState();
3144
+ const gateText = await this.browser.extractText().catch(() => "");
3145
+ const gateInv = postOAuthInv;
3146
+ // (a) Manual-login fallback (DigitalOcean, Hyperbolic). Service
3147
+ // dropped the OAuth session and rendered a /login form with
3148
+ // email + password inputs. Bot can't manually log in.
3149
+ if (detectManualLoginFallback(gateState.url, gateInv)) {
3150
+ return {
3151
+ success: false,
3152
+ error: `oauth_session_not_persisted: signed in via ${provider.label} but ${task.service} ` +
3153
+ `dropped the OAuth callback and rendered a manual /login form (${pathOf(gateState.url)}). ` +
3154
+ `Finish the signup manually — this typically indicates anti-bot rejection of the ` +
3155
+ `OAuth callback or a service-side session-storage issue.`,
3156
+ steps,
3157
+ ...this.resultTail(),
3158
+ };
3159
+ }
3160
+ // (b) Email-OTP wall (Porter, Koyeb / WorkOS-backed). Code went
3161
+ // to the operator's gmail. rc.27 — instead of immediately
3162
+ // aborting, try reading the OTP from the operator's inbox via
3163
+ // POST /v1/inbox/poll-operator-otp. If a code arrives, push a
3164
+ // step trail hint and continue to the post-verify loop —
3165
+ // the planner sees the hint and fills the input.
3166
+ if (detectEmailOtpGate(gateState.url, gateState.title, gateText)) {
3167
+ const domain = fromDomainFromUrl(gateState.url);
3168
+ const machineToken = task.machineToken;
3169
+ let otpResult;
3170
+ if (machineToken === undefined ||
3171
+ machineToken.length === 0) {
3172
+ otpResult = { code: null, reason: "no_machine_token" };
3173
+ }
3174
+ else {
3175
+ steps.push(`Email-OTP gate detected (${pathOf(gateState.url)}) — polling operator gmail for the code` +
3176
+ (domain !== null ? ` (from_domain=${domain})` : ""));
3177
+ otpResult = await readOperatorOtp({
3178
+ machineToken,
3179
+ ...(task.apiBase !== undefined ? { apiBase: task.apiBase } : {}),
3180
+ ...(domain !== null ? { fromDomain: domain } : {}),
3181
+ maxWaitSeconds: 90,
3182
+ });
3183
+ }
3184
+ if (otpResult.code !== null) {
3185
+ // rc.27 — log the code's existence + length but never the
3186
+ // digits. The planner gets the digits via a system-prompt
3187
+ // hint passed through scopeHint-style on the next round.
3188
+ steps.push(`Email-OTP retrieved (${otpResult.code.length} digits, ending …${otpResult.code.slice(-2)}) — continuing into post-verify so the planner can fill the verification input.`);
3189
+ this.pendingOtpCode = otpResult.code;
3190
+ // Fall through to extract + postVerifyLoop normally.
3191
+ }
3192
+ else {
3193
+ return {
3194
+ success: false,
3195
+ error: `email_otp_required: ${task.service} sent a verification code but the bot ` +
3196
+ `couldn't fetch it from the operator's gmail (reason=${otpResult.reason}). ` +
3197
+ `Finish the signup manually by entering the OTP from the email.`,
3198
+ steps,
3199
+ ...this.resultTail(),
3200
+ };
3201
+ }
3202
+ }
3203
+ // (c) SSO restriction (Fly.io). Org SSO blocks programmatic
3204
+ // token creation — the page explicitly says so.
3205
+ if (detectSsoRestriction(gateText)) {
3206
+ return {
3207
+ success: false,
3208
+ error: `sso_restricted: ${task.service} requires SSO/SAML for token creation. The bot ` +
3209
+ `cannot complete an SSO handshake. Finish via your organization's SSO portal.`,
3210
+ steps,
3211
+ ...this.resultTail(),
3212
+ };
3213
+ }
3214
+ // (d) Stuck on Google OAuth screens (Upstash class). Bot
3215
+ // signed in via Google but the OAuth flow didn't redirect off
3216
+ // accounts.google.com — usually a Clerk-mediated chooser the
3217
+ // post-verify planner can't navigate.
3218
+ //
3219
+ // rc.27 → rc.28 — instead of aborting immediately, try to
3220
+ // click an account card on the chooser. Google's chooser
3221
+ // renders each account as `<div data-identifier="email@…"
3222
+ // role="link" jsaction="…">` (or similar accountchooser shape).
3223
+ // Picking the first visible card forwards the flow off the
3224
+ // chooser. If the click doesn't move us off accounts.google.com
3225
+ // within a few seconds, abort with oauth_stuck_on_chooser.
3226
+ if (detectStuckOnGoogleOAuth(gateState.url)) {
3227
+ steps.push(`Post-OAuth: stuck on Google account chooser (${pathOf(gateState.url)}). ` +
3228
+ `Trying to click an account card.`);
3229
+ const clicked = await this.tryClickGoogleChooserCard();
3230
+ if (clicked) {
3231
+ await this.browser.wait(3);
3232
+ await saveDebugSnapshot(this.browser, "oauth-chooser-click");
3233
+ const afterUrl = this.browser.currentUrl();
3234
+ steps.push(`Post-OAuth: chooser card clicked — now at ${pathOf(afterUrl)} ` +
3235
+ `(host=${(() => { try {
3236
+ return new URL(afterUrl).hostname;
3237
+ }
3238
+ catch {
3239
+ return "?";
3240
+ } })()})`);
3241
+ // If the click moved us off accounts.google.com, fall
3242
+ // through to the post-verify loop normally.
3243
+ if (!detectStuckOnGoogleOAuth(afterUrl)) {
3244
+ // continue to extract + postVerifyLoop
3245
+ }
3246
+ else {
3247
+ return {
3248
+ success: false,
3249
+ error: `oauth_stuck_on_chooser: clicked an account card on the chooser but the URL ` +
3250
+ `stayed on accounts.google.com (${pathOf(afterUrl)}). Finish the signup manually.`,
3251
+ steps,
3252
+ ...this.resultTail(),
3253
+ };
3254
+ }
3255
+ }
3256
+ else {
3257
+ return {
3258
+ success: false,
3259
+ error: `oauth_stuck_on_chooser: ${task.service}'s Google OAuth flow did not redirect off ` +
3260
+ `accounts.google.com (${pathOf(gateState.url)}) and no clickable account card was ` +
3261
+ `found on the chooser. Finish the signup manually.`,
3262
+ steps,
3263
+ ...this.resultTail(),
3264
+ };
3265
+ }
3266
+ }
3267
+ }
2407
3268
  let credentials = await this.extractCredentials();
2408
3269
  if (credentials.api_key === undefined) {
2409
3270
  credentials = await this.postVerifyLoop({
@@ -2423,8 +3284,15 @@ export class SignupAgent {
2423
3284
  }
2424
3285
  // No API key. Distinguish a billing/card wall (onboarding_blocked)
2425
3286
  // from a generic navigation miss — never grep-loop a paid wall.
3287
+ // rc.39 — fold the planner's `done` reason into the text we
3288
+ // grep. Some services (Koyeb) gate API issuance on a billing
3289
+ // confirmation whose visible text doesn't match the regex set
3290
+ // but whose planner reason clearly describes the wall.
2426
3291
  const finalText = await this.browser.extractText().catch(() => "");
2427
- if (isAtPaywall(finalText)) {
3292
+ const paywallCheckText = this.lastPostVerifyDoneReason !== null
3293
+ ? `${finalText}\n${this.lastPostVerifyDoneReason}`
3294
+ : finalText;
3295
+ if (isAtPaywall(paywallCheckText)) {
2428
3296
  return {
2429
3297
  success: false,
2430
3298
  error: `onboarding_blocked: ${task.service}'s API key sits behind a billing or ` +
@@ -2433,6 +3301,24 @@ export class SignupAgent {
2433
3301
  ...this.resultTail(),
2434
3302
  };
2435
3303
  }
3304
+ // rc.39 — anti-bot interstitial that survived the post-OAuth
3305
+ // landing. Turso's GitHub SSO callback runs a Cloudflare check
3306
+ // that never clears for our Chromium fingerprint; the planner's
3307
+ // done reason / wait reasons name the vendor explicitly. Classify
3308
+ // as anti_bot_blocked so the operator sees an accurate status
3309
+ // (and the harvester routes it the same way as the form-fill-
3310
+ // phase anti-bot detector does).
3311
+ const ANTI_BOT_REASON = /\b(?:cloudflare\b.*?(?:verification|challenge|check)|just\s+a\s+moment|verifying\s+you\s+are\s+human|0\s+interactive\s+elements)/i;
3312
+ if (this.lastPostVerifyDoneReason !== null &&
3313
+ ANTI_BOT_REASON.test(this.lastPostVerifyDoneReason)) {
3314
+ return {
3315
+ success: false,
3316
+ error: `anti_bot_blocked: ${task.service}'s post-OAuth landing is gated by an anti-bot ` +
3317
+ `interstitial (Cloudflare or similar) the bot cannot clear — finish the signup manually.`,
3318
+ steps,
3319
+ ...this.resultTail(),
3320
+ };
3321
+ }
2436
3322
  return {
2437
3323
  success: false,
2438
3324
  error: `oauth_onboarding_failed: signed in to ${task.service} via ${provider.label} but ` +
@@ -2595,6 +3481,42 @@ ${formatInventory(input.inventory)}`,
2595
3481
  // Drive the browser toward the API key after the account exists —
2596
3482
  // used by BOTH the email-verification path and the OAuth path (T9).
2597
3483
  // Each round asks Claude what to do next given the current page; we
3484
+ // Heightened-auth detector for the POST-VERIFY path. runOAuthFlow
3485
+ // catches Google's device-verification challenge during the OAuth
3486
+ // handshake itself; this catches the case where the planner
3487
+ // bumped into the same challenge mid-post-verify (Algolia, etc.).
3488
+ // Patterns match the planner's natural-language done-reason; the
3489
+ // number-match is extracted with the same shape the bot's
3490
+ // existing extractGoogleNumberMatch expects ("tap 71", "number 42").
3491
+ // Fire-and-forget; idempotent per-signup via heightenedAuthFired.
3492
+ maybeFirePostVerifyHeightenedAuth(reason, service, steps) {
3493
+ if (this.heightenedAuthFired)
3494
+ return false;
3495
+ const CHALLENGE_PATTERNS = /\b(?:device verification|security challenge|2[- ]?step|2fa|number(?: match)?(?: on (?:your |the )?(?:phone|screen|device))?|tap \d+|tap the number|confirm.{0,15}sign[- ]?in|verify it'?s you)\b/i;
3496
+ if (!CHALLENGE_PATTERNS.test(reason))
3497
+ return false;
3498
+ const digitMatch = reason.match(/\btap (\d{1,3})\b|\bnumber (\d{1,3})\b/i);
3499
+ const digit = digitMatch !== null ? (digitMatch[1] ?? digitMatch[2] ?? null) : null;
3500
+ this.heightenedAuthFired = true;
3501
+ const msg = digit !== null
3502
+ ? `Google challenge detected mid-post-verify: tap ${digit} on your phone — 2 minute window`
3503
+ : `Google challenge detected mid-post-verify (number extractor missed it — read the planner reason): ${reason.slice(0, 200)}`;
3504
+ console.error(`[universal-bot] ${msg}`);
3505
+ steps.push(`Post-verify: ${msg}`);
3506
+ void notifyHeightenedAuth({
3507
+ service,
3508
+ digit,
3509
+ windowSeconds: 120,
3510
+ machineToken: this.currentMachineToken,
3511
+ apiBase: this.currentApiBase,
3512
+ });
3513
+ void sendTelegramHeightenedAuth({
3514
+ service,
3515
+ digit,
3516
+ windowSeconds: 120,
3517
+ });
3518
+ return true;
3519
+ }
2598
3520
  // stop when Claude says "done" or when we extract a credential.
2599
3521
  // Bounded by maxRounds so a confused agent can't burn the context.
2600
3522
  //
@@ -2603,6 +3525,76 @@ ${formatInventory(input.inventory)}`,
2603
3525
  // with the just-created account (SendPulse). On the OAuth path it is
2604
3526
  // absent: there is no password, and the Google session already
2605
3527
  // authenticated the user — a `login` step is then a no-op.
3528
+ // Tier 4 — DOM-proximity labeled extraction. Walks the page's
3529
+ // visible DOM via the BrowserController helper, pairs each
3530
+ // credential-shape string with the nearest credential-label text,
3531
+ // returns the canonical-key → value map. Used as a fallback after
3532
+ // the Phase E planner-quoted path when the planner mentioned only
3533
+ // one of several visible credentials.
3534
+ async extractFromDomProximity() {
3535
+ // Vocabulary matches the LABEL_ALIASES used by Phase E so the
3536
+ // canonical keys stay consistent across paths.
3537
+ const LABEL_TO_KEY = {
3538
+ "api key": "api_key",
3539
+ "api token": "api_key",
3540
+ "api secret": "api_secret",
3541
+ "secret key": "secret_key",
3542
+ "publishable key": "publishable_key",
3543
+ "access key": "access_key_id",
3544
+ "access key id": "access_key_id",
3545
+ "access token": "access_token",
3546
+ "bearer token": "access_token",
3547
+ "personal access token": "access_token",
3548
+ "auth token": "auth_token",
3549
+ "client id": "client_id",
3550
+ "client secret": "client_secret",
3551
+ "client key": "client_id",
3552
+ "cloud name": "cloud_name",
3553
+ "cloudname": "cloud_name",
3554
+ "application id": "application_id",
3555
+ "app id": "application_id",
3556
+ "admin api key": "admin_api_key",
3557
+ "search api key": "search_api_key",
3558
+ "search-only api key": "search_api_key",
3559
+ "monitoring api key": "monitoring_api_key",
3560
+ "account sid": "account_sid",
3561
+ "secret access key": "secret_access_key",
3562
+ "consumer key": "consumer_key",
3563
+ "consumer secret": "consumer_secret",
3564
+ "access token secret": "access_token_secret",
3565
+ "project api key": "project_api_key",
3566
+ "personal api key": "personal_api_key",
3567
+ "organization id": "org_id",
3568
+ "org id": "org_id",
3569
+ "app key": "app_key",
3570
+ "app secret": "app_secret",
3571
+ };
3572
+ let labeled = [];
3573
+ try {
3574
+ labeled = await this.browser.extractLabeledCredentialCandidates();
3575
+ }
3576
+ catch {
3577
+ return {};
3578
+ }
3579
+ const out = {};
3580
+ for (const c of labeled) {
3581
+ // Skip masked values — even if we have a label, the on-DOM
3582
+ // string is bullets, not the real credential. The reveal pass
3583
+ // ran before this so anything still masked is genuinely
3584
+ // unreachable.
3585
+ if (c.isMasked)
3586
+ continue;
3587
+ if (c.label === null)
3588
+ continue;
3589
+ const canonical = LABEL_TO_KEY[c.label];
3590
+ if (canonical === undefined)
3591
+ continue;
3592
+ if (out[canonical] !== undefined)
3593
+ continue; // first-wins
3594
+ out[canonical] = c.value;
3595
+ }
3596
+ return out;
3597
+ }
2606
3598
  async postVerifyLoop(args) {
2607
3599
  let credentials = await this.extractCredentials();
2608
3600
  let loginAttempts = 0;
@@ -2614,6 +3606,20 @@ ${formatInventory(input.inventory)}`,
2614
3606
  // string and keeps asking to extract it forever), or when the
2615
3607
  // planner's last step was rejected.
2616
3608
  let hint;
3609
+ // rc.27 — when the email_otp gate handler retrieved a code from
3610
+ // the operator's gmail, seed the FIRST round's hint with the
3611
+ // code + explicit fill+submit instructions. Cleared after one
3612
+ // round so it doesn't echo into unrelated downstream rounds.
3613
+ if (this.pendingOtpCode !== null) {
3614
+ hint =
3615
+ `Operator email-OTP retrieved from gmail: code is "${this.pendingOtpCode}". ` +
3616
+ `The current page is an email-verification gate. Find the SINGLE visible OTP/code input ` +
3617
+ `on this page (it usually has placeholder text like "Code", "Verification code", or a ` +
3618
+ `numeric placeholder; some sites render 6 individual digit inputs — in that case fill the ` +
3619
+ `FIRST one and the browser auto-distributes). Issue {"kind":"fill", "selector":"…", ` +
3620
+ `"value":"${this.pendingOtpCode}"} on it. NEXT round, click the Verify/Continue/Submit button.`;
3621
+ this.pendingOtpCode = null;
3622
+ }
2617
3623
  // Failed-extract counter. The stuck-loop detector below exempts
2618
3624
  // `extract` on the theory that "extract is its own progress signal"
2619
3625
  // — true when extract succeeds, FALSE when it returns no key. A
@@ -2634,6 +3640,24 @@ ${formatInventory(input.inventory)}`,
2634
3640
  // and inject a forced "no-progress" hint on the second repeat.
2635
3641
  let prevSignature = null;
2636
3642
  let prevInventorySize = -1;
3643
+ // rc.39 — wait-loop tracker. Turso's GitHub OAuth handshake
3644
+ // succeeds, then the SSO-callback page stays empty (0 elements)
3645
+ // while a Cloudflare verification widget runs that never clears
3646
+ // for this Chromium fingerprint. The planner kept emitting wait
3647
+ // for all 12 rounds; the run timed out as oauth_onboarding_failed.
3648
+ // Better classification: anti-bot block. Track consecutive wait
3649
+ // rounds with no inventory change and break out with the proper
3650
+ // status before burning the post-verify budget.
3651
+ let consecutiveWaits = 0;
3652
+ // rc.39 — navigate-loop tracker. Perplexity / Koyeb / Porter all
3653
+ // had post-verify loops where the planner emitted `navigate`
3654
+ // 5-6 rounds in a row and the URL never changed — the service
3655
+ // silently redirected each attempt back to the same onboarding
3656
+ // page. Track the URL state observed BEFORE each navigate; if
3657
+ // two consecutive navigates fire from the same URL, the previous
3658
+ // navigate produced no progress. Inject a hint forcing a CLICK
3659
+ // on something visible in the current inventory.
3660
+ let prevNavigateFromUrl = null;
2637
3661
  for (let round = 0; round < args.maxRounds; round++) {
2638
3662
  if (credentials.api_key !== undefined || credentials.username !== undefined) {
2639
3663
  args.steps.push(`Post-verify: credentials found on round ${round}.`);
@@ -2699,7 +3723,13 @@ ${formatInventory(input.inventory)}`,
2699
3723
  "and never the whole line.";
2700
3724
  continue;
2701
3725
  }
2702
- args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: ${nextStep.kind} ${nextStep.reason}`);
3726
+ // rc.22 redact tokens before pushing to the step trail.
3727
+ // The planner's reason field sometimes quotes the actual API
3728
+ // value it just observed ("The full API token 'sbp_xxx' is
3729
+ // visible…"); the harvester then posts step trails to a public
3730
+ // GitHub issue, leaking the credential. Redactor patterns mirror
3731
+ // tools/archived-harvester/redact.mjs — defense in depth.
3732
+ args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: ${nextStep.kind} — ${redactCredentials(nextStep.reason)}`);
2703
3733
  // Dump this round's real page state + inventory in the E1
2704
3734
  // eval-corpus format so onboarding adapters can be iterated
2705
3735
  // offline without re-running the rate-limited OAuth handshake.
@@ -2742,6 +3772,44 @@ ${formatInventory(input.inventory)}`,
2742
3772
  }
2743
3773
  })();
2744
3774
  }
3775
+ // rc.39 — navigate-loop detector. Perplexity/Koyeb/Porter spun
3776
+ // 5+ rounds of `navigate` because each navigate landed back at
3777
+ // the same URL — the service redirected past the requested URL.
3778
+ // If THIS plan is also navigate and the URL we're observing now
3779
+ // is the same one we navigated FROM last round, the previous
3780
+ // navigate produced no progress. Inject a hint forcing a click.
3781
+ if (nextStep.kind === "navigate" && prevNavigateFromUrl === state.url) {
3782
+ const candidateClicks = inventory
3783
+ .filter((e) => (e.tag === "button" ||
3784
+ e.tag === "a" ||
3785
+ e.role === "button" ||
3786
+ e.role === "link") &&
3787
+ e.interactedThisRun !== true)
3788
+ .slice(0, 8)
3789
+ .map((e) => {
3790
+ const label = e.visibleText ?? e.ariaLabel ?? e.labelText ?? "(no label)";
3791
+ return ` - ${JSON.stringify(label)} → selector=${e.selector}`;
3792
+ });
3793
+ args.steps.push(`Post-verify: navigate did not advance the page (URL still ${state.url}) — forcing a click on an inventory element.`);
3794
+ hint =
3795
+ `Your last 'navigate' to a guessed URL did NOT advance the page — the service ` +
3796
+ `redirected you back to ${state.url}. STOP navigating and CLICK an element ` +
3797
+ `from the current inventory below. The page is gating you behind an onboarding ` +
3798
+ `CTA (e.g. "Get started", "Continue", "Activate") or a setup step that must be ` +
3799
+ `clicked before the API console becomes reachable.` +
3800
+ (candidateClicks.length > 0
3801
+ ? `\n\nClickable elements you haven't tried:\n${candidateClicks.join("\n")}`
3802
+ : "");
3803
+ // Don't execute this navigate — re-plan with the hint.
3804
+ prevNavigateFromUrl = null;
3805
+ continue;
3806
+ }
3807
+ if (nextStep.kind === "navigate") {
3808
+ prevNavigateFromUrl = state.url;
3809
+ }
3810
+ else {
3811
+ prevNavigateFromUrl = null;
3812
+ }
2745
3813
  // Stuck-loop detector. Re-planning steps (done/extract/login/
2746
3814
  // wait/navigate) are exempt: extract is its own progress signal,
2747
3815
  // navigate intentionally changes the URL not the current DOM,
@@ -2815,6 +3883,47 @@ ${formatInventory(input.inventory)}`,
2815
3883
  const defaultedSelectHint = defaultedSelects.length > 0
2816
3884
  ? `\n\nVisible DEFAULTED dropdowns on this page (value="" — React form-state likely treats these as UNTOUCHED, which silently fails submit):\n${defaultedSelects.join("\n")}\n\nIssue {"kind":"select", "option_text":"…"} to commit a choice. Even if the default visible label ("No workspace", "None") is what you want, you MUST emit the select step to register it with the form's state.`
2817
3885
  : "";
3886
+ // rc.20 — also enumerate custom combobox triggers (Cohere's
3887
+ // role picker, Fireworks's survey, similar). These render as
3888
+ // <button role="combobox"> or as <button> with placeholder-
3889
+ // shaped text ("Select your role", "Choose a country") and
3890
+ // gate a submit-disabled state that the planner can't see.
3891
+ // Surface them so the planner emits `select` instead of
3892
+ // re-clicking the dead submit. Tightly scoped: must be a
3893
+ // button-shaped element with role=combobox OR placeholder-
3894
+ // shaped visible text, and NOT touched this run.
3895
+ const SELECT_PROMPT_TEXT = /^(?:select|choose|pick)\b|^select an?\b|\bselect\.{3}|\bchoose\.{3}/i;
3896
+ const customComboboxes = inventory
3897
+ .filter((e) => (e.role === "combobox" ||
3898
+ (e.tag === "button" &&
3899
+ SELECT_PROMPT_TEXT.test((e.visibleText ?? "").trim()))) &&
3900
+ e.interactedThisRun !== true)
3901
+ .slice(0, 5)
3902
+ .map((e) => {
3903
+ const label = e.labelText ?? e.ariaLabel ?? e.name ?? e.visibleText ?? "(no label)";
3904
+ return ` - ${JSON.stringify(label)} → selector=${e.selector}`;
3905
+ });
3906
+ const customComboboxHint = customComboboxes.length > 0
3907
+ ? `\n\nVisible custom comboboxes / placeholder-state pickers that you haven't touched yet:\n${customComboboxes.join("\n")}\n\nIssue {"kind":"select", "option_text":"…"} with a sensible option_text — these are commonly the gate on a submit-disabled state.`
3908
+ : "";
3909
+ // rc.20 — and enumerate unchecked agreement checkboxes.
3910
+ // Mistral's TOS, GitHub-app sign-up, many onboarding forms
3911
+ // gate submit on a checkbox that isn't yet ticked.
3912
+ const uncheckedBoxes = inventory
3913
+ .filter((e) => e.tag === "input" &&
3914
+ e.type === "checkbox" &&
3915
+ // We can't read the actual `checked` from the inventory
3916
+ // shape, but interactedThisRun is set after a successful
3917
+ // `check` step. Show checkboxes the bot hasn't touched.
3918
+ e.interactedThisRun !== true)
3919
+ .slice(0, 5)
3920
+ .map((e) => {
3921
+ const label = e.labelText ?? e.ariaLabel ?? e.name ?? e.placeholder ?? "(no label)";
3922
+ return ` - ${JSON.stringify(label)} → selector=${e.selector}`;
3923
+ });
3924
+ const uncheckedBoxHint = uncheckedBoxes.length > 0
3925
+ ? `\n\nVisible checkboxes you haven't ticked yet (often a TOS / agreement gate):\n${uncheckedBoxes.join("\n")}\n\nIssue {"kind":"check"} on any that look like agreements / required confirmations.`
3926
+ : "";
2818
3927
  args.steps.push(sameSelector
2819
3928
  ? `Post-verify: no-progress detected — same ${nextStep.kind} on same selector, inventory unchanged. Re-planning instead of re-running.`
2820
3929
  : `Post-verify: no-progress detected — successive click steps with no inventory change. Forcing a non-click action.`);
@@ -2827,7 +3936,9 @@ ${formatInventory(input.inventory)}`,
2827
3936
  `any unticked checkbox, {"kind":"select"} on any unselected dropdown, or ` +
2828
3937
  `{"kind":"done"} if there is genuinely nothing to do.` +
2829
3938
  emptyInputHint +
2830
- defaultedSelectHint;
3939
+ defaultedSelectHint +
3940
+ customComboboxHint +
3941
+ uncheckedBoxHint;
2831
3942
  prevSignature = signature;
2832
3943
  prevInventorySize = inventory.length;
2833
3944
  continue;
@@ -2842,8 +3953,43 @@ ${formatInventory(input.inventory)}`,
2842
3953
  prevSignature = null;
2843
3954
  prevInventorySize = inventory.length;
2844
3955
  }
2845
- if (nextStep.kind === "done")
3956
+ if (nextStep.kind === "done") {
3957
+ // When the planner bails because it encountered Google's
3958
+ // device-verification challenge mid-post-verify (Algolia +
3959
+ // similar redirect their `signup_url` to a sign-in page,
3960
+ // OAuth handoff happens AFTER runOAuthFlow already exited),
3961
+ // fire the heightened-auth notifier AND wait the 2-minute
3962
+ // window for the operator to tap their phone. After the
3963
+ // wait, re-read the page state and continue post-verify —
3964
+ // Google's challenge typically clears within seconds of the
3965
+ // tap and the dashboard becomes accessible.
3966
+ if (this.maybeFirePostVerifyHeightenedAuth(nextStep.reason, args.service, args.steps)) {
3967
+ args.steps.push("Post-verify: waiting 120s for the operator to clear Google's device challenge");
3968
+ await this.browser.wait(120);
3969
+ // Re-loop — next iteration's waitForFormReady + inventory
3970
+ // read see the post-challenge dashboard.
3971
+ continue;
3972
+ }
3973
+ this.lastPostVerifyDoneReason = nextStep.reason;
2846
3974
  break;
3975
+ }
3976
+ // rc.39 — wait-loop break. The planner is asking us to wait
3977
+ // round after round on an empty page (Turso's Cloudflare SSO
3978
+ // callback). Cap at three consecutive waits with zero inventory
3979
+ // and surface the empty-page reason so the caller can classify.
3980
+ if (nextStep.kind === "wait" && inventory.length === 0) {
3981
+ consecutiveWaits += 1;
3982
+ if (consecutiveWaits >= 3) {
3983
+ this.lastPostVerifyDoneReason =
3984
+ `post-OAuth landing rendered 0 interactive elements for ${consecutiveWaits} rounds — ` +
3985
+ `most recent planner reason: ${nextStep.reason}`;
3986
+ args.steps.push(`Post-verify: wait-loop on an empty page (${consecutiveWaits} consecutive rounds, 0 elements) — breaking out.`);
3987
+ break;
3988
+ }
3989
+ }
3990
+ else {
3991
+ consecutiveWaits = 0;
3992
+ }
2847
3993
  hint = undefined;
2848
3994
  try {
2849
3995
  if (nextStep.kind === "extract") {
@@ -2855,10 +4001,82 @@ ${formatInventory(input.inventory)}`,
2855
4001
  // quotes the value. Accept it IF it's also present
2856
4002
  // verbatim in the visible page text — that's the
2857
4003
  // anti-hallucination guardrail.
2858
- const pageText = await this.browser
2859
- .extractText()
2860
- .catch(() => "");
2861
- const quoted = extractQuotedTokenFromReason(nextStep.reason, pageText);
4004
+ // rc.38 verify the planner-quoted value against both
4005
+ // visible text AND every input's `value` attribute. The
4006
+ // rc.37 Upstash retest showed the bot quoting a bare UUID
4007
+ // it observed in a create-key modal whose UUID lived in
4008
+ // an <input readonly value="…"> — textContent doesn't
4009
+ // include input values, so the verbatim-in-page check
4010
+ // rejected a real credential. Concatenating input values
4011
+ // closes the gap without weakening the anti-hallucination
4012
+ // guarantee (the candidate still has to appear SOMEWHERE
4013
+ // verifiable on the page).
4014
+ const [pageText, inputValues] = await Promise.all([
4015
+ this.browser.extractText().catch(() => ""),
4016
+ this.browser.extractAllInputValues().catch(() => []),
4017
+ ]);
4018
+ const verifySource = pageText + "\n" + inputValues.join("\n");
4019
+ // Phase E — multi-cred-aware extraction. Try the labeled
4020
+ // multi-credential parser FIRST. If the planner labeled
4021
+ // 2+ distinct credentials in its reason, fold them all
4022
+ // into the credentials Record. If the parser found at
4023
+ // least one new value (cloud_name, api_secret, etc. —
4024
+ // anything beyond the single api_key the legacy path
4025
+ // captures), prefer this. Falls through to the single-
4026
+ // value extractQuotedTokenFromReason when no labeled
4027
+ // tokens parsed (single-cred services, ad-hoc planner
4028
+ // prose without explicit labels).
4029
+ const labeled = extractAllLabeledTokensFromReason(nextStep.reason, verifySource);
4030
+ const labeledKeys = Object.keys(labeled);
4031
+ if (labeledKeys.length >= 2 || (labeledKeys.length === 1 && labeled["api_key"] === undefined)) {
4032
+ credentials = { ...credentials, ...labeled };
4033
+ const summary = labeledKeys
4034
+ .map((k) => `${k}=${labeled[k].slice(0, 4)}…${labeled[k].slice(-4)}`)
4035
+ .join(", ");
4036
+ args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: extracted ${labeledKeys.length} labeled credential(s) ` +
4037
+ `via Phase E parser (${summary})`);
4038
+ // When the planner's reason explicitly flags a masked
4039
+ // credential ("api_secret is masked", "hidden behind
4040
+ // asterisks", "click Reveal to show"), Phase E only
4041
+ // captured the visible values — try to reveal + extract
4042
+ // the rest on the same round before continuing. Without
4043
+ // this, the loop returns success with a partial bundle
4044
+ // and never tries the reveal click.
4045
+ const MASKED_HINT = /\b(?:masked|hidden|bullets?|asterisks?|••+|\*{3,}|reveal|unmask)\b/i;
4046
+ if (MASKED_HINT.test(nextStep.reason)) {
4047
+ try {
4048
+ const revealRes = await this.browser.revealMaskedCredentials();
4049
+ args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: reveal pass clicked=${revealRes.clicked} diagnostic=[${revealRes.diagnostic.join("; ")}]`);
4050
+ if (revealRes.clicked > 0) {
4051
+ const labeledAfter = await this.extractFromDomProximity();
4052
+ const newKeys = Object.keys(labeledAfter).filter((k) => credentials[k] === undefined);
4053
+ if (newKeys.length > 0) {
4054
+ for (const k of newKeys)
4055
+ credentials[k] = labeledAfter[k];
4056
+ args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: post-reveal DOM-proximity extracted ${newKeys.length} more (${newKeys.join(", ")})`);
4057
+ }
4058
+ else {
4059
+ // Surface ALL labeled candidates we found, so
4060
+ // we can see whether the value is on-page but
4061
+ // mislabeled vs. genuinely not surfaced.
4062
+ const allLabeled = await this.browser.extractLabeledCredentialCandidates();
4063
+ const summary = allLabeled
4064
+ .filter((c) => !c.isMasked)
4065
+ .slice(0, 8)
4066
+ .map((c) => `${c.value.slice(0, 6)}…(${c.value.length}ch)/${c.label ?? "no-label"}`)
4067
+ .join(", ");
4068
+ args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: post-reveal had ${allLabeled.length} candidates; visible: ${summary}`);
4069
+ }
4070
+ }
4071
+ }
4072
+ catch (err) {
4073
+ args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: reveal pass error (${err instanceof Error ? err.message : String(err)})`);
4074
+ }
4075
+ }
4076
+ consecutiveFailedExtracts = 0;
4077
+ continue;
4078
+ }
4079
+ const quoted = extractQuotedTokenFromReason(nextStep.reason, verifySource);
2862
4080
  if (quoted !== null) {
2863
4081
  credentials = { ...credentials, api_key: quoted };
2864
4082
  args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: extracted token via ` +
@@ -2866,6 +4084,38 @@ ${formatInventory(input.inventory)}`,
2866
4084
  consecutiveFailedExtracts = 0;
2867
4085
  continue;
2868
4086
  }
4087
+ // Tier 4 — DOM-proximity labeled credential extraction.
4088
+ // Run BEFORE bailing the extract. Walks the visible DOM,
4089
+ // finds credential-shape strings, pairs each with its
4090
+ // nearest credential-label text by Euclidean center
4091
+ // distance. Catches multi-cred pages where the planner
4092
+ // mentioned ONE value but the DOM shows several (the
4093
+ // planner's narrative-style extract reason missed the
4094
+ // sibling labels). Also tries to unmask hidden secrets
4095
+ // first by clicking visible Reveal/Eye/Copy buttons.
4096
+ try {
4097
+ await this.browser.revealMaskedCredentials();
4098
+ }
4099
+ catch {
4100
+ // Best-effort; never block the extract pass on a
4101
+ // reveal-click failure.
4102
+ }
4103
+ const labeledFromDom = await this.extractFromDomProximity();
4104
+ const newKeys = Object.keys(labeledFromDom).filter((k) => credentials[k] === undefined);
4105
+ if (newKeys.length > 0) {
4106
+ for (const k of newKeys)
4107
+ credentials[k] = labeledFromDom[k];
4108
+ const summary = newKeys
4109
+ .map((k) => {
4110
+ const v = labeledFromDom[k];
4111
+ return `${k}=${v.slice(0, 4)}…${v.slice(-4)}`;
4112
+ })
4113
+ .join(", ");
4114
+ args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: extracted ${newKeys.length} labeled credential(s) ` +
4115
+ `via DOM-proximity fallback (${summary})`);
4116
+ consecutiveFailedExtracts = 0;
4117
+ continue;
4118
+ }
2869
4119
  consecutiveFailedExtracts += 1;
2870
4120
  // Best-effort diagnostic upload: when extract returns
2871
4121
  // null despite the planner asserting a credential is
@@ -2936,7 +4186,31 @@ ${formatInventory(input.inventory)}`,
2936
4186
  }
2937
4187
  else if (nextStep.kind === "click") {
2938
4188
  await this.browser.click(nextStep.selector);
2939
- await this.browser.wait(2);
4189
+ // F7 / 0.6.15-rc.11 — Modal-based credential reveals
4190
+ // (OpenRouter, Anthropic, OpenAI Create-Key flows) render
4191
+ // the new key into a modal AFTER a server round-trip — the
4192
+ // prior blind 2s wait was racing the API response. The
4193
+ // implicit-extract that follows this branch then found
4194
+ // nothing, the round ended, and by the next round the modal
4195
+ // had auto-closed. Poll up to 8s for the key to appear in
4196
+ // the post-action DOM. Early-exit as soon as
4197
+ // extractCredentials returns one — typical happy path on
4198
+ // services without modal-delay returns in <1s. Saves both
4199
+ // time (no overshoot wait) and correctness (catches the
4200
+ // modal-render race).
4201
+ const credentialDeadline = Date.now() + 8000;
4202
+ let pollExtract = {};
4203
+ while (Date.now() < credentialDeadline) {
4204
+ await this.browser.wait(0.5);
4205
+ try {
4206
+ pollExtract = await this.extractCredentials();
4207
+ if (pollExtract.api_key !== undefined)
4208
+ break;
4209
+ }
4210
+ catch {
4211
+ // Page mid-render — keep polling; next tick may settle.
4212
+ }
4213
+ }
2940
4214
  }
2941
4215
  else if (nextStep.kind === "fill") {
2942
4216
  await this.browser.type(nextStep.selector, nextStep.value);
@@ -2979,7 +4253,15 @@ ${formatInventory(input.inventory)}`,
2979
4253
  }
2980
4254
  else if (nextStep.kind === "navigate") {
2981
4255
  await this.browser.goto(nextStep.url);
2982
- // PERF: next round opens with waitForFormReady; no blind dwell.
4256
+ // rc.33 wait for the SPA to actually render before the
4257
+ // next round reads inventory. waitForFormReady (called at
4258
+ // the top of the next round) handles the basic "DOM
4259
+ // parsed" signal, but Porter / Koyeb's API-tokens pages
4260
+ // load over 5-15 seconds — the planner-driven post-verify
4261
+ // loop was running on the empty initial shell and burning
4262
+ // rounds clicking nothing. Wait for at least 5 interactive
4263
+ // elements (Porter's tokens page has 20+ once rendered).
4264
+ await this.browser.waitForInteractiveDom(5, 20_000);
2983
4265
  }
2984
4266
  else if (nextStep.kind === "wait") {
2985
4267
  await this.browser.wait(Math.min(nextStep.seconds, 15));
@@ -3150,14 +4432,53 @@ Schema:
3150
4432
 
3151
4433
  Strategy:
3152
4434
  - If a FULL, untruncated API key is visible, return {"kind":"extract"}.
4435
+ - **MULTI-CREDENTIAL SERVICES** — when the page shows TWO OR MORE
4436
+ DISTINCT labeled credentials (e.g. Cloudinary shows cloud_name +
4437
+ api_key + api_secret; Algolia shows application_id + admin_api_key +
4438
+ search_api_key; Twilio shows account_sid + auth_token; Stripe shows
4439
+ publishable_key + secret_key; AWS shows access_key_id +
4440
+ secret_access_key) — return ONE {"kind":"extract"} step whose reason
4441
+ labels EVERY visible credential in the format
4442
+ \`<canonical_label>='<value>'\` (use SINGLE quotes around values).
4443
+ The bot's labeled-extractor will pull EACH labeled value into the
4444
+ credentials object. Example reason:
4445
+ "The Cloudinary API Keys page shows cloud_name='dlq4xgrca' and
4446
+ api_key='491741466469613' in the table; api_secret is hidden behind
4447
+ a Reveal button."
4448
+ Use the standard canonical labels: api_key, api_secret, secret_key,
4449
+ publishable_key, access_token, client_id, client_secret, cloud_name,
4450
+ application_id, admin_api_key, search_api_key, account_sid,
4451
+ auth_token, app_key, org_id, consumer_key, consumer_secret,
4452
+ access_token_secret, project_api_key, personal_api_key.
4453
+ If only ONE credential is visible, omit the labels and quote the
4454
+ value as before — the single-cred fallback handles that path.
3153
4455
  - A key shown masked or truncated (with "...", dots, or "•") is NOT
3154
4456
  extractable — its full value is shown only once, at creation. Do NOT
3155
4457
  return "extract" for a masked key, and do not return "extract" twice
3156
4458
  in a row. Instead click "Create API Key" / "New API Key" / "Generate"
3157
4459
  to make a fresh key, then extract its full value.
4460
+ - **REVEAL-CLICK BEFORE EXTRACT** — when a credential is shown masked
4461
+ (•••••, asterisks, dots) AND there is a VISIBLE "Show", "Reveal",
4462
+ "Eye", or eye-icon button NEXT TO IT (typically same row in a
4463
+ credentials table — Cloudinary, Twilio, Stripe all follow this
4464
+ pattern for api_secret / auth_token / secret_key), emit a CLICK
4465
+ on that show/reveal button FIRST. Do NOT return extract on the same
4466
+ round as the masked display — the masked text would be parsed as
4467
+ the value. Next round the value will be visible and your extract
4468
+ step can quote it. The bot's reveal-pass is a fallback; explicit
4469
+ clicks via the planner are more reliable because you can see the
4470
+ exact button in the screenshot.
3158
4471
  - To reach API keys, prefer a {"kind":"navigate"} straight to the
3159
4472
  service's API-keys settings URL — note these usually live under the
3160
4473
  user/ACCOUNT settings, not a project or workspace's settings.
4474
+ - **EXCEPT** when the page has a very small inventory (5 or fewer elements)
4475
+ and one of them is an onboarding CTA — patterns like "Get started",
4476
+ "Continue", "Activate", "Enable API", "Start free trial", "Set up".
4477
+ These are gating CTAs that unlock the API console; CLICKING them
4478
+ is required, navigate will just redirect you back. If you've already
4479
+ emitted a navigate once on this URL and the URL did not change on the
4480
+ next round, that is the signal — the service is gating the route. Click
4481
+ the visible CTA instead.
3161
4482
  - Otherwise click a dashboard menu link like "API Keys" / "Tokens" /
3162
4483
  "Developer" / "Settings" — using its inventory selector.
3163
4484
  - If there's an onboarding modal or a "Skip" link blocking, dismiss it.