@trusty-squire/mcp 0.9.18 → 0.9.19-rc.10

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 (86) hide show
  1. package/README.md +4 -4
  2. package/dist/api-client.d.ts +5 -0
  3. package/dist/api-client.d.ts.map +1 -1
  4. package/dist/api-client.js.map +1 -1
  5. package/dist/bot/agent.d.ts +12 -0
  6. package/dist/bot/agent.d.ts.map +1 -1
  7. package/dist/bot/agent.js +469 -6
  8. package/dist/bot/agent.js.map +1 -1
  9. package/dist/bot/browser.d.ts +9 -0
  10. package/dist/bot/browser.d.ts.map +1 -1
  11. package/dist/bot/browser.js +258 -4
  12. package/dist/bot/browser.js.map +1 -1
  13. package/dist/bot/credential-extraction-flow.d.ts.map +1 -1
  14. package/dist/bot/credential-extraction-flow.js +6 -0
  15. package/dist/bot/credential-extraction-flow.js.map +1 -1
  16. package/dist/bot/form-fill.d.ts +1 -0
  17. package/dist/bot/form-fill.d.ts.map +1 -1
  18. package/dist/bot/form-fill.js +18 -6
  19. package/dist/bot/form-fill.js.map +1 -1
  20. package/dist/bot/index.d.ts +1 -0
  21. package/dist/bot/index.d.ts.map +1 -1
  22. package/dist/bot/index.js +1 -0
  23. package/dist/bot/index.js.map +1 -1
  24. package/dist/bot/login-state.d.ts +2 -1
  25. package/dist/bot/login-state.d.ts.map +1 -1
  26. package/dist/bot/login-state.js +22 -5
  27. package/dist/bot/login-state.js.map +1 -1
  28. package/dist/bot/onboarding-capture.d.ts +3 -0
  29. package/dist/bot/onboarding-capture.d.ts.map +1 -1
  30. package/dist/bot/onboarding-capture.js +34 -0
  31. package/dist/bot/onboarding-capture.js.map +1 -1
  32. package/dist/bot/post-signup-recovery-state.d.ts +5 -0
  33. package/dist/bot/post-signup-recovery-state.d.ts.map +1 -1
  34. package/dist/bot/post-signup-recovery-state.js +14 -0
  35. package/dist/bot/post-signup-recovery-state.js.map +1 -1
  36. package/dist/bot/promote-to-skill.d.ts.map +1 -1
  37. package/dist/bot/promote-to-skill.js +34 -7
  38. package/dist/bot/promote-to-skill.js.map +1 -1
  39. package/dist/bot/provision-session.d.ts +149 -0
  40. package/dist/bot/provision-session.d.ts.map +1 -0
  41. package/dist/bot/provision-session.js +954 -0
  42. package/dist/bot/provision-session.js.map +1 -0
  43. package/dist/bot/redact.d.ts.map +1 -1
  44. package/dist/bot/redact.js +1 -0
  45. package/dist/bot/redact.js.map +1 -1
  46. package/dist/bot/replay-skill.d.ts +1 -0
  47. package/dist/bot/replay-skill.d.ts.map +1 -1
  48. package/dist/bot/replay-skill.js +141 -6
  49. package/dist/bot/replay-skill.js.map +1 -1
  50. package/dist/bot/skill-hint.d.ts +7 -0
  51. package/dist/bot/skill-hint.d.ts.map +1 -0
  52. package/dist/bot/skill-hint.js +105 -0
  53. package/dist/bot/skill-hint.js.map +1 -0
  54. package/dist/bot/unwinnable-services.d.ts.map +1 -1
  55. package/dist/bot/unwinnable-services.js +5 -5
  56. package/dist/bot/unwinnable-services.js.map +1 -1
  57. package/dist/install/cli.d.ts +9 -1
  58. package/dist/install/cli.d.ts.map +1 -1
  59. package/dist/install/cli.js +207 -78
  60. package/dist/install/cli.js.map +1 -1
  61. package/dist/install/interactive.d.ts +9 -3
  62. package/dist/install/interactive.d.ts.map +1 -1
  63. package/dist/install/interactive.js +80 -140
  64. package/dist/install/interactive.js.map +1 -1
  65. package/dist/install/proxy-url.d.ts +2 -0
  66. package/dist/install/proxy-url.d.ts.map +1 -0
  67. package/dist/install/proxy-url.js +20 -0
  68. package/dist/install/proxy-url.js.map +1 -0
  69. package/dist/session.d.ts +3 -0
  70. package/dist/session.d.ts.map +1 -1
  71. package/dist/session.js.map +1 -1
  72. package/dist/skill-registry-client.d.ts +8 -0
  73. package/dist/skill-registry-client.d.ts.map +1 -1
  74. package/dist/skill-registry-client.js +70 -53
  75. package/dist/skill-registry-client.js.map +1 -1
  76. package/dist/tools/index.d.ts.map +1 -1
  77. package/dist/tools/index.js +5 -0
  78. package/dist/tools/index.js.map +1 -1
  79. package/dist/tools/provision-any.d.ts.map +1 -1
  80. package/dist/tools/provision-any.js +17 -5
  81. package/dist/tools/provision-any.js.map +1 -1
  82. package/dist/tools/provision-drive.d.ts +83 -0
  83. package/dist/tools/provision-drive.d.ts.map +1 -0
  84. package/dist/tools/provision-drive.js +245 -0
  85. package/dist/tools/provision-drive.js.map +1 -0
  86. package/package.json +2 -2
package/dist/bot/agent.js CHANGED
@@ -32,7 +32,7 @@ import { MAX_POST_VERIFY_NAVIGATES, MAX_PREMATURE_DONE_FALLBACKS, MAX_UPSTREAM_B
32
32
  import { PostSignupSyntheticCapture } from "./post-signup-synthetic-capture.js";
33
33
  import { CredentialExtractionFlow, DOM_LABEL_TO_KEY, NON_CREDENTIAL_KEYS, extractAllLabeledTokensFromReason, hasAnyExtractedCredential, hasUsableCredentialBundle, } from "./credential-extraction-flow.js";
34
34
  export { extractAllLabeledTokensFromReason, hasAnyExtractedCredential, isMultiCredBundle, } from "./credential-extraction-flow.js";
35
- import { captureOnboardingRound, updateCapturedRoundSemantic, } from "./onboarding-capture.js";
35
+ import { captureOnboardingRound, hasCapturedAnyRound, hasCapturedExtractRound, nextCaptureRound, updateCapturedRoundSemantic, } from "./onboarding-capture.js";
36
36
  import { evaluateSemanticTransition, inferSemanticTransition, } from "./semantic-transition.js";
37
37
  import { KEYS_DESTINATION_URL, runNavSearch, } from "./nav-search.js";
38
38
  import { wasRecentlyPrewarmed, recordPrewarmSuccess } from "./prewarm-cache.js";
@@ -401,6 +401,16 @@ const SERVICE_KEYS_PATHS = {
401
401
  // /settings/* and /api-keys guesses miss it and the planner can get stuck
402
402
  // in Business Account > Team Members.
403
403
  paddle: ["/authentication-v2"],
404
+ // Together AI's project-scoped keys route is discoverable in the DOM, but the
405
+ // planner can loop between Create key and payment-skip affordances after it
406
+ // lands there. Pin the route so recovery starts at the credential surface.
407
+ togetherai: [
408
+ "https://api.together.ai/settings/projects/~current/api-keys",
409
+ "/settings/projects/~current/api-keys",
410
+ ],
411
+ // Cartesia's authenticated root/dashboard can expose key rows with Reveal
412
+ // controls; generic /settings/* guesses hit 404/error shells first.
413
+ cartesia: ["/", "/dashboard", "/start", "/api-keys"],
404
414
  };
405
415
  // Normalize a service name to the slug used as a SERVICE_KEYS_PATHS key:
406
416
  // lowercased, alphanumerics only. Mirrors guessSignupUrl's slug rule so
@@ -409,6 +419,80 @@ const SERVICE_KEYS_PATHS = {
409
419
  export function serviceSlug(service) {
410
420
  return service.toLowerCase().replace(/[^a-z0-9]/g, "");
411
421
  }
422
+ export function hasCuratedServiceKeyPath(service) {
423
+ return SERVICE_KEYS_PATHS[serviceSlug(service)] !== undefined;
424
+ }
425
+ function elementLabel(el) {
426
+ return [
427
+ el.visibleText,
428
+ el.ariaLabel,
429
+ el.title,
430
+ el.labelText,
431
+ el.iconLabel,
432
+ el.href,
433
+ el.testId,
434
+ ]
435
+ .filter((s) => s !== null && s !== undefined)
436
+ .join(" ")
437
+ .replace(/\s+/g, " ")
438
+ .trim();
439
+ }
440
+ export function shouldRevealBeforeCredentialSweep(input) {
441
+ const haystack = `${input.url}\n${input.pageText}`;
442
+ 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) &&
443
+ /(?:•{3,}|\*{3,}|[A-Za-z0-9]{2,4}[•*]{4,}|\b(?:reveal|show|unmask|view)\b)/i.test(haystack));
444
+ }
445
+ export function credentialActionSignature(step, inventory) {
446
+ if (!("selector" in step) || step.selector === undefined)
447
+ return null;
448
+ const target = inventory.find((e) => e.selector === step.selector);
449
+ const label = [
450
+ target?.visibleText,
451
+ target?.ariaLabel,
452
+ target?.title,
453
+ target?.labelText,
454
+ target?.iconLabel,
455
+ step.reason,
456
+ ]
457
+ .filter((s) => s !== null && s !== undefined)
458
+ .join(" ")
459
+ .replace(/\s+/g, " ")
460
+ .trim();
461
+ if (label.length === 0)
462
+ return null;
463
+ const lower = label.toLowerCase();
464
+ if (/\b(?:create|generate|new|add|issue|mint)\b.*\b(?:api\s*)?(?:key|token|secret|credential)s?\b/.test(lower) ||
465
+ /\b(?:api\s*)?(?:key|token|secret|credential)s?\b.*\b(?:create|generate|new|add|issue|mint)\b/.test(lower)) {
466
+ return `${step.kind}:create-key`;
467
+ }
468
+ if (/\b(?:skip|maybe later|not now|without)\b.*\b(?:payment|billing|card|deposit|credits?)\b/.test(lower) ||
469
+ /\b(?:payment|billing|card|deposit|credits?)\b.*\b(?:skip|maybe later|not now|without)\b/.test(lower)) {
470
+ return `${step.kind}:skip-payment`;
471
+ }
472
+ if (/\b(?:add|make|initial)\b.*\b(?:payment|deposit|credits?)\b/.test(lower)) {
473
+ return `${step.kind}:payment`;
474
+ }
475
+ return null;
476
+ }
477
+ export function isRepeatingCredentialActionCycle(signatures, pageText = "") {
478
+ const interesting = signatures.some((s) => /(?:create-key|payment|deposit)/.test(s)) ||
479
+ /\b(?:api\s*keys?|tokens?|payment|billing|deposit|credits?)\b/i.test(pageText);
480
+ if (!interesting)
481
+ return false;
482
+ const last3 = signatures.slice(-3);
483
+ if (last3.length === 3 &&
484
+ last3.every((s) => s === last3[0]) &&
485
+ /(?:create-key|skip-payment|payment)/.test(last3[0] ?? "")) {
486
+ return true;
487
+ }
488
+ const last5 = signatures.slice(-5);
489
+ return (last5.length === 5 &&
490
+ last5[0] === last5[2] &&
491
+ last5[2] === last5[4] &&
492
+ last5[1] === last5[3] &&
493
+ last5[0] !== last5[1] &&
494
+ last5.some((s) => /(?:create-key|skip-payment|payment)/.test(s)));
495
+ }
412
496
  // 0.8.2-rc.10 — heuristic for "this account already exists on the
413
497
  // service and its API keys are masked, with no path to reveal them."
414
498
  // The test identity (methoxine@gmail.com) accumulates state across
@@ -613,6 +697,51 @@ export function findKeyNameInput(inventory) {
613
697
  // should the bot. Exported, pure (operates on the inventory shape only).
614
698
  const API_KEYS_HREF = /\/(?:api[-_]?keys?|api[-_]?tokens?|access[-_]?tokens?|auth[-_]?tokens?|secret[-_]?keys?|personal[-_]?access[-_]?tokens?|developers?|keys?|tokens?)(?:[/?#]|$)/i;
615
699
  const API_KEYS_TEXT = /\b(?:api|access|secret|auth|personal\s+access)\s*(?:keys?|tokens?)\b/i;
700
+ // Find a settings / API-keys / dashboard nav link to ESCAPE a stuck onboarding
701
+ // wizard. MEASURED 2026-06-24 (cloudinary /app/welcome): the welcome survey's
702
+ // clicks don't register (STALLED), but it's an OVERLAY on the real product
703
+ // dashboard whose sidebar — Home → /app/home, Settings → /app/settings — is
704
+ // already in the inventory. Navigating to settings/keys/home reaches the
705
+ // credential surface behind the wizard. Prefers settings/account/keys (where
706
+ // API credentials live) over a bare home/dashboard link. Anchor href is
707
+ // authoritative; text is the backstop (cloudinary's Settings link is icon-only,
708
+ // no visible text — only the href identifies it).
709
+ const WIZARD_ESCAPE_HREF = /\/(?:settings|account|api[-_]?keys?|developers?|dashboard|home|console)(?:\/|$|\?)/i;
710
+ const WIZARD_ESCAPE_TEXT = /\b(?:settings|account|dashboard|api\s*keys?|developers?|console)\b/i;
711
+ export function findWizardEscapeNavLink(inventory, alreadyClicked = new Set()) {
712
+ let best = null;
713
+ for (const el of inventory) {
714
+ // Escape via a NAV LINK (anchor), not a wizard survey button — a role
715
+ // option like "Back End Developer" is a button and must never be mistaken
716
+ // for a "Developers" settings link.
717
+ const isNavLink = el.tag === "a" || el.role === "link";
718
+ if (!isNavLink || el.visible === false)
719
+ continue;
720
+ if (alreadyClicked.has(el.selector))
721
+ continue;
722
+ const href = el.href ?? "";
723
+ const text = [el.visibleText, el.ariaLabel, el.title, el.labelText, el.iconLabel]
724
+ .filter((s) => s !== null && s !== undefined)
725
+ .join(" ");
726
+ const hrefHit = href.length > 0 && WIZARD_ESCAPE_HREF.test(href);
727
+ const textHit = WIZARD_ESCAPE_TEXT.test(text);
728
+ if (!hrefHit && !textHit)
729
+ continue;
730
+ let score = 0;
731
+ if (hrefHit)
732
+ score += 4; // a navigable target beats a text guess
733
+ // settings/account/keys are where API credentials live — rank above home.
734
+ if (/\/(?:settings|account|api[-_]?keys?|developers?)(?:\/|$|\?)/i.test(href))
735
+ score += 3;
736
+ if (/\b(?:settings|account|api\s*keys?|developers?)\b/i.test(text))
737
+ score += 2;
738
+ if (el.tag === "a" && href.length > 0)
739
+ score += 1;
740
+ if (best === null || score > best.score)
741
+ best = { el, score };
742
+ }
743
+ return best?.el ?? null;
744
+ }
616
745
  export function findApiKeysNavLink(inventory, alreadyClicked = new Set()) {
617
746
  const candidates = [];
618
747
  for (const el of inventory) {
@@ -661,6 +790,58 @@ export function findApiKeysNavLink(inventory, alreadyClicked = new Set()) {
661
790
  candidates.sort((a, b) => b.score - a.score);
662
791
  return candidates[0].el;
663
792
  }
793
+ export function findRenderAccountSettingsLink(inventory, alreadyClicked = new Set()) {
794
+ for (const el of inventory) {
795
+ if (el.visible === false)
796
+ continue;
797
+ if (alreadyClicked.has(el.selector))
798
+ continue;
799
+ const clickable = el.tag === "a" ||
800
+ el.tag === "button" ||
801
+ el.role === "link" ||
802
+ el.role === "button";
803
+ if (!clickable)
804
+ continue;
805
+ const label = elementLabel(el);
806
+ if (/\baccount settings\b/i.test(label) || /\/u\/[^/\s]+\/settings(?:[#?\s]|$)/i.test(label)) {
807
+ return el;
808
+ }
809
+ }
810
+ return null;
811
+ }
812
+ export function findRenderAccountMenuTrigger(inventory) {
813
+ const candidates = [];
814
+ for (const el of inventory) {
815
+ if (el.visible === false)
816
+ continue;
817
+ const clickable = el.tag === "button" || el.role === "button";
818
+ if (!clickable)
819
+ continue;
820
+ const label = elementLabel(el);
821
+ if (label.length === 0)
822
+ continue;
823
+ const normalized = label.toLowerCase().trim();
824
+ if (/\b(?:new|upgrade|search|settings|billing|projects?|blueprints?|notifications?|contact support|create|add)\b/i.test(normalized)) {
825
+ continue;
826
+ }
827
+ let score = 0;
828
+ if (el.landmark === "header")
829
+ score += 4;
830
+ if (/^[a-z]{1,3}$/i.test(normalized))
831
+ score += 3;
832
+ if (/\b(?:profile|account|user|avatar)\b/i.test(label))
833
+ score += 2;
834
+ if (el.inViewport === true)
835
+ score += 1;
836
+ if (score <= 0)
837
+ continue;
838
+ candidates.push({ el, score });
839
+ }
840
+ if (candidates.length === 0)
841
+ return null;
842
+ candidates.sort((a, b) => b.score - a.score);
843
+ return candidates[0].el;
844
+ }
664
845
  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;
665
846
  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;
666
847
  const RELOCATION_RECOVERY_TEXT = /\b(?:teams?|organisations?|organizations?|accounts?|settings|profile|dashboard|developers?|api|keys?|tokens?|credentials?)\b/i;
@@ -3728,6 +3909,16 @@ export function extractApiKeyFromText(text) {
3728
3909
  // pattern only fires on Zeabur-style keys. Surfaced from the
3729
3910
  // rc.23 snapshot review.
3730
3911
  /\bsk-[a-z0-9]{28,38}\b/, // Zeabur
3912
+ // PostHog PERSONAL API key — `phx_<43+ alnum>`. MEASURED 2026-06-24: the
3913
+ // post-OAuth create-personal-api-key flow mints a `phx_`-prefixed secret —
3914
+ // the planner SAW it (and even OCR-transcribed it with errors), but no regex
3915
+ // matched so the DOM extractor stored nothing and the run bailed
3916
+ // oauth_onboarding_failed while a real key sat on the page. The {40,60}
3917
+ // bound keeps it clear of short prefixed noise. Deliberately NOT matching
3918
+ // the `phc_` PROJECT key —
3919
+ // that's a PUBLIC analytics key embedded in client JS on every PostHog site
3920
+ // (see extract-credentials.test "ignores a PostHog project key").
3921
+ /\bphx_[A-Za-z0-9]{40,60}\b/, // PostHog personal API key
3731
3922
  // OpenRouter, Anthropic, OpenAI — these are the dominant
3732
3923
  // OAuth-completed-then-copy-needed services. Specific-prefix
3733
3924
  // patterns first so a labeled-pattern fallback isn't load-
@@ -5841,7 +6032,7 @@ export class SignupAgent {
5841
6032
  // branch will actually consult it (candidates present, NOT committed, and
5842
6033
  // no provider button hit yet). Computing it unconditionally would fire a
5843
6034
  // spurious extractText() every round and diverge from the inline path.
5844
- const needScanShell = oauthCandidates.length > 0 && !state.committedToEmailPath && oauthButtonHitRaw === null;
6035
+ const needScanShell = oauthCandidates.length > 0 && !state.oauthProvenUnavailable && oauthButtonHitRaw === null;
5845
6036
  const oauthScanShell = needScanShell &&
5846
6037
  (inventory.length <= 1 ||
5847
6038
  !hasCredentialInput ||
@@ -7568,6 +7759,7 @@ export class SignupAgent {
7568
7759
  ...(task.scopeHint !== undefined ? { scopeHint: task.scopeHint } : {}),
7569
7760
  ...(task.machineToken !== undefined ? { machineToken: task.machineToken } : {}),
7570
7761
  ...(task.apiBase !== undefined ? { apiBase: task.apiBase } : {}),
7762
+ allowOperatorInboxOtp: task.allowOperatorInboxOtp === true,
7571
7763
  });
7572
7764
  }
7573
7765
  if (hasUsableCredentialBundle(credentials)) {
@@ -7839,6 +8031,7 @@ export class SignupAgent {
7839
8031
  ...(task.scopeHint !== undefined ? { scopeHint: task.scopeHint } : {}),
7840
8032
  ...(task.machineToken !== undefined ? { machineToken: task.machineToken } : {}),
7841
8033
  ...(task.apiBase !== undefined ? { apiBase: task.apiBase } : {}),
8034
+ allowOperatorInboxOtp: task.allowOperatorInboxOtp === true,
7842
8035
  });
7843
8036
  }
7844
8037
  }
@@ -7887,6 +8080,9 @@ export class SignupAgent {
7887
8080
  }
7888
8081
  }
7889
8082
  if (credentials.api_key !== undefined || credentials.username !== undefined) {
8083
+ // Form-fill + email success returns here WITHOUT entering the post-verify
8084
+ // loop, so its extract-round salvage never ran — write one now (mailjet).
8085
+ await this.salvageExtractCaptureIfNeeded(task.service, credentials, false);
7890
8086
  return {
7891
8087
  success: true,
7892
8088
  credentials: { ...credentials, password, email: task.email },
@@ -8451,7 +8647,8 @@ export class SignupAgent {
8451
8647
  // degrades to the phone-tap path below.
8452
8648
  if (provider.id === "github" &&
8453
8649
  task.machineToken !== undefined &&
8454
- task.machineToken.length > 0) {
8650
+ task.machineToken.length > 0 &&
8651
+ task.allowOperatorInboxOtp === true) {
8455
8652
  steps.push("GitHub: verify-it's-you challenge — polling operator inbox for a device-confirmation link (up to 60s)");
8456
8653
  try {
8457
8654
  const { readGitHubChallengeLink } = await import("./read-otp.js");
@@ -9069,6 +9266,9 @@ export class SignupAgent {
9069
9266
  machineToken.length === 0) {
9070
9267
  otpResult = { code: null, reason: "no_machine_token" };
9071
9268
  }
9269
+ else if (task.allowOperatorInboxOtp !== true) {
9270
+ otpResult = { code: null, reason: "operator_inbox_consent_missing" };
9271
+ }
9072
9272
  else {
9073
9273
  steps.push(`Email-OTP gate detected (${pathOf(gateState.url)}) — polling operator inbox for the code` +
9074
9274
  (domain !== null ? ` (from_domain=${domain})` : ""));
@@ -9222,6 +9422,7 @@ export class SignupAgent {
9222
9422
  ...(task.scopeHint !== undefined ? { scopeHint: task.scopeHint } : {}),
9223
9423
  ...(task.machineToken !== undefined ? { machineToken: task.machineToken } : {}),
9224
9424
  ...(task.apiBase !== undefined ? { apiBase: task.apiBase } : {}),
9425
+ allowOperatorInboxOtp: task.allowOperatorInboxOtp === true,
9225
9426
  });
9226
9427
  }
9227
9428
  catch (err) {
@@ -9692,6 +9893,7 @@ ${formatInventory(input.inventory)}`,
9692
9893
  ...(task.scopeHint !== undefined ? { scopeHint: task.scopeHint } : {}),
9693
9894
  ...(task.machineToken !== undefined ? { machineToken: task.machineToken } : {}),
9694
9895
  ...(task.apiBase !== undefined ? { apiBase: task.apiBase } : {}),
9896
+ allowOperatorInboxOtp: task.allowOperatorInboxOtp === true,
9695
9897
  });
9696
9898
  }
9697
9899
  // Drive the browser toward the API key after the account exists —
@@ -10351,6 +10553,38 @@ Prefer items naming keys / tokens / API / developer / secrets; then credentials
10351
10553
  // form-fill phase already captured (captureSignupFormRounds); 0 on the
10352
10554
  // OAuth path, so this is unchanged for OAuth skills.
10353
10555
  let capturedRound = this.captureChainRound;
10556
+ if (hasCuratedServiceKeyPath(args.service)) {
10557
+ const fallback = pickStuckLoopFallbackUrl(this.browser.currentUrl(), recovery.triedFallbackUrls, args.service, this.resolvedSignupUrl);
10558
+ if (fallback !== null) {
10559
+ recovery.triedFallbackUrls.add(fallback);
10560
+ args.steps.push(`Post-verify: nav-search exhausted but ${args.service} has a curated credential route — trying ${fallback} before greedy planning.`);
10561
+ try {
10562
+ await this.browser.goto(fallback);
10563
+ await this.browser.waitForInteractiveDom(5, 15_000).catch(() => undefined);
10564
+ const direct = await this.harvestVisibleCredentials();
10565
+ for (const [k, v] of Object.entries(direct)) {
10566
+ if (credentials[k] === undefined)
10567
+ credentials[k] = v;
10568
+ }
10569
+ if (hasAnyExtractedCredential(credentials)) {
10570
+ await this.writeFastPathSyntheticCapture(args.service, capturedRound, oauth, "curated credential route synthetic extract — nav-search exhausted, fallback route exposed credentials");
10571
+ return credentials;
10572
+ }
10573
+ const minted = await this.attemptMintNewKey(args.steps, args.service);
10574
+ if (minted !== null && hasAnyExtractedCredential(minted)) {
10575
+ for (const [k, v] of Object.entries(minted)) {
10576
+ if (credentials[k] === undefined)
10577
+ credentials[k] = v;
10578
+ }
10579
+ await this.writeFastPathSyntheticCapture(args.service, capturedRound, oauth, "curated credential route synthetic extract — minted/extracted after nav-search fallback");
10580
+ return credentials;
10581
+ }
10582
+ }
10583
+ catch (err) {
10584
+ args.steps.push(`Post-verify: curated credential route fallback errored (${err instanceof Error ? err.message : String(err)}) — continuing with greedy planner.`);
10585
+ }
10586
+ }
10587
+ }
10354
10588
  const credentialTracker = new PostSignupCredentialTracker(credentials);
10355
10589
  // Gate URLs we've already polled the operator's gmail for, so a
10356
10590
  // multi-round wait on the same email-OTP page doesn't re-poll.
@@ -10390,6 +10624,23 @@ Prefer items naming keys / tokens / API / developer / secrets; then credentials
10390
10624
  // the planner choosing the right click, so an on-screen key is never
10391
10625
  // missed into a maxRounds bail. Merge-only (never overwrites a prior
10392
10626
  // capture); both extractors are best-effort.
10627
+ try {
10628
+ const currentUrl = this.browser.currentUrl();
10629
+ if (!recovery.revealSweepUrls.has(currentUrl)) {
10630
+ const sweepText = await this.browser.extractVisibleText().catch(() => "");
10631
+ if (shouldRevealBeforeCredentialSweep({ url: currentUrl, pageText: sweepText })) {
10632
+ recovery.revealSweepUrls.add(currentUrl);
10633
+ const reveal = await this.browser.revealMaskedCredentials();
10634
+ if (reveal.clicked > 0) {
10635
+ args.steps.push(`Post-verify round ${round}: credential surface has reveal controls — clicked ${reveal.clicked} before sweeping.`);
10636
+ await this.browser.wait(1);
10637
+ }
10638
+ }
10639
+ }
10640
+ }
10641
+ catch {
10642
+ // reveal is opportunistic; normal extraction still runs below
10643
+ }
10393
10644
  try {
10394
10645
  const sweep = await this.extractCredentials();
10395
10646
  for (const [k, v] of Object.entries(sweep)) {
@@ -10449,10 +10700,17 @@ Prefer items naming keys / tokens / API / developer / secrets; then credentials
10449
10700
  try {
10450
10701
  if (await this.browser.hasDisabledSubmit()) {
10451
10702
  const picked = await this.browser.fillRequiredComboboxes();
10452
- if (picked.length > 0) {
10703
+ // The scope/preset pattern (API-key creation forms) gates submit
10704
+ // behind a segmented "All access" + a LemonSelect preset the
10705
+ // combobox filler can't see — satisfy those too before giving up.
10706
+ const scoped = picked.length > 0
10707
+ ? []
10708
+ : await this.browser.satisfyScopePresets().catch(() => []);
10709
+ const satisfied = [...picked, ...scoped];
10710
+ if (satisfied.length > 0) {
10453
10711
  comboboxFilled = true;
10454
- args.steps.push(`Post-verify: satisfied ${picked.length} required onboarding select(s) ` +
10455
- `to clear a disabled submit — ${picked.join(", ")}`);
10712
+ args.steps.push(`Post-verify: satisfied ${satisfied.length} required onboarding select(s) ` +
10713
+ `to clear a disabled submit — ${satisfied.join(", ")}`);
10456
10714
  await this.browser.waitForFormReady().catch(() => undefined);
10457
10715
  }
10458
10716
  else {
@@ -10706,6 +10964,77 @@ Prefer items naming keys / tokens / API / developer / secrets; then credentials
10706
10964
  else {
10707
10965
  lastReachablePostVerifyUrl = state.url;
10708
10966
  }
10967
+ if (serviceSlug(args.service) === "render" &&
10968
+ !hasAnyExtractedCredential(credentials) &&
10969
+ /dashboard\.render\.com/i.test(state.url)) {
10970
+ const onRenderAccountSettings = /\/u\/[^/]+\/settings\b/i.test(state.url);
10971
+ if (!onRenderAccountSettings) {
10972
+ const accountSettingsLink = findRenderAccountSettingsLink(inventory, recovery.clickedKeysLinks);
10973
+ if (accountSettingsLink !== null) {
10974
+ recovery.clickedKeysLinks.add(accountSettingsLink.selector);
10975
+ const label = accountSettingsLink.visibleText ??
10976
+ accountSettingsLink.ariaLabel ??
10977
+ accountSettingsLink.href ??
10978
+ accountSettingsLink.selector;
10979
+ args.steps.push(`Post-verify round ${round}: Render account menu exposes "${label.slice(0, 60)}" — entering account settings before API-key navigation.`);
10980
+ try {
10981
+ await this.browser.click(accountSettingsLink.selector);
10982
+ await this.browser.waitForInteractiveDom(5, 15_000).catch(() => undefined);
10983
+ hint = undefined;
10984
+ recovery.prevSignature = null;
10985
+ recovery.prevInventorySize = -1;
10986
+ continue;
10987
+ }
10988
+ catch (err) {
10989
+ args.steps.push(`Post-verify round ${round}: Render account-settings click failed (${err instanceof Error ? err.message : String(err)}) — continuing.`);
10990
+ }
10991
+ }
10992
+ else if (!recovery.triedRenderAccountMenu) {
10993
+ const accountMenu = findRenderAccountMenuTrigger(inventory);
10994
+ if (accountMenu !== null) {
10995
+ recovery.triedRenderAccountMenu = true;
10996
+ const label = accountMenu.visibleText ??
10997
+ accountMenu.ariaLabel ??
10998
+ accountMenu.title ??
10999
+ accountMenu.selector;
11000
+ args.steps.push(`Post-verify round ${round}: Render dashboard has no API-key link visible — opening account menu "${label.slice(0, 40)}".`);
11001
+ try {
11002
+ await this.browser.click(accountMenu.selector);
11003
+ await this.browser.waitForInteractiveDom(5, 15_000).catch(() => undefined);
11004
+ hint = undefined;
11005
+ recovery.prevSignature = null;
11006
+ recovery.prevInventorySize = -1;
11007
+ continue;
11008
+ }
11009
+ catch (err) {
11010
+ args.steps.push(`Post-verify round ${round}: Render account-menu click failed (${err instanceof Error ? err.message : String(err)}) — continuing.`);
11011
+ }
11012
+ }
11013
+ }
11014
+ }
11015
+ else if (!/#api-keys\b/i.test(state.url)) {
11016
+ const keysLink = findApiKeysNavLink(inventory, recovery.clickedKeysLinks);
11017
+ if (keysLink !== null) {
11018
+ recovery.clickedKeysLinks.add(keysLink.selector);
11019
+ const label = keysLink.visibleText ??
11020
+ keysLink.ariaLabel ??
11021
+ keysLink.href ??
11022
+ keysLink.selector;
11023
+ args.steps.push(`Post-verify round ${round}: Render account settings reached — clicking API-key anchor "${label.slice(0, 60)}".`);
11024
+ try {
11025
+ await this.browser.click(keysLink.selector);
11026
+ await this.browser.waitForInteractiveDom(5, 15_000).catch(() => undefined);
11027
+ hint = undefined;
11028
+ recovery.prevSignature = null;
11029
+ recovery.prevInventorySize = -1;
11030
+ continue;
11031
+ }
11032
+ catch (err) {
11033
+ args.steps.push(`Post-verify round ${round}: Render API-key anchor click failed (${err instanceof Error ? err.message : String(err)}) — continuing.`);
11034
+ }
11035
+ }
11036
+ }
11037
+ }
10709
11038
  const emailAlias = args.verificationEmailAlias ??
10710
11039
  args.credentials?.email ??
10711
11040
  args.oauthAccountEmail;
@@ -10978,6 +11307,72 @@ Prefer items naming keys / tokens / API / developer / secrets; then credentials
10978
11307
  }
10979
11308
  }
10980
11309
  }
11310
+ // JS-click recovery FIRST. The dominant wizard-stall cause is a custom
11311
+ // radio-card selection whose React onClick a normal (coordinate/CDP)
11312
+ // click doesn't fire, so the page never changes and "Next" stays
11313
+ // inert — MEASURED 2026-06-24 (auth0 Account-Type Personal/Company,
11314
+ // cloudinary survey, posthog role-picker class). Re-dispatch the recent
11315
+ // page-mutating clicks via page.evaluate(el.click()) (clickViaJs), which
11316
+ // fires the handler; the selection commits and the next forward click
11317
+ // advances. One attempt, then fall through to the escape/stall.
11318
+ if (!recovery.triedWizardJsClick && typeof this.browser.clickViaJs === "function") {
11319
+ recovery.triedWizardJsClick = true;
11320
+ const recentClickSelectors = recovery.actionEffects
11321
+ .filter((e) => e.kind === "click" && e.selector !== null)
11322
+ .map((e) => e.selector)
11323
+ .filter((s, i, a) => a.indexOf(s) === i);
11324
+ if (recentClickSelectors.length > 0) {
11325
+ args.steps.push(`Post-verify: STALLED in a wizard — re-dispatching ${recentClickSelectors.length} recent click(s) via JS ` +
11326
+ `(custom radio-card onClick that the normal click didn't fire).`);
11327
+ for (const sel of recentClickSelectors) {
11328
+ await this.browser.clickViaJs(sel).catch(() => undefined);
11329
+ await this.browser.wait(1);
11330
+ }
11331
+ // Then JS-click a forward/submit button so a now-enabled Next advances.
11332
+ const fwd = pickOnboardingSubmit(inventory);
11333
+ if (fwd !== null) {
11334
+ await this.browser.clickViaJs(fwd.selector).catch(() => undefined);
11335
+ }
11336
+ await this.browser.waitForInteractiveDom(5, 15_000).catch(() => undefined);
11337
+ recovery.actionEffects.length = 0;
11338
+ recovery.prevContentSig = null;
11339
+ hint = undefined;
11340
+ continue;
11341
+ }
11342
+ }
11343
+ // Wizard-escape before giving up. A re-presenting onboarding wizard
11344
+ // whose clicks don't register is an OVERLAY on the real product
11345
+ // dashboard — its sidebar (Home/Settings/API Keys) is already in the
11346
+ // inventory. Navigate to a settings/keys/home nav link to reach the
11347
+ // credential surface behind it (MEASURED 2026-06-24, cloudinary
11348
+ // /app/welcome: the survey no-ops but Settings → /app/settings is right
11349
+ // there). One attempt, then honor the stall.
11350
+ if (!recovery.triedWizardEscape) {
11351
+ recovery.triedWizardEscape = true;
11352
+ const escapeLink = findWizardEscapeNavLink(inventory, recovery.clickedKeysLinks);
11353
+ if (escapeLink !== null) {
11354
+ recovery.clickedKeysLinks.add(escapeLink.selector);
11355
+ const label = escapeLink.visibleText ?? escapeLink.ariaLabel ?? escapeLink.href ?? "the dashboard";
11356
+ args.steps.push(`Post-verify: STALLED in an onboarding wizard — escaping to ${JSON.stringify(label.slice(0, 40))} ` +
11357
+ `(the wizard overlays the product dashboard; the key surface is behind it).`);
11358
+ try {
11359
+ if (escapeLink.href !== undefined && escapeLink.href !== null && escapeLink.href.length > 0) {
11360
+ await this.browser.goto(new URL(escapeLink.href, state.url).toString());
11361
+ }
11362
+ else {
11363
+ await this.browser.click(escapeLink.selector);
11364
+ }
11365
+ await this.browser.waitForInteractiveDom(5, 15_000).catch(() => undefined);
11366
+ recovery.actionEffects.length = 0;
11367
+ recovery.prevContentSig = null;
11368
+ hint = undefined;
11369
+ continue;
11370
+ }
11371
+ catch (err) {
11372
+ args.steps.push(`Post-verify: wizard-escape navigate failed (${err instanceof Error ? err.message : String(err)}) — honoring the stall.`);
11373
+ }
11374
+ }
11375
+ }
10981
11376
  // Capture the exact page that defeated the wizard so the N1
10982
11377
  // onboarding-wizard class is debuggable post-hoc (post-verify pages
10983
11378
  // weren't being snapshotted — imagekit's step-1/3 role-select stall
@@ -11037,6 +11432,7 @@ Prefer items naming keys / tokens / API / developer / secrets; then credentials
11037
11432
  if (hint === undefined &&
11038
11433
  args.machineToken !== undefined &&
11039
11434
  args.machineToken.length > 0 &&
11435
+ args.allowOperatorInboxOtp === true &&
11040
11436
  !otpPolledUrls.has(state.url) &&
11041
11437
  detectEmailOtpGate(state.url, state.title, await this.browser.extractText().catch(() => ""), inventory)) {
11042
11438
  otpPolledUrls.add(state.url);
@@ -11604,6 +12000,46 @@ Prefer items naming keys / tokens / API / developer / secrets; then credentials
11604
12000
  recovery.prevSignature = null;
11605
12001
  recovery.prevInventorySize = inventory.length;
11606
12002
  }
12003
+ const actionCycleSignature = credentialActionSignature(nextStep, inventory);
12004
+ if (actionCycleSignature !== null) {
12005
+ recovery.recentCredentialActionSignatures.push(actionCycleSignature);
12006
+ if (recovery.recentCredentialActionSignatures.length > 8) {
12007
+ recovery.recentCredentialActionSignatures =
12008
+ recovery.recentCredentialActionSignatures.slice(-8);
12009
+ }
12010
+ if (isRepeatingCredentialActionCycle(recovery.recentCredentialActionSignatures, visiblePageText)) {
12011
+ args.steps.push(`Post-verify: detected repeating credential-action cycle (${recovery.recentCredentialActionSignatures.slice(-5).join(" → ")}) — trying extraction/fallback instead of repeating it.`);
12012
+ const harvested = await this.harvestVisibleCredentials().catch(() => ({}));
12013
+ for (const [k, v] of Object.entries(harvested)) {
12014
+ if (credentials[k] === undefined)
12015
+ credentials[k] = v;
12016
+ }
12017
+ if (hasAnyExtractedCredential(credentials)) {
12018
+ await this.writeFastPathSyntheticCapture(args.service, capturedRound, oauth, "repeating credential-action cycle synthetic extract — credentials were already visible");
12019
+ return credentials;
12020
+ }
12021
+ const fallback = pickStuckLoopFallbackUrl(state.url, recovery.triedFallbackUrls, args.service, this.resolvedSignupUrl);
12022
+ if (fallback !== null) {
12023
+ recovery.triedFallbackUrls.add(fallback);
12024
+ args.steps.push(`Post-verify: credential-action cycle fallback — navigating to ${fallback}`);
12025
+ try {
12026
+ await this.browser.goto(fallback);
12027
+ await this.browser.waitForInteractiveDom(5, 15_000).catch(() => undefined);
12028
+ }
12029
+ catch (err) {
12030
+ args.steps.push(`Post-verify: credential-action cycle fallback navigate failed (${err instanceof Error ? err.message : String(err)}) — continuing.`);
12031
+ }
12032
+ recovery.prevSignature = null;
12033
+ recovery.prevInventorySize = -1;
12034
+ hint = undefined;
12035
+ continue;
12036
+ }
12037
+ this.lastPostVerifyDoneReason =
12038
+ `[stuck_loop] planner repeated credential/payment actions (${recovery.recentCredentialActionSignatures.slice(-5).join(" → ")}) with no usable credential and no fallback URL remaining.`;
12039
+ args.steps.push(`Post-verify: credential-action cycle unresolvable — breaking out with planner_stuck.`);
12040
+ break;
12041
+ }
12042
+ }
11607
12043
  // Record the kind of the step we're ABOUT to execute (all re-plan
11608
12044
  // `continue` guards are behind us here) so next round can judge
11609
12045
  // whether it changed the page — the stalled-wizard breaker above.
@@ -11983,6 +12419,16 @@ Prefer items naming keys / tokens / API / developer / secrets; then credentials
11983
12419
  }
11984
12420
  }
11985
12421
  }
12422
+ // Salvage the synthesis path. The bot's credential-tracker / background
12423
+ // extraction can populate `credentials` without a planner `extract` action
12424
+ // ever running — the loop then ends on a `done`/`click`, so the on-disk
12425
+ // capture has no extract round and the synthesizer REJECTS it
12426
+ // (no_extract_step) even though the run SUCCEEDED. MEASURED 2026-06-24
12427
+ // (serpapi/galileo/mailjet: extracted api_key, last round was `done`, skill
12428
+ // never promoted). When we're about to return a real credential but no
12429
+ // extract round was captured this run, write one for the final page so the
12430
+ // success becomes a replayable skill instead of an OF#2-only win.
12431
+ await this.salvageExtractCaptureIfNeeded(args.service, credentials, oauth);
11986
12432
  return credentials;
11987
12433
  }
11988
12434
  // 0.8.3-rc.1 — write a single synthetic extract round when the
@@ -12000,6 +12446,23 @@ Prefer items naming keys / tokens / API / developer / secrets; then credentials
12000
12446
  //
12001
12447
  // Best-effort: a capture failure here must NEVER block returning
12002
12448
  // the credential we already have.
12449
+ // Salvage the synthesis path for ANY successful exit (not just the post-verify
12450
+ // loop). When a run returns a real credential but no `extract` round was
12451
+ // captured — the form-fill+email path (mailjet) returns before the post-verify
12452
+ // loop ever runs, so its salvage never fires — write a synthetic extract round
12453
+ // for the final page so the success becomes a replayable skill. Gated on
12454
+ // hasCapturedAnyRound: a LONE extract round (no captured nav/OAuth steps — the
12455
+ // no_rounds class, e.g. galileo's OAuth-fast-path) can't be replayed and would
12456
+ // synth-reject anyway, so don't write one. Idempotent via hasCapturedExtractRound.
12457
+ async salvageExtractCaptureIfNeeded(service, credentials, oauth) {
12458
+ if (!hasAnyExtractedCredential(credentials))
12459
+ return;
12460
+ if (hasCapturedExtractRound(service))
12461
+ return;
12462
+ if (!hasCapturedAnyRound(service))
12463
+ return;
12464
+ await this.writeFastPathSyntheticCapture(service, nextCaptureRound(service), oauth, "salvage synthetic extract — credential captured on a non-post-verify exit; no planner extract round ran");
12465
+ }
12003
12466
  async writeFastPathSyntheticCapture(service, capturedRound, oauth, reason = "fast-path synthetic extract — credentials were already on the page before any planner round ran") {
12004
12467
  try {
12005
12468
  const [state, inventory] = await Promise.all([