@trusty-squire/mcp 0.9.4 → 0.9.6

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.
package/dist/bot/agent.js CHANGED
@@ -431,6 +431,13 @@ export function pickStuckLoopFallbackUrl(currentUrl, alreadyTried, service) {
431
431
  catch {
432
432
  return null;
433
433
  }
434
+ // about:blank / data: / chrome-error pages have an opaque origin that
435
+ // serializes to the literal string "null" — building "${origin}${path}"
436
+ // then yields an unnavigable "null/settings/keys". Only compose
437
+ // fallbacks against a real http(s) origin.
438
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
439
+ return null;
440
+ }
434
441
  const origin = parsed.origin;
435
442
  // Skip a candidate when the current URL's path ALREADY matches it
436
443
  // (case-insensitive, trailing-slash tolerant). The planner is stuck
@@ -2519,6 +2526,13 @@ export function extractAllLabeledTokensFromReason(reason, pageText) {
2519
2526
  const value = m[2];
2520
2527
  if (canonical === undefined || value === undefined)
2521
2528
  continue;
2529
+ // Email local-part guard. The value class stops at '@', so an email
2530
+ // ("giselle703@gmail.com") is captured as its local-part ("giselle703")
2531
+ // — a digit-bearing string that passes credential-shape. An email is
2532
+ // never a credential; reject when the captured value is immediately
2533
+ // followed by '@' in the source. (Cloudinary email-settings page.)
2534
+ if (reason.includes(value + "@") || pageText.includes(value + "@"))
2535
+ continue;
2522
2536
  if (!pageText.includes(value))
2523
2537
  continue;
2524
2538
  if (out[canonical] === undefined)
@@ -2564,6 +2578,9 @@ export function extractAllLabeledTokensFromReason(reason, pageText) {
2564
2578
  continue; // quoted-form already won
2565
2579
  if (PROSE_BLACKLIST.has(value.toLowerCase()))
2566
2580
  continue;
2581
+ // Email local-part guard — see the quoted loop above.
2582
+ if (reason.includes(value + "@") || pageText.includes(value + "@"))
2583
+ continue;
2567
2584
  if (!looksCredentialShape(value))
2568
2585
  continue;
2569
2586
  if (!pageText.includes(value))
@@ -2612,6 +2629,46 @@ export function isMultiCredBundle(creds) {
2612
2629
  }
2613
2630
  return false;
2614
2631
  }
2632
+ // DOM-label phrase → canonical credential key. Shared by
2633
+ // extractFromDomProximity (which harvests VALUES) and
2634
+ // countPresentedCredentialLabels (which counts how many distinct
2635
+ // credentials a page PRESENTS, masked included). Kept in lockstep with
2636
+ // the Phase E LABEL_ALIASES vocabulary.
2637
+ const DOM_LABEL_TO_KEY = {
2638
+ "api key": "api_key",
2639
+ "api token": "api_key",
2640
+ "api secret": "api_secret",
2641
+ "secret key": "secret_key",
2642
+ "publishable key": "publishable_key",
2643
+ "access key": "access_key_id",
2644
+ "access key id": "access_key_id",
2645
+ "access token": "access_token",
2646
+ "bearer token": "access_token",
2647
+ "personal access token": "access_token",
2648
+ "auth token": "auth_token",
2649
+ "client id": "client_id",
2650
+ "client secret": "client_secret",
2651
+ "client key": "client_id",
2652
+ "cloud name": "cloud_name",
2653
+ cloudname: "cloud_name",
2654
+ "application id": "application_id",
2655
+ "app id": "application_id",
2656
+ "admin api key": "admin_api_key",
2657
+ "search api key": "search_api_key",
2658
+ "search-only api key": "search_api_key",
2659
+ "monitoring api key": "monitoring_api_key",
2660
+ "account sid": "account_sid",
2661
+ "secret access key": "secret_access_key",
2662
+ "consumer key": "consumer_key",
2663
+ "consumer secret": "consumer_secret",
2664
+ "access token secret": "access_token_secret",
2665
+ "project api key": "project_api_key",
2666
+ "personal api key": "personal_api_key",
2667
+ "organization id": "org_id",
2668
+ "org id": "org_id",
2669
+ "app key": "app_key",
2670
+ "app secret": "app_secret",
2671
+ };
2615
2672
  export function extractApiKeyFromText(text) {
2616
2673
  const prefixed = [
2617
2674
  /\bre_[a-zA-Z0-9_]{20,}\b/, // Resend (key body contains underscores)
@@ -2872,6 +2929,60 @@ export function pickVerificationLinkFromHtml(bodyHtml) {
2872
2929
  }
2873
2930
  return best !== null && best.score > 0 ? best.url : null;
2874
2931
  }
2932
+ // Last-resort verification-CODE extraction from an email body, for the
2933
+ // passwordless "we emailed you a code" flow (axiom: "Axiom sign-in
2934
+ // verification code") when the inbox parser's parsed_codes came back empty.
2935
+ // Without this the bot bailed "no usable verification link" on a code-only
2936
+ // email — treating a routine code flow as a dead end. Conservative: prefers a
2937
+ // 4-8 digit run next to a code/verify keyword, then a space/dash-grouped code,
2938
+ // then a standalone 6-digit number (the most common verification length).
2939
+ // Returns null when nothing code-shaped is found so the caller still bails
2940
+ // honestly rather than typing garbage. Exported for unit testing.
2941
+ export function extractCodeFromEmailBody(email) {
2942
+ const text = [
2943
+ email.subject ?? "",
2944
+ email.body_text ?? "",
2945
+ (email.body_html ?? "").replace(/<[^>]+>/g, " "),
2946
+ ].join("\n");
2947
+ // 1) A code sitting next to a verification keyword — the strongest signal.
2948
+ const kw = text.match(/(?:verification code|sign[\s-]?in code|one[\s-]?time(?:\s+(?:code|password))?|security code|your code|confirmation code|code is|enter(?:\s+this)?\s+code)\b[^0-9]{0,40}([0-9]{4,8})\b/i);
2949
+ if (kw?.[1] !== undefined)
2950
+ return kw[1];
2951
+ // 2) A grouped code ("123-456" / "1234 5678").
2952
+ const grouped = text.match(/(?<![0-9])([0-9]{3,4}[ -][0-9]{3,4})(?![0-9])/);
2953
+ if (grouped?.[1] !== undefined)
2954
+ return grouped[1].replace(/[ -]/g, "");
2955
+ // 3) A standalone 6-digit number (most verification codes).
2956
+ const six = text.match(/(?<![0-9])([0-9]{6})(?![0-9])/);
2957
+ if (six?.[1] !== undefined)
2958
+ return six[1];
2959
+ return null;
2960
+ }
2961
+ // True when the page is an email verification-CODE entry gate: a single
2962
+ // code-style input (name/id/placeholder/label ~ code/token/otp/verification),
2963
+ // NO email/password/tel field still to fill, and verify/code copy in the body.
2964
+ // axiom-class passwordless ("Send Code to Email" lands here). Distinct from the
2965
+ // no-fields verification WALL: this page HAS an input, but it's a CODE field,
2966
+ // so the form-fill planner would otherwise type an empty literal into it and
2967
+ // loop. The caller returns "submitted" to route to the inbox-poll + code-entry
2968
+ // path (the code was emailed to our alias). Exported for unit testing.
2969
+ export function isVerificationCodeGate(inventory, pageText) {
2970
+ const inputs = inventory.filter((e) => e.tag === "input" && e.visible !== false);
2971
+ // Any email/password/tel field still present → still a signup form, not a
2972
+ // pure code gate.
2973
+ if (inputs.some((e) => e.type === "email" || e.type === "password" || e.type === "tel")) {
2974
+ return false;
2975
+ }
2976
+ const codeRe = /\b(?:code|token|otp|verification|verify|one[\s-]?time|2fa|mfa)\b/i;
2977
+ const hasCodeInput = inputs.some((e) => {
2978
+ const hay = `${e.name ?? ""} ${e.id ?? ""} ${e.placeholder ?? ""} ${e.ariaLabel ?? ""} ${e.labelText ?? ""}`;
2979
+ return codeRe.test(hay);
2980
+ });
2981
+ if (!hasCodeInput)
2982
+ return false;
2983
+ const t = pageText.toLowerCase();
2984
+ return /verification code|enter (?:the |your )?code|code is required|verify and continue|we (?:sent|emailed)|check your email|one[\s-]?time (?:code|password)|sign[\s-]?in code/.test(t);
2985
+ }
2875
2986
  // Discriminates LLMPair from LLMClient. LLMPair has `primary` (an
2876
2987
  // LLMClient); LLMClient has `createMessage`. They're mutually exclusive
2877
2988
  // shapes so a structural check is reliable.
@@ -3279,6 +3390,15 @@ export class SignupAgent {
3279
3390
  let progressReplans = 0;
3280
3391
  let emptyPlans = 0;
3281
3392
  let oauthScanRetries = 0;
3393
+ // Bound upstream-proxy blips. The blip carve-out below deliberately
3394
+ // does NOT tick errorReplans (a transient 502 isn't a logic failure),
3395
+ // but with no cap a SUSTAINED LLM-proxy outage spins this loop until
3396
+ // the 600s run deadline — meilisearch burned 177 planner calls this
3397
+ // way on a degraded `trusty-squire-proxy:cheap` upstream. The
3398
+ // post-verify loop already caps blips at 8; mirror that here so a
3399
+ // sustained outage fails fast as planning_failed instead of timing out.
3400
+ let upstreamBlipRetries = 0;
3401
+ const MAX_UPSTREAM_BLIP_RETRIES = 8;
3282
3402
  // Bounded click-throughs of a generic "Sign In to Continue"
3283
3403
  // interstitial that gates the provider buttons (Qdrant). Capped so
3284
3404
  // an SSO-only page that keeps re-showing a sign-in button (or a
@@ -3355,9 +3475,20 @@ export class SignupAgent {
3355
3475
  const aliasPollable = wallAlias === null ||
3356
3476
  wallAlias.slice(wallAlias.indexOf("@") + 1).toLowerCase() ===
3357
3477
  ourInboxDomain;
3478
+ // A no-input page that offers an OAuth signup affordance
3479
+ // ("SIGN UP WITH GOOGLE/GITHUB") is a signup-METHOD chooser, not
3480
+ // a post-submit verification wall — nothing has been submitted
3481
+ // yet, so there's no mail to poll. Cloudinary's register page is
3482
+ // exactly this: no fields, three "SIGN UP WITH …" links, and
3483
+ // marketing copy that trips expectsVerificationEmail. Skipping the
3484
+ // wall here lets control fall through to the OAuth-first scan
3485
+ // below, which clicks Google.
3486
+ const offersOAuthSignup = oauthCandidates.length > 0 &&
3487
+ findFirstOAuthButton(inventory, oauthCandidates) !== null;
3358
3488
  if (!hasFillableInput &&
3359
3489
  expectsVerificationEmail(wallText) &&
3360
- aliasPollable) {
3490
+ aliasPollable &&
3491
+ !offersOAuthSignup) {
3361
3492
  const alias = wallAlias;
3362
3493
  this.pendingVerificationAlias = alias;
3363
3494
  steps.push(`Form: email-verification wall (no fields to fill${alias !== null ? `, check ${alias}` : ""}) — ` +
@@ -3383,6 +3514,19 @@ export class SignupAgent {
3383
3514
  return { kind: "submitted" };
3384
3515
  }
3385
3516
  }
3517
+ // Email verification-CODE gate (axiom-class passwordless). The no-fields
3518
+ // wall above misses it because this page HAS an input — but it's a CODE
3519
+ // field, not email/password, and the code was emailed to our alias.
3520
+ // Return "submitted" so the post-submit inbox-poll + code-entry path
3521
+ // (extractCodeFromEmailBody → enterEmailVerificationCode) handles it,
3522
+ // instead of the planner typing an empty literal into the code field and
3523
+ // looping. Gated on committedToEmailPath — a code gate only appears after
3524
+ // the email was submitted.
3525
+ if (committedToEmailPath && isVerificationCodeGate(inventory, state.html)) {
3526
+ this.pendingVerificationAlias = this.pendingVerificationAlias ?? task.email;
3527
+ steps.push("Form: email verification-CODE gate detected — routing to the inbox-poll + code-entry flow.");
3528
+ return { kind: "submitted" };
3529
+ }
3386
3530
  // OAuth-first (T6/T13 + auto-prefer): when the page carries a
3387
3531
  // "Sign in with <provider>" affordance for a provider the bot can
3388
3532
  // use, that button unconditionally outranks any form field — hand
@@ -3561,13 +3705,25 @@ export class SignupAgent {
3561
3705
  if (!isUpstreamBlip && ++errorReplans > MAX_ERROR_REPLANS) {
3562
3706
  return { kind: "planning_failed", reason: `planner output never validated: ${reason}` };
3563
3707
  }
3708
+ if (isUpstreamBlip && ++upstreamBlipRetries > MAX_UPSTREAM_BLIP_RETRIES) {
3709
+ return {
3710
+ kind: "planning_failed",
3711
+ reason: `llm_proxy_unavailable: planner request failed ${upstreamBlipRetries}x on upstream proxy errors (${reason}) — sustained LLM-proxy/upstream outage, not a page problem`,
3712
+ };
3713
+ }
3564
3714
  steps.push(isUpstreamBlip
3565
- ? `⚠ planner request hit a transient upstream blip (${reason}) — retrying`
3715
+ ? `⚠ planner request hit a transient upstream blip (${reason}) — retrying (${upstreamBlipRetries}/${MAX_UPSTREAM_BLIP_RETRIES})`
3566
3716
  : `⚠ plan rejected (${reason}) — re-planning`);
3567
3717
  if (!isUpstreamBlip) {
3568
3718
  hint =
3569
3719
  "Your previous plan used a selector not in the inventory. Use ONLY selectors copied verbatim from a `selector=` field.";
3570
3720
  }
3721
+ else {
3722
+ // Brief backoff so a genuinely transient blip can recover
3723
+ // before the next attempt, instead of hammering a degraded
3724
+ // upstream as fast as the proxy can 502.
3725
+ await this.browser.wait(2);
3726
+ }
3571
3727
  continue;
3572
3728
  }
3573
3729
  steps.push(`Plan: ${plan.actions.length} action(s), confidence=${plan.confidence}` +
@@ -4829,7 +4985,17 @@ export class SignupAgent {
4829
4985
  credentials = await this.enterEmailVerificationCode(email.parsed_codes[0] ?? "", task, password, steps);
4830
4986
  }
4831
4987
  else {
4832
- steps.push("Email had no usable verification link.");
4988
+ // No link and the inbox parser found no code last-resort
4989
+ // scan the email body ourselves for a verification code
4990
+ // (passwordless "we emailed you a code" flow, e.g. axiom).
4991
+ const bodyCode = extractCodeFromEmailBody(email);
4992
+ if (bodyCode !== null) {
4993
+ steps.push(`Email had no link but carried a verification code (…${bodyCode.slice(-2)}) — entering it.`);
4994
+ credentials = await this.enterEmailVerificationCode(bodyCode, task, password, steps);
4995
+ }
4996
+ else {
4997
+ steps.push("Email had no usable verification link or code.");
4998
+ }
4833
4999
  }
4834
5000
  }
4835
5001
  else if (email.parsed_codes.length > 0) {
@@ -4980,6 +5146,21 @@ export class SignupAgent {
4980
5146
  steps.push(`OAuth: Google Identity Services / FedCM widget — resolved via ${gsi.via}` +
4981
5147
  (gsi.ok ? "" : " (no FedCM dialog or popup appeared — the widget may need a different trigger)"));
4982
5148
  }
5149
+ // OmniAuth POST-only recovery prep. Capture the affordance's href + the
5150
+ // page's CSRF token NOW, while we're still on the signin page — the
5151
+ // "Authentication passthru" page a GET-click lands on is bare (no token).
5152
+ // See the typesense root-cause (2026-06-07): Rails/OmniAuth 2.0 is
5153
+ // POST-only; the GitHub button is a GET <a href="/users/auth/github">
5154
+ // upgraded to POST by page JS, and the bot's GET-click hit the passthru.
5155
+ const omniauthHref = typeof this.browser.getElementAttribute === "function"
5156
+ ? await this.browser
5157
+ .getElementAttribute(oauthSelector, "href")
5158
+ .catch(() => null)
5159
+ : null;
5160
+ const omniauthToken = typeof this.browser.getMetaCsrfToken === "function"
5161
+ ? await this.browser.getMetaCsrfToken().catch(() => null)
5162
+ : null;
5163
+ let omniauthPostTried = false;
4983
5164
  if (!gsiHandled) {
4984
5165
  await this.browser.startOAuth(oauthSelector);
4985
5166
  }
@@ -4994,6 +5175,15 @@ export class SignupAgent {
4994
5175
  // /consent, etc.) get the soft-advance path instead of an abort —
4995
5176
  // because the scope-grant decision was already made and validated.
4996
5177
  let consentAlreadyApproved = false;
5178
+ // Bounded soft-waits for a consent page whose approve control hasn't
5179
+ // rendered yet. Google's gaia consent SPA paints "Loading" then
5180
+ // hydrates the Allow/Continue button a few seconds later (and for an
5181
+ // already-authorized app it may auto-redirect with no button at all).
5182
+ // Rather than aborting the instant advanceOAuthConsent finds nothing,
5183
+ // wait + re-loop a few times so a slow hydrate / auto-redirect can
5184
+ // complete first.
5185
+ let consentAdvanceWaits = 0;
5186
+ const MAX_CONSENT_ADVANCE_WAITS = 3;
4997
5187
  for (let i = 0; i < MAX_OAUTH_NAV; i++) {
4998
5188
  if (this.browser.oauthPageClosed()) {
4999
5189
  steps.push(`OAuth: the ${provider.label} window closed — handshake returned to the service`);
@@ -5026,8 +5216,37 @@ export class SignupAgent {
5026
5216
  }
5027
5217
  const authState = provider.classifyAuthState(url, body);
5028
5218
  steps.push(`OAuth: ${provider.label} auth state = ${authState} (url=${url.slice(0, 120)})`);
5029
- if (authState === "not_provider")
5219
+ if (authState === "not_provider") {
5220
+ // OmniAuth 2.0 POST-only recovery. The provider button can be a GET
5221
+ // <a href="/.../auth/<provider>"> that page-JS upgrades to a POST; if
5222
+ // the bot's click hit the default GET, Rails/OmniAuth answers
5223
+ // "Not found. Authentication passthru." and OAuth never started — the
5224
+ // bot then misreads it as "signed in" and bails. Detect that bare
5225
+ // passthru page and re-initiate via POST with the signin page's CSRF
5226
+ // token (proven to 302 to the provider). MEASURED 2026-06-07: typesense.
5227
+ const onOmniAuthPassthru = /authentication passthru|not found/i.test(body) &&
5228
+ /\/auth\/[a-z0-9_-]+\/?$/i.test(url);
5229
+ if (onOmniAuthPassthru &&
5230
+ !omniauthPostTried &&
5231
+ omniauthToken !== null &&
5232
+ omniauthHref !== null) {
5233
+ omniauthPostTried = true;
5234
+ const action = new URL(omniauthHref, url).toString();
5235
+ steps.push(`OAuth: ${provider.label} endpoint is POST-only (OmniAuth GET passthru) — ` +
5236
+ `re-initiating via POST with the page CSRF token`);
5237
+ try {
5238
+ await this.browser.submitPostForm(action, {
5239
+ authenticity_token: omniauthToken,
5240
+ });
5241
+ await this.browser.wait(3);
5242
+ continue; // re-read state — should now be on the provider's page
5243
+ }
5244
+ catch (err) {
5245
+ steps.push(`OAuth: OmniAuth POST recovery failed (${err instanceof Error ? err.message : String(err)})`);
5246
+ }
5247
+ }
5030
5248
  break; // flow left the provider — back on the service
5249
+ }
5031
5250
  if (authState === "challenge") {
5032
5251
  // rc.26 — always capture forensic state at the moment the
5033
5252
  // challenge is detected. Before this, snapshots fired only at
@@ -5318,8 +5537,18 @@ export class SignupAgent {
5318
5537
  `no scope-grant verb phrases detected in page DOM)`);
5319
5538
  const advanced = await this.browser.advanceOAuthConsent(provider.id);
5320
5539
  if (!advanced) {
5540
+ // The approve button may not have hydrated yet, or Google is
5541
+ // auto-redirecting an already-authorized app. Wait + re-loop a
5542
+ // bounded number of times before giving up.
5543
+ if (consentAdvanceWaits < MAX_CONSENT_ADVANCE_WAITS) {
5544
+ consentAdvanceWaits += 1;
5545
+ steps.push(`OAuth: consent approve control not present yet — waiting for hydrate/redirect ` +
5546
+ `(${consentAdvanceWaits}/${MAX_CONSENT_ADVANCE_WAITS})`);
5547
+ await this.browser.wait(4);
5548
+ continue;
5549
+ }
5321
5550
  return this.oauthAbort("oauth_consent_needs_review", `blind-consent approved but no approve control found on the ` +
5322
- `${provider.label} consent page — sign up manually.`, steps);
5551
+ `${provider.label} consent page after ${consentAdvanceWaits} waits — sign up manually.`, steps);
5323
5552
  }
5324
5553
  consentAlreadyApproved = true;
5325
5554
  await this.browser.wait(3);
@@ -6175,41 +6404,7 @@ ${formatInventory(input.inventory)}`,
6175
6404
  async extractFromDomProximity() {
6176
6405
  // Vocabulary matches the LABEL_ALIASES used by Phase E so the
6177
6406
  // canonical keys stay consistent across paths.
6178
- const LABEL_TO_KEY = {
6179
- "api key": "api_key",
6180
- "api token": "api_key",
6181
- "api secret": "api_secret",
6182
- "secret key": "secret_key",
6183
- "publishable key": "publishable_key",
6184
- "access key": "access_key_id",
6185
- "access key id": "access_key_id",
6186
- "access token": "access_token",
6187
- "bearer token": "access_token",
6188
- "personal access token": "access_token",
6189
- "auth token": "auth_token",
6190
- "client id": "client_id",
6191
- "client secret": "client_secret",
6192
- "client key": "client_id",
6193
- "cloud name": "cloud_name",
6194
- "cloudname": "cloud_name",
6195
- "application id": "application_id",
6196
- "app id": "application_id",
6197
- "admin api key": "admin_api_key",
6198
- "search api key": "search_api_key",
6199
- "search-only api key": "search_api_key",
6200
- "monitoring api key": "monitoring_api_key",
6201
- "account sid": "account_sid",
6202
- "secret access key": "secret_access_key",
6203
- "consumer key": "consumer_key",
6204
- "consumer secret": "consumer_secret",
6205
- "access token secret": "access_token_secret",
6206
- "project api key": "project_api_key",
6207
- "personal api key": "personal_api_key",
6208
- "organization id": "org_id",
6209
- "org id": "org_id",
6210
- "app key": "app_key",
6211
- "app secret": "app_secret",
6212
- };
6407
+ const LABEL_TO_KEY = DOM_LABEL_TO_KEY;
6213
6408
  let labeled = [];
6214
6409
  try {
6215
6410
  labeled = await this.browser.extractLabeledCredentialCandidates();
@@ -6236,6 +6431,30 @@ ${formatInventory(input.inventory)}`,
6236
6431
  }
6237
6432
  return out;
6238
6433
  }
6434
+ // Count the DISTINCT credentials the current page PRESENTS — masked
6435
+ // ones included. This detects a multi-cred page BEFORE every value is
6436
+ // captured, so the post-verify loop can stay open to harvest the 2nd/
6437
+ // 3rd key instead of exiting the moment the first surfaces ("stops at
6438
+ // one"). Uses the DOM-proximity harvester's labels (which fire even on
6439
+ // masked/bullet'd values) mapped to canonical keys; values are NOT read
6440
+ // here, only the label set. Best-effort → 0 on any browser error.
6441
+ async countPresentedCredentialLabels() {
6442
+ try {
6443
+ const cands = await this.browser.extractLabeledCredentialCandidates();
6444
+ const canon = new Set();
6445
+ for (const c of cands) {
6446
+ if (c.label === null)
6447
+ continue;
6448
+ const key = DOM_LABEL_TO_KEY[c.label];
6449
+ if (key !== undefined)
6450
+ canon.add(key);
6451
+ }
6452
+ return canon.size;
6453
+ }
6454
+ catch {
6455
+ return 0;
6456
+ }
6457
+ }
6239
6458
  // Run every visible-credential extraction tier the post-verify loop
6240
6459
  // uses (legacy regex/clipboard/hidden-input + DOM-proximity labeled),
6241
6460
  // merging first-wins into a single bundle. Used by attemptMintNewKey
@@ -6527,6 +6746,16 @@ ${formatInventory(input.inventory)}`,
6527
6746
  // and inject a forced "no-progress" hint on the second repeat.
6528
6747
  let prevSignature = null;
6529
6748
  let prevInventorySize = -1;
6749
+ // Selectors the planner has CLICKED while the inventory count has held
6750
+ // steady. A multi-step onboarding wizard (axiom: role → company-size →
6751
+ // plan) advances by clicking distinct radio-style cards that flip an
6752
+ // aria-checked but add/remove no elements, so inventory.length never
6753
+ // moves — and the kind-level stuck detector below would false-positive
6754
+ // on the 2nd DISTINCT selection. We exempt a brand-new selector (wizard
6755
+ // progress) and only call it stuck once a selector REPEATS (the Railway
6756
+ // Create→Focus→Create cycle). Reset whenever the inventory count changes
6757
+ // (genuine page mutation → fresh wizard step / new page).
6758
+ let clickSelectorsSinceInventoryChange = new Set();
6530
6759
  // rc.39 — wait-loop tracker. Turso's GitHub OAuth handshake
6531
6760
  // succeeds, then the SSO-callback page stays empty (0 elements)
6532
6761
  // while a Cloudflare verification widget runs that never clears
@@ -6573,6 +6802,13 @@ ${formatInventory(input.inventory)}`,
6573
6802
  let stuckFiresAtUrl = 0;
6574
6803
  let lastStuckFireUrl = null;
6575
6804
  const triedFallbackUrls = new Set();
6805
+ // Premature-done guard budget. When the planner gives up (`done`)
6806
+ // with zero credentials captured, we navigate to an unvisited
6807
+ // canonical keys URL and re-plan — bounded so a service that
6808
+ // genuinely has no self-serve key doesn't burn the whole run budget
6809
+ // walking every fallback path.
6810
+ let prematureDoneFallbacks = 0;
6811
+ const MAX_PREMATURE_DONE_FALLBACKS = 3;
6576
6812
  // Dead-URL memory. The planner guesses credential-page URLs
6577
6813
  // (e.g. /user/personal_access_tokens/new) that 404; without memory it
6578
6814
  // re-guesses the same dead URL round after round — xata and fly each
@@ -6617,6 +6853,15 @@ ${formatInventory(input.inventory)}`,
6617
6853
  // surveyed the labeled credentials surface.
6618
6854
  const seedHadCredential = credentials.api_key !== undefined || credentials.username !== undefined;
6619
6855
  let plannerExtractEmitted = false;
6856
+ // 2026-06-07 — "stops at one" fix. The legacy loop-exit treated a run
6857
+ // as single-cred based on what was ALREADY captured (isMultiCredBundle),
6858
+ // so a page with 3 credentials whose 1st surfaced first — siblings still
6859
+ // masked or missed on the first harvest pass — exited before the rest
6860
+ // were caught. Set once the page is observed to PRESENT >=2 distinct
6861
+ // credentials (masked included); the loop-exit then holds open (bounded
6862
+ // by roundsSinceLastNewCredential) so the reveal pass + DOM harvest get
6863
+ // more rounds to capture the siblings.
6864
+ let pageOffersMultiCred = false;
6620
6865
  // Gate URLs we've already polled the operator's gmail for, so a
6621
6866
  // multi-round wait on the same email-OTP page doesn't re-poll.
6622
6867
  const otpPolledUrls = new Set();
@@ -6641,7 +6886,7 @@ ${formatInventory(input.inventory)}`,
6641
6886
  // when the api_key came from the pre-loop seed and the
6642
6887
  // planner hasn't yet emitted an explicit extract step. In
6643
6888
  // that case we let the planner run until extract fires.
6644
- const inMultiCredMode = isMultiCredBundle(credentials);
6889
+ const inMultiCredMode = isMultiCredBundle(credentials) || pageOffersMultiCred;
6645
6890
  const haveOnlySeedCredentials = seedHadCredential && !plannerExtractEmitted;
6646
6891
  if (!inMultiCredMode &&
6647
6892
  (credentials.api_key !== undefined || credentials.username !== undefined) &&
@@ -6680,6 +6925,24 @@ ${formatInventory(input.inventory)}`,
6680
6925
  // widened (the "API Keys"/"Settings" links must survive ranking).
6681
6926
  // Reading state can still race a navigation — a transient throw
6682
6927
  // burns the round rather than crashing the whole run.
6928
+ // Dismiss any cookie/consent banner BEFORE reading the page or
6929
+ // planning a click. A consent overlay (Google "Accept all", GDPR
6930
+ // banners) intercepts pointer events, so the planner's clicks land
6931
+ // on the banner instead of the dashboard and the loop stalls / loops /
6932
+ // times out. MEASURED 2026-06-07: meilisearch reached /settings/keys
6933
+ // but sat behind an Accept-All overlay and ran out the 600s clock.
6934
+ // The form-fill loop already dismisses banners every round; the
6935
+ // post-verify loop never did. Best-effort — a dismiss failure must
6936
+ // not burn the round.
6937
+ try {
6938
+ const dismissed = await this.browser.dismissConsentBanner();
6939
+ if (dismissed !== null) {
6940
+ args.steps.push(`Post-verify round ${round}: dismissed consent banner ("${dismissed}")`);
6941
+ }
6942
+ }
6943
+ catch {
6944
+ // best-effort
6945
+ }
6683
6946
  let state;
6684
6947
  let inventory;
6685
6948
  try {
@@ -7046,6 +7309,13 @@ ${formatInventory(input.inventory)}`,
7046
7309
  // the same selector AND the inventory size matches the prior
7047
7310
  // round's — strong evidence the previous step did nothing.
7048
7311
  const repeatableKinds = new Set(["click", "fill", "select", "check", "scroll"]);
7312
+ // An inventory-count change means the page genuinely mutated — a fresh
7313
+ // wizard step or a new page. Reset the per-stable-run click-selector
7314
+ // memory so distinct clicks on the NEW state aren't judged against the
7315
+ // old one.
7316
+ if (inventory.length !== prevInventorySize) {
7317
+ clickSelectorsSinceInventoryChange = new Set();
7318
+ }
7049
7319
  if (repeatableKinds.has(nextStep.kind)) {
7050
7320
  const sel = "selector" in nextStep ? (nextStep.selector ?? "<none>") : "<none>";
7051
7321
  const signature = `${nextStep.kind}|${sel}`;
@@ -7056,10 +7326,21 @@ ${formatInventory(input.inventory)}`,
7056
7326
  // (planner cycles through Create, Focus-input, Create again,
7057
7327
  // …). When that happens, force a non-click action.
7058
7328
  const sameSelector = signature === prevSignature && inventory.length === prevInventorySize;
7329
+ // A brand-new click selector (never clicked since the inventory last
7330
+ // changed) is wizard PROGRESS, not a cycle — selecting role, then
7331
+ // company-size, then a plan flips aria-checked without moving the
7332
+ // element count (axiom). Only treat repeated clicks as stuck: the
7333
+ // selector has already been clicked in this stable-inventory run.
7334
+ const clickSelectorIsRepeat = nextStep.kind === "click" &&
7335
+ clickSelectorsSinceInventoryChange.has(sel);
7059
7336
  const stuckOnKind = nextStep.kind === "click" &&
7060
7337
  prevSignature !== null &&
7061
7338
  prevSignature.startsWith("click|") &&
7062
- inventory.length === prevInventorySize;
7339
+ inventory.length === prevInventorySize &&
7340
+ clickSelectorIsRepeat;
7341
+ if (nextStep.kind === "click") {
7342
+ clickSelectorsSinceInventoryChange.add(sel);
7343
+ }
7063
7344
  if (sameSelector || stuckOnKind) {
7064
7345
  const emptyInputs = inventory
7065
7346
  .filter((e) => (e.tag === "input" || e.tag === "textarea") &&
@@ -7138,8 +7419,16 @@ ${formatInventory(input.inventory)}`,
7138
7419
  // Mistral's TOS, GitHub-app sign-up, many onboarding forms
7139
7420
  // gate submit on a checkbox that isn't yet ticked.
7140
7421
  const uncheckedBoxes = inventory
7141
- .filter((e) => e.tag === "input" &&
7142
- e.type === "checkbox" &&
7422
+ .filter((e) =>
7423
+ // Native <input type=checkbox> OR a custom ARIA checkbox
7424
+ // (<button role="checkbox">, <div role="checkbox">). The
7425
+ // input-only filter missed meilisearch's required agreement,
7426
+ // which renders as <button role="checkbox"> — so the planner
7427
+ // was never told to tick it and "Next" stayed disabled. The
7428
+ // check() executor already handles role=checkbox (it clicks +
7429
+ // verifies aria-checked).
7430
+ ((e.tag === "input" && e.type === "checkbox") ||
7431
+ e.role === "checkbox") &&
7143
7432
  // We can't read the actual `checked` from the inventory
7144
7433
  // shape, but interactedThisRun is set after a successful
7145
7434
  // `check` step. Show checkboxes the bot hasn't touched.
@@ -7323,6 +7612,34 @@ ${formatInventory(input.inventory)}`,
7323
7612
  // read see the post-challenge dashboard.
7324
7613
  continue;
7325
7614
  }
7615
+ // Premature-done guard. The planner sometimes concludes "nothing
7616
+ // to extract" on an authenticated dashboard whose API keys live on
7617
+ // a settings/API-keys page it never visited — render's case: an
7618
+ // empty SERVICES list ("no services created yet") is NOT the same
7619
+ // as "no API keys", which sit under Account Settings. Before
7620
+ // accepting `done` with zero credentials captured, navigate to an
7621
+ // unvisited canonical keys URL (same fallback list the stuck-loop
7622
+ // escalation uses). Bounded by triedFallbackUrls — once every
7623
+ // candidate is exhausted, `done` is honored.
7624
+ const capturedCredCount = Object.keys(credentials).filter((k) => !NON_CREDENTIAL_KEYS.has(k)).length;
7625
+ if (capturedCredCount === 0 && prematureDoneFallbacks < MAX_PREMATURE_DONE_FALLBACKS) {
7626
+ const fallback = pickStuckLoopFallbackUrl(state.url, triedFallbackUrls, args.service);
7627
+ if (fallback !== null) {
7628
+ prematureDoneFallbacks += 1;
7629
+ triedFallbackUrls.add(fallback);
7630
+ args.steps.push(`Post-verify: planner emitted done with no credential captured — ` +
7631
+ `navigating to an unvisited API-keys URL before giving up: ${fallback}`);
7632
+ try {
7633
+ await this.browser.goto(fallback);
7634
+ await this.browser.waitForInteractiveDom(5, 15_000);
7635
+ }
7636
+ catch (err) {
7637
+ args.steps.push(`Post-verify: premature-done fallback navigate failed (${err instanceof Error ? err.message : String(err)}) — continuing.`);
7638
+ }
7639
+ hint = undefined;
7640
+ continue;
7641
+ }
7642
+ }
7326
7643
  this.lastPostVerifyDoneReason = nextStep.reason;
7327
7644
  break;
7328
7645
  }
@@ -7452,6 +7769,18 @@ ${formatInventory(input.inventory)}`,
7452
7769
  // best-effort; never abort an extract pass on DOM-proximity
7453
7770
  // failure (page mid-navigation etc).
7454
7771
  }
7772
+ // "Stops at one" guard: does THIS page present >=2 distinct
7773
+ // credentials (masked included)? If so, hold the loop open past
7774
+ // the first key so the reveal pass + DOM harvest get more rounds
7775
+ // to capture the siblings — even when only one value is in hand
7776
+ // right now. Bounded downstream by roundsSinceLastNewCredential.
7777
+ if (!pageOffersMultiCred) {
7778
+ const presented = await this.countPresentedCredentialLabels();
7779
+ if (presented >= 2) {
7780
+ pageOffersMultiCred = true;
7781
+ args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: page presents ${presented} distinct credentials — holding the loop open to harvest all (not just the first).`);
7782
+ }
7783
+ }
7455
7784
  // Anything found across all tiers? hasMultiCredCredentials
7456
7785
  // also catches non-api_key labels (cloud_name, application_id).
7457
7786
  if (hasAnyExtractedCredential(credentials)) {