@trusty-squire/mcp 0.9.13 → 0.9.14-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/bot/agent.d.ts +5 -1
  2. package/dist/bot/agent.d.ts.map +1 -1
  3. package/dist/bot/agent.js +496 -20
  4. package/dist/bot/agent.js.map +1 -1
  5. package/dist/bot/browser.d.ts +12 -0
  6. package/dist/bot/browser.d.ts.map +1 -1
  7. package/dist/bot/browser.js +838 -83
  8. package/dist/bot/browser.js.map +1 -1
  9. package/dist/bot/captcha-solver-2captcha.d.ts +18 -0
  10. package/dist/bot/captcha-solver-2captcha.d.ts.map +1 -1
  11. package/dist/bot/captcha-solver-2captcha.js +21 -0
  12. package/dist/bot/captcha-solver-2captcha.js.map +1 -1
  13. package/dist/bot/email-code-fetcher.d.ts +5 -0
  14. package/dist/bot/email-code-fetcher.d.ts.map +1 -0
  15. package/dist/bot/email-code-fetcher.js +33 -0
  16. package/dist/bot/email-code-fetcher.js.map +1 -0
  17. package/dist/bot/inbox-client.d.ts +1 -0
  18. package/dist/bot/inbox-client.d.ts.map +1 -1
  19. package/dist/bot/inbox-client.js +55 -15
  20. package/dist/bot/inbox-client.js.map +1 -1
  21. package/dist/bot/index.d.ts +0 -1
  22. package/dist/bot/index.d.ts.map +1 -1
  23. package/dist/bot/index.js +45 -19
  24. package/dist/bot/index.js.map +1 -1
  25. package/dist/bot/promote-to-skill.d.ts +2 -1
  26. package/dist/bot/promote-to-skill.d.ts.map +1 -1
  27. package/dist/bot/promote-to-skill.js +115 -6
  28. package/dist/bot/promote-to-skill.js.map +1 -1
  29. package/dist/bot/replay-skill.d.ts +17 -0
  30. package/dist/bot/replay-skill.d.ts.map +1 -1
  31. package/dist/bot/replay-skill.js +243 -10
  32. package/dist/bot/replay-skill.js.map +1 -1
  33. package/dist/bot/signup-lock.d.ts +17 -0
  34. package/dist/bot/signup-lock.d.ts.map +1 -0
  35. package/dist/bot/signup-lock.js +174 -0
  36. package/dist/bot/signup-lock.js.map +1 -0
  37. package/dist/tools/provision-any.d.ts.map +1 -1
  38. package/dist/tools/provision-any.js +25 -12
  39. package/dist/tools/provision-any.js.map +1 -1
  40. package/package.json +2 -2
  41. package/dist/bot/oauth-lock.d.ts +0 -2
  42. package/dist/bot/oauth-lock.d.ts.map +0 -1
  43. package/dist/bot/oauth-lock.js +0 -28
  44. package/dist/bot/oauth-lock.js.map +0 -1
package/dist/bot/agent.js CHANGED
@@ -1623,8 +1623,20 @@ export function detectAlreadySignedIn(args) {
1623
1623
  // /redis, /kafka, /vector, /cluster, /databases?, /instances?,
1624
1624
  // /apps?, /deployments?, /services? — all common product-name
1625
1625
  // routes that almost always indicate authenticated state.
1626
+ // An /organizations/<…> or /orgs/<…> prefix is an authenticated-only
1627
+ // marker: a logged-OUT visitor never has an organization-scoped route.
1628
+ // pinecone's post-OAuth plan-chooser sits at
1629
+ // app.pinecone.io/organizations/registration — the trailing
1630
+ // "registration" tripped the register-exclusion below and the bot, fully
1631
+ // signed in via the operator's existing Google-linked account, bailed
1632
+ // no_signup_link instead of routing to key-extraction (MEASURED
1633
+ // 2026-06-11: pinecone account was created May 25, so every later run
1634
+ // lands here authenticated). An org-prefixed path forces dashboardy and
1635
+ // bypasses the auth-route exclusion.
1636
+ const hasOrgPrefix = /\/(?:organizations?|orgs?)\//i.test(parsed.pathname);
1626
1637
  dashboardyPath =
1627
- /\/(?:new|dashboard|projects?|account|settings|workspace|home|redis|kafka|vector|cluster|databases?|instances?|apps?|deployments?|services?|onboarding|welcome|getting-started|get-started|setup)(?:\/|$)/i.test(parsed.pathname) && !/\/(?:signup|sign-up|register|login|sign-in|signin)/i.test(parsed.pathname);
1638
+ hasOrgPrefix ||
1639
+ (/\/(?:new|dashboard|projects?|account|settings|workspace|home|redis|kafka|vector|cluster|databases?|instances?|apps?|deployments?|services?|onboarding|welcome|getting-started|get-started|setup)(?:\/|$)/i.test(parsed.pathname) && !/\/(?:signup|sign-up|register|login|sign-in|signin)/i.test(parsed.pathname));
1628
1640
  }
1629
1641
  catch {
1630
1642
  // Malformed URL — skip URL signal.
@@ -2128,6 +2140,29 @@ export function detectGoogleNoAccount(url, bodyText) {
2128
2140
  // post-verify planner can't reliably target, and the bot loops
2129
2141
  // trying. Defining trait: hostname accounts.google.com (or
2130
2142
  // accounts.googleusercontent.com) at the post-OAuth gate.
2143
+ // Is this URL on an OAuth PROVIDER's own domain (github.com, accounts.google.com,
2144
+ // gitlab.com, …)? Such a URL is mid-handshake — its `/login/oauth/authorize`
2145
+ // path reads as a "login route", but navigating to the provider's ROOT abandons
2146
+ // the handshake on the provider's domain instead of returning to the service
2147
+ // (MEASURED 2026-06-11: typesense's GitHub OAuth landed on
2148
+ // github.com/login/oauth/authorize and the dead-route escape navigated to
2149
+ // github.com/, breaking the flow). The dead-route escape must skip these.
2150
+ export function isOAuthProviderHost(url) {
2151
+ try {
2152
+ const h = new URL(url).hostname.toLowerCase();
2153
+ return (h === "github.com" ||
2154
+ h === "gitlab.com" ||
2155
+ h === "bitbucket.org" ||
2156
+ h === "accounts.google.com" ||
2157
+ h === "accounts.googleusercontent.com" ||
2158
+ h.endsWith(".accounts.google.com") ||
2159
+ h === "login.microsoftonline.com" ||
2160
+ h === "appleid.apple.com");
2161
+ }
2162
+ catch {
2163
+ return false;
2164
+ }
2165
+ }
2131
2166
  export function detectStuckOnGoogleOAuth(url) {
2132
2167
  try {
2133
2168
  const h = new URL(url).hostname.toLowerCase();
@@ -2428,7 +2463,12 @@ export function extractQuotedTokenFromReason(reason, pageText) {
2428
2463
  // its MAX_CREDENTIAL_LENGTH counterpart. Character class matches
2429
2464
  // what real API tokens look like: alphanumeric, underscores,
2430
2465
  // hyphens; no spaces, no punctuation that would gather UI text.
2431
- const matches = reason.matchAll(/['"`]([A-Za-z0-9_\-]{10,80})['"`]/g);
2466
+ // `.` is in the class: many tokens are dot-separated (Zerops
2467
+ // `LhJbaP.VeODh3ZZ…`, GitLab PATs, JWTs, Slack `xox*`); excluding it
2468
+ // dropped every dotted token to null and looped to run_timeout
2469
+ // (MEASURED 2026-06-12: zerops). The verbatim pageText.includes guard
2470
+ // below keeps a sentence's trailing period from matching.
2471
+ const matches = reason.matchAll(/['"`]([A-Za-z0-9_.\-]{10,80})['"`]/g);
2432
2472
  for (const m of matches) {
2433
2473
  const candidate = m[1];
2434
2474
  if (candidate === undefined)
@@ -2582,7 +2622,7 @@ export function extractAllLabeledTokensFromReason(reason, pageText) {
2582
2622
  // credential-shape (mixed alpha+digit, ≥16 chars, OR a known
2583
2623
  // credential prefix); (2) hard-reject a curated set of common
2584
2624
  // English status words that look label-like in extract prose.
2585
- const quotedRe = new RegExp(`\\b(${labelAltLoose})\\b\\s*[=:]\\s*['"\`]([A-Za-z0-9_\\-]{4,80})['"\`]`, "gi");
2625
+ const quotedRe = new RegExp(`\\b(${labelAltLoose})\\b\\s*[=:]\\s*['"\`]([A-Za-z0-9_.\\-]{4,80})['"\`]`, "gi");
2586
2626
  for (const m of reason.matchAll(quotedRe)) {
2587
2627
  const rawLabel = (m[1] ?? "").toLowerCase().replace(/[-\s]+/g, "_");
2588
2628
  const normalized = rawLabel.replace(/_+/g, "_");
@@ -2630,7 +2670,7 @@ export function extractAllLabeledTokensFromReason(reason, pageText) {
2630
2670
  // Same separator vocab as quoted, plus optional quotes around the
2631
2671
  // value. The credential-shape + blacklist guards run on the
2632
2672
  // captured (possibly-unquoted) value.
2633
- const proseRe = new RegExp(`\\b(${labelAltLoose})\\b\\s*(?:[=:]|\\b(?:is|are)\\b)\\s*['"\`]?([A-Za-z0-9_\\-]{4,80})['"\`]?`, "gi");
2673
+ const proseRe = new RegExp(`\\b(${labelAltLoose})\\b\\s*(?:[=:]|\\b(?:is|are)\\b)\\s*['"\`]?([A-Za-z0-9_.\\-]{4,80})['"\`]?`, "gi");
2634
2674
  for (const m of reason.matchAll(proseRe)) {
2635
2675
  const rawLabel = (m[1] ?? "").toLowerCase().replace(/[-\s]+/g, "_");
2636
2676
  const normalized = rawLabel.replace(/_+/g, "_");
@@ -2899,6 +2939,26 @@ const CREDENTIAL_NOISE_TOKENS = [
2899
2939
  "protonpass",
2900
2940
  "autofill",
2901
2941
  "passwords",
2942
+ // Cookie-consent widget vocabulary (CookieScript/OneTrust-class banners
2943
+ // render these as checkbox values and category labels on EVERY page,
2944
+ // earlier in DOM order than any credential — zilliz's banner fed
2945
+ // "personalization" to the validator-shaped scan tier as the "key").
2946
+ // Whole-token equality with a generic English word is never a real
2947
+ // credential, so rejecting these costs nothing.
2948
+ "necessary",
2949
+ "analytics",
2950
+ "personalization",
2951
+ "personalisation",
2952
+ "advertising",
2953
+ "advertisement",
2954
+ "marketing",
2955
+ "functional",
2956
+ "preferences",
2957
+ "statistics",
2958
+ "performance",
2959
+ "targeting",
2960
+ "unclassified",
2961
+ "security",
2902
2962
  ];
2903
2963
  // Verb-prefixed UI affordances ("Save to 1Password", "Copy to
2904
2964
  // clipboard", "Add to vault"). The candidate-scan tiers tokenize on
@@ -3009,12 +3069,23 @@ export function pickVerificationLinkFromHtml(bodyHtml) {
3009
3069
  // then a standalone 6-digit number (the most common verification length).
3010
3070
  // Returns null when nothing code-shaped is found so the caller still bails
3011
3071
  // honestly rather than typing garbage. Exported for unit testing.
3012
- export function extractCodeFromEmailBody(email) {
3013
- const text = [
3072
+ export function extractCodeFromEmailBody(email,
3073
+ // The recipient address, when known. Verification emails routinely echo
3074
+ // the recipient ("sent to sandra.young487@…"); if its local part carries
3075
+ // digits they can be mistaken for the code. Strip the address out before
3076
+ // scanning so a human-looking alias never poisons the extraction.
3077
+ recipient) {
3078
+ let text = [
3014
3079
  email.subject ?? "",
3015
3080
  email.body_text ?? "",
3016
3081
  (email.body_html ?? "").replace(/<[^>]+>/g, " "),
3017
3082
  ].join("\n");
3083
+ if (recipient !== undefined && recipient.length > 0) {
3084
+ text = text.split(recipient).join(" ");
3085
+ const local = recipient.split("@")[0];
3086
+ if (local !== undefined && local.length > 0)
3087
+ text = text.split(local).join(" ");
3088
+ }
3018
3089
  // 1) A code sitting next to a verification keyword — the strongest signal.
3019
3090
  const kw = text.match(/(?:verification code|sign[\s-]?in code|one[\s-]?time(?:\s+(?:code|password))?|security code|your code|confirmation code|code is|enter(?:\s+this)?\s+code)\b[^0-9]{0,40}([0-9]{4,8})\b/i);
3020
3091
  if (kw?.[1] !== undefined)
@@ -3222,6 +3293,17 @@ export class SignupAgent {
3222
3293
  // burn through more than MAX_LLM_CALLS_PER_SIGNUP. Reset isn't needed
3223
3294
  // because each signup gets a fresh SignupAgent in index.ts.
3224
3295
  llmCallCount = 0;
3296
+ // Capture-chain round counter shared across the signup-form-fill phase
3297
+ // (captureSignupFormRounds) and the post-verify loop so they form ONE
3298
+ // contiguous chain. Stays 0 on the OAuth path (no form-fill capture), so
3299
+ // post-verify starts at 0 exactly as before. Per-run instance → no reset.
3300
+ captureChainRound = 0;
3301
+ // The stable signup-form entry URL the bot navigated to (e.g.
3302
+ // cloud.zilliz.com/signup). captureSignupFormRounds stamps it as the
3303
+ // preamble rounds' URL instead of the transient SPA URL getState() reads
3304
+ // mid-fill (zilliz settles to /login/loading) — so the synthesized
3305
+ // signup_url points a fresh replay at the real form, not a loading shell.
3306
+ resolvedSignupUrl;
3225
3307
  // Tracks which backend handled each call, for debugging cost/quality.
3226
3308
  // backends_used[i] is the .name string of the LLMClient that produced
3227
3309
  // the i-th reply this run.
@@ -3294,7 +3376,20 @@ export class SignupAgent {
3294
3376
  steps.push(`${label} captcha gate skipped — session already captcha-blocked (${kind}).`);
3295
3377
  return { found: true, solved: false, blocked: true, kind };
3296
3378
  }
3297
- let result = await this.browser.solveVisibleCaptcha();
3379
+ // Best-effort: captcha DETECTION must never abort a signup. A bounded
3380
+ // boundingBox / detect race inside solveVisibleCaptcha that throws is
3381
+ // treated as "no widget here, proceed" — the OAuth-first path already
3382
+ // wraps this call (browser.ts ~6351); the form-fill path didn't, so an
3383
+ // invisible-mode Turnstile (which patchright + residential pass) crashed
3384
+ // the run instead of falling through to submit.
3385
+ let result;
3386
+ try {
3387
+ result = await this.browser.solveVisibleCaptcha();
3388
+ }
3389
+ catch (err) {
3390
+ steps.push(`${label} captcha gate: detection error (${err instanceof Error ? err.message : String(err)}) — treating as no widget, continuing`);
3391
+ return { found: false, solved: false, blocked: false, kind: "turnstile" };
3392
+ }
3298
3393
  if (!result.found) {
3299
3394
  // No VISIBLE widget — but an invisible Turnstile / reCAPTCHA-v3 may
3300
3395
  // be present and scoring silently. Record its presence once (a
@@ -3398,6 +3493,40 @@ export class SignupAgent {
3398
3493
  steps.push(`${label} captcha: hCaptcha widget detected but no sitekey found — cannot Tier-3 solve`);
3399
3494
  }
3400
3495
  }
3496
+ // Turnstile Tier 3 (2026-06-12). Mirrors the hCaptcha path; the "Cloudflare
3497
+ // IP-scores Turnstile so a solver token is rejected" belief that kept this
3498
+ // off is FALSIFIED (exa fails on a fresh direct residential IP + real GPU
3499
+ // — not IP-bound; STATE.md). The 2Captcha key is configured. Covers the
3500
+ // post-SUBMIT form Turnstile (cartesia-class), complementing the
3501
+ // OAuth-precheck branch in runOAuthFlow.
3502
+ if (!result.solved &&
3503
+ result.kind === "turnstile" &&
3504
+ this.captchaSolver?.isAvailable() === true) {
3505
+ const sitekey = await this.browser.extractTurnstileSitekey();
3506
+ const pageUrl = (await this.browser.getState().catch(() => null))?.url;
3507
+ if (sitekey !== null && pageUrl !== undefined) {
3508
+ steps.push(`${label} captcha: Tier 3 — submitting Turnstile sitekey to 2Captcha (${sitekey.slice(0, 12)}…)`);
3509
+ const solveRes = await this.captchaSolver.solveTurnstile({ sitekey, pageUrl });
3510
+ if (solveRes.kind === "ok") {
3511
+ const injected = await this.browser.injectTurnstileToken(solveRes.token);
3512
+ if (injected) {
3513
+ steps.push(`${label} captcha: Tier 3 Turnstile solved in ${Math.round(solveRes.durationMs / 1000)}s via 2Captcha`);
3514
+ result = { ...result, solved: true };
3515
+ }
3516
+ else {
3517
+ steps.push(`${label} captcha: Tier 3 Turnstile token arrived but page injection failed — captcha stays blocked`);
3518
+ }
3519
+ }
3520
+ else {
3521
+ steps.push(`${label} captcha: Tier 3 Turnstile ${solveRes.kind}` +
3522
+ ("reason" in solveRes ? `: ${solveRes.reason}` : "") +
3523
+ ("durationMs" in solveRes ? ` (${Math.round(solveRes.durationMs / 1000)}s)` : ""));
3524
+ }
3525
+ }
3526
+ else if (sitekey === null) {
3527
+ steps.push(`${label} captcha: Turnstile widget detected but no sitekey found — cannot Tier-3 solve`);
3528
+ }
3529
+ }
3401
3530
  // rc.32 — forensic snapshot after the captcha attempt. Without
3402
3531
  // this, the only snapshot near the captcha is the pre-fill one
3403
3532
  // taken BEFORE the click, so when a Turnstile fails to solve we
@@ -3672,7 +3801,27 @@ export class SignupAgent {
3672
3801
  // default 2 retries (otherwise the bot gives up at ~6s and wrongly
3673
3802
  // falls back to the email-signup path before the GitHub button
3674
3803
  // even exists).
3675
- const oauthScanShell = isLoadingShellText(await this.browser.extractText().catch(() => ""));
3804
+ // An almost-empty inventory is itself a strong unhydrated-SPA signal,
3805
+ // even when the page TEXT doesn't match the loading-shell phrases
3806
+ // (lancedb's accounts.lancedb.com/sign-up renders its Google button
3807
+ // late and shows 0 interactive candidates meanwhile — MEASURED
3808
+ // 2026-06-11: it bailed oauth_required after only 2 retries because
3809
+ // the text heuristic missed it). Treat ≤1 interactive elements as a
3810
+ // loading shell so the late-rendering provider button gets the patient
3811
+ // 8-retry budget instead of a premature email-fallback bail.
3812
+ //
3813
+ // Also patient when the page has NO provider button (we're in this
3814
+ // branch because the scan found none) AND no email/password form yet:
3815
+ // the auth surface simply hasn't hydrated. An OAuth-ONLY signup
3816
+ // (replit: "Continue with Google/GitHub", no credential input) renders
3817
+ // its provider buttons a beat late, and with >1 element it otherwise
3818
+ // got only 2 retries and bailed oauth_required. If a form IS present,
3819
+ // it's a genuine form-signup → fall back to form-fill without waiting.
3820
+ const hasCredentialInput = inventory.some((e) => e.tag === "input" &&
3821
+ (e.type === "email" || e.type === "password" || e.type === "tel"));
3822
+ const oauthScanShell = inventory.length <= 1 ||
3823
+ !hasCredentialInput ||
3824
+ isLoadingShellText(await this.browser.extractText().catch(() => ""));
3676
3825
  const maxOauthScanRetries = oauthScanShell ? 8 : 2;
3677
3826
  if (oauthScanRetries < maxOauthScanRetries) {
3678
3827
  oauthScanRetries += 1;
@@ -4104,9 +4253,67 @@ export class SignupAgent {
4104
4253
  hint = `The previous submit produced validation errors. Visible page text: ${afterText.slice(0, 600)}`;
4105
4254
  continue;
4106
4255
  }
4256
+ // Capture the signup-form preamble (email + password fills + the
4257
+ // submit click) as the FIRST rounds of the chain, so a synthesized
4258
+ // email-OTP skill's graph DISPATCHES the verification email before
4259
+ // its await_email_code step. Without it the capture begins post-
4260
+ // verify and the skill can never replay (zilliz). Only the email-form
4261
+ // path reaches here (OAuth returns `already_oauth` earlier), so OAuth
4262
+ // skills keep starting their chain at round 0.
4263
+ await this.captureSignupFormRounds(task.service, plan, inventory, fillValues);
4107
4264
  return { kind: "submitted" };
4108
4265
  }
4109
4266
  }
4267
+ // Emit the signup-form-fill rounds (email + password + submit) into the
4268
+ // capture chain. Shares this.captureChainRound with the post-verify loop
4269
+ // so the two phases form one contiguous 0..N chain. The captured email
4270
+ // value is templatized to ${EMAIL_ALIAS} by the synthesizer; the
4271
+ // generated throwaway password is baked literally (a fresh account each
4272
+ // replay, so reuse is harmless). Best-effort — capture must never fail a
4273
+ // signup.
4274
+ async captureSignupFormRounds(service, plan, inventory, fillValues) {
4275
+ try {
4276
+ const live = await this.browser.getState();
4277
+ // Stamp the STABLE signup-form URL (not the transient SPA URL the SPA
4278
+ // may have settled to mid-fill); the synthesizer derives signup_url
4279
+ // from round 0's url, and a fresh replay must land on the real form.
4280
+ const state = {
4281
+ ...live,
4282
+ url: this.resolvedSignupUrl ?? live.url,
4283
+ };
4284
+ const emit = (observed) => {
4285
+ captureOnboardingRound({
4286
+ service,
4287
+ round: this.captureChainRound,
4288
+ oauth: false,
4289
+ state,
4290
+ inventory,
4291
+ observed,
4292
+ });
4293
+ this.captureChainRound += 1;
4294
+ };
4295
+ for (const action of plan.actions) {
4296
+ if (action.kind !== "fill")
4297
+ continue;
4298
+ if (action.value_kind !== "email" && action.value_kind !== "password")
4299
+ continue;
4300
+ emit({
4301
+ kind: "fill",
4302
+ selector: action.selector,
4303
+ value: fillValues[action.value_kind],
4304
+ reason: `Fill the signup ${action.value_kind}`,
4305
+ });
4306
+ }
4307
+ emit({
4308
+ kind: "click",
4309
+ selector: plan.submit_selector,
4310
+ reason: "Submit the signup form to dispatch the verification email",
4311
+ });
4312
+ }
4313
+ catch {
4314
+ // Capture is a synthesis input + forensic aid; never fatal.
4315
+ }
4316
+ }
4110
4317
  // Extract + rank the page's interactive elements (F3 T1/T2).
4111
4318
  // `oauthProviders` keeps those providers' OAuth affordances from
4112
4319
  // being ranked out of the capped inventory (T6/T13 + auto-prefer).
@@ -4631,6 +4838,7 @@ export class SignupAgent {
4631
4838
  await this.runPrewarm(signupUrl, steps);
4632
4839
  }
4633
4840
  steps.push(`Navigating to ${signupUrl}`);
4841
+ this.resolvedSignupUrl = signupUrl;
4634
4842
  await this.browser.goto(signupUrl);
4635
4843
  // Clear any anti-bot interstitial BEFORE the landing read below.
4636
4844
  // goto() only awaits domcontentloaded, so a Cloudflare "Verifying you
@@ -4683,12 +4891,44 @@ export class SignupAgent {
4683
4891
  // detectAlreadySignedIn's precondition (no email/password/tel input
4684
4892
  // visible) makes this safe: a real signup/login page short-circuits to
4685
4893
  // false before any dashboard marker is considered.
4686
- const landed = await this.browser.getState();
4687
- const landedInventory = await this.browser.extractInteractiveElements();
4688
- if (detectAlreadySignedIn({
4894
+ let landed = await this.browser.getState();
4895
+ let landedInventory = await this.browser.extractInteractiveElements();
4896
+ let signedIn = detectAlreadySignedIn({
4689
4897
  inventory: landedInventory,
4690
4898
  url: landed.url,
4691
- })) {
4899
+ });
4900
+ // SPA-settle re-check (returning-user cluster). An authenticated SPA
4901
+ // redirects the session AFTER the first read — pinecone's
4902
+ // `/?sessionType=signup` routes to `/organizations/registration` once
4903
+ // React hydrates — so a returning-user landing is initially captured as
4904
+ // an un-hydrated shell (no dashboard markers, no credential input yet)
4905
+ // and missed → no_signup_link. When the first detect is false, the page
4906
+ // is shell-like (few elements), AND there's no credential form rendered,
4907
+ // dwell once and re-read before classifying. Safe: a real login/signup
4908
+ // page renders its credential input or signup affordance, so
4909
+ // detectAlreadySignedIn stays false on the re-check (no false positive).
4910
+ if (!signedIn && landedInventory.length <= 4) {
4911
+ const hasCredInput = landedInventory.some((e) => e.tag === "input" &&
4912
+ (e.type === "email" || e.type === "password" || e.type === "tel"));
4913
+ // Only a BARE shell (no credential form AND no OAuth/signup button)
4914
+ // is a returning-user-redirect candidate. A page that already shows a
4915
+ // provider button is a usable signup entry — take it as-is (and don't
4916
+ // perturb the OAuth-first flow with an extra settle).
4917
+ const hasOAuthHere = findFirstOAuthButton(landedInventory, ["google", "github"]) !== null;
4918
+ if (!hasCredInput && !hasOAuthHere) {
4919
+ await this.browser.wait(3);
4920
+ await this.browser.waitForInteractiveDom(5, 12_000).catch(() => undefined);
4921
+ const reLanded = await this.browser.getState();
4922
+ const reInv = await this.browser.extractInteractiveElements();
4923
+ if (detectAlreadySignedIn({ inventory: reInv, url: reLanded.url })) {
4924
+ landed = reLanded;
4925
+ landedInventory = reInv;
4926
+ signedIn = true;
4927
+ steps.push(`${task.service}: returning-user dashboard surfaced after SPA settle (${pathOf(reLanded.url)})`);
4928
+ }
4929
+ }
4930
+ }
4931
+ if (signedIn) {
4692
4932
  steps.push(`${task.service}: already authenticated (dashboard markers, no signup CTA) — ` +
4693
4933
  `skipping signup, routing straight to key extraction`);
4694
4934
  alreadyAuthenticated = true;
@@ -5188,7 +5428,9 @@ export class SignupAgent {
5188
5428
  credentials = await this.extractCredentials();
5189
5429
  // If no creds yet, run the Claude-planned navigation loop.
5190
5430
  if (credentials.api_key === undefined && credentials.username === undefined) {
5191
- const maxRounds = task.postVerifyMaxRounds ?? 6;
5431
+ // 24 (not 6) same multi-step onboarding wizard the OAuth
5432
+ // path budgets for; see the enterEmailVerificationCode note.
5433
+ const maxRounds = task.postVerifyMaxRounds ?? 24;
5192
5434
  credentials = await this.postVerifyLoop({
5193
5435
  service: task.service,
5194
5436
  credentials: { email: task.email, password },
@@ -5207,7 +5449,7 @@ export class SignupAgent {
5207
5449
  // No link and the inbox parser found no code — last-resort
5208
5450
  // scan the email body ourselves for a verification code
5209
5451
  // (passwordless "we emailed you a code" flow, e.g. axiom).
5210
- const bodyCode = extractCodeFromEmailBody(email);
5452
+ const bodyCode = extractCodeFromEmailBody(email, task.email);
5211
5453
  if (bodyCode !== null) {
5212
5454
  steps.push(`Email had no link but carried a verification code (…${bodyCode.slice(-2)}) — entering it.`);
5213
5455
  credentials = await this.enterEmailVerificationCode(bodyCode, task, password, steps);
@@ -5376,6 +5618,42 @@ export class SignupAgent {
5376
5618
  }
5377
5619
  }
5378
5620
  }
5621
+ // Turnstile Tier-3 (2026-06-12). The "Cloudflare IP-scores Turnstile
5622
+ // so a solver token is rejected" belief that kept this OFF was
5623
+ // FALSIFIED — exa fails on a fresh direct residential IP + real GPU, so
5624
+ // its Turnstile is NOT IP-bound (STATE.md). The 2Captcha key is
5625
+ // configured (harvester.env). Try the solver token; if Cloudflare still
5626
+ // rejects it the step trail says so and we fall through to the
5627
+ // inert-click path (which now bails captcha_blocked truthfully).
5628
+ if (!solvedViaTier3 &&
5629
+ captcha.kind === "turnstile" &&
5630
+ this.captchaSolver?.isAvailable() === true) {
5631
+ const sitekey = await this.browser.extractTurnstileSitekey();
5632
+ const pageUrl = (await this.browser.getState().catch(() => null))?.url;
5633
+ if (sitekey !== null && pageUrl !== undefined) {
5634
+ steps.push(`OAuth: Tier 3 — submitting Turnstile sitekey to 2Captcha (${sitekey.slice(0, 12)}…)`);
5635
+ const solveRes = await this.captchaSolver.solveTurnstile({ sitekey, pageUrl });
5636
+ if (solveRes.kind === "ok") {
5637
+ const injected = await this.browser.injectTurnstileToken(solveRes.token);
5638
+ if (injected) {
5639
+ solvedViaTier3 = true;
5640
+ steps.push(`OAuth: Tier 3 solved the Turnstile in ${Math.round(solveRes.durationMs / 1000)}s via 2Captcha — clicking the ${provider.label} affordance`);
5641
+ }
5642
+ else {
5643
+ steps.push(`OAuth: Tier 3 Turnstile token arrived but page injection failed — clicking the ${provider.label} affordance anyway`);
5644
+ }
5645
+ }
5646
+ else {
5647
+ steps.push(`OAuth: Tier 3 Turnstile ${solveRes.kind}` +
5648
+ ("reason" in solveRes ? `: ${solveRes.reason}` : "") +
5649
+ ("durationMs" in solveRes ? ` (${Math.round(solveRes.durationMs / 1000)}s)` : "") +
5650
+ ` — clicking the ${provider.label} affordance anyway`);
5651
+ }
5652
+ }
5653
+ else {
5654
+ steps.push(`OAuth: Tier 3 Turnstile — no sitekey found on page, skipping solver`);
5655
+ }
5656
+ }
5379
5657
  if (!solvedViaTier3) {
5380
5658
  steps.push(`OAuth: visible ${captcha.kind} present but did not solve in 20s — clicking the ${provider.label} affordance anyway`);
5381
5659
  }
@@ -5945,7 +6223,8 @@ export class SignupAgent {
5945
6223
  // ORIGIN ROOT lets the service redirect an authenticated user to its
5946
6224
  // real dashboard. Generalizes: a service already on its dashboard has a
5947
6225
  // non-auth path here and is left alone.
5948
- if (isSignupOrLoginRoute(this.browser.currentUrl())) {
6226
+ if (isSignupOrLoginRoute(this.browser.currentUrl()) &&
6227
+ !isOAuthProviderHost(this.browser.currentUrl())) {
5949
6228
  const root = originRoot(this.browser.currentUrl());
5950
6229
  if (root !== null) {
5951
6230
  steps.push(`OAuth: post-auth landing is a signup/login route (${pathOf(this.browser.currentUrl())}) — ` +
@@ -5960,6 +6239,34 @@ export class SignupAgent {
5960
6239
  }
5961
6240
  }
5962
6241
  await saveDebugSnapshot(this.browser, "oauth-post-consent");
6242
+ // Captcha-gated OAuth detection (truthful failure, not a false "signed in").
6243
+ // exa's login lives at auth.exa.ai/?callbackUrl=… (path "/"), which
6244
+ // isLoginPageUrl doesn't recognise, so the settle loop above declares
6245
+ // "redirected to the app" even when we never left the gated login page.
6246
+ // If we're STILL showing a provider OAuth button AND a Turnstile/verify
6247
+ // challenge, the sign-in click was INERT — the unsolved captcha gated the
6248
+ // button, the URL never reached the provider, and we are NOT signed in.
6249
+ // MEASURED 2026-06-12: exa fails identically on a real laptop + fresh
6250
+ // direct residential IP (so it is the Turnstile/automation layer, NOT
6251
+ // IP/fingerprint — see STATE.md). Bail captcha_blocked instead of burning
6252
+ // the whole post-verify budget flailing on the login page.
6253
+ {
6254
+ const postText = (await this.browser.extractText().catch(() => "")).toLowerCase();
6255
+ const captchaChallenge = /complete the verification challenge|verify you are human|are you human|please complete the (?:captcha|challenge|verification)/i.test(postText);
6256
+ if (captchaChallenge) {
6257
+ const postInv = await this.browser
6258
+ .extractInteractiveElements()
6259
+ .catch(() => []);
6260
+ const stillGatedByProviderButton = findFirstOAuthButton(postInv, [provider.id]) !== null;
6261
+ if (stillGatedByProviderButton) {
6262
+ return this.oauthAbort("captcha_blocked", `${task.service}'s ${provider.label} sign-in is gated by an unsolved ` +
6263
+ `Turnstile/verification challenge — the OAuth click was inert (still on the ` +
6264
+ `pre-auth login page). Not IP/fingerprint (a real browser + fresh direct IP ` +
6265
+ `fails identically); this service runs a strict Turnstile our automated solve ` +
6266
+ `can't clear.`, steps);
6267
+ }
6268
+ }
6269
+ }
5963
6270
  steps.push(`OAuth: signed in via ${provider.label} — driving post-OAuth onboarding to the API key`);
5964
6271
  // amplitude class — OAuth drops the bot into the service's READ-ONLY DEMO
5965
6272
  // sandbox (app.amplitude.com/analytics/demo) instead of a real account: it
@@ -6529,6 +6836,16 @@ Output rules:
6529
6836
  modify a selector. A selector not in the inventory is rejected and
6530
6837
  you will be asked to re-plan.
6531
6838
  - Include the TOS/agree checkbox ONLY if the inventory has a real input of type=checkbox for it. If there is no such checkbox, OMIT the check action entirely — never substitute a link or a button.
6839
+ - Email verification-CODE buttons: if the inventory has a button like
6840
+ "Send code", "Send verification code", "Get code", or "Email me a
6841
+ code" beside an OTP / verification-code field, you MUST include a
6842
+ click action on it, ORDERED AFTER the email fill. The bot operates a
6843
+ live email inbox: clicking it dispatches the code to the filled
6844
+ email, and the bot fetches the emailed code and fills the code field
6845
+ itself in a later step. NEVER skip this button or treat the code as
6846
+ un-automatable / manual — if you omit it, the service never sends the
6847
+ email and the signup stalls. Do NOT fill the code field yourself (you
6848
+ don't have the code yet); just click the send-code button.
6532
6849
  - Skip elements marked [cookie-consent — avoid], and skip optional
6533
6850
  marketing-opt-in checkboxes.
6534
6851
  - Do NOT add a separate password-confirmation fill unless the
@@ -6632,7 +6949,13 @@ ${formatInventory(input.inventory)}`,
6632
6949
  return this.postVerifyLoop({
6633
6950
  service: task.service,
6634
6951
  credentials: { email: task.email, password },
6635
- maxRounds: task.postVerifyMaxRounds ?? 6,
6952
+ // Match the OAuth post-verify budget (24). The onboarding form
6953
+ // reached after EMAIL verification is the same multi-step wizard the
6954
+ // OAuth path hits — a profile form (name, country, company, role) +
6955
+ // a "create your first project" step can need 8-10 rounds alone.
6956
+ // The old 6 starved zilliz's "Set up your account" form: the planner
6957
+ // had filled only name+jobTitle when the budget ran out.
6958
+ maxRounds: task.postVerifyMaxRounds ?? 24,
6636
6959
  steps,
6637
6960
  initialHint: hint,
6638
6961
  ...(task.scopeHint !== undefined ? { scopeHint: task.scopeHint } : {}),
@@ -7046,6 +7369,13 @@ ${formatInventory(input.inventory)}`,
7046
7369
  // persisted (anti-bot/IP rejection) ⇒ oauth_session_not_persisted, not
7047
7370
  // a navigation bug. Generalizes without per-service URLs.
7048
7371
  let consecutiveOauthLoginPageRounds = 0;
7372
+ // Fired once: before declaring the OAuth session dead, reload the page —
7373
+ // an authenticated session whose cookie IS set often shows a transient
7374
+ // login screen (Auth0/WorkOS silent re-auth round-trip, or a slow SPA that
7375
+ // renders the login shell before hydrating the dashboard). A reload lands
7376
+ // the dashboard for those; a genuine callback rejection stays on login
7377
+ // even after reload, so this never masks a real wall.
7378
+ let oauthBounceReloadTried = false;
7049
7379
  let planFailures = 0;
7050
7380
  // 0.8.2-rc.6 — separate counter for upstream-blip retries. Doesn't
7051
7381
  // gate planFailures (so a transient 502 won't push us into the
@@ -7116,6 +7446,18 @@ ${formatInventory(input.inventory)}`,
7116
7446
  // rounds with no inventory change and break out with the proper
7117
7447
  // status before burning the post-verify budget.
7118
7448
  let consecutiveWaits = 0;
7449
+ // Writer-class hung-redirect tracker. A post-OAuth interstitial
7450
+ // (app.writer.com/redirect-auth?…&registered=true) renders a spinner
7451
+ // SHELL — non-zero interactive elements — that never resolves because the
7452
+ // new account's bootstrap (workspace/org provisioning) hangs. The planner
7453
+ // correctly emits `wait`, but the 0-element guard above never fires (the
7454
+ // shell has elements), so it waits out the entire 600s budget (MEASURED
7455
+ // 2026-06-11: writer). Track consecutive waits on the SAME url regardless
7456
+ // of element count; reload once (the redirect usually completes on a fresh
7457
+ // load), then break with a clean terminal reason.
7458
+ let consecutiveSameUrlWaits = 0;
7459
+ let lastWaitUrl = null;
7460
+ let waitReloadTried = false;
7119
7461
  // rc.39 — navigate-loop tracker. Perplexity / Koyeb / Porter all
7120
7462
  // had post-verify loops where the planner emitted `navigate`
7121
7463
  // 5-6 rounds in a row and the URL never changed — the service
@@ -7203,8 +7545,10 @@ ${formatInventory(input.inventory)}`,
7203
7545
  // rejected the run as `missing_round`, and auto-promote silently
7204
7546
  // dropped it. By tracking `capturedRound` separately we get a
7205
7547
  // contiguous 0..N-1 chain regardless of how many planner re-plans
7206
- // happen mid-run.
7207
- let capturedRound = 0;
7548
+ // happen mid-run. Continues from any signup-form preamble rounds the
7549
+ // form-fill phase already captured (captureSignupFormRounds); 0 on the
7550
+ // OAuth path, so this is unchanged for OAuth skills.
7551
+ let capturedRound = this.captureChainRound;
7208
7552
  // 0.8.2-rc.12 — multi-cred-aware loop exit. Track the number of
7209
7553
  // distinct credential keys we've accumulated; if we're in a
7210
7554
  // multi-cred bundle (cloud_name, api_secret, application_id, …)
@@ -7531,9 +7875,28 @@ ${formatInventory(input.inventory)}`,
7531
7875
  // without-residential-egress) oauth_session_not_persisted wall.
7532
7876
  if (args.credentials === undefined && isLoginPageUrl(state.url)) {
7533
7877
  consecutiveOauthLoginPageRounds += 1;
7878
+ if (consecutiveOauthLoginPageRounds >= 3 && !oauthBounceReloadTried) {
7879
+ // One reload before giving up. A set session cookie + a transient
7880
+ // login shell (silent re-auth bounce / slow hydration — measured on
7881
+ // the activeloop/galileo/turbopuffer class) lands the dashboard on a
7882
+ // fresh load; a genuine rejection stays on login and bails below.
7883
+ oauthBounceReloadTried = true;
7884
+ args.steps.push(`Post-verify: OAuth run still on a login page (${pathOf(state.url)}) for ` +
7885
+ `${consecutiveOauthLoginPageRounds} rounds — reloading once before bailing ` +
7886
+ `(a set session cookie often lands the dashboard on reload).`);
7887
+ try {
7888
+ await this.browser.goto(originRoot(state.url) ?? state.url);
7889
+ await this.browser.waitForInteractiveDom(5, 15_000).catch(() => undefined);
7890
+ }
7891
+ catch {
7892
+ // reload failed — next login-page round bails below
7893
+ }
7894
+ consecutiveOauthLoginPageRounds = 0;
7895
+ continue;
7896
+ }
7534
7897
  if (consecutiveOauthLoginPageRounds >= 3) {
7535
7898
  args.steps.push(`Post-verify: OAuth run still on a login page (${pathOf(state.url)}) for ` +
7536
- `${consecutiveOauthLoginPageRounds} rounds — the OAuth callback never persisted; bailing.`);
7899
+ `${consecutiveOauthLoginPageRounds} rounds (incl. a reload) — the OAuth callback never persisted; bailing.`);
7537
7900
  throw new OAuthSessionNotPersistedError(`oauth_session_not_persisted: signed in to ${args.service} via OAuth but the page ` +
7538
7901
  `still presents a login screen (${pathOf(state.url)}) after ` +
7539
7902
  `${consecutiveOauthLoginPageRounds} rounds — the OAuth callback never established a ` +
@@ -7716,6 +8079,29 @@ ${formatInventory(input.inventory)}`,
7716
8079
  }
7717
8080
  lastNavigatedTo = null;
7718
8081
  }
8082
+ // Credential-domain grounding. The OAuth provider (GitHub/GitLab) is
8083
+ // the LOGIN method, never the API-key source — but the planner, told to
8084
+ // "find an API token", sometimes navigates to the provider's own
8085
+ // token-minting settings and tries to create a GitHub PAT as if it were
8086
+ // the service's key (MEASURED 2026-06-11: typesense — the bot went
8087
+ // straight to github.com/settings/tokens and walked into GitHub's
8088
+ // sudo-2FA gate, then mislabeled it the typesense wall). A PAT for
8089
+ // GitHub has nothing to do with the service. Block it and point the
8090
+ // planner back to the service's own dashboard.
8091
+ if (nextStep.kind === "navigate" &&
8092
+ /^https?:\/\/(?:github\.com\/settings\/(?:tokens|personal-access-tokens|apps)|gitlab\.com\/-\/(?:profile\/personal_access_tokens|user_settings))/i.test(nextStep.url) &&
8093
+ args.service.toLowerCase() !== "github" &&
8094
+ args.service.toLowerCase() !== "gitlab") {
8095
+ args.steps.push(`Post-verify: planner tried to mint a third-party token at ${nextStep.url} — blocked (provider is the login method, not the key source).`);
8096
+ hint =
8097
+ `STOP — ${nextStep.url} is the OAuth PROVIDER's own token page. You signed in ` +
8098
+ `THROUGH that provider, but the ${args.service} API key lives on ${args.service}'s ` +
8099
+ `OWN dashboard, NOT in a GitHub/GitLab personal access token. Do NOT create a ` +
8100
+ `provider PAT. Navigate back to the ${args.service} dashboard and find its API-keys / ` +
8101
+ `tokens / credentials page there (it is often per-project or per-cluster — create the ` +
8102
+ `project/cluster first if none exists).`;
8103
+ continue;
8104
+ }
7719
8105
  // Refuse to re-navigate to a URL already known to 404 — force a
7720
8106
  // click-based re-plan instead of letting the planner re-guess it.
7721
8107
  if (nextStep.kind === "navigate" && deadUrls.has(nextStep.url)) {
@@ -8050,6 +8436,17 @@ ${formatInventory(input.inventory)}`,
8050
8436
  args.steps.push(sameSelector
8051
8437
  ? `Post-verify: no-progress detected — same ${nextStep.kind} on same selector, inventory unchanged. Re-planning instead of re-running.`
8052
8438
  : `Post-verify: no-progress detected — successive click steps with no inventory change. Forcing a non-click action.`);
8439
+ // A click that changed nothing often means an INLINE validation
8440
+ // error is gating submit (e.g. deepseek's "Please enter the
8441
+ // verification code" — red text, not a toast). Surface it + a
8442
+ // forensic snapshot so the stall is diagnosable instead of silent.
8443
+ const stallError = await this.browser.captureTransientAlert(0);
8444
+ let stallHint = "";
8445
+ if (stallError.length > 0) {
8446
+ args.steps.push(`Post-verify: page shows an inline message: "${stallError}"`);
8447
+ stallHint = ` The page currently shows this message: "${stallError}" — it explains the block; address it (the named field is likely empty/invalid to the form despite looking filled).`;
8448
+ }
8449
+ await saveDebugSnapshot(this.browser, "post-verify-stuck");
8053
8450
  hint =
8054
8451
  `Your previous ${sameSelector ? `'${nextStep.kind}' on ${JSON.stringify(sel)}` : "click steps"} had NO observable effect — the inventory ` +
8055
8452
  `count is unchanged. The element you targeted is either disabled or gated on ` +
@@ -8061,7 +8458,8 @@ ${formatInventory(input.inventory)}`,
8061
8458
  emptyInputHint +
8062
8459
  defaultedSelectHint +
8063
8460
  customComboboxHint +
8064
- uncheckedBoxHint;
8461
+ uncheckedBoxHint +
8462
+ stallHint;
8065
8463
  prevSignature = signature;
8066
8464
  prevInventorySize = inventory.length;
8067
8465
  continue;
@@ -8149,6 +8547,41 @@ ${formatInventory(input.inventory)}`,
8149
8547
  else {
8150
8548
  consecutiveWaits = 0;
8151
8549
  }
8550
+ // Writer-class hung post-OAuth redirect: consecutive waits on the SAME
8551
+ // url even though the page has elements (a spinner shell). Reload once
8552
+ // at the 4th, break at the 6th — don't burn the whole budget waiting.
8553
+ if (nextStep.kind === "wait") {
8554
+ if (state.url === lastWaitUrl) {
8555
+ consecutiveSameUrlWaits += 1;
8556
+ }
8557
+ else {
8558
+ consecutiveSameUrlWaits = 1;
8559
+ lastWaitUrl = state.url;
8560
+ }
8561
+ if (consecutiveSameUrlWaits === 4 && !waitReloadTried) {
8562
+ waitReloadTried = true;
8563
+ args.steps.push(`Post-verify: ${consecutiveSameUrlWaits} consecutive waits on ${state.url} — reloading once to unstick a hung post-OAuth redirect.`);
8564
+ try {
8565
+ await this.browser.goto(state.url);
8566
+ await this.browser.waitForInteractiveDom(5, 15_000).catch(() => undefined);
8567
+ }
8568
+ catch {
8569
+ // reload failed — next round's wait will reach the break below
8570
+ }
8571
+ continue;
8572
+ }
8573
+ if (consecutiveSameUrlWaits >= 6) {
8574
+ this.lastPostVerifyDoneReason =
8575
+ `post-OAuth interstitial (${state.url}) never resolved after ${consecutiveSameUrlWaits} waits — ` +
8576
+ `likely a hung redirect or onboarding bootstrap for a freshly-created account`;
8577
+ args.steps.push(`Post-verify: wait-loop on ${state.url} (${consecutiveSameUrlWaits} rounds, page has elements but never advances) — breaking out.`);
8578
+ break;
8579
+ }
8580
+ }
8581
+ else {
8582
+ consecutiveSameUrlWaits = 0;
8583
+ lastWaitUrl = null;
8584
+ }
8152
8585
  hint = undefined;
8153
8586
  try {
8154
8587
  if (nextStep.kind === "extract") {
@@ -8383,8 +8816,18 @@ ${formatInventory(input.inventory)}`,
8383
8816
  // shaped regex, so a multi-cred reveal landed nothing
8384
8817
  // unless the explicit extract round re-fired afterward.
8385
8818
  const credentialDeadline = Date.now() + 8000;
8819
+ let alertSeen = "";
8820
+ let alertChecked = false;
8386
8821
  while (Date.now() < credentialDeadline) {
8387
8822
  await this.browser.wait(0.5);
8823
+ // Reuse the first 0.5s settle to grab any transient toast/notification
8824
+ // a submit-like click raised (validation error, rate-limit, "operation
8825
+ // failed") before it auto-dismisses — otherwise a failed submit reads
8826
+ // as a SILENT no-op. MEASURED 2026-06-11 (deepseek Sign-up).
8827
+ if (!alertChecked) {
8828
+ alertChecked = true;
8829
+ alertSeen = await this.browser.captureTransientAlert(0);
8830
+ }
8388
8831
  try {
8389
8832
  const pollExtract = await this.extractCredentials();
8390
8833
  for (const [k, v] of Object.entries(pollExtract)) {
@@ -8414,6 +8857,22 @@ ${formatInventory(input.inventory)}`,
8414
8857
  // Page mid-render — keep polling; next tick may settle.
8415
8858
  }
8416
8859
  }
8860
+ // A click that raised a notification but yielded no key — surface the
8861
+ // toast text so the planner addresses the real error instead of
8862
+ // re-clicking the same dead button into a stuck-loop.
8863
+ if (credentials.api_key === undefined && alertSeen.length > 0) {
8864
+ args.steps.push(`Post-verify: the page showed a notification after the click: "${alertSeen}"`);
8865
+ // Forensic snapshot of the page in its post-click error state.
8866
+ // The before-fill/after-submit snapshots only cover the FIRST
8867
+ // submit; a failure on a post-verify re-submit (e.g. deepseek's
8868
+ // "Submitted failed. Please try again." after the OTP is filled)
8869
+ // was otherwise unobservable. Non-fatal by contract.
8870
+ await saveDebugSnapshot(this.browser, "post-verify-alert");
8871
+ hint =
8872
+ `After your last click the page showed this notification: "${alertSeen}". ` +
8873
+ `It likely explains why the page did not advance — address it (fix the named ` +
8874
+ `field, wait, or choose a different action) rather than repeating the same click.`;
8875
+ }
8417
8876
  }
8418
8877
  else if (nextStep.kind === "fill") {
8419
8878
  await this.browser.type(nextStep.selector, nextStep.value);
@@ -8885,6 +9344,23 @@ Strategy:
8885
9344
  ALSO shows preset choice buttons for the same step (e.g. "Optimize assets",
8886
9345
  "Transform images", "Next"). That input is a placeholder, not a field to
8887
9346
  complete — click a preset option button or "Next" instead.
9347
+ - **MULTI-FIELD PROFILE / ONBOARDING FORM ("Set up your account",
9348
+ "Tell us about yourself").** When the page is a form with SEVERAL fields
9349
+ (first/last name, country/region, company, job title, phone, use-case,
9350
+ cloud region) gating a "Continue" / "Next" / "Submit" button, you must
9351
+ fill EVERY visible empty text input and pick an option for EVERY
9352
+ unselected dropdown — across your one-action-per-turn budget — BEFORE
9353
+ clicking the gating button. Clicking it while ANY required field is
9354
+ empty just re-renders the same page with red "X is required" errors and
9355
+ wastes a round. Consult STEPS ALREADY TAKEN to see which fields you've
9356
+ done and fill a REMAINING empty one each turn; only click Continue once
9357
+ nothing is left empty.
9358
+ - When choosing a dropdown/select option for a profile field (job title,
9359
+ role, use-case, company size, "how did you hear"), prefer a CONCRETE
9360
+ real option (e.g. "Software Engineer", "Startup") — NEVER pick
9361
+ "Other" / "None" / "Prefer not to say". Selecting "Other" SPAWNS an
9362
+ extra required free-text field ("Please specify") that you then also
9363
+ have to fill, costing rounds for no benefit.
8888
9364
  ${loginGuidance}
8889
9365
  - If we're on a "verify your phone" / "verify email" wall, return done (we can't solve those).
8890
9366
  - **EMPTY DASHBOARD — create the first resource.** Many services do NOT expose