@trusty-squire/mcp 0.9.10 → 0.9.12

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
@@ -1121,6 +1121,28 @@ function stripHtmlToText(html) {
1121
1121
  .replace(/\s+/g, " ")
1122
1122
  .toLowerCase();
1123
1123
  }
1124
+ // True when a page is a 404 / route-not-found shell. Used to abort the
1125
+ // hardcoded keys-URL walk early: when guessed keys paths keep 404ing, this
1126
+ // host's routing convention simply isn't in our list, and continuing the
1127
+ // ~25-path walk (each a goto + up-to-15s DOM wait) just burns the run budget.
1128
+ // MEASURED 2026-06-09: axiom/fathom/loops each hit the FULL walk with every
1129
+ // path a 404 and blew the 600s deadline. Matches the three observed shells:
1130
+ // title "Not Found" (fathom), body "404 … page you're looking for doesn't
1131
+ // exist" (loops), body "404 page not found" (axiom).
1132
+ export function looksLike404(title, bodyText) {
1133
+ const hay = `${title} ${bodyText}`.toLowerCase().slice(0, 600);
1134
+ const has404Token = /\b404\b/.test(hay);
1135
+ const notFoundPhrase = /page not found|not found|could ?n[o'’]?t be found|could not be found|does ?n[o'’]?t exist|does not exist|page you[''’]?re looking for/.test(hay);
1136
+ // A bare "404" token, or a clear not-found phrase, is enough — guessed
1137
+ // keys URLs that hit either are dead ends.
1138
+ return has404Token || notFoundPhrase;
1139
+ }
1140
+ // Pull the <title> text out of a raw HTML string (lowercased work is left to
1141
+ // the caller). Empty string when absent. Cheap — no DOM round-trip.
1142
+ export function titleFromHtml(html) {
1143
+ const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html);
1144
+ return m?.[1]?.trim() ?? "";
1145
+ }
1124
1146
  // Classify a fetched page as a signup form, a login form, or neither.
1125
1147
  //
1126
1148
  // WHY this exists: looksLikeSignupPage() answers "does this page have a
@@ -1715,19 +1737,34 @@ export function detectFormFillIsDashboard(plan) {
1715
1737
  .join(" ")
1716
1738
  .toLowerCase();
1717
1739
  // Billing / payment wall — the planner sees a credit-card / billing
1718
- // form, which is never a signup form.
1740
+ // form, which is never a signup form. (Checked first: a "free trial"
1741
+ // page that ALSO demands a card is a wall, not a path to signup.)
1719
1742
  const BILLING_WALL = /\b(?:add (?:a )?(?:credit card|payment method)|enter (?:your )?(?:credit card|payment)|billing (?:information|details)|payment information required)\b/;
1720
1743
  if (BILLING_WALL.test(haystack))
1721
1744
  return true;
1745
+ // Negative guard — a LOGIN / SIGN-IN page (pre-auth), or a plan that
1746
+ // proposes navigating TO a signup/trial form, is the OPPOSITE of a
1747
+ // logged-in dashboard. MEASURED 2026-06-09: fathom's planner correctly
1748
+ // said "this is a login page, NOT a signup page; click 'Start a free
1749
+ // trial' to reach the actual signup form" — pivoting to key extraction
1750
+ // there is wrong (we're not authenticated; the key page 404s). The
1751
+ // bot must EXECUTE the planner's click toward signup. Runs before the
1752
+ // ambiguous "not a signup" check below so it can't be swallowed.
1753
+ const REACH_SIGNUP = /\blog[\s-]?in page|sign[\s-]?in page|free trial|start a (?:free )?trial|create an? account|navigate to the (?:actual )?sign-?up/;
1754
+ if (REACH_SIGNUP.test(haystack))
1755
+ return false;
1722
1756
  // Product-creation form — the planner describes creating a
1723
1757
  // database / cluster / instance / deployment / app / project /
1724
1758
  // workspace / service, which is post-signup territory.
1725
1759
  const PRODUCT_CREATION = /\b(?:create(?:s|d)?|creating|provision(?:s|ed|ing)?)\s+(?:(?:the|a|an|new|your|this)\s+){0,3}(?:database|cluster|instance|deployment|app|service|project|workspace|index|environment|tenant)\b/;
1726
1760
  if (PRODUCT_CREATION.test(haystack))
1727
1761
  return true;
1728
- // Explicit "not a signup" / "logged in" / "dashboard" statements
1729
- // from the planner.
1730
- const EXPLICIT = /\b(?:not\s+(?:a\s+)?(?:sign-?up|signup)|already\s+(?:signed[\s-]?in|logged[\s-]?in|authenticated)|logged[\s-]?in (?:dashboard|user))\b/;
1762
+ // Explicit "logged in" / "dashboard" statements from the planner. NB:
1763
+ // bare "not a signup" is deliberately NOT here — it's ambiguous (a
1764
+ // LOGIN page is also "not a signup") and false-pivoted fathom into key
1765
+ // extraction. A genuine dashboard says "already signed in" / "logged-in
1766
+ // dashboard", which the REACH_SIGNUP guard above doesn't touch.
1767
+ const EXPLICIT = /\b(?:already\s+(?:signed[\s-]?in|logged[\s-]?in|authenticated)|logged[\s-]?in (?:dashboard|user))\b/;
1731
1768
  if (EXPLICIT.test(haystack))
1732
1769
  return true;
1733
1770
  return false;
@@ -2043,27 +2080,45 @@ export function detectSsoRestriction(pageText) {
2043
2080
  // post-login dashboard (which never carries these phrases) must NOT
2044
2081
  // match, or we'd wrongly abandon a working OAuth session.
2045
2082
  export function detectGoogleNoAccount(url, bodyText) {
2046
- // Inspect the decoded query string (where plunk parks its message)
2047
- // plus the page body both lowercased for case-insensitive matching.
2083
+ // Inspect the decoded query string (where plunk parks its message) and
2084
+ // the URL path (to decide whether a BODY match is trustworthy).
2048
2085
  let query = "";
2086
+ let path = "";
2087
+ let urlParsed = false;
2049
2088
  try {
2050
2089
  const u = new URL(url);
2051
2090
  query = decodeURIComponent(u.search).toLowerCase();
2091
+ path = u.pathname.toLowerCase();
2092
+ urlParsed = true;
2052
2093
  }
2053
2094
  catch {
2054
2095
  query = "";
2096
+ path = "";
2055
2097
  }
2056
- const haystack = `${query}\n${bodyText.toLowerCase()}`;
2057
2098
  // MEASURED 2026-06-04 (clerk): after Google OAuth, clerk bounces to its
2058
2099
  // sign-in showing "The External Account was not found" — Google signed
2059
2100
  // in but no clerk account exists for this identity (same class as plunk's
2060
- // "No account found"). The added "…not found" / "couldn't find an
2061
- // account" / "no such account" variants below catch clerk's wording.
2062
- // Every phrase still requires the word "account" (or "external account"),
2063
- // so a bare 404 "Page not found" does NOT trip this and abandon a working
2064
- // OAuth session.
2101
+ // "No account found").
2065
2102
  const noAccountPhrase = /no account found|external account was not found|account (?:was )?not found|no (?:such )?account (?:found|exists)|account (?:doesn['’]?t|does not) exist|couldn['’]?t find (?:an|your) account|no account associated|sign up (?:first|to continue)|create an account|[?&]google-auth-error|register first/;
2066
- return noAccountPhrase.test(haystack);
2103
+ // The QUERY string is an unambiguous auth-error redirect param (plunk parks
2104
+ // its message there; some services use ?google-auth-error) — trust it
2105
+ // anywhere.
2106
+ if (noAccountPhrase.test(query))
2107
+ return true;
2108
+ // The BODY phrases ("create an account", "register first", "sign up to
2109
+ // continue", …) ALSO appear as generic CTAs/links on a LOGGED-IN dashboard.
2110
+ // MEASURED 2026-06-09 (together): after a successful Google sign-in the bot
2111
+ // landed on the app root "/", whose body carried such a CTA — and the old
2112
+ // body match abandoned the working session and dead-ended at oauth_required.
2113
+ // A genuine "no account for this identity" message only renders on a
2114
+ // login/sign-in/auth ROUTE, so only honor a body match there. On a dashboard
2115
+ // or app route we keep the session and let post-OAuth navigation extract.
2116
+ // If the URL couldn't be parsed at all, we have no route context to gate on
2117
+ // — fall back to trusting the body (rare, and the phrases are strong).
2118
+ const onAuthRoute = /(?:^|\/)(?:login|signin|sign-in|sign-up|signup|auth|authenticate|sso)(?:\/|$)/.test(path);
2119
+ if (urlParsed && !onAuthRoute)
2120
+ return false;
2121
+ return noAccountPhrase.test(bodyText.toLowerCase());
2067
2122
  }
2068
2123
  // (d) Stuck-on-Google-OAuth-screens (Upstash class). After
2069
2124
  // settleAfterOAuth the URL is STILL on accounts.google.com — the
@@ -3008,6 +3063,35 @@ function isLLMPair(x) {
3008
3063
  // Navigates / waits / extracts are excluded — they legitimately don't
3009
3064
  // change the current DOM (navigate changes URL, wait pauses). Pure +
3010
3065
  // exported for unit tests.
3066
+ // Pick the onboarding field to overwrite with a unique value when a "name
3067
+ // taken" collision stalls the wizard. Prefer a business/org/workspace NAME
3068
+ // field (which commonly drives a derived subdomain — editing the domain
3069
+ // directly gets re-derived away), falling back to a bare subdomain/slug field.
3070
+ // Returns null when no such field is present (then the stall is a genuine
3071
+ // click-not-registering case, not a name collision). Exported for tests.
3072
+ export function pickUniqueNameField(inventory) {
3073
+ const textInputs = inventory.filter((e) => e.tag === "input" && (e.type === null || e.type === "text"));
3074
+ const hay = (e) => `${e.name ?? ""} ${e.id ?? ""} ${e.placeholder ?? ""} ${e.labelText ?? ""} ${e.ariaLabel ?? ""}`.toLowerCase();
3075
+ const NAME_RE = /business[_ -]?name|company[_ -]?name|organi[sz]ation|\borg\b|workspace[_ -]?name|team[_ -]?name|account[_ -]?name|site[_ -]?name|project[_ -]?name|tenant/;
3076
+ const byName = textInputs.find((e) => NAME_RE.test(hay(e)));
3077
+ if (byName !== undefined)
3078
+ return byName;
3079
+ const DOMAIN_RE = /subdomain|\bdomain\b|\bslug\b|workspace[_ ]?url|handle/;
3080
+ const byDomain = textInputs.find((e) => DOMAIN_RE.test(hay(e)));
3081
+ return byDomain ?? null;
3082
+ }
3083
+ // Pick the submit/advance control for an onboarding form. Matches a
3084
+ // type=submit button or the conventional advance verbs. Returns null when no
3085
+ // obvious submit affordance is present.
3086
+ export function pickOnboardingSubmit(inventory) {
3087
+ const buttons = inventory.filter((e) => e.tag === "button" || e.role === "button");
3088
+ const ADVANCE_RE = /^(?:next|continue|submit|create|register|get started|finish|done|save)\b/i;
3089
+ const byText = buttons.find((e) => ADVANCE_RE.test((e.visibleText ?? e.ariaLabel ?? "").trim()));
3090
+ if (byText !== undefined)
3091
+ return byText;
3092
+ const bySubmit = buttons.find((e) => e.type === "submit");
3093
+ return bySubmit ?? buttons[0] ?? null;
3094
+ }
3011
3095
  export function isStalledOnActions(effects, threshold = 3) {
3012
3096
  if (effects.length < threshold)
3013
3097
  return false;
@@ -4105,6 +4189,7 @@ export class SignupAgent {
4105
4189
  const page = this.browser.page;
4106
4190
  if (page === null || page === undefined)
4107
4191
  return false;
4192
+ const urlBefore = page.url();
4108
4193
  // First-choice selector: data-identifier on an interactive element.
4109
4194
  const candidates = [
4110
4195
  '[data-identifier]:visible',
@@ -4116,12 +4201,27 @@ export class SignupAgent {
4116
4201
  try {
4117
4202
  await loc.waitFor({ state: "visible", timeout: 2_000 });
4118
4203
  await loc.click({ timeout: 3_000 });
4204
+ if (process.env.BOT_DEBUG_CHOOSER) {
4205
+ await page.waitForTimeout(1500).catch(() => undefined);
4206
+ console.error(`[chooser-debug] matched sel=${JSON.stringify(sel)} urlBefore=${urlBefore} urlAfter=${page.url()}`);
4207
+ }
4119
4208
  return true;
4120
4209
  }
4121
4210
  catch {
4122
4211
  continue;
4123
4212
  }
4124
4213
  }
4214
+ if (process.env.BOT_DEBUG_CHOOSER) {
4215
+ const dump = await page
4216
+ .evaluate(() => ({
4217
+ url: location.href,
4218
+ buttons: Array.from(document.querySelectorAll('[data-identifier],[role="link"],div[jsaction]'))
4219
+ .slice(0, 12)
4220
+ .map((e) => `${e.tagName}|${(e.getAttribute("data-identifier") ?? "").slice(0, 40)}|${(e.textContent ?? "").trim().slice(0, 50)}`),
4221
+ }))
4222
+ .catch(() => null);
4223
+ console.error(`[chooser-debug] NO selector matched. dump=${JSON.stringify(dump)}`);
4224
+ }
4125
4225
  return false;
4126
4226
  }
4127
4227
  catch {
@@ -4525,9 +4625,18 @@ export class SignupAgent {
4525
4625
  }
4526
4626
  steps.push(`Navigating to ${signupUrl}`);
4527
4627
  await this.browser.goto(signupUrl);
4528
- // PERF: goto() awaits domcontentloaded; the subsequent
4529
- // waitForFormReady in planExecuteWithRetry handles SPA settle.
4530
- // No need for a blind 2s dwell here.
4628
+ // Clear any anti-bot interstitial BEFORE the landing read below.
4629
+ // goto() only awaits domcontentloaded, so a Cloudflare "Verifying you
4630
+ // are human" / "Just a moment…" page is often still up — and the
4631
+ // already-signed-in / classify / OAuth-affordance decisions a few lines
4632
+ // down would then run against the interstitial's 2-element DOM and
4633
+ // misjudge the page. MEASURED 2026-06-09: perplexity's /settings/api
4634
+ // landed on a CF interstitial (inventory = just [Cloudflare, Privacy]),
4635
+ // so the bot saw no OAuth button, classified "other", and bailed
4636
+ // no_signup_link instead of reaching the real login/dashboard. The
4637
+ // later waitForFormReady in planExecuteWithRetry was too late. Best-
4638
+ // effort: a page with no interstitial returns fast.
4639
+ await this.browser.waitForFormReady().catch(() => undefined);
4531
4640
  // After load: does the rendered page look like a signup form?
4532
4641
  // looksLikeSignupPage() can't tell signup from login (both have
4533
4642
  // email+password), so we ALSO classify the rendered HTML's copy via
@@ -4583,8 +4692,31 @@ export class SignupAgent {
4583
4692
  else {
4584
4693
  const klass = classifySignupHtml(landed.html);
4585
4694
  if (klass !== "signup" && !(await this.looksLikeSignupPage())) {
4586
- needsRecovery = true;
4587
- steps.push(`curated signup_url for ${task.service} rendered as "${klass}", not a signup form — attempting recovery`);
4695
+ // A "login"-classified page is still a valid signup ENTRY when it
4696
+ // offers OAuth: clicking "Continue with GitHub/Google" auto-
4697
+ // provisions the account on first use, and plenty of modern SPAs
4698
+ // (qdrant: cloud.qdrant.io/login) ship ONE unified page for both
4699
+ // login and signup. Only chase a separate signup page when there's
4700
+ // no OAuth affordance to ride — otherwise recovery falls through to
4701
+ // the Google-search fallback, which Google ERR_ABORTs through the
4702
+ // residential proxy, and the run dies on a page that would have
4703
+ // worked via OAuth.
4704
+ const oauthHere = findFirstOAuthButton(landedInventory, ["google", "github"]);
4705
+ if (process.env.BOT_DEBUG_CHOOSER) {
4706
+ console.error(`[login-entry-debug] ${task.service} klass=${klass} oauthHere=${oauthHere?.provider ?? "none"} ` +
4707
+ `inv(${landedInventory.length})=` +
4708
+ JSON.stringify(landedInventory
4709
+ .slice(0, 20)
4710
+ .map((e) => `${e.tag}|${(e.visibleText ?? e.ariaLabel ?? "").slice(0, 36)}`)));
4711
+ }
4712
+ if (oauthHere !== null) {
4713
+ steps.push(`curated signup_url for ${task.service} rendered as "${klass}", but it offers ` +
4714
+ `${oauthHere.provider} OAuth — treating the login page as the signup entry`);
4715
+ }
4716
+ else {
4717
+ needsRecovery = true;
4718
+ steps.push(`curated signup_url for ${task.service} rendered as "${klass}", not a signup form — attempting recovery`);
4719
+ }
4588
4720
  }
4589
4721
  }
4590
4722
  if (needsRecovery) {
@@ -5024,6 +5156,26 @@ export class SignupAgent {
5024
5156
  // dashboard. Previous 3s was over-cautious.
5025
5157
  await this.browser.wait(1);
5026
5158
  await saveDebugSnapshot(this.browser, "after-verify");
5159
+ // Verify-link SPA bounce (MEASURED 2026-06-09: amplitude). The
5160
+ // emailed link is a click-tracker that redirects to
5161
+ // app.amplitude.com/signup?token=… — the token IS consumed
5162
+ // server-side, but the single-page app still renders the
5163
+ // "check your email" wall until the client re-fetches session
5164
+ // state. The post-verify loop then can't get past it. A single
5165
+ // reload makes the SPA re-read the now-verified session.
5166
+ // Bounded + guarded on the wall still showing, so a service
5167
+ // that verified cleanly pays nothing.
5168
+ try {
5169
+ const afterText = await this.browser.extractText();
5170
+ if (expectsVerificationEmail(afterText)) {
5171
+ steps.push("Verification link landed but the page still shows the email-verify wall — reloading so the SPA re-reads the verified session.");
5172
+ await this.browser.reload();
5173
+ await this.browser.wait(2);
5174
+ }
5175
+ }
5176
+ catch {
5177
+ // best-effort — fall through to extraction regardless
5178
+ }
5027
5179
  // Try extracting first — many services drop the API key
5028
5180
  // straight onto the landing page after verification.
5029
5181
  credentials = await this.extractCredentials();
@@ -5184,6 +5336,39 @@ export class SignupAgent {
5184
5336
  }
5185
5337
  }
5186
5338
  }
5339
+ // hCaptcha Tier-3 — the OAuth-first path historically only escalated
5340
+ // reCAPTCHA, so an hCaptcha-gated OAuth button (MEASURED 2026-06-09:
5341
+ // supabase) timed out at Tier-2 and the bot then clicked a button
5342
+ // still hidden behind the unsolved widget → 20s waitFor crash. The
5343
+ // form-fill gate (runCaptchaGate) already solves hCaptcha via 2Captcha;
5344
+ // mirror it here. Unlike Turnstile, hCaptcha is a real image challenge
5345
+ // 2Captcha can solve, and its token is NOT IP-scored.
5346
+ if (!solvedViaTier3 &&
5347
+ captcha.kind === "hcaptcha" &&
5348
+ this.captchaSolver?.isAvailable() === true) {
5349
+ const sitekey = await this.browser.extractHcaptchaSitekey();
5350
+ const pageUrl = (await this.browser.getState().catch(() => null))?.url;
5351
+ if (sitekey !== null && pageUrl !== undefined) {
5352
+ steps.push(`OAuth: Tier 3 — submitting hCaptcha sitekey to 2Captcha (${sitekey.slice(0, 10)}…)`);
5353
+ const solveRes = await this.captchaSolver.solveHcaptcha({ sitekey, pageUrl });
5354
+ if (solveRes.kind === "ok") {
5355
+ const injected = await this.browser.injectHcaptchaToken(solveRes.token);
5356
+ if (injected) {
5357
+ solvedViaTier3 = true;
5358
+ steps.push(`OAuth: Tier 3 solved the hCaptcha in ${Math.round(solveRes.durationMs / 1000)}s via 2Captcha — clicking the ${provider.label} affordance`);
5359
+ }
5360
+ else {
5361
+ steps.push(`OAuth: Tier 3 hCaptcha token arrived but page injection failed — clicking the ${provider.label} affordance anyway`);
5362
+ }
5363
+ }
5364
+ else {
5365
+ steps.push(`OAuth: Tier 3 hCaptcha ${solveRes.kind}` +
5366
+ ("reason" in solveRes ? `: ${solveRes.reason}` : "") +
5367
+ ("durationMs" in solveRes ? ` (${Math.round(solveRes.durationMs / 1000)}s)` : "") +
5368
+ ` — clicking the ${provider.label} affordance anyway`);
5369
+ }
5370
+ }
5371
+ }
5187
5372
  if (!solvedViaTier3) {
5188
5373
  steps.push(`OAuth: visible ${captcha.kind} present but did not solve in 20s — clicking the ${provider.label} affordance anyway`);
5189
5374
  }
@@ -5214,6 +5399,25 @@ export class SignupAgent {
5214
5399
  (gsiHandled
5215
5400
  ? ""
5216
5401
  : " (FedCM dialog/popup did not complete — looking for another provider)"));
5402
+ // Classic-redirect-misrouted-as-GSI recovery (MEASURED 2026-06-08:
5403
+ // netlify). hasGoogleGsiAffordance() returns true whenever the GSI
5404
+ // client *script* is loaded — but many pages load that script next to a
5405
+ // CLASSIC redirect "Sign up with Google" button (netlify renders
5406
+ // button[name="google"]). Routing that button through tryGoogleGsiLogin
5407
+ // clicks it, which fires a normal same-tab redirect to
5408
+ // accounts.google.com; GSI then reports via:"none" (no FedCM dialog, no
5409
+ // popup) even though the OAuth redirect IS now in flight. Without this
5410
+ // check the code below would (a) scan the GOOGLE page for a github
5411
+ // fallback (finds none) and then (b) re-run startOAuth on
5412
+ // button[name="google"] — a button that no longer exists on
5413
+ // accounts.google.com → 20s waitFor timeout → the whole signup dies.
5414
+ // If the page has actually navigated onto a Google auth host, the
5415
+ // redirect already fired: treat it as handled and let the consent loop
5416
+ // below drive it, exactly as it would for any classic redirect OAuth.
5417
+ if (!gsiHandled && detectStuckOnGoogleOAuth(this.browser.currentUrl())) {
5418
+ gsiHandled = true;
5419
+ steps.push("OAuth: the GSI dialog never fired, but the click started a classic Google redirect — continuing the consent flow.");
5420
+ }
5217
5421
  }
5218
5422
  // GSI/FedCM dead end. Google won't render the FedCM dialog for an
5219
5423
  // automated browser (measured: FedCm.enable ok, dialogShown never fires),
@@ -5222,17 +5426,37 @@ export class SignupAgent {
5222
5426
  // caller a TRY_NEXT_PROVIDER signal so it re-dispatches via that provider
5223
5427
  // instead of dead-ending on Google. meilisearch offers both.
5224
5428
  if (gsiAttempted && !gsiHandled) {
5225
- try {
5226
- const inv = await this.browser.extractInteractiveElements();
5227
- const alt = findFirstOAuthButton(inv, ["github"]);
5228
- if (alt !== null) {
5229
- steps.push(`OAuth: Google GSI/FedCM is a dead end for the bot — falling back to ${alt.provider}.`);
5230
- return { kind: "try_next_provider", selector: alt.button.selector, provider: alt.provider };
5429
+ // The GSI click commonly leaves the page mid-SPA-transition (netlify:
5430
+ // the button[name="google"] goes invisible, the page hasn't navigated
5431
+ // anywhere useful), so a single inventory read here can THROW or return
5432
+ // empty and the old code then fell through to startOAuth(google),
5433
+ // which re-clicked the now-hidden button and hung 20s before dying.
5434
+ // Settle + retry the read a few times so the redirect-provider fallback
5435
+ // (github) actually fires — github is the redirect provider whose
5436
+ // session we keep warm.
5437
+ let alt = null;
5438
+ for (let attempt = 0; attempt < 3 && alt === null; attempt++) {
5439
+ if (attempt > 0)
5440
+ await this.browser.wait(2);
5441
+ try {
5442
+ const inv = await this.browser.extractInteractiveElements();
5443
+ alt = findFirstOAuthButton(inv, ["github"]);
5444
+ }
5445
+ catch {
5446
+ // page still navigating — retry after the settle
5231
5447
  }
5232
5448
  }
5233
- catch {
5234
- // inventory read failed fall through to the normal redirect path
5449
+ if (alt !== null) {
5450
+ steps.push(`OAuth: Google GSI/FedCM is a dead end for the bot falling back to ${alt.provider}.`);
5451
+ return { kind: "try_next_provider", selector: alt.button.selector, provider: alt.provider };
5235
5452
  }
5453
+ // No redirect provider to hand off to. Re-clicking the Google affordance
5454
+ // (startOAuth below) is pointless — it only re-raises the same dead GSI
5455
+ // dialog, and on netlify-class pages crashes on the hidden button. Skip
5456
+ // it: fall into the consent loop on the current page, which aborts
5457
+ // cleanly (needs_login) instead of a misleading 20s waitFor timeout.
5458
+ gsiHandled = true;
5459
+ steps.push("OAuth: Google GSI/FedCM dead-ended with no redirect provider to fall back to.");
5236
5460
  }
5237
5461
  // OmniAuth POST-only recovery prep. Capture the affordance's href + the
5238
5462
  // page's CSRF token NOW, while we're still on the signin page — the
@@ -5468,7 +5692,7 @@ export class SignupAgent {
5468
5692
  if (provider.id === "github" &&
5469
5693
  task.machineToken !== undefined &&
5470
5694
  task.machineToken.length > 0) {
5471
- steps.push("GitHub: verify-it's-you challenge — polling operator gmail for a device-confirmation link (up to 60s)");
5695
+ steps.push("GitHub: verify-it's-you challenge — polling operator inbox for a device-confirmation link (up to 60s)");
5472
5696
  try {
5473
5697
  const { readGitHubChallengeLink } = await import("./read-otp.js");
5474
5698
  const linkResult = await readGitHubChallengeLink({
@@ -5903,7 +6127,7 @@ export class SignupAgent {
5903
6127
  otpResult = { code: null, reason: "no_machine_token" };
5904
6128
  }
5905
6129
  else {
5906
- steps.push(`Email-OTP gate detected (${pathOf(gateState.url)}) — polling operator gmail for the code` +
6130
+ steps.push(`Email-OTP gate detected (${pathOf(gateState.url)}) — polling operator inbox for the code` +
5907
6131
  (domain !== null ? ` (from_domain=${domain})` : ""));
5908
6132
  otpResult = await readOperatorOtp({
5909
6133
  machineToken,
@@ -6726,6 +6950,8 @@ ${formatInventory(input.inventory)}`,
6726
6950
  // ran (current page WAS a keys page but had no affordance), a
6727
6951
  // different keys URL on the same origin may carry the create
6728
6952
  // control (org-scoped vs account-scoped keys pages).
6953
+ let consecutive404 = 0;
6954
+ const MAX_CONSECUTIVE_404 = 3;
6729
6955
  for (let i = 0; i < STUCK_LOOP_FALLBACK_PATHS.length; i++) {
6730
6956
  let currentUrl;
6731
6957
  try {
@@ -6745,6 +6971,28 @@ ${formatInventory(input.inventory)}`,
6745
6971
  catch {
6746
6972
  continue;
6747
6973
  }
6974
+ // Abort the walk once guessed keys paths keep 404ing — this host's
6975
+ // convention isn't in our list, so the remaining ~20 paths would all
6976
+ // 404 too and burn the run's 600s budget (axiom/fathom/loops). A real
6977
+ // (non-404) page resets the counter so a host that mixes 404s with a
6978
+ // live keys page is still fully walked.
6979
+ try {
6980
+ const after = await this.browser.getState();
6981
+ const bodyText = await this.browser.extractText().catch(() => "");
6982
+ if (looksLike404(titleFromHtml(after.html), bodyText)) {
6983
+ consecutive404 += 1;
6984
+ if (consecutive404 >= MAX_CONSECUTIVE_404) {
6985
+ steps.push(`Existing-account recovery: ${consecutive404} consecutive 404s on guessed keys URLs — ` +
6986
+ `this host's keys path isn't in our list; aborting the walk.`);
6987
+ break;
6988
+ }
6989
+ continue;
6990
+ }
6991
+ consecutive404 = 0;
6992
+ }
6993
+ catch {
6994
+ // best-effort — fall through to the extract attempt
6995
+ }
6748
6996
  const here = await tryHere();
6749
6997
  if (here !== null)
6750
6998
  return here;
@@ -6877,6 +7125,11 @@ ${formatInventory(input.inventory)}`,
6877
7125
  let prevContentSig = null;
6878
7126
  let lastActionKind = null;
6879
7127
  let lastActionSelector = null;
7128
+ // Fired once: the unique-org-name recovery on a stall whose real cause is
7129
+ // a "name taken" validation (kinde's business_details — the operator's
7130
+ // prior account already used "tsagent", so the pre-filled name collides
7131
+ // and the auto-derived subdomain is rejected, re-presenting the step).
7132
+ let uniqueNameRetried = false;
6880
7133
  const actionEffects = [];
6881
7134
  // 0.8.2-rc.10 — escalation for the stuck-loop detector.
6882
7135
  //
@@ -6905,6 +7158,17 @@ ${formatInventory(input.inventory)}`,
6905
7158
  // walking every fallback path.
6906
7159
  let prematureDoneFallbacks = 0;
6907
7160
  const MAX_PREMATURE_DONE_FALLBACKS = 3;
7161
+ // Navigate budget. A planner that can't find the key page (project-
7162
+ // scoped URLs it can't construct — supabase; or an SPA that ignores
7163
+ // direct navs and stays put — last9) keeps emitting `navigate` round
7164
+ // after round, burning the entire 600s deadline (MEASURED 2026-06-09).
7165
+ // `navigate` is exempt from the stuck-loop detector (it's meant to
7166
+ // change the URL), so cap the TOTAL navigates: a legit dashboard is
7167
+ // reachable in a handful, and past the cap the planner is just guessing.
7168
+ // Past the cap we force non-navigate planning and, if still nothing,
7169
+ // break — converting a 600s hang into a prompt, honest failure.
7170
+ let navigateCount = 0;
7171
+ const MAX_POST_VERIFY_NAVIGATES = 8;
6908
7172
  // Dead-URL memory. The planner guesses credential-page URLs
6909
7173
  // (e.g. /user/personal_access_tokens/new) that 404; without memory it
6910
7174
  // re-guesses the same dead URL round after round — xata and fly each
@@ -7151,6 +7415,51 @@ ${formatInventory(input.inventory)}`,
7151
7415
  }
7152
7416
  prevContentSig = contentSig;
7153
7417
  if (isStalledOnActions(actionEffects)) {
7418
+ // Unique-name-collision recovery (fires ONCE, before giving up). The
7419
+ // stall is often NOT "clicks not registering" but a server-side
7420
+ // validation: an org/business/workspace NAME the operator's prior
7421
+ // account already took, which on many forms drives a derived subdomain
7422
+ // — so editing the domain directly doesn't stick; the NAME field must
7423
+ // change. MEASURED 2026-06-09 (kinde): the pre-filled "tsagent" was
7424
+ // taken, the domain went aria-invalid, and Next re-presented the step.
7425
+ // Detect a taken/unavailable signal, overwrite the name field with a
7426
+ // unique value (which re-derives a unique subdomain), resubmit, re-loop.
7427
+ const bodyLow = (await this.browser.extractText().catch(() => "")).toLowerCase();
7428
+ // NB: leading \b only, NO trailing \b — extractText() glues adjacent
7429
+ // elements without whitespace ("…already taken" + "region" →
7430
+ // "takenregion"), so a trailing boundary made this miss (MEASURED:
7431
+ // kinde, takenSignal=false while the body clearly contained "taken").
7432
+ // The leading \b still rejects mid-word hits like "mistaken"/"overtaken".
7433
+ const takenSignal = /\b(?:taken|already (?:in use|exists|registered|taken)|not available|unavailable|already exists|choose another|try another)/.test(bodyLow);
7434
+ const nameField = takenSignal ? pickUniqueNameField(inventory) : null;
7435
+ if (process.env.BOT_DEBUG_CHOOSER) {
7436
+ console.error(`[uniquename-debug] takenSignal=${takenSignal} nameField=${nameField?.name ?? nameField?.id ?? "null"} ` +
7437
+ `textInputs=${JSON.stringify(inventory
7438
+ .filter((e) => e.tag === "input")
7439
+ .map((e) => `${e.type}|${e.name ?? e.id ?? "?"}`))} bodyHas_taken=${bodyLow.includes("taken")}`);
7440
+ }
7441
+ if (!uniqueNameRetried && nameField !== null) {
7442
+ uniqueNameRetried = true;
7443
+ const unique = `tsq${Math.floor(100000 + Math.random() * 900000)}`;
7444
+ args.steps.push(`Post-verify: stall is a name-taken collision — overwriting ${JSON.stringify(nameField.name ?? nameField.id ?? "name field")} with a unique value and resubmitting.`);
7445
+ try {
7446
+ await this.browser.type(nameField.selector, unique);
7447
+ await this.browser.wait(1);
7448
+ const submit = pickOnboardingSubmit(inventory);
7449
+ if (submit !== null) {
7450
+ await this.browser.click(submit.selector);
7451
+ await this.browser.wait(2);
7452
+ }
7453
+ // Reset the stall window so the resubmit gets a fair shot.
7454
+ actionEffects.length = 0;
7455
+ prevContentSig = null;
7456
+ hint = undefined;
7457
+ continue;
7458
+ }
7459
+ catch (err) {
7460
+ args.steps.push(`Post-verify: unique-name retry failed (${err instanceof Error ? err.message : String(err)}) — falling through.`);
7461
+ }
7462
+ }
7154
7463
  // Capture the exact page that defeated the wizard so the N1
7155
7464
  // onboarding-wizard class is debuggable post-hoc (post-verify pages
7156
7465
  // weren't being snapshotted — imagekit's step-1/3 role-select stall
@@ -7201,7 +7510,7 @@ ${formatInventory(input.inventory)}`,
7201
7510
  detectEmailOtpGate(state.url, state.title, await this.browser.extractText().catch(() => ""))) {
7202
7511
  otpPolledUrls.add(state.url);
7203
7512
  const domain = fromDomainFromUrl(state.url);
7204
- args.steps.push(`Post-verify round ${round}: post-OAuth email-OTP gate (${pathOf(state.url)}) — polling operator gmail for the code` +
7513
+ args.steps.push(`Post-verify round ${round}: post-OAuth email-OTP gate (${pathOf(state.url)}) — polling operator inbox for the code` +
7205
7514
  (domain !== null ? ` (from_domain=${domain})` : ""));
7206
7515
  const otp = await readOperatorOtp({
7207
7516
  machineToken: args.machineToken,
@@ -7402,6 +7711,28 @@ ${formatInventory(input.inventory)}`,
7402
7711
  continue;
7403
7712
  }
7404
7713
  if (nextStep.kind === "navigate") {
7714
+ navigateCount += 1;
7715
+ if (navigateCount > MAX_POST_VERIFY_NAVIGATES) {
7716
+ // Over budget: the planner is URL-guessing (supabase project-scoped
7717
+ // keys it can't address; last9's SPA that ignores direct navs). One
7718
+ // more shot CLICKING the current inventory, then give up — don't
7719
+ // burn the rest of the 600s deadline on more dead navigates.
7720
+ const clickable = inventory.filter((e) => e.tag === "button" || e.tag === "a");
7721
+ if (clickable.length > 0 && navigateCount <= MAX_POST_VERIFY_NAVIGATES + 2) {
7722
+ args.steps.push(`Post-verify: navigate budget (${MAX_POST_VERIFY_NAVIGATES}) exhausted — forcing a click on the current page instead of guessing more URLs.`);
7723
+ hint =
7724
+ `You have navigated ${navigateCount} times without reaching an API-key page. ` +
7725
+ `STOP navigating to guessed URLs. CLICK an element from the inventory below ` +
7726
+ `to advance the onboarding/dashboard, or emit 'done' if there is genuinely no ` +
7727
+ `key affordance here.`;
7728
+ continue;
7729
+ }
7730
+ this.lastPostVerifyDoneReason =
7731
+ `[stuck_loop] post-verify exhausted the navigate budget (${navigateCount} navigates) without ` +
7732
+ `reaching a credential page — the key is behind onboarding/URL the planner can't address.`;
7733
+ args.steps.push(`Post-verify: navigate budget exhausted (${navigateCount}) with no credential — breaking out instead of burning the run deadline.`);
7734
+ break;
7735
+ }
7405
7736
  prevNavigateFromUrl = state.url;
7406
7737
  // Remember where we're going so the next round can blocklist it
7407
7738
  // if it 404s.