@trusty-squire/mcp 0.9.19-rc.2 → 0.9.19-rc.21

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 (106) hide show
  1. package/README.md +15 -33
  2. package/dist/api-client.d.ts +6 -0
  3. package/dist/api-client.d.ts.map +1 -1
  4. package/dist/api-client.js.map +1 -1
  5. package/dist/bin.js +4 -10
  6. package/dist/bin.js.map +1 -1
  7. package/dist/bot/agent.d.ts +24 -2
  8. package/dist/bot/agent.d.ts.map +1 -1
  9. package/dist/bot/agent.js +634 -55
  10. package/dist/bot/agent.js.map +1 -1
  11. package/dist/bot/browser.d.ts +7 -0
  12. package/dist/bot/browser.d.ts.map +1 -1
  13. package/dist/bot/browser.js +182 -3
  14. package/dist/bot/browser.js.map +1 -1
  15. package/dist/bot/credential-extraction-flow.d.ts +2 -0
  16. package/dist/bot/credential-extraction-flow.d.ts.map +1 -1
  17. package/dist/bot/credential-extraction-flow.js +71 -1
  18. package/dist/bot/credential-extraction-flow.js.map +1 -1
  19. package/dist/bot/form-fill.d.ts.map +1 -1
  20. package/dist/bot/form-fill.js +11 -0
  21. package/dist/bot/form-fill.js.map +1 -1
  22. package/dist/bot/google-login.d.ts.map +1 -1
  23. package/dist/bot/google-login.js +37 -1
  24. package/dist/bot/google-login.js.map +1 -1
  25. package/dist/bot/index.d.ts +1 -0
  26. package/dist/bot/index.d.ts.map +1 -1
  27. package/dist/bot/index.js +1 -0
  28. package/dist/bot/index.js.map +1 -1
  29. package/dist/bot/login-state.d.ts +2 -1
  30. package/dist/bot/login-state.d.ts.map +1 -1
  31. package/dist/bot/login-state.js +22 -5
  32. package/dist/bot/login-state.js.map +1 -1
  33. package/dist/bot/nav-search.d.ts.map +1 -1
  34. package/dist/bot/nav-search.js +9 -0
  35. package/dist/bot/nav-search.js.map +1 -1
  36. package/dist/bot/post-signup-flow.d.ts.map +1 -1
  37. package/dist/bot/post-signup-flow.js +21 -0
  38. package/dist/bot/post-signup-flow.js.map +1 -1
  39. package/dist/bot/post-signup-recovery-state.d.ts +3 -0
  40. package/dist/bot/post-signup-recovery-state.d.ts.map +1 -1
  41. package/dist/bot/post-signup-recovery-state.js +3 -0
  42. package/dist/bot/post-signup-recovery-state.js.map +1 -1
  43. package/dist/bot/provision-session.d.ts +116 -1
  44. package/dist/bot/provision-session.d.ts.map +1 -1
  45. package/dist/bot/provision-session.js +885 -41
  46. package/dist/bot/provision-session.js.map +1 -1
  47. package/dist/bot/redact.d.ts.map +1 -1
  48. package/dist/bot/redact.js +25 -2
  49. package/dist/bot/redact.js.map +1 -1
  50. package/dist/bot/replay-skill.d.ts +6 -0
  51. package/dist/bot/replay-skill.d.ts.map +1 -1
  52. package/dist/bot/replay-skill.js +39 -5
  53. package/dist/bot/replay-skill.js.map +1 -1
  54. package/dist/bot/skill-hint.d.ts +7 -0
  55. package/dist/bot/skill-hint.d.ts.map +1 -0
  56. package/dist/bot/skill-hint.js +105 -0
  57. package/dist/bot/skill-hint.js.map +1 -0
  58. package/dist/bot/terminal-gate.d.ts +3 -1
  59. package/dist/bot/terminal-gate.d.ts.map +1 -1
  60. package/dist/bot/terminal-gate.js +19 -0
  61. package/dist/bot/terminal-gate.js.map +1 -1
  62. package/dist/install/agents.d.ts.map +1 -1
  63. package/dist/install/agents.js +12 -2
  64. package/dist/install/agents.js.map +1 -1
  65. package/dist/install/cli.d.ts +14 -2
  66. package/dist/install/cli.d.ts.map +1 -1
  67. package/dist/install/cli.js +346 -150
  68. package/dist/install/cli.js.map +1 -1
  69. package/dist/install/interactive.d.ts +9 -3
  70. package/dist/install/interactive.d.ts.map +1 -1
  71. package/dist/install/interactive.js +80 -140
  72. package/dist/install/interactive.js.map +1 -1
  73. package/dist/install/proxy-url.d.ts +2 -0
  74. package/dist/install/proxy-url.d.ts.map +1 -0
  75. package/dist/install/proxy-url.js +20 -0
  76. package/dist/install/proxy-url.js.map +1 -0
  77. package/dist/install/ui.js +1 -1
  78. package/dist/install/ui.js.map +1 -1
  79. package/dist/session.d.ts +3 -0
  80. package/dist/session.d.ts.map +1 -1
  81. package/dist/session.js.map +1 -1
  82. package/dist/skill-registry-client.d.ts +8 -0
  83. package/dist/skill-registry-client.d.ts.map +1 -1
  84. package/dist/skill-registry-client.js +70 -53
  85. package/dist/skill-registry-client.js.map +1 -1
  86. package/dist/tools/index.d.ts +1 -2
  87. package/dist/tools/index.d.ts.map +1 -1
  88. package/dist/tools/index.js +10 -19
  89. package/dist/tools/index.js.map +1 -1
  90. package/dist/tools/provision-any.d.ts +3 -0
  91. package/dist/tools/provision-any.d.ts.map +1 -1
  92. package/dist/tools/provision-any.js +162 -32
  93. package/dist/tools/provision-any.js.map +1 -1
  94. package/dist/tools/provision-drive.d.ts +121 -5
  95. package/dist/tools/provision-drive.d.ts.map +1 -1
  96. package/dist/tools/provision-drive.js +339 -48
  97. package/dist/tools/provision-drive.js.map +1 -1
  98. package/dist/tools/store-credential.d.ts +5 -0
  99. package/dist/tools/store-credential.d.ts.map +1 -1
  100. package/dist/tools/store-credential.js +5 -0
  101. package/dist/tools/store-credential.js.map +1 -1
  102. package/package.json +1 -3
  103. package/dist/bot/telegram-notify.d.ts +0 -8
  104. package/dist/bot/telegram-notify.d.ts.map +0 -1
  105. package/dist/bot/telegram-notify.js +0 -134
  106. package/dist/bot/telegram-notify.js.map +0 -1
package/dist/bot/agent.js CHANGED
@@ -14,7 +14,6 @@ import { decideOAuthStep } from "./oauth-flow.js";
14
14
  import { decideFormFillStep, FORM_FILL_BUDGETS as B_FF, findSignupLinkOnLoginPage, initialFormFillState, pickEmailCodeSubmitSelector, } from "./form-fill.js";
15
15
  import { accumulateCandidate, hasFullHit, initialExtractionState, resolveExtraction, } from "./extraction.js";
16
16
  import { notifyHeightenedAuth } from "./notify-api.js";
17
- import { sendTelegramHeightenedAuth } from "./telegram-notify.js";
18
17
  import { TwoCaptchaSolver } from "./captcha-solver-2captcha.js";
19
18
  import { redactCredentials } from "./redact.js";
20
19
  import { readOperatorOtp, fromDomainFromUrl } from "./read-otp.js";
@@ -23,18 +22,18 @@ import { SignupOAuthFlow } from "./signup-oauth-flow.js";
23
22
  import { saveDebugSnapshot } from "./debug.js";
24
23
  import { captureObservationFrame } from "./observation-frame.js";
25
24
  import { classifyObservationFrame } from "./state-classifier.js";
26
- import { classifyTerminalGate, isAtAccountReviewGate, isAtPaywall, isOnboardingReviewGate, isSignupsClosed, } from "./terminal-gate.js";
27
- export { isAtAccountReviewGate, isAtPaywall, isOnboardingReviewGate, isSignupsClosed, } from "./terminal-gate.js";
25
+ import { classifyTerminalGate, isAtAccountReviewGate, isAtOAuthAccountLinkVerificationGate, isAtPaywall, isOnboardingReviewGate, isSignupsClosed, } from "./terminal-gate.js";
26
+ export { isAtAccountReviewGate, isAtOAuthAccountLinkVerificationGate, isAtPaywall, isOnboardingReviewGate, isSignupsClosed, } from "./terminal-gate.js";
28
27
  import { PostSignupCredentialTracker, classifyNoCredentialPostSignup, } from "./post-signup-flow.js";
29
28
  import { PostSignupActionExecutor, } from "./post-signup-action-executor.js";
30
29
  import { PostSignupLoginFlow } from "./post-signup-login-flow.js";
31
30
  import { MAX_POST_VERIFY_NAVIGATES, MAX_PREMATURE_DONE_FALLBACKS, MAX_UPSTREAM_BLIP_RETRIES, PostSignupRecoveryFlow, PostSignupRecoveryState, } from "./post-signup-recovery-state.js";
32
31
  import { PostSignupSyntheticCapture } from "./post-signup-synthetic-capture.js";
33
- import { CredentialExtractionFlow, DOM_LABEL_TO_KEY, NON_CREDENTIAL_KEYS, extractAllLabeledTokensFromReason, hasAnyExtractedCredential, hasUsableCredentialBundle, } from "./credential-extraction-flow.js";
34
- export { extractAllLabeledTokensFromReason, hasAnyExtractedCredential, isMultiCredBundle, } from "./credential-extraction-flow.js";
32
+ import { CredentialExtractionFlow, DOM_LABEL_TO_KEY, NON_CREDENTIAL_KEYS, extractAllLabeledTokensFromReason, hasAnyExtractedCredential, hasUsableCredentialBundle, terminalReasonInvalidatesCredentialSuccess, } from "./credential-extraction-flow.js";
33
+ export { extractAllLabeledTokensFromReason, hasAnyExtractedCredential, isMultiCredBundle, terminalReasonInvalidatesCredentialSuccess, } from "./credential-extraction-flow.js";
35
34
  import { captureOnboardingRound, hasCapturedAnyRound, hasCapturedExtractRound, nextCaptureRound, updateCapturedRoundSemantic, } from "./onboarding-capture.js";
36
35
  import { evaluateSemanticTransition, inferSemanticTransition, } from "./semantic-transition.js";
37
- import { KEYS_DESTINATION_URL, runNavSearch, } from "./nav-search.js";
36
+ import { KEYS_DESTINATION_URL, isRequiredAccountSetupForm, runNavSearch, } from "./nav-search.js";
38
37
  import { wasRecentlyPrewarmed, recordPrewarmSuccess } from "./prewarm-cache.js";
39
38
  import { pickLLMPair, } from "./llm-client.js";
40
39
  import { getDomain } from "tldts";
@@ -401,6 +400,16 @@ const SERVICE_KEYS_PATHS = {
401
400
  // /settings/* and /api-keys guesses miss it and the planner can get stuck
402
401
  // in Business Account > Team Members.
403
402
  paddle: ["/authentication-v2"],
403
+ // Together AI's project-scoped keys route is discoverable in the DOM, but the
404
+ // planner can loop between Create key and payment-skip affordances after it
405
+ // lands there. Pin the route so recovery starts at the credential surface.
406
+ togetherai: [
407
+ "https://api.together.ai/settings/projects/~current/api-keys",
408
+ "/settings/projects/~current/api-keys",
409
+ ],
410
+ // Cartesia's authenticated root/dashboard can expose key rows with Reveal
411
+ // controls; generic /settings/* guesses hit 404/error shells first.
412
+ cartesia: ["/", "/dashboard", "/start", "/api-keys"],
404
413
  };
405
414
  // Normalize a service name to the slug used as a SERVICE_KEYS_PATHS key:
406
415
  // lowercased, alphanumerics only. Mirrors guessSignupUrl's slug rule so
@@ -409,6 +418,80 @@ const SERVICE_KEYS_PATHS = {
409
418
  export function serviceSlug(service) {
410
419
  return service.toLowerCase().replace(/[^a-z0-9]/g, "");
411
420
  }
421
+ export function hasCuratedServiceKeyPath(service) {
422
+ return SERVICE_KEYS_PATHS[serviceSlug(service)] !== undefined;
423
+ }
424
+ function elementLabel(el) {
425
+ return [
426
+ el.visibleText,
427
+ el.ariaLabel,
428
+ el.title,
429
+ el.labelText,
430
+ el.iconLabel,
431
+ el.href,
432
+ el.testId,
433
+ ]
434
+ .filter((s) => s !== null && s !== undefined)
435
+ .join(" ")
436
+ .replace(/\s+/g, " ")
437
+ .trim();
438
+ }
439
+ export function shouldRevealBeforeCredentialSweep(input) {
440
+ const haystack = `${input.url}\n${input.pageText}`;
441
+ return (/\b(?:api[\s_-]*(?:keys?|tokens?)|access[\s_-]*tokens?|personal[\s_-]*access[\s_-]*tokens?|secret[\s_-]*keys?|auth[\s_-]*tokens?|credentials?|reveal|show\s+(?:key|token|secret)|copy\s+(?:key|token|secret))\b/i.test(haystack) &&
442
+ /(?:•{3,}|\*{3,}|[A-Za-z0-9]{2,4}[•*]{4,}|\b(?:reveal|show|unmask|view)\b)/i.test(haystack));
443
+ }
444
+ export function credentialActionSignature(step, inventory) {
445
+ if (!("selector" in step) || step.selector === undefined)
446
+ return null;
447
+ const target = inventory.find((e) => e.selector === step.selector);
448
+ const label = [
449
+ target?.visibleText,
450
+ target?.ariaLabel,
451
+ target?.title,
452
+ target?.labelText,
453
+ target?.iconLabel,
454
+ step.reason,
455
+ ]
456
+ .filter((s) => s !== null && s !== undefined)
457
+ .join(" ")
458
+ .replace(/\s+/g, " ")
459
+ .trim();
460
+ if (label.length === 0)
461
+ return null;
462
+ const lower = label.toLowerCase();
463
+ if (/\b(?:create|generate|new|add|issue|mint)\b.*\b(?:api\s*)?(?:key|token|secret|credential)s?\b/.test(lower) ||
464
+ /\b(?:api\s*)?(?:key|token|secret|credential)s?\b.*\b(?:create|generate|new|add|issue|mint)\b/.test(lower)) {
465
+ return `${step.kind}:create-key`;
466
+ }
467
+ if (/\b(?:skip|maybe later|not now|without)\b.*\b(?:payment|billing|card|deposit|credits?)\b/.test(lower) ||
468
+ /\b(?:payment|billing|card|deposit|credits?)\b.*\b(?:skip|maybe later|not now|without)\b/.test(lower)) {
469
+ return `${step.kind}:skip-payment`;
470
+ }
471
+ if (/\b(?:add|make|initial)\b.*\b(?:payment|deposit|credits?)\b/.test(lower)) {
472
+ return `${step.kind}:payment`;
473
+ }
474
+ return null;
475
+ }
476
+ export function isRepeatingCredentialActionCycle(signatures, pageText = "") {
477
+ const interesting = signatures.some((s) => /(?:create-key|payment|deposit)/.test(s)) ||
478
+ /\b(?:api\s*keys?|tokens?|payment|billing|deposit|credits?)\b/i.test(pageText);
479
+ if (!interesting)
480
+ return false;
481
+ const last3 = signatures.slice(-3);
482
+ if (last3.length === 3 &&
483
+ last3.every((s) => s === last3[0]) &&
484
+ /(?:create-key|skip-payment|payment)/.test(last3[0] ?? "")) {
485
+ return true;
486
+ }
487
+ const last5 = signatures.slice(-5);
488
+ return (last5.length === 5 &&
489
+ last5[0] === last5[2] &&
490
+ last5[2] === last5[4] &&
491
+ last5[1] === last5[3] &&
492
+ last5[0] !== last5[1] &&
493
+ last5.some((s) => /(?:create-key|skip-payment|payment)/.test(s)));
494
+ }
412
495
  // 0.8.2-rc.10 — heuristic for "this account already exists on the
413
496
  // service and its API keys are masked, with no path to reveal them."
414
497
  // The test identity (methoxine@gmail.com) accumulates state across
@@ -706,6 +789,58 @@ export function findApiKeysNavLink(inventory, alreadyClicked = new Set()) {
706
789
  candidates.sort((a, b) => b.score - a.score);
707
790
  return candidates[0].el;
708
791
  }
792
+ export function findRenderAccountSettingsLink(inventory, alreadyClicked = new Set()) {
793
+ for (const el of inventory) {
794
+ if (el.visible === false)
795
+ continue;
796
+ if (alreadyClicked.has(el.selector))
797
+ continue;
798
+ const clickable = el.tag === "a" ||
799
+ el.tag === "button" ||
800
+ el.role === "link" ||
801
+ el.role === "button";
802
+ if (!clickable)
803
+ continue;
804
+ const label = elementLabel(el);
805
+ if (/\baccount settings\b/i.test(label) || /\/u\/[^/\s]+\/settings(?:[#?\s]|$)/i.test(label)) {
806
+ return el;
807
+ }
808
+ }
809
+ return null;
810
+ }
811
+ export function findRenderAccountMenuTrigger(inventory) {
812
+ const candidates = [];
813
+ for (const el of inventory) {
814
+ if (el.visible === false)
815
+ continue;
816
+ const clickable = el.tag === "button" || el.role === "button";
817
+ if (!clickable)
818
+ continue;
819
+ const label = elementLabel(el);
820
+ if (label.length === 0)
821
+ continue;
822
+ const normalized = label.toLowerCase().trim();
823
+ if (/\b(?:new|upgrade|search|settings|billing|projects?|blueprints?|notifications?|contact support|create|add)\b/i.test(normalized)) {
824
+ continue;
825
+ }
826
+ let score = 0;
827
+ if (el.landmark === "header")
828
+ score += 4;
829
+ if (/^[a-z]{1,3}$/i.test(normalized))
830
+ score += 3;
831
+ if (/\b(?:profile|account|user|avatar)\b/i.test(label))
832
+ score += 2;
833
+ if (el.inViewport === true)
834
+ score += 1;
835
+ if (score <= 0)
836
+ continue;
837
+ candidates.push({ el, score });
838
+ }
839
+ if (candidates.length === 0)
840
+ return null;
841
+ candidates.sort((a, b) => b.score - a.score);
842
+ return candidates[0].el;
843
+ }
709
844
  const RELOCATED_PAGE_TEXT = /\b(?:404|page not found|not found|does not exist|no longer exists|has moved|moved|migrated|now (?:lives|exists)|part of .* instead|teams? list|organisations? list|organizations? list)\b/i;
710
845
  const CREDENTIAL_CONTEXT_TEXT = /\b(?:api[\s_-]*(?:keys?|tokens?)|access[\s_-]*tokens?|personal[\s_-]*access[\s_-]*tokens?|secret[\s_-]*keys?|auth[\s_-]*tokens?|credentials?|developers?)\b/i;
711
846
  const RELOCATION_RECOVERY_TEXT = /\b(?:teams?|organisations?|organizations?|accounts?|settings|profile|dashboard|developers?|api|keys?|tokens?|credentials?)\b/i;
@@ -751,12 +886,17 @@ export function findRelocatedCredentialPageRecoveryLink(input) {
751
886
  const haystack = `${href} ${text}`;
752
887
  if (!RELOCATION_RECOVERY_TEXT.test(haystack))
753
888
  continue;
889
+ const isScopeRecovery = /\b(?:teams?|organisations?|organizations?)\b/i.test(haystack);
890
+ const pageAdvertisesScopeList = /\b(?:teams? list|organisations? list|organizations? list|part of .* instead)\b/i.test(relocatedText);
891
+ if (isScopeRecovery && !pageAdvertisesScopeList && !SCOPE_ENTITY_HREF.test(href)) {
892
+ continue;
893
+ }
754
894
  let score = 0;
755
895
  if (API_KEYS_HREF.test(href))
756
896
  score += 6;
757
897
  if (API_KEYS_TEXT.test(text))
758
898
  score += 4;
759
- if (/\b(?:teams?|organisations?|organizations?)\b/i.test(haystack))
899
+ if (isScopeRecovery)
760
900
  score += 3;
761
901
  if (/\b(?:settings|accounts?|profile|developers?)\b/i.test(haystack))
762
902
  score += 2;
@@ -1004,18 +1144,64 @@ function shouldComposeFallbackOnAppOrigin(current, app) {
1004
1144
  // replace every current origin with the original signup URL's origin.
1005
1145
  return /^(?:auth|authkit|login|accounts?|idp|sso|oauth|signin|signup)$/.test(firstLabel);
1006
1146
  }
1147
+ const SERVICE_NAME_TLD_SUFFIXES = new Set([
1148
+ "ai",
1149
+ "app",
1150
+ "co",
1151
+ "dev",
1152
+ "io",
1153
+ "so",
1154
+ "xyz",
1155
+ ]);
1007
1156
  // Last-resort canonical signup URL when the caller passed none and no
1008
- // promoted skill / model resolution applies: <name>.com/signup, which
1009
- // catches the common dev-SaaS case (Resend, Postmark, IPInfo, …). Non-.com
1010
- // products and non-obvious entry points are handled upstream by
1011
- // resolveSignupUrl (promoted-skill signup_url model); a wrong .com guess
1012
- // is recovered by the looksLikeSignupPage Google-search fallback. The old
1013
- // hand-maintained KNOWN_DOMAINS table was retired once the model + the
1014
- // verified skill cache covered it. Exported for unit testing.
1157
+ // promoted skill / model resolution applies. Plain service names still use
1158
+ // <name>.com/signup for the common dev-SaaS case (Resend, Postmark, IPInfo,
1159
+ // …). Service names written with a plausible TLD suffix (together-ai, fly-io,
1160
+ // x-ai) preserve that signal as <name>.<tld>/signup; dogfood showed the old
1161
+ // compact .com fallback can send these to a different registered domain.
1162
+ // Non-obvious entry points are still handled upstream by promoted-skill URLs,
1163
+ // the canonical map, or the model.
1015
1164
  export function guessSignupUrl(service) {
1016
- const slug = service.toLowerCase().replace(/[^a-z0-9]/g, "");
1165
+ const parts = service
1166
+ .toLowerCase()
1167
+ .split(/[^a-z0-9]+/)
1168
+ .filter((part) => part.length > 0);
1169
+ if (parts.length >= 2) {
1170
+ const tld = parts[parts.length - 1];
1171
+ const root = parts.slice(0, -1).join("");
1172
+ if (tld !== undefined && SERVICE_NAME_TLD_SUFFIXES.has(tld) && root.length > 0) {
1173
+ return `https://${root}.${tld}/signup`;
1174
+ }
1175
+ }
1176
+ const slug = parts.join("");
1017
1177
  return `https://${slug}.com/signup`;
1018
1178
  }
1179
+ function explicitServiceDomain(service) {
1180
+ const parts = service
1181
+ .toLowerCase()
1182
+ .split(/[^a-z0-9]+/)
1183
+ .filter((part) => part.length > 0);
1184
+ if (parts.length < 2)
1185
+ return null;
1186
+ const tld = parts[parts.length - 1];
1187
+ const root = parts.slice(0, -1).join("");
1188
+ if (tld === undefined || root.length === 0 || !SERVICE_NAME_TLD_SUFFIXES.has(tld)) {
1189
+ return null;
1190
+ }
1191
+ return `${root}.${tld}`;
1192
+ }
1193
+ function modelUrlContradictsExplicitServiceDomain(service, url) {
1194
+ const expected = explicitServiceDomain(service);
1195
+ if (expected === null)
1196
+ return false;
1197
+ try {
1198
+ const host = new URL(url).hostname.toLowerCase();
1199
+ return host !== expected && !host.endsWith(`.${expected}`);
1200
+ }
1201
+ catch {
1202
+ return false;
1203
+ }
1204
+ }
1019
1205
  const CANONICAL_SIGNUP_URLS = {
1020
1206
  // The model repeatedly returns the stale/dead host
1021
1207
  // console.cloud.clickhouse.com, which no longer resolves. ClickHouse's own
@@ -1042,6 +1228,13 @@ const CANONICAL_SIGNUP_URLS = {
1042
1228
  // verification. Current Anyscale docs identify console.anyscale.com as the
1043
1229
  // organization signup flow.
1044
1230
  anyscale: "https://console.anyscale.com",
1231
+ // Cartesia's marketing /signup is currently a rendered 404/dead page. The
1232
+ // self-serve auth app exposes the registration surface under its sign-in SPA.
1233
+ cartesia: "https://play.cartesia.ai/sign-in/sign-up",
1234
+ // Pinecone Assistant is a product, not a standalone domain. Reusing the
1235
+ // generic service-name guess produced pineconeassistant.com, which does not
1236
+ // resolve; assistant keys are issued from the normal Pinecone console.
1237
+ pineconeassistant: "https://app.pinecone.io/signup",
1045
1238
  };
1046
1239
  function canonicalSignupUrl(service) {
1047
1240
  const slug = service.toLowerCase().replace(/[^a-z0-9]/g, "");
@@ -1144,6 +1337,10 @@ export async function resolveSignupUrl(service, llm, opts = {}) {
1144
1337
  });
1145
1338
  const url = firstHttpsUrl(resp.text);
1146
1339
  if (url !== null) {
1340
+ if (modelUrlContradictsExplicitServiceDomain(service, url)) {
1341
+ opts.log?.(`Model URL for "${service}" contradicts explicit service domain (${url}) — using guess`);
1342
+ return guessSignupUrl(service);
1343
+ }
1147
1344
  opts.log?.(`Resolved signup URL for "${service}" via ${resp.backend}: ${url}`);
1148
1345
  return url;
1149
1346
  }
@@ -2108,6 +2305,26 @@ export function looksLike404(title, bodyText) {
2108
2305
  // keys URLs that hit either are dead ends.
2109
2306
  return has404Token || notFoundPhrase;
2110
2307
  }
2308
+ export function looksLikeParkedDomainPage(title, bodyText) {
2309
+ const hay = `${title} ${bodyText}`.toLowerCase().replace(/\s+/g, " ").slice(0, 1200);
2310
+ const saleSignal = /\b(?:domain|website)\s+(?:name\s+)?(?:is\s+)?for sale\b/.test(hay) ||
2311
+ /\bbuy this domain\b/.test(hay) ||
2312
+ /\bthis domain may be for sale\b/.test(hay);
2313
+ const parkingSignal = /\b(?:domain parking|parked domain|sedo domain parking|hugedomains|dan\.com|afternic)\b/.test(hay) ||
2314
+ /\bsearch for information\b/.test(hay);
2315
+ return saleSignal && parkingSignal;
2316
+ }
2317
+ export function looksLikeBrowserNetworkError(title, bodyText) {
2318
+ const hay = `${title} ${bodyText}`.toLowerCase().replace(/\s+/g, " ").slice(0, 1200);
2319
+ return (/\bthis site can'?t be reached\b/.test(hay) ||
2320
+ /\bcheck if there is a typo in\b/.test(hay) ||
2321
+ /\b(?:dns_probe_finished|err_name_not_resolved|err_tunnel_connection_failed|err_connection_timed_out|err_connection_refused)\b/i.test(hay));
2322
+ }
2323
+ export function shouldAbortOAuthFirstOnDeadPage(title, bodyText) {
2324
+ return (looksLike404(title, bodyText) ||
2325
+ looksLikeParkedDomainPage(title, bodyText) ||
2326
+ looksLikeBrowserNetworkError(title, bodyText));
2327
+ }
2111
2328
  // Services whose credential is the auto-created Firebase/GCP "Browser key"
2112
2329
  // reachable from the Google Cloud API Credentials page. Both share the same
2113
2330
  // project-creation flow and the SAME AIzaSy key (it's both the firebase web
@@ -2203,6 +2420,9 @@ export function classifySignupHtml(html, title) {
2203
2420
  titleLower.includes("page not found")) {
2204
2421
  return "other";
2205
2422
  }
2423
+ if (looksLikeParkedDomainPage(title ?? "", text)) {
2424
+ return "other";
2425
+ }
2206
2426
  // A password field is the structural prerequisite for an auth form. We
2207
2427
  // regex the RAW html (not the stripped text) because attribute values
2208
2428
  // live inside the tags the stripper removes. Either the input type or a
@@ -2420,6 +2640,45 @@ export function findCreateAccountCta(inventory) {
2420
2640
  }
2421
2641
  return null;
2422
2642
  }
2643
+ // Post-OAuth account-link bridges (Auth0/Keycloak first-broker-login class)
2644
+ // can say "Account already exists" and offer a safe in-page action like
2645
+ // "Add to existing account". This is not a terminal verification wall: taking
2646
+ // the explicit link action usually completes the provider/session attachment
2647
+ // and redirects to the app. Conservative text gate + clickable CTA only.
2648
+ export function findOAuthAccountLinkCta(pageText, inventory) {
2649
+ const text = pageText.toLowerCase().replace(/\s+/g, " ");
2650
+ const accountLinkState = /\baccount already exists\b/.test(text) ||
2651
+ /\blink (?:your )?(?:google|github|oauth|social) account\b/.test(text) ||
2652
+ /\badd to existing account\b/.test(text);
2653
+ if (!accountLinkState)
2654
+ return null;
2655
+ const acceptLink = /\b(?:add to existing account|link (?:account|accounts|existing account)|continue with existing account|use existing account|connect (?:account|accounts))\b/i;
2656
+ const rejectLink = /\b(?:create new account|new account|review profile|cancel|back|not now|sign out|log out)\b/i;
2657
+ for (const el of inventory) {
2658
+ if (el.visible === false)
2659
+ continue;
2660
+ if (el.tag !== "a" && el.tag !== "button" && el.role !== "button")
2661
+ continue;
2662
+ const label = [
2663
+ el.visibleText,
2664
+ el.ariaLabel,
2665
+ el.labelText,
2666
+ el.title,
2667
+ el.name,
2668
+ el.id,
2669
+ ]
2670
+ .filter((part) => typeof part === "string" && part.trim().length > 0)
2671
+ .join(" ")
2672
+ .trim();
2673
+ if (label === "")
2674
+ continue;
2675
+ if (rejectLink.test(label))
2676
+ continue;
2677
+ if (acceptLink.test(label))
2678
+ return el;
2679
+ }
2680
+ return null;
2681
+ }
2423
2682
  // Conventional signup paths to probe, in priority order. Small + ordered
2424
2683
  // on purpose — we want the FIRST real signup form, not a fan-out across
2425
2684
  // dozens of guesses that each cost a round-trip over a residential
@@ -2650,6 +2909,16 @@ export function detectAntiBotBlock(html) {
2650
2909
  return "Imperva";
2651
2910
  return null;
2652
2911
  }
2912
+ export function detectCredentialExtractionBlock(html, visibleText = "") {
2913
+ const vendor = detectAntiBotBlock(html);
2914
+ if (vendor !== null) {
2915
+ return `${vendor} anti-bot interstitial`;
2916
+ }
2917
+ if (visibleText.trim().length <= 600 && isAntiBotInterstitialText(visibleText)) {
2918
+ return "anti-bot interstitial";
2919
+ }
2920
+ return null;
2921
+ }
2653
2922
  // F17 — True when the page looks like an authenticated dashboard
2654
2923
  // rather than a sign-up page. Triggers when a prior OAuth bind
2655
2924
  // already linked the account and the service auto-redirects past
@@ -3245,6 +3514,18 @@ export function detectSsoRestriction(pageText) {
3245
3514
  // "Single Sign-On is required", "SSO organization membership".
3246
3515
  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);
3247
3516
  }
3517
+ export function detectOAuthRegistrationDisabled(url, bodyText) {
3518
+ let query = "";
3519
+ try {
3520
+ query = decodeURIComponent(new URL(url).search.replace(/\+/g, " ")).toLowerCase();
3521
+ }
3522
+ catch {
3523
+ query = "";
3524
+ }
3525
+ const hay = `${query} ${bodyText}`.toLowerCase().replace(/\s+/g, " ");
3526
+ return (/\b(?:sso|oauth|social|google|github)\s+account\s+registration\s+is\s+disabled\b/.test(hay) ||
3527
+ /\bregistration\s+(?:via|with)\s+(?:sso|oauth|social|google|github)\s+is\s+disabled\b/.test(hay));
3528
+ }
3248
3529
  // Google-OAuth-is-LOGIN-ONLY (plunk class). Some services accept Google
3249
3530
  // only to log an EXISTING account in; they do NOT auto-provision a new
3250
3531
  // account for a first-time Google identity. The OAuth handshake
@@ -4366,6 +4647,23 @@ export function isSignupOrLoginRoute(url) {
4366
4647
  return false;
4367
4648
  }
4368
4649
  }
4650
+ export function isPostVerifyAuthResetRoute(input) {
4651
+ return (input.round > 0 &&
4652
+ !input.hasExtractedCredential &&
4653
+ isSignupOrLoginRoute(input.url));
4654
+ }
4655
+ export function isGitHubOAuthRateLimitPage(url, htmlOrText) {
4656
+ try {
4657
+ const u = new URL(url);
4658
+ if (u.hostname !== "github.com" || !/^\/login\/oauth\/authorize\b/i.test(u.pathname)) {
4659
+ return false;
4660
+ }
4661
+ }
4662
+ catch {
4663
+ return false;
4664
+ }
4665
+ return /\btoo many requests\b/i.test(htmlOrText) || /\bsecondary rate limit\b/i.test(htmlOrText);
4666
+ }
4369
4667
  // The scheme://host root of a URL (no path/query) — the place a service
4370
4668
  // redirects an authenticated user to their dashboard. Null on a malformed
4371
4669
  // URL. Exported for unit tests.
@@ -5037,6 +5335,8 @@ export class SignupAgent {
5037
5335
  // regression that produced the Security Code challenge on
5038
5336
  // methoxine's account during the rc.30 Railway run.
5039
5337
  let committedToEmailPath = forceFormFill;
5338
+ let lastGitHubAuthorizeUrl = null;
5339
+ let repeatedGitHubAuthorizeApprovals = 0;
5040
5340
  const oauthCandidates = await this.resolveOAuthCandidates(task, steps);
5041
5341
  for (;;) {
5042
5342
  await this.browser.waitForFormReady();
@@ -5072,9 +5372,26 @@ export class SignupAgent {
5072
5372
  };
5073
5373
  }
5074
5374
  if (/^https:\/\/github\.com\/login\/oauth\/authorize\b/i.test(state.url)) {
5375
+ if (isGitHubOAuthRateLimitPage(state.url, state.html)) {
5376
+ steps.push("OAuth: GitHub authorize page returned a secondary rate limit — stopping instead of retrying consent.");
5377
+ return {
5378
+ kind: "planning_failed",
5379
+ reason: "oauth_provider_rate_limited: GitHub returned a secondary rate limit during OAuth authorize",
5380
+ };
5381
+ }
5075
5382
  const advanced = await this.browser.advanceOAuthConsent("github");
5076
5383
  steps.push(`OAuth: GitHub authorize page appeared during form-fill — ${advanced ? "approved consent" : "no approve control found"}`);
5077
5384
  if (advanced) {
5385
+ repeatedGitHubAuthorizeApprovals =
5386
+ lastGitHubAuthorizeUrl === state.url ? repeatedGitHubAuthorizeApprovals + 1 : 1;
5387
+ lastGitHubAuthorizeUrl = state.url;
5388
+ if (repeatedGitHubAuthorizeApprovals > 2) {
5389
+ steps.push("OAuth: GitHub authorize page repeated after consent approval — aborting instead of re-approving in a loop.");
5390
+ return {
5391
+ kind: "planning_failed",
5392
+ reason: "oauth_loop_detected: GitHub OAuth authorize page repeated after consent approval during form-fill",
5393
+ };
5394
+ }
5078
5395
  await this.browser.wait(3);
5079
5396
  continue;
5080
5397
  }
@@ -5824,6 +6141,8 @@ export class SignupAgent {
5824
6141
  }
5825
6142
  };
5826
6143
  const oauthCandidates = await this.resolveOAuthCandidates(task, steps);
6144
+ let lastGitHubAuthorizeUrl = null;
6145
+ let repeatedGitHubAuthorizeApprovals = 0;
5827
6146
  for (;;) {
5828
6147
  await this.browser.waitForFormReady();
5829
6148
  const dismissed = await this.browser.dismissConsentBanner();
@@ -5837,6 +6156,20 @@ export class SignupAgent {
5837
6156
  }
5838
6157
  const browserState = frame.state;
5839
6158
  const inventory = frame.inventory;
6159
+ if (looksLikeParkedDomainPage(browserState.title, browserState.html)) {
6160
+ steps.push("Form-fill: parked/domain-for-sale page detected — aborting signup automation.");
6161
+ return {
6162
+ kind: "planning_failed",
6163
+ reason: "parked_domain: signup URL resolved to a domain-for-sale or parking page",
6164
+ };
6165
+ }
6166
+ if (shouldAbortOAuthFirstOnDeadPage(browserState.title, browserState.html)) {
6167
+ steps.push("Form-fill: dead/404 signup page detected — aborting OAuth-first wait.");
6168
+ return {
6169
+ kind: "planning_failed",
6170
+ reason: "dead_signup_url: signup URL rendered a 404/dead page before any OAuth provider appeared",
6171
+ };
6172
+ }
5840
6173
  const googleIdentifierAdvance = await this.advancePinnedGoogleIdentifierPage({
5841
6174
  task,
5842
6175
  url: browserState.url,
@@ -5853,9 +6186,28 @@ export class SignupAgent {
5853
6186
  };
5854
6187
  }
5855
6188
  if (/^https:\/\/github\.com\/login\/oauth\/authorize\b/i.test(browserState.url)) {
6189
+ if (isGitHubOAuthRateLimitPage(browserState.url, browserState.html)) {
6190
+ steps.push("OAuth: GitHub authorize page returned a secondary rate limit — stopping instead of retrying consent.");
6191
+ return {
6192
+ kind: "planning_failed",
6193
+ reason: "oauth_provider_rate_limited: GitHub returned a secondary rate limit during OAuth authorize",
6194
+ };
6195
+ }
5856
6196
  const advanced = await this.browser.advanceOAuthConsent("github");
5857
6197
  steps.push(`OAuth: GitHub authorize page appeared during form-fill — ${advanced ? "approved consent" : "no approve control found"}`);
5858
6198
  if (advanced) {
6199
+ repeatedGitHubAuthorizeApprovals =
6200
+ lastGitHubAuthorizeUrl === browserState.url
6201
+ ? repeatedGitHubAuthorizeApprovals + 1
6202
+ : 1;
6203
+ lastGitHubAuthorizeUrl = browserState.url;
6204
+ if (repeatedGitHubAuthorizeApprovals > 2) {
6205
+ steps.push("OAuth: GitHub authorize page repeated after consent approval — aborting instead of re-approving in a loop.");
6206
+ return {
6207
+ kind: "planning_failed",
6208
+ reason: "oauth_loop_detected: GitHub OAuth authorize page repeated after consent approval during form-fill",
6209
+ };
6210
+ }
5859
6211
  await this.browser.wait(3);
5860
6212
  continue;
5861
6213
  }
@@ -6758,7 +7110,7 @@ export class SignupAgent {
6758
7110
  }
6759
7111
  }
6760
7112
  // 0.8.3-rc.1 — widened from 2 → 4 minutes. The 2-min window forced
6761
- // the operator to drop everything immediately on a Telegram alert.
7113
+ // the operator to drop everything immediately on a heightened-auth alert.
6762
7114
  // For batch-harvest runs the operator is rarely staring at the
6763
7115
  // phone; 4 minutes gives realistic time to switch devices, unlock,
6764
7116
  // open the Google app, and tap. Matches the same wait window the
@@ -7118,10 +7470,11 @@ export class SignupAgent {
7118
7470
  // landing as a wrong-URL signal: clear signedIn so the recovery path
7119
7471
  // (Tier B CTA → real signup entry) runs instead of skipping signup.
7120
7472
  const landedBodyText = await this.browser.extractText().catch(() => "");
7473
+ const landedDead = shouldAbortOAuthFirstOnDeadPage(landed.title, landedBodyText);
7121
7474
  const landed404 = looksLike404(landed.title, landedBodyText);
7122
- if (landed404 && signedIn) {
7475
+ if (landedDead && signedIn) {
7123
7476
  signedIn = false;
7124
- steps.push(`${task.service}: landing ${pathOf(landed.url)} is a 404 shell, not an ` +
7477
+ steps.push(`${task.service}: landing ${pathOf(landed.url)} is a dead/404 shell, not an ` +
7125
7478
  `authenticated dashboard — recovering the real signup entry`);
7126
7479
  }
7127
7480
  // SPA-settle re-check (returning-user cluster). An authenticated SPA
@@ -7160,6 +7513,10 @@ export class SignupAgent {
7160
7513
  `skipping signup, routing straight to key extraction`);
7161
7514
  alreadyAuthenticated = true;
7162
7515
  }
7516
+ else if (landedDead) {
7517
+ needsRecovery = true;
7518
+ steps.push(`${task.service}: landing ${pathOf(landed.url)} is a dead/404 signup page — attempting recovery`);
7519
+ }
7163
7520
  else if (task.signupUrl === undefined) {
7164
7521
  needsRecovery = !(await this.looksLikeSignupPage());
7165
7522
  }
@@ -7623,9 +7980,11 @@ export class SignupAgent {
7623
7980
  ...(task.scopeHint !== undefined ? { scopeHint: task.scopeHint } : {}),
7624
7981
  ...(task.machineToken !== undefined ? { machineToken: task.machineToken } : {}),
7625
7982
  ...(task.apiBase !== undefined ? { apiBase: task.apiBase } : {}),
7983
+ allowOperatorInboxOtp: task.allowOperatorInboxOtp === true,
7626
7984
  });
7627
7985
  }
7628
- if (hasUsableCredentialBundle(credentials)) {
7986
+ if (hasUsableCredentialBundle(credentials) &&
7987
+ !terminalReasonInvalidatesCredentialSuccess(this.lastPostVerifyDoneReason)) {
7629
7988
  // 0.8.3-rc.1 — when extractCredentials short-circuited
7630
7989
  // before postVerifyLoop ran, no captures were written.
7631
7990
  // Emit a synthetic extract round so auto-promote can
@@ -7640,6 +7999,10 @@ export class SignupAgent {
7640
7999
  ...this.resultTail(),
7641
8000
  };
7642
8001
  }
8002
+ if (terminalReasonInvalidatesCredentialSuccess(this.lastPostVerifyDoneReason)) {
8003
+ this.lastPostVerifyDoneReason =
8004
+ `[existing_account_no_extract] terminal planner reason invalidated the extracted candidate: ${this.lastPostVerifyDoneReason}`;
8005
+ }
7643
8006
  // 0.8.2-rc.10 — same sentinel-pattern routing the runOAuthFlow
7644
8007
  // path uses. The post-verify loop sets lastPostVerifyDoneReason
7645
8008
  // with [stuck_loop] or [existing_account_no_extract] markers
@@ -7894,6 +8257,7 @@ export class SignupAgent {
7894
8257
  ...(task.scopeHint !== undefined ? { scopeHint: task.scopeHint } : {}),
7895
8258
  ...(task.machineToken !== undefined ? { machineToken: task.machineToken } : {}),
7896
8259
  ...(task.apiBase !== undefined ? { apiBase: task.apiBase } : {}),
8260
+ allowOperatorInboxOtp: task.allowOperatorInboxOtp === true,
7897
8261
  });
7898
8262
  }
7899
8263
  }
@@ -7941,7 +8305,7 @@ export class SignupAgent {
7941
8305
  }
7942
8306
  }
7943
8307
  }
7944
- if (credentials.api_key !== undefined || credentials.username !== undefined) {
8308
+ if (hasUsableCredentialBundle(credentials)) {
7945
8309
  // Form-fill + email success returns here WITHOUT entering the post-verify
7946
8310
  // loop, so its extract-round salvage never ran — write one now (mailjet).
7947
8311
  await this.salvageExtractCaptureIfNeeded(task.service, credentials, false);
@@ -8418,14 +8782,6 @@ export class SignupAgent {
8418
8782
  machineToken: task.machineToken,
8419
8783
  apiBase: task.apiBase,
8420
8784
  });
8421
- // rc.18 — opt-in Telegram fallback. Bypasses the email
8422
- // path (which collapses to Sent only when GMAIL_USER ==
8423
- // account.email). No-op without TELEGRAM_BOT_TOKEN env.
8424
- void sendTelegramHeightenedAuth({
8425
- service: task.service,
8426
- digit: String(matchNum),
8427
- windowSeconds: 240,
8428
- });
8429
8785
  }
8430
8786
  else {
8431
8787
  // Extractor missed the number — Google phrasing has
@@ -8443,11 +8799,6 @@ export class SignupAgent {
8443
8799
  machineToken: task.machineToken,
8444
8800
  apiBase: task.apiBase,
8445
8801
  });
8446
- void sendTelegramHeightenedAuth({
8447
- service: task.service,
8448
- digit: null,
8449
- windowSeconds: 240,
8450
- });
8451
8802
  }
8452
8803
  // Either way (number found or not), the user can still
8453
8804
  // clear the challenge in the bot's browser window or by
@@ -8509,7 +8860,8 @@ export class SignupAgent {
8509
8860
  // degrades to the phone-tap path below.
8510
8861
  if (provider.id === "github" &&
8511
8862
  task.machineToken !== undefined &&
8512
- task.machineToken.length > 0) {
8863
+ task.machineToken.length > 0 &&
8864
+ task.allowOperatorInboxOtp === true) {
8513
8865
  steps.push("GitHub: verify-it's-you challenge — polling operator inbox for a device-confirmation link (up to 60s)");
8514
8866
  try {
8515
8867
  const { readGitHubChallengeLink } = await import("./read-otp.js");
@@ -8538,8 +8890,8 @@ export class SignupAgent {
8538
8890
  catch (err) {
8539
8891
  steps.push(`GitHub: challenge-clearing import/call threw (${err instanceof Error ? err.message : String(err)})`);
8540
8892
  }
8541
- // 0.8.3-rc.1 — fall back to the phone-tap path: fire
8542
- // Telegram + heightened-auth notifications and wait 4
8893
+ // 0.8.3-rc.1 — fall back to the phone-tap path: fire a
8894
+ // heightened-auth notification and wait 4
8543
8895
  // minutes for the operator to tap their phone. This is the
8544
8896
  // same shape Google's challenge path already uses; without
8545
8897
  // it the bot just times out silently with no operator
@@ -8553,11 +8905,6 @@ export class SignupAgent {
8553
8905
  machineToken: task.machineToken,
8554
8906
  apiBase: task.apiBase,
8555
8907
  });
8556
- void sendTelegramHeightenedAuth({
8557
- service: task.service,
8558
- digit: null,
8559
- windowSeconds: 240,
8560
- });
8561
8908
  const cleared = await this.waitForGitHubChallenge(steps);
8562
8909
  if (cleared) {
8563
8910
  steps.push("GitHub: challenge cleared — re-classifying for the consent flow");
@@ -9090,6 +9437,15 @@ export class SignupAgent {
9090
9437
  // detectManualLoginFallback would otherwise swallow it as
9091
9438
  // oauth_session_not_persisted and abort. The account simply needs
9092
9439
  // creating via email, so re-route to form-fill instead of bailing.
9440
+ if (detectOAuthRegistrationDisabled(gateState.url, gateText)) {
9441
+ return {
9442
+ success: false,
9443
+ error: `oauth_signup_disabled: ${task.service} rejected OAuth signup because account ` +
9444
+ `registration through this SSO/OAuth provider is disabled. Try a non-OAuth signup path manually.`,
9445
+ steps,
9446
+ ...this.resultTail(),
9447
+ };
9448
+ }
9093
9449
  if (detectGoogleNoAccount(gateState.url, gateText)) {
9094
9450
  // Commit to email for the rest of the run — OAuth is login-only here, so
9095
9451
  // the OAuth-first scan must not re-fire after the form-fill re-route.
@@ -9127,6 +9483,9 @@ export class SignupAgent {
9127
9483
  machineToken.length === 0) {
9128
9484
  otpResult = { code: null, reason: "no_machine_token" };
9129
9485
  }
9486
+ else if (task.allowOperatorInboxOtp !== true) {
9487
+ otpResult = { code: null, reason: "operator_inbox_consent_missing" };
9488
+ }
9130
9489
  else {
9131
9490
  steps.push(`Email-OTP gate detected (${pathOf(gateState.url)}) — polling operator inbox for the code` +
9132
9491
  (domain !== null ? ` (from_domain=${domain})` : ""));
@@ -9280,6 +9639,7 @@ export class SignupAgent {
9280
9639
  ...(task.scopeHint !== undefined ? { scopeHint: task.scopeHint } : {}),
9281
9640
  ...(task.machineToken !== undefined ? { machineToken: task.machineToken } : {}),
9282
9641
  ...(task.apiBase !== undefined ? { apiBase: task.apiBase } : {}),
9642
+ allowOperatorInboxOtp: task.allowOperatorInboxOtp === true,
9283
9643
  });
9284
9644
  }
9285
9645
  catch (err) {
@@ -9314,7 +9674,8 @@ export class SignupAgent {
9314
9674
  // complete, usable bundle. Require >=2 named (non-metadata) credentials
9315
9675
  // so a lone ID (e.g. just application_id) still fails honestly: an ID
9316
9676
  // without a secret isn't a usable credential.
9317
- if (hasUsableCredentialBundle(credentials)) {
9677
+ if (hasUsableCredentialBundle(credentials) &&
9678
+ !terminalReasonInvalidatesCredentialSuccess(this.lastPostVerifyDoneReason)) {
9318
9679
  return {
9319
9680
  success: true,
9320
9681
  credentials: { ...credentials },
@@ -9322,6 +9683,10 @@ export class SignupAgent {
9322
9683
  ...this.resultTail(),
9323
9684
  };
9324
9685
  }
9686
+ if (terminalReasonInvalidatesCredentialSuccess(this.lastPostVerifyDoneReason)) {
9687
+ this.lastPostVerifyDoneReason =
9688
+ `[existing_account_no_extract] terminal planner reason invalidated the extracted candidate: ${this.lastPostVerifyDoneReason}`;
9689
+ }
9325
9690
  // No API key. Distinguish a billing/card wall (onboarding_blocked)
9326
9691
  // from a generic navigation miss — never grep-loop a paid wall.
9327
9692
  // rc.39 — fold the planner's `done` reason into the text we
@@ -9349,7 +9714,8 @@ export class SignupAgent {
9349
9714
  };
9350
9715
  }
9351
9716
  if (postSignupGate.failure?.kind === "payment" ||
9352
- postSignupGate.failure?.kind === "phone") {
9717
+ postSignupGate.failure?.kind === "phone" ||
9718
+ postSignupGate.failure?.kind === "permission_denied") {
9353
9719
  return {
9354
9720
  success: false,
9355
9721
  error: postSignupGate.failure.error,
@@ -9750,6 +10116,7 @@ ${formatInventory(input.inventory)}`,
9750
10116
  ...(task.scopeHint !== undefined ? { scopeHint: task.scopeHint } : {}),
9751
10117
  ...(task.machineToken !== undefined ? { machineToken: task.machineToken } : {}),
9752
10118
  ...(task.apiBase !== undefined ? { apiBase: task.apiBase } : {}),
10119
+ allowOperatorInboxOtp: task.allowOperatorInboxOtp === true,
9753
10120
  });
9754
10121
  }
9755
10122
  // Drive the browser toward the API key after the account exists —
@@ -9784,15 +10151,10 @@ ${formatInventory(input.inventory)}`,
9784
10151
  machineToken: this.currentMachineToken,
9785
10152
  apiBase: this.currentApiBase,
9786
10153
  });
9787
- void sendTelegramHeightenedAuth({
9788
- service,
9789
- digit,
9790
- windowSeconds: 240,
9791
- });
9792
10154
  // 0.8.3-rc.1 — vision-LLM fallback for the mid-post-verify path.
9793
10155
  // When the planner's reason names a challenge but no digit, take
9794
10156
  // a screenshot, ask Claude vision what number is on screen, and
9795
- // fire a SECOND Telegram with the extracted number. The first
10157
+ // fire a SECOND notification with the extracted number. The first
9796
10158
  // notification went out immediately (so the operator knows to
9797
10159
  // grab their phone); this follows up with the number as soon as
9798
10160
  // vision returns (~2-5s).
@@ -9804,11 +10166,6 @@ ${formatInventory(input.inventory)}`,
9804
10166
  const followUp = `Google challenge mid-post-verify: vision LLM read "${visionDigit}" from the screen — tap that on your phone.`;
9805
10167
  console.error(`[universal-bot] ${followUp}`);
9806
10168
  steps.push(`Post-verify: ${followUp}`);
9807
- void sendTelegramHeightenedAuth({
9808
- service,
9809
- digit: visionDigit,
9810
- windowSeconds: 240,
9811
- });
9812
10169
  void notifyHeightenedAuth({
9813
10170
  service,
9814
10171
  digit: visionDigit,
@@ -10370,9 +10727,17 @@ Prefer items naming keys / tokens / API / developer / secrets; then credentials
10370
10727
  if (!/^(0|false|off|no)$/i.test(process.env.NAV_SEARCH ?? "")) {
10371
10728
  try {
10372
10729
  const navResult = await this.runNavSearchPhase(args, oauth);
10373
- if (Object.keys(navResult).some((k) => !NON_CREDENTIAL_KEYS.has(k))) {
10730
+ if (hasUsableCredentialBundle(navResult)) {
10374
10731
  return navResult;
10375
10732
  }
10733
+ if (hasAnyExtractedCredential(navResult)) {
10734
+ for (const [k, v] of Object.entries(navResult)) {
10735
+ if (credentials[k] === undefined)
10736
+ credentials[k] = v;
10737
+ }
10738
+ args.steps.push("nav-search: found credential-shaped data, but not a final usable bundle yet — " +
10739
+ "continuing post-verify extraction instead of returning a false failure");
10740
+ }
10376
10741
  if (this.resolvedSignupUrl !== undefined &&
10377
10742
  KEYS_DESTINATION_URL.test(this.resolvedSignupUrl)) {
10378
10743
  args.steps.push("nav-search: curated signup_url is a credential surface and no key was found — " +
@@ -10380,6 +10745,32 @@ Prefer items naming keys / tokens / API / developer / secrets; then credentials
10380
10745
  return navResult;
10381
10746
  }
10382
10747
  args.steps.push("nav-search: no key via navigation alone — handing off to the planner from the current surface");
10748
+ const afterNavText = await this.browser.extractText().catch(() => "");
10749
+ const afterNavInventory = await this.browser
10750
+ .extractInteractiveElements()
10751
+ .catch(() => []);
10752
+ const accountLinkCta = findOAuthAccountLinkCta(afterNavText, afterNavInventory);
10753
+ if (accountLinkCta !== null) {
10754
+ const label = (accountLinkCta.visibleText ??
10755
+ accountLinkCta.ariaLabel ??
10756
+ accountLinkCta.labelText ??
10757
+ "link existing account").trim();
10758
+ args.steps.push(`Post-verify: OAuth account-link bridge detected — clicking "${label}" before planner handoff.`);
10759
+ try {
10760
+ await this.browser.click(accountLinkCta.selector);
10761
+ await this.browser.wait(2);
10762
+ await this.browser.waitForInteractiveDom(5, 15_000).catch(() => undefined);
10763
+ }
10764
+ catch (err) {
10765
+ args.steps.push(`Post-verify: account-link bridge click threw (${err instanceof Error ? err.message : String(err)}) — continuing with planner handoff.`);
10766
+ }
10767
+ }
10768
+ if (isAtOAuthAccountLinkVerificationGate(afterNavText)) {
10769
+ this.lastPostVerifyDoneReason =
10770
+ `[oauth_account_link_verification] ${afterNavText.slice(0, 300)}`;
10771
+ args.steps.push("Post-verify: OAuth account-link email verification wall detected — stopping before fallback URL guesses.");
10772
+ return {};
10773
+ }
10383
10774
  }
10384
10775
  catch (err) {
10385
10776
  args.steps.push(`nav-search: errored (${err instanceof Error ? err.message : String(err)}) — falling back to the planner`);
@@ -10409,6 +10800,43 @@ Prefer items naming keys / tokens / API / developer / secrets; then credentials
10409
10800
  // form-fill phase already captured (captureSignupFormRounds); 0 on the
10410
10801
  // OAuth path, so this is unchanged for OAuth skills.
10411
10802
  let capturedRound = this.captureChainRound;
10803
+ const prePlannerInventory = await this.browser.extractInteractiveElements().catch(() => []);
10804
+ const setupFormBlocksCuratedRoute = isRequiredAccountSetupForm(prePlannerInventory);
10805
+ if (hasCuratedServiceKeyPath(args.service) && !setupFormBlocksCuratedRoute) {
10806
+ const fallback = pickStuckLoopFallbackUrl(this.browser.currentUrl(), recovery.triedFallbackUrls, args.service, this.resolvedSignupUrl);
10807
+ if (fallback !== null) {
10808
+ recovery.triedFallbackUrls.add(fallback);
10809
+ args.steps.push(`Post-verify: nav-search exhausted but ${args.service} has a curated credential route — trying ${fallback} before greedy planning.`);
10810
+ try {
10811
+ await this.browser.goto(fallback);
10812
+ await this.browser.waitForInteractiveDom(5, 15_000).catch(() => undefined);
10813
+ const direct = await this.harvestVisibleCredentials();
10814
+ for (const [k, v] of Object.entries(direct)) {
10815
+ if (credentials[k] === undefined)
10816
+ credentials[k] = v;
10817
+ }
10818
+ if (hasAnyExtractedCredential(credentials)) {
10819
+ await this.writeFastPathSyntheticCapture(args.service, capturedRound, oauth, "curated credential route synthetic extract — nav-search exhausted, fallback route exposed credentials");
10820
+ return credentials;
10821
+ }
10822
+ const minted = await this.attemptMintNewKey(args.steps, args.service);
10823
+ if (minted !== null && hasAnyExtractedCredential(minted)) {
10824
+ for (const [k, v] of Object.entries(minted)) {
10825
+ if (credentials[k] === undefined)
10826
+ credentials[k] = v;
10827
+ }
10828
+ await this.writeFastPathSyntheticCapture(args.service, capturedRound, oauth, "curated credential route synthetic extract — minted/extracted after nav-search fallback");
10829
+ return credentials;
10830
+ }
10831
+ }
10832
+ catch (err) {
10833
+ args.steps.push(`Post-verify: curated credential route fallback errored (${err instanceof Error ? err.message : String(err)}) — continuing with greedy planner.`);
10834
+ }
10835
+ }
10836
+ }
10837
+ else if (setupFormBlocksCuratedRoute) {
10838
+ args.steps.push("Post-verify: required account setup form is still active — deferring curated credential route until setup is complete.");
10839
+ }
10412
10840
  const credentialTracker = new PostSignupCredentialTracker(credentials);
10413
10841
  // Gate URLs we've already polled the operator's gmail for, so a
10414
10842
  // multi-round wait on the same email-OTP page doesn't re-poll.
@@ -10448,6 +10876,23 @@ Prefer items naming keys / tokens / API / developer / secrets; then credentials
10448
10876
  // the planner choosing the right click, so an on-screen key is never
10449
10877
  // missed into a maxRounds bail. Merge-only (never overwrites a prior
10450
10878
  // capture); both extractors are best-effort.
10879
+ try {
10880
+ const currentUrl = this.browser.currentUrl();
10881
+ if (!recovery.revealSweepUrls.has(currentUrl)) {
10882
+ const sweepText = await this.browser.extractVisibleText().catch(() => "");
10883
+ if (shouldRevealBeforeCredentialSweep({ url: currentUrl, pageText: sweepText })) {
10884
+ recovery.revealSweepUrls.add(currentUrl);
10885
+ const reveal = await this.browser.revealMaskedCredentials();
10886
+ if (reveal.clicked > 0) {
10887
+ args.steps.push(`Post-verify round ${round}: credential surface has reveal controls — clicked ${reveal.clicked} before sweeping.`);
10888
+ await this.browser.wait(1);
10889
+ }
10890
+ }
10891
+ }
10892
+ }
10893
+ catch {
10894
+ // reveal is opportunistic; normal extraction still runs below
10895
+ }
10451
10896
  try {
10452
10897
  const sweep = await this.extractCredentials();
10453
10898
  for (const [k, v] of Object.entries(sweep)) {
@@ -10771,6 +11216,89 @@ Prefer items naming keys / tokens / API / developer / secrets; then credentials
10771
11216
  else {
10772
11217
  lastReachablePostVerifyUrl = state.url;
10773
11218
  }
11219
+ if (isPostVerifyAuthResetRoute({
11220
+ url: state.url,
11221
+ round,
11222
+ hasExtractedCredential: hasAnyExtractedCredential(credentials),
11223
+ })) {
11224
+ this.lastPostVerifyDoneReason =
11225
+ `[stuck_loop] post-verify credential search returned to auth/signup route ${state.url}; ` +
11226
+ `stopping instead of starting a second signup.`;
11227
+ args.steps.push(`Post-verify round ${round}: credential search returned to auth/signup route (${pathOf(state.url)}) — ` +
11228
+ `stopping instead of starting a second signup.`);
11229
+ break;
11230
+ }
11231
+ if (serviceSlug(args.service) === "render" &&
11232
+ !hasAnyExtractedCredential(credentials) &&
11233
+ /dashboard\.render\.com/i.test(state.url)) {
11234
+ const onRenderAccountSettings = /\/u\/[^/]+\/settings\b/i.test(state.url);
11235
+ if (!onRenderAccountSettings) {
11236
+ const accountSettingsLink = findRenderAccountSettingsLink(inventory, recovery.clickedKeysLinks);
11237
+ if (accountSettingsLink !== null) {
11238
+ recovery.clickedKeysLinks.add(accountSettingsLink.selector);
11239
+ const label = accountSettingsLink.visibleText ??
11240
+ accountSettingsLink.ariaLabel ??
11241
+ accountSettingsLink.href ??
11242
+ accountSettingsLink.selector;
11243
+ args.steps.push(`Post-verify round ${round}: Render account menu exposes "${label.slice(0, 60)}" — entering account settings before API-key navigation.`);
11244
+ try {
11245
+ await this.browser.click(accountSettingsLink.selector);
11246
+ await this.browser.waitForInteractiveDom(5, 15_000).catch(() => undefined);
11247
+ hint = undefined;
11248
+ recovery.prevSignature = null;
11249
+ recovery.prevInventorySize = -1;
11250
+ continue;
11251
+ }
11252
+ catch (err) {
11253
+ args.steps.push(`Post-verify round ${round}: Render account-settings click failed (${err instanceof Error ? err.message : String(err)}) — continuing.`);
11254
+ }
11255
+ }
11256
+ else if (!recovery.triedRenderAccountMenu) {
11257
+ const accountMenu = findRenderAccountMenuTrigger(inventory);
11258
+ if (accountMenu !== null) {
11259
+ recovery.triedRenderAccountMenu = true;
11260
+ const label = accountMenu.visibleText ??
11261
+ accountMenu.ariaLabel ??
11262
+ accountMenu.title ??
11263
+ accountMenu.selector;
11264
+ args.steps.push(`Post-verify round ${round}: Render dashboard has no API-key link visible — opening account menu "${label.slice(0, 40)}".`);
11265
+ try {
11266
+ await this.browser.click(accountMenu.selector);
11267
+ await this.browser.waitForInteractiveDom(5, 15_000).catch(() => undefined);
11268
+ hint = undefined;
11269
+ recovery.prevSignature = null;
11270
+ recovery.prevInventorySize = -1;
11271
+ continue;
11272
+ }
11273
+ catch (err) {
11274
+ args.steps.push(`Post-verify round ${round}: Render account-menu click failed (${err instanceof Error ? err.message : String(err)}) — continuing.`);
11275
+ }
11276
+ }
11277
+ }
11278
+ }
11279
+ else if (!/#api-keys\b/i.test(state.url)) {
11280
+ const keysLink = findApiKeysNavLink(inventory, recovery.clickedKeysLinks);
11281
+ if (keysLink !== null) {
11282
+ recovery.clickedKeysLinks.add(keysLink.selector);
11283
+ const label = keysLink.visibleText ??
11284
+ keysLink.ariaLabel ??
11285
+ keysLink.href ??
11286
+ keysLink.selector;
11287
+ args.steps.push(`Post-verify round ${round}: Render account settings reached — clicking API-key anchor "${label.slice(0, 60)}".`);
11288
+ try {
11289
+ await this.browser.click(keysLink.selector);
11290
+ await this.browser.waitForInteractiveDom(5, 15_000).catch(() => undefined);
11291
+ hint = undefined;
11292
+ recovery.prevSignature = null;
11293
+ recovery.prevInventorySize = -1;
11294
+ continue;
11295
+ }
11296
+ catch (err) {
11297
+ args.steps.push(`Post-verify round ${round}: Render API-key anchor click failed (${err instanceof Error ? err.message : String(err)}) — continuing.`);
11298
+ }
11299
+ }
11300
+ }
11301
+ }
10774
11302
  const emailAlias = args.verificationEmailAlias ??
10775
11303
  args.credentials?.email ??
10776
11304
  args.oauthAccountEmail;
@@ -11168,6 +11696,7 @@ Prefer items naming keys / tokens / API / developer / secrets; then credentials
11168
11696
  if (hint === undefined &&
11169
11697
  args.machineToken !== undefined &&
11170
11698
  args.machineToken.length > 0 &&
11699
+ args.allowOperatorInboxOtp === true &&
11171
11700
  !otpPolledUrls.has(state.url) &&
11172
11701
  detectEmailOtpGate(state.url, state.title, await this.browser.extractText().catch(() => ""), inventory)) {
11173
11702
  otpPolledUrls.add(state.url);
@@ -11735,6 +12264,46 @@ Prefer items naming keys / tokens / API / developer / secrets; then credentials
11735
12264
  recovery.prevSignature = null;
11736
12265
  recovery.prevInventorySize = inventory.length;
11737
12266
  }
12267
+ const actionCycleSignature = credentialActionSignature(nextStep, inventory);
12268
+ if (actionCycleSignature !== null) {
12269
+ recovery.recentCredentialActionSignatures.push(actionCycleSignature);
12270
+ if (recovery.recentCredentialActionSignatures.length > 8) {
12271
+ recovery.recentCredentialActionSignatures =
12272
+ recovery.recentCredentialActionSignatures.slice(-8);
12273
+ }
12274
+ if (isRepeatingCredentialActionCycle(recovery.recentCredentialActionSignatures, visiblePageText)) {
12275
+ args.steps.push(`Post-verify: detected repeating credential-action cycle (${recovery.recentCredentialActionSignatures.slice(-5).join(" → ")}) — trying extraction/fallback instead of repeating it.`);
12276
+ const harvested = await this.harvestVisibleCredentials().catch(() => ({}));
12277
+ for (const [k, v] of Object.entries(harvested)) {
12278
+ if (credentials[k] === undefined)
12279
+ credentials[k] = v;
12280
+ }
12281
+ if (hasAnyExtractedCredential(credentials)) {
12282
+ await this.writeFastPathSyntheticCapture(args.service, capturedRound, oauth, "repeating credential-action cycle synthetic extract — credentials were already visible");
12283
+ return credentials;
12284
+ }
12285
+ const fallback = pickStuckLoopFallbackUrl(state.url, recovery.triedFallbackUrls, args.service, this.resolvedSignupUrl);
12286
+ if (fallback !== null) {
12287
+ recovery.triedFallbackUrls.add(fallback);
12288
+ args.steps.push(`Post-verify: credential-action cycle fallback — navigating to ${fallback}`);
12289
+ try {
12290
+ await this.browser.goto(fallback);
12291
+ await this.browser.waitForInteractiveDom(5, 15_000).catch(() => undefined);
12292
+ }
12293
+ catch (err) {
12294
+ args.steps.push(`Post-verify: credential-action cycle fallback navigate failed (${err instanceof Error ? err.message : String(err)}) — continuing.`);
12295
+ }
12296
+ recovery.prevSignature = null;
12297
+ recovery.prevInventorySize = -1;
12298
+ hint = undefined;
12299
+ continue;
12300
+ }
12301
+ this.lastPostVerifyDoneReason =
12302
+ `[stuck_loop] planner repeated credential/payment actions (${recovery.recentCredentialActionSignatures.slice(-5).join(" → ")}) with no usable credential and no fallback URL remaining.`;
12303
+ args.steps.push(`Post-verify: credential-action cycle unresolvable — breaking out with planner_stuck.`);
12304
+ break;
12305
+ }
12306
+ }
11738
12307
  // Record the kind of the step we're ABOUT to execute (all re-plan
11739
12308
  // `continue` guards are behind us here) so next round can judge
11740
12309
  // whether it changed the page — the stalled-wizard breaker above.
@@ -12829,6 +13398,11 @@ ${formatInventory(input.inventory)}${input.hint !== undefined ? `\n\nIMPORTANT
12829
13398
  if (typeof curUrl === "string" && isDocumentationUrl(curUrl)) {
12830
13399
  return credentials;
12831
13400
  }
13401
+ const currentState = await this.browser.getState().catch(() => null);
13402
+ if (currentState !== null &&
13403
+ detectCredentialExtractionBlock(currentState.html, titleFromHtml(currentState.html)) !== null) {
13404
+ return credentials;
13405
+ }
12832
13406
  for (const candidate of await this.browser.extractCredentialCandidates()) {
12833
13407
  const hit = extractApiKeyFromText(candidate);
12834
13408
  if (hit === null)
@@ -12931,6 +13505,11 @@ ${formatInventory(input.inventory)}${input.hint !== undefined ? `\n\nIMPORTANT
12931
13505
  const curUrl = typeof this.browser.currentUrl === "function" ? this.browser.currentUrl() : "";
12932
13506
  if (typeof curUrl === "string" && isDocumentationUrl(curUrl))
12933
13507
  return {};
13508
+ const currentState = await this.browser.getState().catch(() => null);
13509
+ if (currentState !== null &&
13510
+ detectCredentialExtractionBlock(currentState.html, titleFromHtml(currentState.html)) !== null) {
13511
+ return {};
13512
+ }
12934
13513
  let st = initialExtractionState();
12935
13514
  const classify = (text) => {
12936
13515
  const hit = extractApiKeyFromText(text);