@trusty-squire/mcp 0.8.15 → 0.8.16

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
@@ -55,10 +55,48 @@ const VERIFICATION_EXPECTED_PATTERNS = [
55
55
  "almost there",
56
56
  "one more step",
57
57
  ];
58
- // Short probe when the post-submit page never prompted the user to check
59
- // their email. Legitimate verification mail almost always lands inside a
58
+ // Short probe when, even after a settle, the post-submit page still never
59
+ // prompted the user to check their email AND no account-created signal
60
+ // appeared. Legitimate verification mail almost always lands inside a
60
61
  // minute; this catches the fast case without 300s of dead air.
61
62
  const VERIFICATION_PROBE_SECONDS = 45;
63
+ // Settle window before the SECOND post-submit page read. SPA signups
64
+ // (Postmark, ElevenLabs, Browserbase, Grafana Cloud, …) swap in their
65
+ // "check your email" confirmation screen a beat AFTER submit. Reading the
66
+ // DOM the instant extraction fails races that render and mislabels the
67
+ // run as "no email expected", collapsing the poll to the 45s probe and
68
+ // abandoning mail that was, in fact, on its way.
69
+ const SUBMIT_SETTLE_SECONDS = 3;
70
+ // Poll floor once the form CLEANLY SUBMITTED but the page text stayed
71
+ // inconclusive about an email prompt (no "check your email", but also no
72
+ // hard error/rejection). A clean submit means an account was created, so
73
+ // real verification mail is plausibly inbound; transactional senders on a
74
+ // fresh send (Postmark, SendGrid) routinely take longer than the 45s
75
+ // probe. Polling 120s here — rather than bailing at 45s — is the
76
+ // difference between catching that mail and a false `verification_not_sent`.
77
+ // Still bounded so a genuinely-silent service doesn't hold the run for the
78
+ // full 180s expected-email timeout.
79
+ const SUBMITTED_PROBE_FLOOR_SECONDS = 120;
80
+ // Post-submit page text that means the submit was REJECTED, not accepted —
81
+ // no account was created, so no verification mail is coming and even the
82
+ // 45s probe is wasted. Lets the bot bail immediately instead of polling.
83
+ // Kept conservative: only unambiguous rejection phrasings.
84
+ const SUBMIT_REJECTED_PATTERNS = [
85
+ /\balready\s+(?:registered|exists|in\s+use|taken|have\s+an\s+account)\b/i,
86
+ /\b(?:email|account|username)\s+(?:is\s+)?already\s+(?:registered|taken|in\s+use)\b/i,
87
+ /\bthat\s+email\s+is\s+already\b/i,
88
+ /\ban?\s+account\s+(?:with\s+(?:this|that)\s+email\s+)?already\s+exists\b/i,
89
+ /\bplease\s+(?:try\s+again|correct\s+the\s+errors?)\b/i,
90
+ /\bthis\s+field\s+is\s+required\b/i,
91
+ /\b(?:email|password)\s+cannot\s+be\s+empty\b/i,
92
+ /\binvalid\s+(?:email|password)\b/i,
93
+ ];
94
+ // Exported for unit testing. True when the post-submit page reads like a
95
+ // rejected submit (account not created), so the bot should not poll for a
96
+ // verification email that will never arrive.
97
+ export function submitWasRejected(pageText) {
98
+ return SUBMIT_REJECTED_PATTERNS.some((p) => p.test(pageText));
99
+ }
62
100
  // T7: page text that means the post-OAuth API key sits behind a
63
101
  // billing / payment-method wall. When the OAuth onboarding loop ends
64
102
  // without a key and the page reads like this, the run ends
@@ -174,18 +212,75 @@ const STUCK_LOOP_FALLBACK_PATHS = [
174
212
  "/settings/tokens",
175
213
  "/settings/api-tokens",
176
214
  "/settings/account/api/auth-tokens/",
215
+ // 0.8.3-rc.2 — added after the post-OAuth onboarding drain
216
+ // (amplitude/groq/launchdarkly/modal/weaviate/…). These conventions
217
+ // were absent from the generic list and each cost a service its
218
+ // whole post-verify budget: LaunchDarkly hosts keys at
219
+ // /settings/authorization, a cohort of consoles use a bare
220
+ // /settings/access-tokens or /settings/api, and several put the
221
+ // developer surface at /settings/developers. They sit AFTER the
222
+ // historic, more-common paths so we don't regress an existing hit.
223
+ "/settings/authorization",
224
+ "/settings/access-tokens",
225
+ "/settings/developers",
226
+ "/settings/developer",
227
+ "/settings/api",
177
228
  "/account/api-keys",
178
229
  "/account/api_tokens",
230
+ "/account/api-tokens",
179
231
  "/account/keys",
180
232
  "/account/tokens",
233
+ "/account/access-tokens",
181
234
  "/api-keys",
182
235
  "/api_keys",
236
+ "/api-tokens",
183
237
  "/keys",
184
238
  "/tokens",
239
+ "/access-tokens",
185
240
  "/auth-tokens",
241
+ "/developers",
186
242
  "/dashboard/api-keys",
187
243
  "/dashboard/keys",
188
244
  ];
245
+ // 0.8.3-rc.2 — curated per-service API-key paths, consulted BEFORE the
246
+ // generic STUCK_LOOP_FALLBACK_PATHS list when the stuck origin belongs
247
+ // to one of these vendors. The generic conventions can't reach these:
248
+ // the path is either non-obvious (LaunchDarkly's /settings/authorization),
249
+ // or the vendor splits the convention differently than the majority
250
+ // (groq keys live at a bare /keys but the planner kept guessing
251
+ // /settings/api-keys, which 404s). Keyed by service SLUG (lowercased,
252
+ // alphanumerics only) so it survives the inbox-alias slug the bot is
253
+ // invoked with, and additionally matched against the stuck URL's host so
254
+ // a curated path is only ever composed onto the vendor it was harvested
255
+ // from. Each entry is ordered most-specific-first.
256
+ //
257
+ // This is deliberately a SMALL map of paths the generic heuristic
258
+ // provably can't derive — not a 12-service URL table. Most services in
259
+ // the onboarding-drain cohort are fixed by the widened generic list
260
+ // above; only the ones whose real path is genuinely un-guessable land
261
+ // here.
262
+ const SERVICE_KEYS_PATHS = {
263
+ // console.groq.com/keys — the planner kept trying /settings/api-keys
264
+ // (404). The bare /keys IS in the generic list but lands deep in the
265
+ // order; pin it first for groq so the first escalation hits.
266
+ groq: ["/keys", "/settings/keys"],
267
+ // app.launchdarkly.com/settings/authorization — LD's access-token UI.
268
+ // /settings/api-keys and /settings/generated-credentials both 404.
269
+ launchdarkly: ["/settings/authorization"],
270
+ // Weaviate keys are issued per-cluster, but the org-level admin page
271
+ // is the closest reachable surface; account-scoped guesses all 404.
272
+ weaviate: ["/account", "/settings/api-keys"],
273
+ // northflank hosts user API keys under the account menu, not a
274
+ // top-level /settings/keys.
275
+ northflank: ["/account/api", "/settings/api"],
276
+ };
277
+ // Normalize a service name to the slug used as a SERVICE_KEYS_PATHS key:
278
+ // lowercased, alphanumerics only. Mirrors guessSignupUrl's slug rule so
279
+ // the inbox-alias-derived service string (e.g. "Groq Cloud") resolves to
280
+ // the same key the map is authored under. Exported for unit testing.
281
+ export function serviceSlug(service) {
282
+ return service.toLowerCase().replace(/[^a-z0-9]/g, "");
283
+ }
189
284
  // 0.8.2-rc.10 — heuristic for "this account already exists on the
190
285
  // service and its API keys are masked, with no path to reveal them."
191
286
  // The test identity (methoxine@gmail.com) accumulates state across
@@ -252,34 +347,120 @@ export function detectExistingAccountNoExtract(input) {
252
347
  return true;
253
348
  return false;
254
349
  }
255
- // Pick the next fallback URL to try from STUCK_LOOP_FALLBACK_PATHS
256
- // keyed against the origin of the currently-stuck URL. Returns null
257
- // when every path has already been attempted. Exported for unit tests.
258
- export function pickStuckLoopFallbackUrl(currentUrl, alreadyTried) {
259
- let origin;
350
+ // A "mint a fresh key" affordance a button/link that creates a new
351
+ // API key/token. The label vocabulary is deliberately broad ("create",
352
+ // "generate", "new", "add" paired with a key/token noun) but must be
353
+ // paired with a credential noun so a bare "New project" / "Add member"
354
+ // button on a dashboard isn't mistaken for a key-minting control.
355
+ //
356
+ // Word-boundary-anchored to avoid matching "recreate" / "regenerate
357
+ // password" style false friends — though "regenerate" + a key noun IS
358
+ // a valid mint affordance (rotating a key produces a fresh value), so
359
+ // it's included explicitly.
360
+ const CREATE_KEY_VERB = /\b(?:create|generate|regenerate|new|add|issue|mint)\b/i;
361
+ const CREATE_KEY_NOUN = /\b(?:api[\s_-]*keys?|secret[\s_-]*keys?|access[\s_-]*tokens?|personal[\s_-]*access[\s_-]*tokens?|api[\s_-]*tokens?|auth[\s_-]*tokens?|tokens?|keys?|credentials?)\b/i;
362
+ // A standalone phrase that is unambiguously a key-minting control even
363
+ // without the verb+noun co-occurrence test (some buttons read just
364
+ // "New API key" with the verb folded into "new"). Kept separate so the
365
+ // generic verb/noun pairing can stay strict.
366
+ const CREATE_KEY_PHRASE = /\b(?:create|generate|new|add|issue|mint)\s+(?:a\s+)?(?:new\s+)?(?:api|secret|access|auth|personal\s+access)?\s*(?:keys?|tokens?|credentials?)\b/i;
367
+ // Scan an inventory for the single best "create new key / generate API
368
+ // key / new token" affordance. Returns the matching element or null.
369
+ // Exported for unit tests. Pure — operates on the inventory shape only,
370
+ // no browser access, so it can be unit-tested with synthetic elements.
371
+ export function findCreateKeyAffordance(inventory) {
372
+ const candidates = [];
373
+ for (const el of inventory) {
374
+ // Only buttons / links / role=button are mint controls; an <input>
375
+ // (a text field named "key") is never the create action.
376
+ const isClickable = el.tag === "button" ||
377
+ el.tag === "a" ||
378
+ el.role === "button" ||
379
+ el.role === "link";
380
+ if (!isClickable)
381
+ continue;
382
+ // A non-visible element can't be clicked reliably; skip when the
383
+ // live extractor told us it's hidden (test fixtures that omit
384
+ // `visible` are treated as visible).
385
+ if (el.visible === false)
386
+ continue;
387
+ const haystack = [
388
+ el.visibleText,
389
+ el.ariaLabel,
390
+ el.title,
391
+ el.labelText,
392
+ el.iconLabel,
393
+ ]
394
+ .filter((s) => s !== null && s !== undefined)
395
+ .join(" ")
396
+ .trim();
397
+ if (haystack.length === 0)
398
+ continue;
399
+ const phraseHit = CREATE_KEY_PHRASE.test(haystack);
400
+ const verbNounHit = CREATE_KEY_VERB.test(haystack) && CREATE_KEY_NOUN.test(haystack);
401
+ if (!phraseHit && !verbNounHit)
402
+ continue;
403
+ // Score: a full phrase match is the strongest signal; an explicit
404
+ // "api key" / "token" noun beats a bare "key"; in-viewport beats
405
+ // off-screen. Highest score wins so a precise "Create API Key" is
406
+ // preferred over a generic "Add key".
407
+ let score = 0;
408
+ if (phraseHit)
409
+ score += 4;
410
+ if (/\bapi[\s_-]*keys?\b|\bapi[\s_-]*tokens?\b/i.test(haystack))
411
+ score += 2;
412
+ if (el.inViewport === true)
413
+ score += 1;
414
+ candidates.push({ el, score });
415
+ }
416
+ if (candidates.length === 0)
417
+ return null;
418
+ candidates.sort((a, b) => b.score - a.score);
419
+ return candidates[0].el;
420
+ }
421
+ // Pick the next fallback URL to try, keyed against the origin of the
422
+ // currently-stuck URL. The curated SERVICE_KEYS_PATHS for the run's
423
+ // service (when its host matches the stuck origin) are tried FIRST,
424
+ // then the generic STUCK_LOOP_FALLBACK_PATHS. Returns null when every
425
+ // path has already been attempted. Exported for unit tests.
426
+ export function pickStuckLoopFallbackUrl(currentUrl, alreadyTried, service) {
427
+ let parsed;
260
428
  try {
261
- origin = new URL(currentUrl).origin;
429
+ parsed = new URL(currentUrl);
262
430
  }
263
431
  catch {
264
432
  return null;
265
433
  }
434
+ const origin = parsed.origin;
266
435
  // Skip a candidate when the current URL's path ALREADY matches it
267
436
  // (case-insensitive, trailing-slash tolerant). The planner is stuck
268
437
  // ON the page the candidate points to — navigating to the same URL
269
438
  // again won't break the cycle, only a different path will.
270
- const currentPath = (() => {
271
- try {
272
- return new URL(currentUrl).pathname.replace(/\/+$/, "").toLowerCase();
273
- }
274
- catch {
275
- return "";
276
- }
277
- })();
278
- for (const path of STUCK_LOOP_FALLBACK_PATHS) {
439
+ const currentPath = parsed.pathname.replace(/\/+$/, "").toLowerCase();
440
+ // Compose curated per-service paths first, but only when the stuck
441
+ // origin's host actually belongs to the named service. The slug is
442
+ // a substring of the host for the vendors we curate (groq →
443
+ // console.groq.com, launchdarkly → app.launchdarkly.com, …); this
444
+ // host gate stops a curated path from being composed onto an
445
+ // unrelated origin the bot wandered onto (e.g. an OAuth provider or
446
+ // a redirect to a marketing domain).
447
+ const slug = service !== undefined ? serviceSlug(service) : "";
448
+ const curated = slug !== "" &&
449
+ SERVICE_KEYS_PATHS[slug] !== undefined &&
450
+ parsed.hostname.toLowerCase().includes(slug)
451
+ ? SERVICE_KEYS_PATHS[slug]
452
+ : [];
453
+ // Curated paths lead; the generic list follows. De-dup so a path that
454
+ // appears in both (groq's /keys, /settings/keys) isn't offered twice.
455
+ const seen = new Set();
456
+ for (const path of [...curated, ...STUCK_LOOP_FALLBACK_PATHS]) {
457
+ const candidatePath = path.replace(/\/+$/, "").toLowerCase();
458
+ if (seen.has(candidatePath))
459
+ continue;
460
+ seen.add(candidatePath);
279
461
  const candidate = `${origin}${path}`;
280
462
  if (alreadyTried.has(candidate))
281
463
  continue;
282
- const candidatePath = path.replace(/\/+$/, "").toLowerCase();
283
464
  if (candidatePath === currentPath)
284
465
  continue;
285
466
  return candidate;
@@ -581,6 +762,21 @@ export function parseSignupPlan(raw, allowedSelectors) {
581
762
  ? { actions, submit_selector: submitSelector, confidence, notes }
582
763
  : { actions, submit_selector: submitSelector, confidence };
583
764
  }
765
+ // True when a clickSubmit failure is a Playwright visibility/attach
766
+ // timeout rather than a genuine hard error. A timeout means the submit
767
+ // selector resolved at plan-time but was gone by click-time — almost
768
+ // always because an earlier action in the same plan advanced a
769
+ // multi-step SPA (Paddle's "Continue" → next screen), so the right
770
+ // recovery is a re-plan against the new page, not a run-ending
771
+ // submit_failed. Matches Playwright's `locator.waitFor`/`waitForSelector`
772
+ // timeout text; deliberately does NOT match `submit_disabled` (handled
773
+ // separately) or other click errors (genuine failures). Exported for
774
+ // unit testing.
775
+ export function isSubmitTimeout(reason) {
776
+ if (reason.startsWith("submit_disabled"))
777
+ return false;
778
+ return /Timeout \d+ms exceeded/i.test(reason) && /waitfor|waiting for/i.test(reason);
779
+ }
584
780
  // Render the element inventory as a compact text block for the
585
781
  // planner — one line per element, ending with the verified
586
782
  // `selector=` the planner must copy verbatim (F3 T3).
@@ -1338,6 +1534,48 @@ export function findFirstOAuthButton(inventory, providers) {
1338
1534
  }
1339
1535
  return null;
1340
1536
  }
1537
+ // A page can gate the real login UI behind a generic "Sign In to
1538
+ // Continue" interstitial that renders NO provider affordance yet —
1539
+ // Qdrant's session-expiry flow redirects to /logout?aerr=expired whose
1540
+ // only element is a "Sign In to Continue" button; the Google button
1541
+ // lives one click deeper. The OAuth-first scan finds no provider
1542
+ // affordance and was bailing `oauth_required` without ever advancing.
1543
+ // This finds a generic sign-in-ish button to CLICK so the next scan can
1544
+ // see the provider buttons. Strictly gated on sign-in vocabulary so we
1545
+ // never click an arbitrary CTA: a bare "Continue" / "Get started" /
1546
+ // "Go to Dashboard" / 404-recovery / "Join Workspace" button does NOT
1547
+ // match — only text that explicitly reads as advancing into a login.
1548
+ // Caller bounds the number of click-throughs. Returns null when no such
1549
+ // affordance exists (the page is then either genuinely SSO-only, a 404,
1550
+ // or already authenticated). Exported for unit testing.
1551
+ const SIGN_IN_ADVANCE_RE = /\b(?:sign[\s-]?in|log[\s-]?in|continue with email|continue to (?:sign|log)|get started)\b/i;
1552
+ export function findSignInAdvanceButton(inventory, providers) {
1553
+ // If a provider affordance is already present, advancing is pointless
1554
+ // — the caller would have taken the OAuth path. Guard so a page that
1555
+ // has both (e.g. a "Sign in" header link + "Continue with Google")
1556
+ // never routes through this click-through path.
1557
+ if (findFirstOAuthButton(inventory, providers) !== null)
1558
+ return null;
1559
+ for (const e of inventory) {
1560
+ const isButtonish = e.tag === "button" ||
1561
+ e.tag === "a" ||
1562
+ e.role === "button" ||
1563
+ e.type === "submit" ||
1564
+ e.type === "button";
1565
+ if (!isButtonish)
1566
+ continue;
1567
+ const text = `${e.visibleText ?? ""} ${e.ariaLabel ?? ""}`
1568
+ .replace(/\s+/g, " ")
1569
+ .trim();
1570
+ // Sanity-cap: a real sign-in button is short. A long string is a
1571
+ // paragraph / card that happens to contain "sign in".
1572
+ if (text.length === 0 || text.length > MAX_OAUTH_BUTTON_TEXT_CHARS)
1573
+ continue;
1574
+ if (SIGN_IN_ADVANCE_RE.test(text))
1575
+ return e;
1576
+ }
1577
+ return null;
1578
+ }
1341
1579
  // Order the OAuth providers the bot may use for a signup, given the
1342
1580
  // service's yaml pin (if any) and the providers the persistent profile
1343
1581
  // actually has a session for. `findFirstOAuthButton` walks this list in
@@ -1915,6 +2153,56 @@ export function extractApiKeyFromText(text) {
1915
2153
  }
1916
2154
  return null;
1917
2155
  }
2156
+ // Password-manager / autofill UI affordances that render as short
2157
+ // word-tokens on credential pages. A render API-keys page ships a
2158
+ // "Save to 1Password" / "1Password" autofill button next to the real
2159
+ // `rnd_…` key; LastPass, Bitwarden, and Dashlane do the same. These
2160
+ // strings are alphanumeric, often carry a digit ("1Password"), and sit
2161
+ // EARLIER in DOM order than the credential — so the validator-blind
2162
+ // candidate-scan tiers (replay-skill.ts) used to return them as the
2163
+ // "credential" and the downstream length validator then rejected them
2164
+ // (the 0DTW2V66 render skill: `got="1Password" length 9 below min 32`).
2165
+ // They are never credentials; reject them at the candidate layer so the
2166
+ // scan moves on to the real key instead of the right key being shadowed
2167
+ // by a UI word. Matched case-insensitively as a whole token (the
2168
+ // candidates the scan tiers feed in are already whitespace-trimmed
2169
+ // single tokens). Exported for unit testing.
2170
+ const CREDENTIAL_NOISE_TOKENS = [
2171
+ "1password",
2172
+ "lastpass",
2173
+ "bitwarden",
2174
+ "dashlane",
2175
+ "keepass",
2176
+ "keeper",
2177
+ "nordpass",
2178
+ "proton pass",
2179
+ "protonpass",
2180
+ "autofill",
2181
+ "passwords",
2182
+ ];
2183
+ // Verb-prefixed UI affordances ("Save to 1Password", "Copy to
2184
+ // clipboard", "Add to vault"). The candidate-scan tiers tokenize on
2185
+ // whitespace so a multi-word affordance rarely survives as one
2186
+ // candidate — but extractText()/innerText passes glue it together, so
2187
+ // guard the leading verbs too.
2188
+ const CREDENTIAL_NOISE_PREFIXES = [
2189
+ "save to ",
2190
+ "copy to ",
2191
+ "add to ",
2192
+ "store in ",
2193
+ ];
2194
+ // True when a candidate string is a password-manager / autofill UI
2195
+ // affordance rather than a real credential value. Used by the replay
2196
+ // engine's raw-candidate scan tiers to keep "1Password"-class words
2197
+ // out of the credential slot. Exported for unit testing.
2198
+ export function isCredentialNoiseCandidate(candidate) {
2199
+ const lower = candidate.trim().toLowerCase();
2200
+ if (lower.length === 0)
2201
+ return false;
2202
+ if (CREDENTIAL_NOISE_TOKENS.includes(lower))
2203
+ return true;
2204
+ return CREDENTIAL_NOISE_PREFIXES.some((p) => lower.startsWith(p));
2205
+ }
1918
2206
  // Choose which link in a verification email to click. Scores each URL
1919
2207
  // by keyword and picks the best — but only if it scored positive.
1920
2208
  //
@@ -2170,6 +2458,12 @@ export class SignupAgent {
2170
2458
  let progressReplans = 0;
2171
2459
  let emptyPlans = 0;
2172
2460
  let oauthScanRetries = 0;
2461
+ // Bounded click-throughs of a generic "Sign In to Continue"
2462
+ // interstitial that gates the provider buttons (Qdrant). Capped so
2463
+ // an SSO-only page that keeps re-showing a sign-in button (or a
2464
+ // redirect loop) still terminates at oauth_required.
2465
+ let signInAdvanceClicks = 0;
2466
+ const MAX_SIGN_IN_ADVANCE_CLICKS = 2;
2173
2467
  let hint;
2174
2468
  // F14 — selectors the planner clicked WITHOUT advancing the page.
2175
2469
  // Each no-progress plan records its click selectors here; the next
@@ -2253,6 +2547,36 @@ export class SignupAgent {
2253
2547
  "treating as already authenticated, jumping to post-verify navigation");
2254
2548
  return { kind: "already_oauth" };
2255
2549
  }
2550
+ // The provider buttons can sit one click behind a generic
2551
+ // "Sign In to Continue" interstitial (Qdrant's session-expiry
2552
+ // /logout?aerr=expired redirect renders only that button). The
2553
+ // OAuth scan above found nothing because the real login UI
2554
+ // hasn't been reached yet. Click the sign-in-ish affordance to
2555
+ // advance, reset the async-render retries, and re-scan — bounded
2556
+ // so a page that just keeps showing a sign-in button (genuine
2557
+ // SSO-only / a redirect loop) still terminates at oauth_required
2558
+ // rather than clicking forever.
2559
+ if (signInAdvanceClicks < MAX_SIGN_IN_ADVANCE_CLICKS) {
2560
+ const advance = findSignInAdvanceButton(inventory, oauthCandidates);
2561
+ if (advance !== null) {
2562
+ signInAdvanceClicks += 1;
2563
+ steps.push(`OAuth-first: no provider affordance, but found a generic ` +
2564
+ `sign-in affordance (${JSON.stringify(advance.visibleText ?? advance.ariaLabel ?? "")}) ` +
2565
+ `— clicking it to advance to the real login page ` +
2566
+ `(${signInAdvanceClicks}/${MAX_SIGN_IN_ADVANCE_CLICKS})`);
2567
+ try {
2568
+ await this.browser.click(advance.selector);
2569
+ }
2570
+ catch (err) {
2571
+ steps.push(`OAuth-first: sign-in advance click failed (${err instanceof Error ? err.message : String(err)}) ` +
2572
+ `— falling through to form-fill`);
2573
+ }
2574
+ // Reset the async-render budget for the now-advanced page so
2575
+ // its provider buttons get the same couple of render retries.
2576
+ oauthScanRetries = 0;
2577
+ continue;
2578
+ }
2579
+ }
2256
2580
  steps.push("OAuth-first: no usable provider affordance on the page — " +
2257
2581
  "falling back to form-fill");
2258
2582
  // Dump visible buttons/links so we can see what the OAuth-
@@ -2539,6 +2863,27 @@ export class SignupAgent {
2539
2863
  emptyInputHint;
2540
2864
  continue;
2541
2865
  }
2866
+ // A submit-selector visibility timeout means the element the
2867
+ // planner picked is no longer on the page — typically because an
2868
+ // earlier action in THIS plan advanced a multi-step SPA (Paddle's
2869
+ // signup: a "Continue" click navigates to a new screen, so the
2870
+ // submit selector that resolved at plan-time has vanished by the
2871
+ // time clickSubmit polls for it). The page DID move forward, so
2872
+ // re-plan against the new state rather than failing the whole run
2873
+ // on a stale selector. Bounded by the same progressReplans
2874
+ // headroom as the disabled-submit / post-validation re-plans.
2875
+ if (isSubmitTimeout(reason)) {
2876
+ if (++progressReplans > MAX_PROGRESS_REPLANS) {
2877
+ return { kind: "submit_failed", reason };
2878
+ }
2879
+ steps.push(`⚠ submit selector went stale (${reason.split("\n")[0]}) — the page likely advanced; re-planning`);
2880
+ hint =
2881
+ "The submit button selected last round was no longer present when " +
2882
+ "we tried to click it — an earlier action probably advanced the page. " +
2883
+ "Re-read the now-visible form and plan the next step (pick the submit " +
2884
+ "button that is actually on the current screen).";
2885
+ continue;
2886
+ }
2542
2887
  steps.push(`⚠ submit click failed: ${reason}`);
2543
2888
  return { kind: "submit_failed", reason };
2544
2889
  }
@@ -3250,10 +3595,25 @@ export class SignupAgent {
3250
3595
  // Step 7: Email verification + post-verification navigation.
3251
3596
  let verificationFailed;
3252
3597
  if (credentials.api_key === undefined && credentials.username === undefined) {
3253
- // S3: read the post-submit page first. Whether it is actually
3254
- // asking the user to confirm by email decides both the no-inbox
3255
- // bail (M2) and, when an inbox exists, the poll duration.
3256
- const expectsEmail = expectsVerificationEmail(await this.browser.extractText());
3598
+ // S3: read the post-submit page to decide both the no-inbox bail
3599
+ // (M2) and, when an inbox exists, the poll duration. The page is
3600
+ // read up to TWICE: once immediately, then only if the first
3601
+ // read was inconclusive — again after a short settle, because SPA
3602
+ // signups render the "check your email" screen a beat after submit
3603
+ // and sampling once races that render (the bug behind the
3604
+ // Postmark/ElevenLabs/Browserbase/Grafana false `verification_not_sent`).
3605
+ let postSubmitText = await this.browser.extractText();
3606
+ let expectsEmail = expectsVerificationEmail(postSubmitText);
3607
+ if (!expectsEmail && !submitWasRejected(postSubmitText)) {
3608
+ await this.browser.wait(SUBMIT_SETTLE_SECONDS);
3609
+ postSubmitText = await this.browser.extractText();
3610
+ expectsEmail = expectsVerificationEmail(postSubmitText);
3611
+ }
3612
+ // A clean submit that did NOT visibly reject created an account —
3613
+ // verification mail is plausibly inbound even without a "check
3614
+ // your email" screen. Distinguish that from a rejected submit
3615
+ // (already-registered, validation error) where no mail is coming.
3616
+ const submitRejected = submitWasRejected(postSubmitText);
3257
3617
  if (task.inbox === undefined) {
3258
3618
  // M2/S3: no inbox to receive a verification email (the SES
3259
3619
  // inbound pipeline is mothballed — TODOS M1). If the page is
@@ -3273,15 +3633,25 @@ export class SignupAgent {
3273
3633
  }
3274
3634
  }
3275
3635
  else {
3276
- // S3: don't blind-wait. If the page explicitly tells the user
3277
- // to check their email, poll the full timeout; if not, the
3278
- // service most likely sent nothing run a short probe.
3636
+ // S3: don't blind-wait, but don't under-poll a clean submit
3637
+ // either. Three cases:
3638
+ // - page says "check your email" → full timeout (mail is
3639
+ // definitely coming).
3640
+ // - submit visibly rejected → 45s probe (no account was
3641
+ // created; no mail is coming).
3642
+ // - inconclusive but submit clean → 120s floor (an account was
3643
+ // created, so transactional mail is plausibly inbound and
3644
+ // can outlast the 45s probe; bounded below the full timeout).
3279
3645
  const verificationTimeoutSeconds = expectsEmail
3280
3646
  ? (task.verificationTimeoutSeconds ?? 180)
3281
- : VERIFICATION_PROBE_SECONDS;
3647
+ : submitRejected
3648
+ ? VERIFICATION_PROBE_SECONDS
3649
+ : SUBMITTED_PROBE_FLOOR_SECONDS;
3282
3650
  steps.push(expectsEmail
3283
3651
  ? `Post-submit page asks to check email — polling inbox (up to ${verificationTimeoutSeconds}s)...`
3284
- : `Post-submit page shows no "check your email" prompt — short ${verificationTimeoutSeconds}s probe (S3: the service likely sent no verification email)...`);
3652
+ : submitRejected
3653
+ ? `Post-submit page shows a rejected submit — short ${verificationTimeoutSeconds}s probe (S3: no account created, no verification email expected)...`
3654
+ : `Post-submit page is inconclusive but submit was clean — polling inbox up to ${verificationTimeoutSeconds}s (S3: an account may have been created and mail can lag)...`);
3285
3655
  try {
3286
3656
  const email = await this.waitForVerificationEmail(task.inbox, task.email, verificationTimeoutSeconds);
3287
3657
  steps.push(`Received: "${email.subject}" from ${email.from_address}`);
@@ -3325,7 +3695,9 @@ export class SignupAgent {
3325
3695
  steps.push(`Inbox poll failed: ${detail}`);
3326
3696
  verificationFailed = expectsEmail
3327
3697
  ? `verification_not_sent: form submitted and the page asked to check email, but none arrived in ${verificationTimeoutSeconds}s — the service likely withheld it (anti-abuse) or requires manual signup`
3328
- : `verification_not_sent: form submitted but the page never prompted to check email and none arrived in the ${verificationTimeoutSeconds}s probe — the service most likely dispatched no verification email`;
3698
+ : submitRejected
3699
+ ? `verification_not_sent: form submitted but the page reported a rejected submit (already-registered / validation error) and no mail arrived in the ${verificationTimeoutSeconds}s probe — no account was created`
3700
+ : `verification_not_sent: form submitted cleanly but no "check your email" prompt appeared and none arrived in ${verificationTimeoutSeconds}s — the service likely withheld it (fresh-domain anti-abuse) or verifies by another channel (SMS / authenticator)`;
3329
3701
  }
3330
3702
  }
3331
3703
  }
@@ -4445,6 +4817,206 @@ ${formatInventory(input.inventory)}`,
4445
4817
  }
4446
4818
  return out;
4447
4819
  }
4820
+ // Run every visible-credential extraction tier the post-verify loop
4821
+ // uses (legacy regex/clipboard/hidden-input + DOM-proximity labeled),
4822
+ // merging first-wins into a single bundle. Used by attemptMintNewKey
4823
+ // so the freshly-minted key — which may render as a modal value, a
4824
+ // copy-button-only token, or a labeled table row — is caught by
4825
+ // whichever tier fits the vendor's reveal UI.
4826
+ async harvestVisibleCredentials() {
4827
+ const out = {};
4828
+ try {
4829
+ const legacy = await this.extractCredentials();
4830
+ for (const [k, v] of Object.entries(legacy)) {
4831
+ if (out[k] === undefined)
4832
+ out[k] = v;
4833
+ }
4834
+ }
4835
+ catch {
4836
+ // best-effort — fall through to DOM-proximity
4837
+ }
4838
+ try {
4839
+ const labeled = await this.extractFromDomProximity();
4840
+ for (const [k, v] of Object.entries(labeled)) {
4841
+ if (out[k] === undefined)
4842
+ out[k] = v;
4843
+ }
4844
+ }
4845
+ catch {
4846
+ // best-effort
4847
+ }
4848
+ return out;
4849
+ }
4850
+ // SUCCEED on an already-signed-in / existing-account dashboard.
4851
+ //
4852
+ // When the bot lands on an authenticated dashboard whose API-keys
4853
+ // page shows only NAMES of pre-existing keys (values are shown once
4854
+ // at create-time and are unrecoverable), the historic behavior was to
4855
+ // bail with existing_account_no_extract / no_credentials_after_already
4856
+ // _signed_in. That's a usable outcome the bot threw away: a new key is
4857
+ // a perfectly valid credential.
4858
+ //
4859
+ // This routine, given the current (existing-account) state:
4860
+ // 1. Tries to extract a READABLE key right where we are — some
4861
+ // dashboards do show a usable value (a default key on a freshly
4862
+ // reused account, or a reveal affordance the loop hadn't fired).
4863
+ // 2. If the current page isn't a keys page, walks the same
4864
+ // hardcoded keys-path fallbacks the stuck-loop escalation uses,
4865
+ // re-trying the readable-key extract at each.
4866
+ // 3. On a keys page with no readable key, finds a "Create new key /
4867
+ // Generate API key / New token" affordance and CLICKS it, then
4868
+ // harvests the freshly-minted value (modal reveal + copy-button +
4869
+ // clipboard + labeled-row, via harvestVisibleCredentials, after a
4870
+ // short poll for the server round-trip that mints the key).
4871
+ //
4872
+ // Returns the minted/extracted credentials on success, or null when
4873
+ // there is genuinely no way to produce a key for this identity (no
4874
+ // create affordance anywhere — e.g. key creation is paywalled). The
4875
+ // caller then falls through to the honest existing_account bail.
4876
+ //
4877
+ // Best-effort throughout: any browser error degrades to "couldn't
4878
+ // mint" (null) rather than throwing — the existing classifier remains
4879
+ // the safety net.
4880
+ async attemptMintNewKey(steps) {
4881
+ // The set of keys-page URLs we've navigated, so the fallback walk
4882
+ // doesn't revisit one and the create-affordance search doesn't
4883
+ // re-click on a page already shown to lack one.
4884
+ const visitedKeysUrls = new Set();
4885
+ // Attempt readable-extract → create-and-extract on whatever page is
4886
+ // currently loaded. Returns credentials on success, null otherwise.
4887
+ const tryHere = async () => {
4888
+ // (a) A readable key already on the page (or behind a reveal
4889
+ // affordance the post-verify loop hadn't clicked yet).
4890
+ let creds = await this.harvestVisibleCredentials();
4891
+ if (hasAnyExtractedCredential(creds)) {
4892
+ steps.push("Existing-account recovery: a readable key was already present on the keys page — extracted it.");
4893
+ return creds;
4894
+ }
4895
+ // (b) Reveal pass — a masked-but-revealable existing key.
4896
+ try {
4897
+ const revealRes = await this.browser.revealMaskedCredentials();
4898
+ if (revealRes.clicked > 0) {
4899
+ await this.browser.wait(1);
4900
+ creds = await this.harvestVisibleCredentials();
4901
+ if (hasAnyExtractedCredential(creds)) {
4902
+ steps.push(`Existing-account recovery: revealed a masked existing key (clicked ${revealRes.clicked}) and extracted it.`);
4903
+ return creds;
4904
+ }
4905
+ }
4906
+ }
4907
+ catch {
4908
+ // best-effort reveal
4909
+ }
4910
+ // (c) Mint a fresh key. Find + click a create affordance, then
4911
+ // harvest the newly-shown value.
4912
+ const inventory = await this.buildInventory(steps, undefined, 80);
4913
+ const createBtn = findCreateKeyAffordance(inventory);
4914
+ if (createBtn === null)
4915
+ return null;
4916
+ const label = (createBtn.visibleText ??
4917
+ createBtn.ariaLabel ??
4918
+ createBtn.title ??
4919
+ "create key").trim();
4920
+ steps.push(`Existing-account recovery: no readable key — clicking a key-minting affordance ${JSON.stringify(label.slice(0, 40))}.`);
4921
+ try {
4922
+ await this.browser.click(createBtn.selector);
4923
+ }
4924
+ catch (err) {
4925
+ steps.push(`Existing-account recovery: create-key click failed (${err instanceof Error ? err.message : String(err)}).`);
4926
+ return null;
4927
+ }
4928
+ // Poll for the freshly-minted key — minting is a server
4929
+ // round-trip (Render/Mistral/Mailtrap render the value into a
4930
+ // modal after the POST returns). Reuse the modal-reveal poll
4931
+ // budget the click branch uses elsewhere (~8s), early-exiting the
4932
+ // moment any tier surfaces a credential. A confirmation dialog
4933
+ // ("Name your key" → Create) is common; fire the reveal pass each
4934
+ // round so a modal that needs a second confirm-then-show click is
4935
+ // still harvested.
4936
+ const deadline = Date.now() + 8000;
4937
+ while (Date.now() < deadline) {
4938
+ await this.browser.wait(0.5);
4939
+ const minted = await this.harvestVisibleCredentials();
4940
+ if (hasAnyExtractedCredential(minted)) {
4941
+ steps.push("Existing-account recovery: extracted the freshly-minted key.");
4942
+ return minted;
4943
+ }
4944
+ // A two-step create modal: clicking the page-level "Create key"
4945
+ // opened a "name + confirm" dialog. Click a now-visible confirm
4946
+ // affordance once, then keep polling.
4947
+ try {
4948
+ const modalInv = await this.browser.extractInteractiveElements();
4949
+ const confirmBtn = findCreateKeyAffordance(modalInv);
4950
+ if (confirmBtn !== null &&
4951
+ confirmBtn.selector !== createBtn.selector) {
4952
+ await this.browser.click(confirmBtn.selector);
4953
+ }
4954
+ }
4955
+ catch {
4956
+ // best-effort confirm
4957
+ }
4958
+ }
4959
+ // Minted but the value didn't surface — try one last reveal +
4960
+ // harvest (some vendors render the new key masked with a Show
4961
+ // toggle even on first display).
4962
+ try {
4963
+ const revealRes = await this.browser.revealMaskedCredentials();
4964
+ if (revealRes.clicked > 0) {
4965
+ await this.browser.wait(1);
4966
+ const afterReveal = await this.harvestVisibleCredentials();
4967
+ if (hasAnyExtractedCredential(afterReveal)) {
4968
+ steps.push("Existing-account recovery: revealed and extracted the freshly-minted key.");
4969
+ return afterReveal;
4970
+ }
4971
+ }
4972
+ }
4973
+ catch {
4974
+ // best-effort
4975
+ }
4976
+ return null;
4977
+ };
4978
+ // Step 1 — try on the current page first.
4979
+ try {
4980
+ const state = await this.browser.getState();
4981
+ visitedKeysUrls.add(state.url);
4982
+ if (EXISTING_KEY_URL_HINT.test(state.url)) {
4983
+ const here = await tryHere();
4984
+ if (here !== null)
4985
+ return here;
4986
+ }
4987
+ }
4988
+ catch {
4989
+ // best-effort — fall through to the fallback walk
4990
+ }
4991
+ // Step 2 — walk the hardcoded keys-path fallbacks. Even if Step 1
4992
+ // ran (current page WAS a keys page but had no affordance), a
4993
+ // different keys URL on the same origin may carry the create
4994
+ // control (org-scoped vs account-scoped keys pages).
4995
+ for (let i = 0; i < STUCK_LOOP_FALLBACK_PATHS.length; i++) {
4996
+ let currentUrl;
4997
+ try {
4998
+ currentUrl = (await this.browser.getState()).url;
4999
+ }
5000
+ catch {
5001
+ break;
5002
+ }
5003
+ const fallback = pickStuckLoopFallbackUrl(currentUrl, visitedKeysUrls);
5004
+ if (fallback === null)
5005
+ break;
5006
+ visitedKeysUrls.add(fallback);
5007
+ try {
5008
+ await this.browser.goto(fallback);
5009
+ await this.browser.waitForInteractiveDom(5, 15_000);
5010
+ }
5011
+ catch {
5012
+ continue;
5013
+ }
5014
+ const here = await tryHere();
5015
+ if (here !== null)
5016
+ return here;
5017
+ }
5018
+ return null;
5019
+ }
4448
5020
  async postVerifyLoop(args) {
4449
5021
  let credentials = await this.extractCredentials();
4450
5022
  // 0.8.2-rc.15 — also seed DOM-proximity at loop entry. If the
@@ -5087,7 +5659,7 @@ ${formatInventory(input.inventory)}`,
5087
5659
  hint = undefined;
5088
5660
  continue;
5089
5661
  }
5090
- const fallback = pickStuckLoopFallbackUrl(state.url, triedFallbackUrls);
5662
+ const fallback = pickStuckLoopFallbackUrl(state.url, triedFallbackUrls, args.service);
5091
5663
  if (fallback !== null) {
5092
5664
  triedFallbackUrls.add(fallback);
5093
5665
  args.steps.push(`Post-verify: stuck-loop detected ${stuckFiresAtUrl}x at ${state.url} — escalating to a hardcoded API-key URL: ${fallback}`);
@@ -5633,24 +6205,69 @@ ${formatInventory(input.inventory)}`,
5633
6205
  // correctly, the state is just unrecoverable for this identity.
5634
6206
  const alreadyClassified = this.lastPostVerifyDoneReason !== null &&
5635
6207
  this.lastPostVerifyDoneReason.startsWith("[");
5636
- if (credentials.api_key === undefined &&
5637
- credentials.username === undefined &&
5638
- !alreadyClassified) {
6208
+ const noCredentialYet = credentials.api_key === undefined && credentials.username === undefined;
6209
+ // Distinct from a generic prior classification: ONLY the existing-
6210
+ // account path is recoverable by minting. A [stuck_loop] / [paywall]
6211
+ // / [anti_bot] marker is a different failure the mint flow can't fix,
6212
+ // so leave those alone.
6213
+ const alreadyExistingAccount = this.lastPostVerifyDoneReason !== null &&
6214
+ this.lastPostVerifyDoneReason.startsWith("[existing_account_no_extract]");
6215
+ // The mint flow is appropriate for the existing-account /
6216
+ // already-signed-in category ONLY. A [stuck_loop] / [paywall] /
6217
+ // [anti_bot] marker is a different, non-recoverable failure — leave
6218
+ // those alone. An UNclassified exit reaching here IS the
6219
+ // already-signed-in case (postVerifyLoop only runs on an
6220
+ // authenticated dashboard — `already_oauth` / post-OAuth), so it's
6221
+ // eligible too; the mint flow self-gates by requiring a real keys
6222
+ // page + a create affordance before it acts.
6223
+ const mintEligible = noCredentialYet && (!alreadyClassified || alreadyExistingAccount);
6224
+ if (mintEligible) {
6225
+ // SUCCEED-EVEN-WHEN-ACCOUNT-EXISTS: before bailing, navigate to
6226
+ // the keys page and either extract a readable key or mint a fresh
6227
+ // one. A new key is a valid outcome. attemptMintNewKey returns
6228
+ // null when there is genuinely no create affordance anywhere (key
6229
+ // creation paywalled / no keys page) — then we fall through to the
6230
+ // honest bail.
6231
+ let minted = null;
5639
6232
  try {
5640
- const finalState = await this.browser.getState();
5641
- const finalText = await this.browser.extractText().catch(() => "");
5642
- if (detectExistingAccountNoExtract({
5643
- url: finalState.url,
5644
- pageText: finalText,
5645
- lastPlannerReason: this.lastPostVerifyDoneReason ?? "",
5646
- })) {
5647
- this.lastPostVerifyDoneReason =
5648
- `[existing_account_no_extract] at ${finalState.url}; latest planner reason: ${this.lastPostVerifyDoneReason ?? "(none — loop exhausted)"}`;
5649
- args.steps.push("Post-verify: classified as existing_account_no_extract — masked pre-existing key on an authenticated dashboard.");
5650
- }
6233
+ minted = await this.attemptMintNewKey(args.steps);
5651
6234
  }
5652
6235
  catch {
5653
- // best-effort classifier never block returning the (empty) credentials
6236
+ // best-effort — degrade to the existing classifier below
6237
+ }
6238
+ if (minted !== null && hasAnyExtractedCredential(minted)) {
6239
+ for (const [k, v] of Object.entries(minted)) {
6240
+ if (credentials[k] === undefined)
6241
+ credentials[k] = v;
6242
+ }
6243
+ // Clear any existing-account sentinel — we recovered.
6244
+ if (alreadyExistingAccount)
6245
+ this.lastPostVerifyDoneReason = null;
6246
+ args.steps.push("Post-verify: existing-account / already-signed-in dashboard recovered — minted/extracted a usable key instead of bailing.");
6247
+ return credentials;
6248
+ }
6249
+ // Mint failed. If the masked-pre-existing-key shape is detectable
6250
+ // AND not already flagged by the stuck-loop early-exit, mark the
6251
+ // honest existing_account_no_extract bail so the caller surfaces
6252
+ // the precise status rather than the generic
6253
+ // no_credentials_after_already_signed_in.
6254
+ if (!alreadyExistingAccount) {
6255
+ try {
6256
+ const finalState = await this.browser.getState();
6257
+ const finalText = await this.browser.extractText().catch(() => "");
6258
+ if (detectExistingAccountNoExtract({
6259
+ url: finalState.url,
6260
+ pageText: finalText,
6261
+ lastPlannerReason: this.lastPostVerifyDoneReason ?? "",
6262
+ })) {
6263
+ this.lastPostVerifyDoneReason =
6264
+ `[existing_account_no_extract] at ${finalState.url}; latest planner reason: ${this.lastPostVerifyDoneReason ?? "(none — loop exhausted)"}`;
6265
+ args.steps.push("Post-verify: classified as existing_account_no_extract — pre-existing keys are masked and no key-minting affordance was found.");
6266
+ }
6267
+ }
6268
+ catch {
6269
+ // best-effort classifier — never block returning credentials
6270
+ }
5654
6271
  }
5655
6272
  }
5656
6273
  return credentials;