@trusty-squire/mcp 0.8.14 → 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
@@ -145,6 +183,17 @@ export class LLMCallBudgetExceeded extends Error {
145
183
  this.name = "LLMCallBudgetExceeded";
146
184
  }
147
185
  }
186
+ // A5 — thrown from postVerifyLoop when an OAuth run keeps landing on a
187
+ // login page (the planner asks to log in ≥2 times): the OAuth callback
188
+ // never established a session, so the post-verify thrash would only end
189
+ // in a mislabeled oauth_onboarding_failed. The OAuth call site catches
190
+ // this and returns the correct oauth_session_not_persisted classification.
191
+ export class OAuthSessionNotPersistedError extends Error {
192
+ constructor(message) {
193
+ super(message);
194
+ this.name = "OAuthSessionNotPersistedError";
195
+ }
196
+ }
148
197
  // 0.8.2-rc.10 — common dashboard paths that vendors host their
149
198
  // per-account API key UI at. Ordered most-specific first so a
150
199
  // fallback navigate doesn't land short of the actual page. Returned
@@ -163,18 +212,75 @@ const STUCK_LOOP_FALLBACK_PATHS = [
163
212
  "/settings/tokens",
164
213
  "/settings/api-tokens",
165
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",
166
228
  "/account/api-keys",
167
229
  "/account/api_tokens",
230
+ "/account/api-tokens",
168
231
  "/account/keys",
169
232
  "/account/tokens",
233
+ "/account/access-tokens",
170
234
  "/api-keys",
171
235
  "/api_keys",
236
+ "/api-tokens",
172
237
  "/keys",
173
238
  "/tokens",
239
+ "/access-tokens",
174
240
  "/auth-tokens",
241
+ "/developers",
175
242
  "/dashboard/api-keys",
176
243
  "/dashboard/keys",
177
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
+ }
178
284
  // 0.8.2-rc.10 — heuristic for "this account already exists on the
179
285
  // service and its API keys are masked, with no path to reveal them."
180
286
  // The test identity (methoxine@gmail.com) accumulates state across
@@ -241,34 +347,120 @@ export function detectExistingAccountNoExtract(input) {
241
347
  return true;
242
348
  return false;
243
349
  }
244
- // Pick the next fallback URL to try from STUCK_LOOP_FALLBACK_PATHS
245
- // keyed against the origin of the currently-stuck URL. Returns null
246
- // when every path has already been attempted. Exported for unit tests.
247
- export function pickStuckLoopFallbackUrl(currentUrl, alreadyTried) {
248
- 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;
249
428
  try {
250
- origin = new URL(currentUrl).origin;
429
+ parsed = new URL(currentUrl);
251
430
  }
252
431
  catch {
253
432
  return null;
254
433
  }
434
+ const origin = parsed.origin;
255
435
  // Skip a candidate when the current URL's path ALREADY matches it
256
436
  // (case-insensitive, trailing-slash tolerant). The planner is stuck
257
437
  // ON the page the candidate points to — navigating to the same URL
258
438
  // again won't break the cycle, only a different path will.
259
- const currentPath = (() => {
260
- try {
261
- return new URL(currentUrl).pathname.replace(/\/+$/, "").toLowerCase();
262
- }
263
- catch {
264
- return "";
265
- }
266
- })();
267
- 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);
268
461
  const candidate = `${origin}${path}`;
269
462
  if (alreadyTried.has(candidate))
270
463
  continue;
271
- const candidatePath = path.replace(/\/+$/, "").toLowerCase();
272
464
  if (candidatePath === currentPath)
273
465
  continue;
274
466
  return candidate;
@@ -570,6 +762,21 @@ export function parseSignupPlan(raw, allowedSelectors) {
570
762
  ? { actions, submit_selector: submitSelector, confidence, notes }
571
763
  : { actions, submit_selector: submitSelector, confidence };
572
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
+ }
573
780
  // Render the element inventory as a compact text block for the
574
781
  // planner — one line per element, ending with the verified
575
782
  // `selector=` the planner must copy verbatim (F3 T3).
@@ -1327,6 +1534,48 @@ export function findFirstOAuthButton(inventory, providers) {
1327
1534
  }
1328
1535
  return null;
1329
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
+ }
1330
1579
  // Order the OAuth providers the bot may use for a signup, given the
1331
1580
  // service's yaml pin (if any) and the providers the persistent profile
1332
1581
  // actually has a session for. `findFirstOAuthButton` walks this list in
@@ -1904,6 +2153,56 @@ export function extractApiKeyFromText(text) {
1904
2153
  }
1905
2154
  return null;
1906
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
+ }
1907
2206
  // Choose which link in a verification email to click. Scores each URL
1908
2207
  // by keyword and picks the best — but only if it scored positive.
1909
2208
  //
@@ -2159,6 +2458,12 @@ export class SignupAgent {
2159
2458
  let progressReplans = 0;
2160
2459
  let emptyPlans = 0;
2161
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;
2162
2467
  let hint;
2163
2468
  // F14 — selectors the planner clicked WITHOUT advancing the page.
2164
2469
  // Each no-progress plan records its click selectors here; the next
@@ -2242,6 +2547,36 @@ export class SignupAgent {
2242
2547
  "treating as already authenticated, jumping to post-verify navigation");
2243
2548
  return { kind: "already_oauth" };
2244
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
+ }
2245
2580
  steps.push("OAuth-first: no usable provider affordance on the page — " +
2246
2581
  "falling back to form-fill");
2247
2582
  // Dump visible buttons/links so we can see what the OAuth-
@@ -2528,6 +2863,27 @@ export class SignupAgent {
2528
2863
  emptyInputHint;
2529
2864
  continue;
2530
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
+ }
2531
2887
  steps.push(`⚠ submit click failed: ${reason}`);
2532
2888
  return { kind: "submit_failed", reason };
2533
2889
  }
@@ -3239,10 +3595,25 @@ export class SignupAgent {
3239
3595
  // Step 7: Email verification + post-verification navigation.
3240
3596
  let verificationFailed;
3241
3597
  if (credentials.api_key === undefined && credentials.username === undefined) {
3242
- // S3: read the post-submit page first. Whether it is actually
3243
- // asking the user to confirm by email decides both the no-inbox
3244
- // bail (M2) and, when an inbox exists, the poll duration.
3245
- 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);
3246
3617
  if (task.inbox === undefined) {
3247
3618
  // M2/S3: no inbox to receive a verification email (the SES
3248
3619
  // inbound pipeline is mothballed — TODOS M1). If the page is
@@ -3262,15 +3633,25 @@ export class SignupAgent {
3262
3633
  }
3263
3634
  }
3264
3635
  else {
3265
- // S3: don't blind-wait. If the page explicitly tells the user
3266
- // to check their email, poll the full timeout; if not, the
3267
- // 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).
3268
3645
  const verificationTimeoutSeconds = expectsEmail
3269
3646
  ? (task.verificationTimeoutSeconds ?? 180)
3270
- : VERIFICATION_PROBE_SECONDS;
3647
+ : submitRejected
3648
+ ? VERIFICATION_PROBE_SECONDS
3649
+ : SUBMITTED_PROBE_FLOOR_SECONDS;
3271
3650
  steps.push(expectsEmail
3272
3651
  ? `Post-submit page asks to check email — polling inbox (up to ${verificationTimeoutSeconds}s)...`
3273
- : `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)...`);
3274
3655
  try {
3275
3656
  const email = await this.waitForVerificationEmail(task.inbox, task.email, verificationTimeoutSeconds);
3276
3657
  steps.push(`Received: "${email.subject}" from ${email.from_address}`);
@@ -3314,7 +3695,9 @@ export class SignupAgent {
3314
3695
  steps.push(`Inbox poll failed: ${detail}`);
3315
3696
  verificationFailed = expectsEmail
3316
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`
3317
- : `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)`;
3318
3701
  }
3319
3702
  }
3320
3703
  }
@@ -3927,14 +4310,25 @@ export class SignupAgent {
3927
4310
  // siblings were never extracted. postVerifyLoop's top-of-iter
3928
4311
  // early-exit is itself multi-cred-aware (rc.13), so when there's
3929
4312
  // nothing more to do, it returns on the first iteration.
3930
- credentials = await this.postVerifyLoop({
3931
- service: task.service,
3932
- maxRounds: task.postVerifyMaxRounds ?? 24,
3933
- steps,
3934
- ...(task.scopeHint !== undefined ? { scopeHint: task.scopeHint } : {}),
3935
- ...(task.machineToken !== undefined ? { machineToken: task.machineToken } : {}),
3936
- ...(task.apiBase !== undefined ? { apiBase: task.apiBase } : {}),
3937
- });
4313
+ try {
4314
+ credentials = await this.postVerifyLoop({
4315
+ service: task.service,
4316
+ maxRounds: task.postVerifyMaxRounds ?? 24,
4317
+ steps,
4318
+ ...(task.scopeHint !== undefined ? { scopeHint: task.scopeHint } : {}),
4319
+ ...(task.machineToken !== undefined ? { machineToken: task.machineToken } : {}),
4320
+ ...(task.apiBase !== undefined ? { apiBase: task.apiBase } : {}),
4321
+ });
4322
+ }
4323
+ catch (err) {
4324
+ // A5 — OAuth bounced back to a login page; classify correctly
4325
+ // (oauth_session_not_persisted) instead of thrashing into
4326
+ // oauth_onboarding_failed.
4327
+ if (err instanceof OAuthSessionNotPersistedError) {
4328
+ return { success: false, error: err.message, steps, ...this.resultTail() };
4329
+ }
4330
+ throw err;
4331
+ }
3938
4332
  if (credentials.api_key !== undefined) {
3939
4333
  return {
3940
4334
  success: true,
@@ -4423,6 +4817,206 @@ ${formatInventory(input.inventory)}`,
4423
4817
  }
4424
4818
  return out;
4425
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
+ }
4426
5020
  async postVerifyLoop(args) {
4427
5021
  let credentials = await this.extractCredentials();
4428
5022
  // 0.8.2-rc.15 — also seed DOM-proximity at loop entry. If the
@@ -4446,6 +5040,13 @@ ${formatInventory(input.inventory)}`,
4446
5040
  // DOM-proximity again, this is just an opportunistic seed.
4447
5041
  }
4448
5042
  let loginAttempts = 0;
5043
+ // A5 — on an OAuth run the planner asks to `login` only when it SEES a
5044
+ // login page, which after a good OAuth it shouldn't. Repeated asks
5045
+ // mean the OAuth callback never established a session (the page is
5046
+ // still a social/login screen, e.g. groq's /authenticate). Count them
5047
+ // so the loop can bail with oauth_session_not_persisted instead of
5048
+ // thrashing maxRounds and mislabeling it oauth_onboarding_failed.
5049
+ let oauthLoginRequests = 0;
4449
5050
  let planFailures = 0;
4450
5051
  // 0.8.2-rc.6 — separate counter for upstream-blip retries. Doesn't
4451
5052
  // gate planFailures (so a transient 502 won't push us into the
@@ -5058,7 +5659,7 @@ ${formatInventory(input.inventory)}`,
5058
5659
  hint = undefined;
5059
5660
  continue;
5060
5661
  }
5061
- const fallback = pickStuckLoopFallbackUrl(state.url, triedFallbackUrls);
5662
+ const fallback = pickStuckLoopFallbackUrl(state.url, triedFallbackUrls, args.service);
5062
5663
  if (fallback !== null) {
5063
5664
  triedFallbackUrls.add(fallback);
5064
5665
  args.steps.push(`Post-verify: stuck-loop detected ${stuckFiresAtUrl}x at ${state.url} — escalating to a hardcoded API-key URL: ${fallback}`);
@@ -5472,10 +6073,23 @@ ${formatInventory(input.inventory)}`,
5472
6073
  }
5473
6074
  else if (nextStep.kind === "login") {
5474
6075
  if (args.credentials === undefined) {
5475
- // OAuth run — no password to give, and the Google session
5476
- // already authenticated us. Treat `login` as a no-op note.
5477
- args.steps.push("Post-verify: planner asked to log in, but this is an OAuth run " +
5478
- "already authenticated via Google; skipping.");
6076
+ // OAuth run — no password to give. A single ask can be a
6077
+ // transient mid-render read, but a SECOND ask means the page
6078
+ // keeps presenting login: the OAuth session didn't persist.
6079
+ // Bail with the right classification (A5) instead of thrashing
6080
+ // the rest of maxRounds and ending in oauth_onboarding_failed.
6081
+ oauthLoginRequests += 1;
6082
+ if (oauthLoginRequests >= 2) {
6083
+ const st = await this.browser.getState().catch(() => null);
6084
+ args.steps.push("Post-verify: planner hit a login page twice on an OAuth run — " +
6085
+ "the OAuth session didn't persist; bailing.");
6086
+ throw new OAuthSessionNotPersistedError(`oauth_session_not_persisted: signed in to ${args.service} via OAuth but the ` +
6087
+ `page still presents a login screen (${st !== null ? pathOf(st.url) : "?"}) — the ` +
6088
+ `OAuth callback never established a session (anti-bot rejection of the callback, ` +
6089
+ `or a service-side session-storage issue). Finish the signup manually.`);
6090
+ }
6091
+ args.steps.push("Post-verify: planner asked to log in on an OAuth run — already " +
6092
+ "authenticated via Google; skipping (1st ask, may be a transient read).");
5479
6093
  }
5480
6094
  else if (loginAttempts >= 2) {
5481
6095
  args.steps.push("Post-verify: already attempted login twice — stopping.");
@@ -5591,24 +6205,69 @@ ${formatInventory(input.inventory)}`,
5591
6205
  // correctly, the state is just unrecoverable for this identity.
5592
6206
  const alreadyClassified = this.lastPostVerifyDoneReason !== null &&
5593
6207
  this.lastPostVerifyDoneReason.startsWith("[");
5594
- if (credentials.api_key === undefined &&
5595
- credentials.username === undefined &&
5596
- !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;
5597
6232
  try {
5598
- const finalState = await this.browser.getState();
5599
- const finalText = await this.browser.extractText().catch(() => "");
5600
- if (detectExistingAccountNoExtract({
5601
- url: finalState.url,
5602
- pageText: finalText,
5603
- lastPlannerReason: this.lastPostVerifyDoneReason ?? "",
5604
- })) {
5605
- this.lastPostVerifyDoneReason =
5606
- `[existing_account_no_extract] at ${finalState.url}; latest planner reason: ${this.lastPostVerifyDoneReason ?? "(none — loop exhausted)"}`;
5607
- args.steps.push("Post-verify: classified as existing_account_no_extract — masked pre-existing key on an authenticated dashboard.");
5608
- }
6233
+ minted = await this.attemptMintNewKey(args.steps);
5609
6234
  }
5610
6235
  catch {
5611
- // 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
+ }
5612
6271
  }
5613
6272
  }
5614
6273
  return credentials;