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

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 (60) hide show
  1. package/dist/api-client.d.ts +28 -0
  2. package/dist/api-client.d.ts.map +1 -1
  3. package/dist/api-client.js +11 -0
  4. package/dist/api-client.js.map +1 -1
  5. package/dist/bot/agent.d.ts +7 -1
  6. package/dist/bot/agent.d.ts.map +1 -1
  7. package/dist/bot/agent.js +631 -40
  8. package/dist/bot/agent.js.map +1 -1
  9. package/dist/bot/browser.d.ts +15 -0
  10. package/dist/bot/browser.d.ts.map +1 -1
  11. package/dist/bot/browser.js +858 -84
  12. package/dist/bot/browser.js.map +1 -1
  13. package/dist/bot/captcha-solver-2captcha.d.ts +18 -0
  14. package/dist/bot/captcha-solver-2captcha.d.ts.map +1 -1
  15. package/dist/bot/captcha-solver-2captcha.js +21 -0
  16. package/dist/bot/captcha-solver-2captcha.js.map +1 -1
  17. package/dist/bot/email-code-fetcher.d.ts +5 -0
  18. package/dist/bot/email-code-fetcher.d.ts.map +1 -0
  19. package/dist/bot/email-code-fetcher.js +33 -0
  20. package/dist/bot/email-code-fetcher.js.map +1 -0
  21. package/dist/bot/inbox-client.d.ts +1 -0
  22. package/dist/bot/inbox-client.d.ts.map +1 -1
  23. package/dist/bot/inbox-client.js +55 -15
  24. package/dist/bot/inbox-client.js.map +1 -1
  25. package/dist/bot/index.d.ts +2 -1
  26. package/dist/bot/index.d.ts.map +1 -1
  27. package/dist/bot/index.js +49 -19
  28. package/dist/bot/index.js.map +1 -1
  29. package/dist/bot/promote-to-skill.d.ts +3 -1
  30. package/dist/bot/promote-to-skill.d.ts.map +1 -1
  31. package/dist/bot/promote-to-skill.js +122 -7
  32. package/dist/bot/promote-to-skill.js.map +1 -1
  33. package/dist/bot/replay-skill.d.ts +18 -0
  34. package/dist/bot/replay-skill.d.ts.map +1 -1
  35. package/dist/bot/replay-skill.js +290 -12
  36. package/dist/bot/replay-skill.js.map +1 -1
  37. package/dist/bot/signup-lock.d.ts +17 -0
  38. package/dist/bot/signup-lock.d.ts.map +1 -0
  39. package/dist/bot/signup-lock.js +174 -0
  40. package/dist/bot/signup-lock.js.map +1 -0
  41. package/dist/tools/grant-app-access.d.ts +31 -0
  42. package/dist/tools/grant-app-access.d.ts.map +1 -0
  43. package/dist/tools/grant-app-access.js +59 -0
  44. package/dist/tools/grant-app-access.js.map +1 -0
  45. package/dist/tools/index.d.ts +2 -1
  46. package/dist/tools/index.d.ts.map +1 -1
  47. package/dist/tools/index.js +4 -1
  48. package/dist/tools/index.js.map +1 -1
  49. package/dist/tools/provision-any.d.ts.map +1 -1
  50. package/dist/tools/provision-any.js +25 -12
  51. package/dist/tools/provision-any.js.map +1 -1
  52. package/dist/tools/store-credential.d.ts +5 -0
  53. package/dist/tools/store-credential.d.ts.map +1 -1
  54. package/dist/tools/store-credential.js +13 -2
  55. package/dist/tools/store-credential.js.map +1 -1
  56. package/package.json +2 -2
  57. package/dist/bot/oauth-lock.d.ts +0 -2
  58. package/dist/bot/oauth-lock.d.ts.map +0 -1
  59. package/dist/bot/oauth-lock.js +0 -28
  60. package/dist/bot/oauth-lock.js.map +0 -1
package/dist/bot/agent.js CHANGED
@@ -643,6 +643,33 @@ export function googleConsentIsBasicFromDom(bodyText) {
643
643
  // approve blind.
644
644
  return GOOGLE_BASIC_CONSENT_PHRASES.some((p) => p.test(bodyText));
645
645
  }
646
+ // The MODERN "Sign in with Google" (GIS) consent screen uses different copy
647
+ // than the classic OAuth consent: "Allow <App> to access this info about you"
648
+ // listing "Name and profile picture" / "Email address" with a Continue button
649
+ // (MEASURED 2026-06-13: langfuse OAuth as a fresh robot lands exactly here, and
650
+ // the bot mislabeled it oauth_stuck_on_chooser because the URL is still
651
+ // accounts.google.com). This recognizes that basic-only GIS screen so the bot
652
+ // can auto-approve it — same safety contract as googleConsentIsBasicFromDom:
653
+ // any sensitive phrase (Drive/Gmail/contacts/…) hard-fails it. Exported for tests.
654
+ export function googleGisConsentIsBasic(bodyText) {
655
+ // Safety first — any sensitive scope wording disqualifies, via both the
656
+ // danger scraper and the explicit non-basic phrase set.
657
+ if (scrapeGoogleScopePhrases(bodyText).length > 0)
658
+ return false;
659
+ if (GOOGLE_NON_BASIC_CONSENT_PHRASES.some((p) => p.test(bodyText)))
660
+ return false;
661
+ // Positive GIS basic signal: the "access this info about you" framing plus a
662
+ // name/email/profile item and nothing else. Fall back to the classic
663
+ // basic-phrase check for the older consent layout.
664
+ const isGisScreen = /to access this info about you|access your google account/i.test(bodyText);
665
+ const hasBasicItem = /name and profile picture/i.test(bodyText) ||
666
+ /\bemail address\b/i.test(bodyText) ||
667
+ /personal info/i.test(bodyText) ||
668
+ /public profile/i.test(bodyText);
669
+ if (isGisScreen && hasBasicItem)
670
+ return true;
671
+ return googleConsentIsBasicFromDom(bodyText);
672
+ }
646
673
  // Detect an image block's media type from its base64 payload's magic bytes.
647
674
  // Live screenshots are JPEG (Playwright `type: "jpeg"`), so that's the
648
675
  // default — but the eval corpus uses a 1x1 PNG sentinel, and a PNG labeled
@@ -1623,8 +1650,20 @@ export function detectAlreadySignedIn(args) {
1623
1650
  // /redis, /kafka, /vector, /cluster, /databases?, /instances?,
1624
1651
  // /apps?, /deployments?, /services? — all common product-name
1625
1652
  // routes that almost always indicate authenticated state.
1653
+ // An /organizations/<…> or /orgs/<…> prefix is an authenticated-only
1654
+ // marker: a logged-OUT visitor never has an organization-scoped route.
1655
+ // pinecone's post-OAuth plan-chooser sits at
1656
+ // app.pinecone.io/organizations/registration — the trailing
1657
+ // "registration" tripped the register-exclusion below and the bot, fully
1658
+ // signed in via the operator's existing Google-linked account, bailed
1659
+ // no_signup_link instead of routing to key-extraction (MEASURED
1660
+ // 2026-06-11: pinecone account was created May 25, so every later run
1661
+ // lands here authenticated). An org-prefixed path forces dashboardy and
1662
+ // bypasses the auth-route exclusion.
1663
+ const hasOrgPrefix = /\/(?:organizations?|orgs?)\//i.test(parsed.pathname);
1626
1664
  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);
1665
+ hasOrgPrefix ||
1666
+ (/\/(?: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
1667
  }
1629
1668
  catch {
1630
1669
  // Malformed URL — skip URL signal.
@@ -2128,6 +2167,29 @@ export function detectGoogleNoAccount(url, bodyText) {
2128
2167
  // post-verify planner can't reliably target, and the bot loops
2129
2168
  // trying. Defining trait: hostname accounts.google.com (or
2130
2169
  // accounts.googleusercontent.com) at the post-OAuth gate.
2170
+ // Is this URL on an OAuth PROVIDER's own domain (github.com, accounts.google.com,
2171
+ // gitlab.com, …)? Such a URL is mid-handshake — its `/login/oauth/authorize`
2172
+ // path reads as a "login route", but navigating to the provider's ROOT abandons
2173
+ // the handshake on the provider's domain instead of returning to the service
2174
+ // (MEASURED 2026-06-11: typesense's GitHub OAuth landed on
2175
+ // github.com/login/oauth/authorize and the dead-route escape navigated to
2176
+ // github.com/, breaking the flow). The dead-route escape must skip these.
2177
+ export function isOAuthProviderHost(url) {
2178
+ try {
2179
+ const h = new URL(url).hostname.toLowerCase();
2180
+ return (h === "github.com" ||
2181
+ h === "gitlab.com" ||
2182
+ h === "bitbucket.org" ||
2183
+ h === "accounts.google.com" ||
2184
+ h === "accounts.googleusercontent.com" ||
2185
+ h.endsWith(".accounts.google.com") ||
2186
+ h === "login.microsoftonline.com" ||
2187
+ h === "appleid.apple.com");
2188
+ }
2189
+ catch {
2190
+ return false;
2191
+ }
2192
+ }
2131
2193
  export function detectStuckOnGoogleOAuth(url) {
2132
2194
  try {
2133
2195
  const h = new URL(url).hostname.toLowerCase();
@@ -2428,7 +2490,12 @@ export function extractQuotedTokenFromReason(reason, pageText) {
2428
2490
  // its MAX_CREDENTIAL_LENGTH counterpart. Character class matches
2429
2491
  // what real API tokens look like: alphanumeric, underscores,
2430
2492
  // hyphens; no spaces, no punctuation that would gather UI text.
2431
- const matches = reason.matchAll(/['"`]([A-Za-z0-9_\-]{10,80})['"`]/g);
2493
+ // `.` is in the class: many tokens are dot-separated (Zerops
2494
+ // `LhJbaP.VeODh3ZZ…`, GitLab PATs, JWTs, Slack `xox*`); excluding it
2495
+ // dropped every dotted token to null and looped to run_timeout
2496
+ // (MEASURED 2026-06-12: zerops). The verbatim pageText.includes guard
2497
+ // below keeps a sentence's trailing period from matching.
2498
+ const matches = reason.matchAll(/['"`]([A-Za-z0-9_.\-]{10,80})['"`]/g);
2432
2499
  for (const m of matches) {
2433
2500
  const candidate = m[1];
2434
2501
  if (candidate === undefined)
@@ -2582,7 +2649,7 @@ export function extractAllLabeledTokensFromReason(reason, pageText) {
2582
2649
  // credential-shape (mixed alpha+digit, ≥16 chars, OR a known
2583
2650
  // credential prefix); (2) hard-reject a curated set of common
2584
2651
  // 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");
2652
+ const quotedRe = new RegExp(`\\b(${labelAltLoose})\\b\\s*[=:]\\s*['"\`]([A-Za-z0-9_.\\-]{4,80})['"\`]`, "gi");
2586
2653
  for (const m of reason.matchAll(quotedRe)) {
2587
2654
  const rawLabel = (m[1] ?? "").toLowerCase().replace(/[-\s]+/g, "_");
2588
2655
  const normalized = rawLabel.replace(/_+/g, "_");
@@ -2630,7 +2697,7 @@ export function extractAllLabeledTokensFromReason(reason, pageText) {
2630
2697
  // Same separator vocab as quoted, plus optional quotes around the
2631
2698
  // value. The credential-shape + blacklist guards run on the
2632
2699
  // 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");
2700
+ const proseRe = new RegExp(`\\b(${labelAltLoose})\\b\\s*(?:[=:]|\\b(?:is|are)\\b)\\s*['"\`]?([A-Za-z0-9_.\\-]{4,80})['"\`]?`, "gi");
2634
2701
  for (const m of reason.matchAll(proseRe)) {
2635
2702
  const rawLabel = (m[1] ?? "").toLowerCase().replace(/[-\s]+/g, "_");
2636
2703
  const normalized = rawLabel.replace(/_+/g, "_");
@@ -2899,6 +2966,26 @@ const CREDENTIAL_NOISE_TOKENS = [
2899
2966
  "protonpass",
2900
2967
  "autofill",
2901
2968
  "passwords",
2969
+ // Cookie-consent widget vocabulary (CookieScript/OneTrust-class banners
2970
+ // render these as checkbox values and category labels on EVERY page,
2971
+ // earlier in DOM order than any credential — zilliz's banner fed
2972
+ // "personalization" to the validator-shaped scan tier as the "key").
2973
+ // Whole-token equality with a generic English word is never a real
2974
+ // credential, so rejecting these costs nothing.
2975
+ "necessary",
2976
+ "analytics",
2977
+ "personalization",
2978
+ "personalisation",
2979
+ "advertising",
2980
+ "advertisement",
2981
+ "marketing",
2982
+ "functional",
2983
+ "preferences",
2984
+ "statistics",
2985
+ "performance",
2986
+ "targeting",
2987
+ "unclassified",
2988
+ "security",
2902
2989
  ];
2903
2990
  // Verb-prefixed UI affordances ("Save to 1Password", "Copy to
2904
2991
  // clipboard", "Add to vault"). The candidate-scan tiers tokenize on
@@ -3009,12 +3096,23 @@ export function pickVerificationLinkFromHtml(bodyHtml) {
3009
3096
  // then a standalone 6-digit number (the most common verification length).
3010
3097
  // Returns null when nothing code-shaped is found so the caller still bails
3011
3098
  // honestly rather than typing garbage. Exported for unit testing.
3012
- export function extractCodeFromEmailBody(email) {
3013
- const text = [
3099
+ export function extractCodeFromEmailBody(email,
3100
+ // The recipient address, when known. Verification emails routinely echo
3101
+ // the recipient ("sent to sandra.young487@…"); if its local part carries
3102
+ // digits they can be mistaken for the code. Strip the address out before
3103
+ // scanning so a human-looking alias never poisons the extraction.
3104
+ recipient) {
3105
+ let text = [
3014
3106
  email.subject ?? "",
3015
3107
  email.body_text ?? "",
3016
3108
  (email.body_html ?? "").replace(/<[^>]+>/g, " "),
3017
3109
  ].join("\n");
3110
+ if (recipient !== undefined && recipient.length > 0) {
3111
+ text = text.split(recipient).join(" ");
3112
+ const local = recipient.split("@")[0];
3113
+ if (local !== undefined && local.length > 0)
3114
+ text = text.split(local).join(" ");
3115
+ }
3018
3116
  // 1) A code sitting next to a verification keyword — the strongest signal.
3019
3117
  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
3118
  if (kw?.[1] !== undefined)
@@ -3222,6 +3320,17 @@ export class SignupAgent {
3222
3320
  // burn through more than MAX_LLM_CALLS_PER_SIGNUP. Reset isn't needed
3223
3321
  // because each signup gets a fresh SignupAgent in index.ts.
3224
3322
  llmCallCount = 0;
3323
+ // Capture-chain round counter shared across the signup-form-fill phase
3324
+ // (captureSignupFormRounds) and the post-verify loop so they form ONE
3325
+ // contiguous chain. Stays 0 on the OAuth path (no form-fill capture), so
3326
+ // post-verify starts at 0 exactly as before. Per-run instance → no reset.
3327
+ captureChainRound = 0;
3328
+ // The stable signup-form entry URL the bot navigated to (e.g.
3329
+ // cloud.zilliz.com/signup). captureSignupFormRounds stamps it as the
3330
+ // preamble rounds' URL instead of the transient SPA URL getState() reads
3331
+ // mid-fill (zilliz settles to /login/loading) — so the synthesized
3332
+ // signup_url points a fresh replay at the real form, not a loading shell.
3333
+ resolvedSignupUrl;
3225
3334
  // Tracks which backend handled each call, for debugging cost/quality.
3226
3335
  // backends_used[i] is the .name string of the LLMClient that produced
3227
3336
  // the i-th reply this run.
@@ -3294,7 +3403,20 @@ export class SignupAgent {
3294
3403
  steps.push(`${label} captcha gate skipped — session already captcha-blocked (${kind}).`);
3295
3404
  return { found: true, solved: false, blocked: true, kind };
3296
3405
  }
3297
- let result = await this.browser.solveVisibleCaptcha();
3406
+ // Best-effort: captcha DETECTION must never abort a signup. A bounded
3407
+ // boundingBox / detect race inside solveVisibleCaptcha that throws is
3408
+ // treated as "no widget here, proceed" — the OAuth-first path already
3409
+ // wraps this call (browser.ts ~6351); the form-fill path didn't, so an
3410
+ // invisible-mode Turnstile (which patchright + residential pass) crashed
3411
+ // the run instead of falling through to submit.
3412
+ let result;
3413
+ try {
3414
+ result = await this.browser.solveVisibleCaptcha();
3415
+ }
3416
+ catch (err) {
3417
+ steps.push(`${label} captcha gate: detection error (${err instanceof Error ? err.message : String(err)}) — treating as no widget, continuing`);
3418
+ return { found: false, solved: false, blocked: false, kind: "turnstile" };
3419
+ }
3298
3420
  if (!result.found) {
3299
3421
  // No VISIBLE widget — but an invisible Turnstile / reCAPTCHA-v3 may
3300
3422
  // be present and scoring silently. Record its presence once (a
@@ -3398,6 +3520,40 @@ export class SignupAgent {
3398
3520
  steps.push(`${label} captcha: hCaptcha widget detected but no sitekey found — cannot Tier-3 solve`);
3399
3521
  }
3400
3522
  }
3523
+ // Turnstile Tier 3 (2026-06-12). Mirrors the hCaptcha path; the "Cloudflare
3524
+ // IP-scores Turnstile so a solver token is rejected" belief that kept this
3525
+ // off is FALSIFIED (exa fails on a fresh direct residential IP + real GPU
3526
+ // — not IP-bound; STATE.md). The 2Captcha key is configured. Covers the
3527
+ // post-SUBMIT form Turnstile (cartesia-class), complementing the
3528
+ // OAuth-precheck branch in runOAuthFlow.
3529
+ if (!result.solved &&
3530
+ result.kind === "turnstile" &&
3531
+ this.captchaSolver?.isAvailable() === true) {
3532
+ const sitekey = await this.browser.extractTurnstileSitekey();
3533
+ const pageUrl = (await this.browser.getState().catch(() => null))?.url;
3534
+ if (sitekey !== null && pageUrl !== undefined) {
3535
+ steps.push(`${label} captcha: Tier 3 — submitting Turnstile sitekey to 2Captcha (${sitekey.slice(0, 12)}…)`);
3536
+ const solveRes = await this.captchaSolver.solveTurnstile({ sitekey, pageUrl });
3537
+ if (solveRes.kind === "ok") {
3538
+ const injected = await this.browser.injectTurnstileToken(solveRes.token);
3539
+ if (injected) {
3540
+ steps.push(`${label} captcha: Tier 3 Turnstile solved in ${Math.round(solveRes.durationMs / 1000)}s via 2Captcha`);
3541
+ result = { ...result, solved: true };
3542
+ }
3543
+ else {
3544
+ steps.push(`${label} captcha: Tier 3 Turnstile token arrived but page injection failed — captcha stays blocked`);
3545
+ }
3546
+ }
3547
+ else {
3548
+ steps.push(`${label} captcha: Tier 3 Turnstile ${solveRes.kind}` +
3549
+ ("reason" in solveRes ? `: ${solveRes.reason}` : "") +
3550
+ ("durationMs" in solveRes ? ` (${Math.round(solveRes.durationMs / 1000)}s)` : ""));
3551
+ }
3552
+ }
3553
+ else if (sitekey === null) {
3554
+ steps.push(`${label} captcha: Turnstile widget detected but no sitekey found — cannot Tier-3 solve`);
3555
+ }
3556
+ }
3401
3557
  // rc.32 — forensic snapshot after the captcha attempt. Without
3402
3558
  // this, the only snapshot near the captcha is the pre-fill one
3403
3559
  // taken BEFORE the click, so when a Turnstile fails to solve we
@@ -3672,7 +3828,27 @@ export class SignupAgent {
3672
3828
  // default 2 retries (otherwise the bot gives up at ~6s and wrongly
3673
3829
  // falls back to the email-signup path before the GitHub button
3674
3830
  // even exists).
3675
- const oauthScanShell = isLoadingShellText(await this.browser.extractText().catch(() => ""));
3831
+ // An almost-empty inventory is itself a strong unhydrated-SPA signal,
3832
+ // even when the page TEXT doesn't match the loading-shell phrases
3833
+ // (lancedb's accounts.lancedb.com/sign-up renders its Google button
3834
+ // late and shows 0 interactive candidates meanwhile — MEASURED
3835
+ // 2026-06-11: it bailed oauth_required after only 2 retries because
3836
+ // the text heuristic missed it). Treat ≤1 interactive elements as a
3837
+ // loading shell so the late-rendering provider button gets the patient
3838
+ // 8-retry budget instead of a premature email-fallback bail.
3839
+ //
3840
+ // Also patient when the page has NO provider button (we're in this
3841
+ // branch because the scan found none) AND no email/password form yet:
3842
+ // the auth surface simply hasn't hydrated. An OAuth-ONLY signup
3843
+ // (replit: "Continue with Google/GitHub", no credential input) renders
3844
+ // its provider buttons a beat late, and with >1 element it otherwise
3845
+ // got only 2 retries and bailed oauth_required. If a form IS present,
3846
+ // it's a genuine form-signup → fall back to form-fill without waiting.
3847
+ const hasCredentialInput = inventory.some((e) => e.tag === "input" &&
3848
+ (e.type === "email" || e.type === "password" || e.type === "tel"));
3849
+ const oauthScanShell = inventory.length <= 1 ||
3850
+ !hasCredentialInput ||
3851
+ isLoadingShellText(await this.browser.extractText().catch(() => ""));
3676
3852
  const maxOauthScanRetries = oauthScanShell ? 8 : 2;
3677
3853
  if (oauthScanRetries < maxOauthScanRetries) {
3678
3854
  oauthScanRetries += 1;
@@ -4104,9 +4280,67 @@ export class SignupAgent {
4104
4280
  hint = `The previous submit produced validation errors. Visible page text: ${afterText.slice(0, 600)}`;
4105
4281
  continue;
4106
4282
  }
4283
+ // Capture the signup-form preamble (email + password fills + the
4284
+ // submit click) as the FIRST rounds of the chain, so a synthesized
4285
+ // email-OTP skill's graph DISPATCHES the verification email before
4286
+ // its await_email_code step. Without it the capture begins post-
4287
+ // verify and the skill can never replay (zilliz). Only the email-form
4288
+ // path reaches here (OAuth returns `already_oauth` earlier), so OAuth
4289
+ // skills keep starting their chain at round 0.
4290
+ await this.captureSignupFormRounds(task.service, plan, inventory, fillValues);
4107
4291
  return { kind: "submitted" };
4108
4292
  }
4109
4293
  }
4294
+ // Emit the signup-form-fill rounds (email + password + submit) into the
4295
+ // capture chain. Shares this.captureChainRound with the post-verify loop
4296
+ // so the two phases form one contiguous 0..N chain. The captured email
4297
+ // value is templatized to ${EMAIL_ALIAS} by the synthesizer; the
4298
+ // generated throwaway password is baked literally (a fresh account each
4299
+ // replay, so reuse is harmless). Best-effort — capture must never fail a
4300
+ // signup.
4301
+ async captureSignupFormRounds(service, plan, inventory, fillValues) {
4302
+ try {
4303
+ const live = await this.browser.getState();
4304
+ // Stamp the STABLE signup-form URL (not the transient SPA URL the SPA
4305
+ // may have settled to mid-fill); the synthesizer derives signup_url
4306
+ // from round 0's url, and a fresh replay must land on the real form.
4307
+ const state = {
4308
+ ...live,
4309
+ url: this.resolvedSignupUrl ?? live.url,
4310
+ };
4311
+ const emit = (observed) => {
4312
+ captureOnboardingRound({
4313
+ service,
4314
+ round: this.captureChainRound,
4315
+ oauth: false,
4316
+ state,
4317
+ inventory,
4318
+ observed,
4319
+ });
4320
+ this.captureChainRound += 1;
4321
+ };
4322
+ for (const action of plan.actions) {
4323
+ if (action.kind !== "fill")
4324
+ continue;
4325
+ if (action.value_kind !== "email" && action.value_kind !== "password")
4326
+ continue;
4327
+ emit({
4328
+ kind: "fill",
4329
+ selector: action.selector,
4330
+ value: fillValues[action.value_kind],
4331
+ reason: `Fill the signup ${action.value_kind}`,
4332
+ });
4333
+ }
4334
+ emit({
4335
+ kind: "click",
4336
+ selector: plan.submit_selector,
4337
+ reason: "Submit the signup form to dispatch the verification email",
4338
+ });
4339
+ }
4340
+ catch {
4341
+ // Capture is a synthesis input + forensic aid; never fatal.
4342
+ }
4343
+ }
4110
4344
  // Extract + rank the page's interactive elements (F3 T1/T2).
4111
4345
  // `oauthProviders` keeps those providers' OAuth affordances from
4112
4346
  // being ranked out of the capped inventory (T6/T13 + auto-prefer).
@@ -4191,6 +4425,43 @@ export class SignupAgent {
4191
4425
  // attribute equal to the account's email, plus role="link" or
4192
4426
  // jsaction. The fallback is any element whose visible text
4193
4427
  // contains an @ — accounts always show their email.
4428
+ // Approve a basic "Sign in with Google" consent screen ("Allow <App> to
4429
+ // access this info about you" → Continue). Returns:
4430
+ // "approved" — basic consent, clicked Continue/Allow
4431
+ // "non_basic" — consent reaches beyond identity; caller must NOT auto-grant
4432
+ // "not_consent" — not a consent screen (no Continue/Allow + access wording)
4433
+ // "error" — page unavailable
4434
+ // Mirrors tryClickGoogleChooserCard's direct-page approach. The safety gate
4435
+ // (googleGisConsentIsBasic) is the same conservative one the popup-flow
4436
+ // consent uses — only the basic identity grant is auto-approved.
4437
+ async tryApproveGoogleConsentScreen() {
4438
+ try {
4439
+ const page = this.browser.page;
4440
+ if (page === null || page === undefined)
4441
+ return "error";
4442
+ const bodyText = await page.evaluate(() => document.body?.innerText ?? "").catch(() => "");
4443
+ const looksLikeConsent = /to access this info about you|access your google account|wants (?:to )?access|allow .{1,40} to access/i.test(bodyText) && /\b(continue|allow)\b/i.test(bodyText);
4444
+ if (!looksLikeConsent)
4445
+ return "not_consent";
4446
+ if (!googleGisConsentIsBasic(bodyText))
4447
+ return "non_basic";
4448
+ for (const name of [/^continue$/i, /^allow$/i]) {
4449
+ const btn = page.getByRole("button", { name }).first();
4450
+ try {
4451
+ await btn.waitFor({ state: "visible", timeout: 2_500 });
4452
+ await btn.click({ timeout: 3_000 });
4453
+ return "approved";
4454
+ }
4455
+ catch {
4456
+ continue;
4457
+ }
4458
+ }
4459
+ return "not_consent"; // basic scopes but no clickable Continue/Allow found
4460
+ }
4461
+ catch {
4462
+ return "error";
4463
+ }
4464
+ }
4194
4465
  async tryClickGoogleChooserCard() {
4195
4466
  try {
4196
4467
  const page = this.browser.page;
@@ -4631,6 +4902,7 @@ export class SignupAgent {
4631
4902
  await this.runPrewarm(signupUrl, steps);
4632
4903
  }
4633
4904
  steps.push(`Navigating to ${signupUrl}`);
4905
+ this.resolvedSignupUrl = signupUrl;
4634
4906
  await this.browser.goto(signupUrl);
4635
4907
  // Clear any anti-bot interstitial BEFORE the landing read below.
4636
4908
  // goto() only awaits domcontentloaded, so a Cloudflare "Verifying you
@@ -4683,12 +4955,44 @@ export class SignupAgent {
4683
4955
  // detectAlreadySignedIn's precondition (no email/password/tel input
4684
4956
  // visible) makes this safe: a real signup/login page short-circuits to
4685
4957
  // false before any dashboard marker is considered.
4686
- const landed = await this.browser.getState();
4687
- const landedInventory = await this.browser.extractInteractiveElements();
4688
- if (detectAlreadySignedIn({
4958
+ let landed = await this.browser.getState();
4959
+ let landedInventory = await this.browser.extractInteractiveElements();
4960
+ let signedIn = detectAlreadySignedIn({
4689
4961
  inventory: landedInventory,
4690
4962
  url: landed.url,
4691
- })) {
4963
+ });
4964
+ // SPA-settle re-check (returning-user cluster). An authenticated SPA
4965
+ // redirects the session AFTER the first read — pinecone's
4966
+ // `/?sessionType=signup` routes to `/organizations/registration` once
4967
+ // React hydrates — so a returning-user landing is initially captured as
4968
+ // an un-hydrated shell (no dashboard markers, no credential input yet)
4969
+ // and missed → no_signup_link. When the first detect is false, the page
4970
+ // is shell-like (few elements), AND there's no credential form rendered,
4971
+ // dwell once and re-read before classifying. Safe: a real login/signup
4972
+ // page renders its credential input or signup affordance, so
4973
+ // detectAlreadySignedIn stays false on the re-check (no false positive).
4974
+ if (!signedIn && landedInventory.length <= 4) {
4975
+ const hasCredInput = landedInventory.some((e) => e.tag === "input" &&
4976
+ (e.type === "email" || e.type === "password" || e.type === "tel"));
4977
+ // Only a BARE shell (no credential form AND no OAuth/signup button)
4978
+ // is a returning-user-redirect candidate. A page that already shows a
4979
+ // provider button is a usable signup entry — take it as-is (and don't
4980
+ // perturb the OAuth-first flow with an extra settle).
4981
+ const hasOAuthHere = findFirstOAuthButton(landedInventory, ["google", "github"]) !== null;
4982
+ if (!hasCredInput && !hasOAuthHere) {
4983
+ await this.browser.wait(3);
4984
+ await this.browser.waitForInteractiveDom(5, 12_000).catch(() => undefined);
4985
+ const reLanded = await this.browser.getState();
4986
+ const reInv = await this.browser.extractInteractiveElements();
4987
+ if (detectAlreadySignedIn({ inventory: reInv, url: reLanded.url })) {
4988
+ landed = reLanded;
4989
+ landedInventory = reInv;
4990
+ signedIn = true;
4991
+ steps.push(`${task.service}: returning-user dashboard surfaced after SPA settle (${pathOf(reLanded.url)})`);
4992
+ }
4993
+ }
4994
+ }
4995
+ if (signedIn) {
4692
4996
  steps.push(`${task.service}: already authenticated (dashboard markers, no signup CTA) — ` +
4693
4997
  `skipping signup, routing straight to key extraction`);
4694
4998
  alreadyAuthenticated = true;
@@ -5188,7 +5492,9 @@ export class SignupAgent {
5188
5492
  credentials = await this.extractCredentials();
5189
5493
  // If no creds yet, run the Claude-planned navigation loop.
5190
5494
  if (credentials.api_key === undefined && credentials.username === undefined) {
5191
- const maxRounds = task.postVerifyMaxRounds ?? 6;
5495
+ // 24 (not 6) same multi-step onboarding wizard the OAuth
5496
+ // path budgets for; see the enterEmailVerificationCode note.
5497
+ const maxRounds = task.postVerifyMaxRounds ?? 24;
5192
5498
  credentials = await this.postVerifyLoop({
5193
5499
  service: task.service,
5194
5500
  credentials: { email: task.email, password },
@@ -5207,7 +5513,7 @@ export class SignupAgent {
5207
5513
  // No link and the inbox parser found no code — last-resort
5208
5514
  // scan the email body ourselves for a verification code
5209
5515
  // (passwordless "we emailed you a code" flow, e.g. axiom).
5210
- const bodyCode = extractCodeFromEmailBody(email);
5516
+ const bodyCode = extractCodeFromEmailBody(email, task.email);
5211
5517
  if (bodyCode !== null) {
5212
5518
  steps.push(`Email had no link but carried a verification code (…${bodyCode.slice(-2)}) — entering it.`);
5213
5519
  credentials = await this.enterEmailVerificationCode(bodyCode, task, password, steps);
@@ -5376,6 +5682,42 @@ export class SignupAgent {
5376
5682
  }
5377
5683
  }
5378
5684
  }
5685
+ // Turnstile Tier-3 (2026-06-12). The "Cloudflare IP-scores Turnstile
5686
+ // so a solver token is rejected" belief that kept this OFF was
5687
+ // FALSIFIED — exa fails on a fresh direct residential IP + real GPU, so
5688
+ // its Turnstile is NOT IP-bound (STATE.md). The 2Captcha key is
5689
+ // configured (harvester.env). Try the solver token; if Cloudflare still
5690
+ // rejects it the step trail says so and we fall through to the
5691
+ // inert-click path (which now bails captcha_blocked truthfully).
5692
+ if (!solvedViaTier3 &&
5693
+ captcha.kind === "turnstile" &&
5694
+ this.captchaSolver?.isAvailable() === true) {
5695
+ const sitekey = await this.browser.extractTurnstileSitekey();
5696
+ const pageUrl = (await this.browser.getState().catch(() => null))?.url;
5697
+ if (sitekey !== null && pageUrl !== undefined) {
5698
+ steps.push(`OAuth: Tier 3 — submitting Turnstile sitekey to 2Captcha (${sitekey.slice(0, 12)}…)`);
5699
+ const solveRes = await this.captchaSolver.solveTurnstile({ sitekey, pageUrl });
5700
+ if (solveRes.kind === "ok") {
5701
+ const injected = await this.browser.injectTurnstileToken(solveRes.token);
5702
+ if (injected) {
5703
+ solvedViaTier3 = true;
5704
+ steps.push(`OAuth: Tier 3 solved the Turnstile in ${Math.round(solveRes.durationMs / 1000)}s via 2Captcha — clicking the ${provider.label} affordance`);
5705
+ }
5706
+ else {
5707
+ steps.push(`OAuth: Tier 3 Turnstile token arrived but page injection failed — clicking the ${provider.label} affordance anyway`);
5708
+ }
5709
+ }
5710
+ else {
5711
+ steps.push(`OAuth: Tier 3 Turnstile ${solveRes.kind}` +
5712
+ ("reason" in solveRes ? `: ${solveRes.reason}` : "") +
5713
+ ("durationMs" in solveRes ? ` (${Math.round(solveRes.durationMs / 1000)}s)` : "") +
5714
+ ` — clicking the ${provider.label} affordance anyway`);
5715
+ }
5716
+ }
5717
+ else {
5718
+ steps.push(`OAuth: Tier 3 Turnstile — no sitekey found on page, skipping solver`);
5719
+ }
5720
+ }
5379
5721
  if (!solvedViaTier3) {
5380
5722
  steps.push(`OAuth: visible ${captcha.kind} present but did not solve in 20s — clicking the ${provider.label} affordance anyway`);
5381
5723
  }
@@ -5873,6 +6215,22 @@ export class SignupAgent {
5873
6215
  await this.browser.wait(3);
5874
6216
  continue;
5875
6217
  }
6218
+ // Defense-in-depth (MEASURED 2026-06-13: clarifai/daytona/gladia hit
6219
+ // this on the OAuth-popup path even after the post-verify consent fix).
6220
+ // Even without blind-consent, auto-approve a provably BASIC GIS consent
6221
+ // (name/email/profile only). googleGisConsentIsBasic hard-fails on ANY
6222
+ // sensitive scope phrase, so this only recovers the identity-only case
6223
+ // Google now hides behind an opaque part= token (no URL-readable scopes).
6224
+ const consentDom = await this.browser.extractText().catch(() => "");
6225
+ if (googleGisConsentIsBasic(consentDom)) {
6226
+ steps.push("OAuth: consent DOM is basic identity-only (GIS; scopes not URL-readable) — auto-approving");
6227
+ const advanced = await this.browser.advanceOAuthConsent(provider.id);
6228
+ if (advanced) {
6229
+ consentAlreadyApproved = true;
6230
+ await this.browser.wait(3);
6231
+ continue;
6232
+ }
6233
+ }
5876
6234
  return this.oauthAbort("oauth_consent_needs_review", `reached a ${provider.label} consent screen but could not read its requested scopes ` +
5877
6235
  `from the URL — pausing for manual review rather than approving blind.`, steps);
5878
6236
  }
@@ -5945,7 +6303,8 @@ export class SignupAgent {
5945
6303
  // ORIGIN ROOT lets the service redirect an authenticated user to its
5946
6304
  // real dashboard. Generalizes: a service already on its dashboard has a
5947
6305
  // non-auth path here and is left alone.
5948
- if (isSignupOrLoginRoute(this.browser.currentUrl())) {
6306
+ if (isSignupOrLoginRoute(this.browser.currentUrl()) &&
6307
+ !isOAuthProviderHost(this.browser.currentUrl())) {
5949
6308
  const root = originRoot(this.browser.currentUrl());
5950
6309
  if (root !== null) {
5951
6310
  steps.push(`OAuth: post-auth landing is a signup/login route (${pathOf(this.browser.currentUrl())}) — ` +
@@ -5960,6 +6319,34 @@ export class SignupAgent {
5960
6319
  }
5961
6320
  }
5962
6321
  await saveDebugSnapshot(this.browser, "oauth-post-consent");
6322
+ // Captcha-gated OAuth detection (truthful failure, not a false "signed in").
6323
+ // exa's login lives at auth.exa.ai/?callbackUrl=… (path "/"), which
6324
+ // isLoginPageUrl doesn't recognise, so the settle loop above declares
6325
+ // "redirected to the app" even when we never left the gated login page.
6326
+ // If we're STILL showing a provider OAuth button AND a Turnstile/verify
6327
+ // challenge, the sign-in click was INERT — the unsolved captcha gated the
6328
+ // button, the URL never reached the provider, and we are NOT signed in.
6329
+ // MEASURED 2026-06-12: exa fails identically on a real laptop + fresh
6330
+ // direct residential IP (so it is the Turnstile/automation layer, NOT
6331
+ // IP/fingerprint — see STATE.md). Bail captcha_blocked instead of burning
6332
+ // the whole post-verify budget flailing on the login page.
6333
+ {
6334
+ const postText = (await this.browser.extractText().catch(() => "")).toLowerCase();
6335
+ const captchaChallenge = /complete the verification challenge|verify you are human|are you human|please complete the (?:captcha|challenge|verification)/i.test(postText);
6336
+ if (captchaChallenge) {
6337
+ const postInv = await this.browser
6338
+ .extractInteractiveElements()
6339
+ .catch(() => []);
6340
+ const stillGatedByProviderButton = findFirstOAuthButton(postInv, [provider.id]) !== null;
6341
+ if (stillGatedByProviderButton) {
6342
+ return this.oauthAbort("captcha_blocked", `${task.service}'s ${provider.label} sign-in is gated by an unsolved ` +
6343
+ `Turnstile/verification challenge — the OAuth click was inert (still on the ` +
6344
+ `pre-auth login page). Not IP/fingerprint (a real browser + fresh direct IP ` +
6345
+ `fails identically); this service runs a strict Turnstile our automated solve ` +
6346
+ `can't clear.`, steps);
6347
+ }
6348
+ }
6349
+ }
5963
6350
  steps.push(`OAuth: signed in via ${provider.label} — driving post-OAuth onboarding to the API key`);
5964
6351
  // amplitude class — OAuth drops the bot into the service's READ-ONLY DEMO
5965
6352
  // sandbox (app.amplitude.com/analytics/demo) instead of a real account: it
@@ -6186,45 +6573,52 @@ export class SignupAgent {
6186
6573
  // chooser. If the click doesn't move us off accounts.google.com
6187
6574
  // within a few seconds, abort with oauth_stuck_on_chooser.
6188
6575
  if (detectStuckOnGoogleOAuth(gateState.url)) {
6189
- steps.push(`Post-OAuth: stuck on Google account chooser (${pathOf(gateState.url)}). ` +
6190
- `Trying to click an account card.`);
6576
+ steps.push(`Post-OAuth: on Google OAuth screen (${pathOf(gateState.url)}). ` +
6577
+ `Trying chooser card + consent.`);
6578
+ // (1) Multi-account chooser: pick the account card (no-op if it's
6579
+ // already the consent screen).
6191
6580
  const clicked = await this.tryClickGoogleChooserCard();
6192
6581
  if (clicked) {
6193
6582
  await this.browser.wait(3);
6194
6583
  await saveDebugSnapshot(this.browser, "oauth-chooser-click");
6195
- const afterUrl = this.browser.currentUrl();
6196
- steps.push(`Post-OAuth: chooser card clicked — now at ${pathOf(afterUrl)} ` +
6197
- `(host=${(() => { try {
6198
- return new URL(afterUrl).hostname;
6199
- }
6200
- catch {
6201
- return "?";
6202
- } })()})`);
6203
- // If the click moved us off accounts.google.com, fall
6204
- // through to the post-verify loop normally.
6205
- if (!detectStuckOnGoogleOAuth(afterUrl)) {
6206
- // continue to extract + postVerifyLoop
6584
+ steps.push(`Post-OAuth: chooser card clicked — now at ${pathOf(this.browser.currentUrl())}`);
6585
+ }
6586
+ // (2) Consent screen: "Allow <App> to access this info about you" →
6587
+ // Continue. This is ALSO on accounts.google.com, so the old code
6588
+ // mislabeled it oauth_stuck_on_chooser and bailed (MEASURED
6589
+ // 2026-06-13: langfuse). Auto-approve the BASIC identity grant
6590
+ // (googleGisConsentIsBasic gates out anything sensitive).
6591
+ if (detectStuckOnGoogleOAuth(this.browser.currentUrl())) {
6592
+ const consent = await this.tryApproveGoogleConsentScreen();
6593
+ steps.push(`Post-OAuth: consent screen ${consent}`);
6594
+ if (consent === "approved") {
6595
+ await this.browser.wait(4);
6596
+ await saveDebugSnapshot(this.browser, "oauth-consent-approved");
6207
6597
  }
6208
- else {
6598
+ else if (consent === "non_basic") {
6209
6599
  return {
6210
6600
  success: false,
6211
- error: `oauth_stuck_on_chooser: clicked an account card on the chooser but the URL ` +
6212
- `stayed on accounts.google.com (${pathOf(afterUrl)}). Finish the signup manually.`,
6601
+ error: `oauth_consent_needs_review: ${task.service}'s Google consent requests scopes ` +
6602
+ `beyond basic identity — not auto-approving. Finish the signup manually.`,
6213
6603
  steps,
6214
6604
  ...this.resultTail(),
6215
6605
  };
6216
6606
  }
6217
6607
  }
6218
- else {
6608
+ const afterUrl = this.browser.currentUrl();
6609
+ // Moved off accounts.google.com → OAuth completed; fall through to
6610
+ // extract + postVerifyLoop. Still stuck → genuine wall.
6611
+ if (detectStuckOnGoogleOAuth(afterUrl)) {
6219
6612
  return {
6220
6613
  success: false,
6221
6614
  error: `oauth_stuck_on_chooser: ${task.service}'s Google OAuth flow did not redirect off ` +
6222
- `accounts.google.com (${pathOf(gateState.url)}) and no clickable account card was ` +
6223
- `found on the chooser. Finish the signup manually.`,
6615
+ `accounts.google.com (${pathOf(afterUrl)}) after chooser + consent handling. ` +
6616
+ `Finish the signup manually.`,
6224
6617
  steps,
6225
6618
  ...this.resultTail(),
6226
6619
  };
6227
6620
  }
6621
+ steps.push(`Post-OAuth: cleared Google screens — now at ${pathOf(afterUrl)}`);
6228
6622
  }
6229
6623
  }
6230
6624
  let credentials = await this.extractCredentials();
@@ -6529,6 +6923,16 @@ Output rules:
6529
6923
  modify a selector. A selector not in the inventory is rejected and
6530
6924
  you will be asked to re-plan.
6531
6925
  - 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.
6926
+ - Email verification-CODE buttons: if the inventory has a button like
6927
+ "Send code", "Send verification code", "Get code", or "Email me a
6928
+ code" beside an OTP / verification-code field, you MUST include a
6929
+ click action on it, ORDERED AFTER the email fill. The bot operates a
6930
+ live email inbox: clicking it dispatches the code to the filled
6931
+ email, and the bot fetches the emailed code and fills the code field
6932
+ itself in a later step. NEVER skip this button or treat the code as
6933
+ un-automatable / manual — if you omit it, the service never sends the
6934
+ email and the signup stalls. Do NOT fill the code field yourself (you
6935
+ don't have the code yet); just click the send-code button.
6532
6936
  - Skip elements marked [cookie-consent — avoid], and skip optional
6533
6937
  marketing-opt-in checkboxes.
6534
6938
  - Do NOT add a separate password-confirmation fill unless the
@@ -6632,7 +7036,13 @@ ${formatInventory(input.inventory)}`,
6632
7036
  return this.postVerifyLoop({
6633
7037
  service: task.service,
6634
7038
  credentials: { email: task.email, password },
6635
- maxRounds: task.postVerifyMaxRounds ?? 6,
7039
+ // Match the OAuth post-verify budget (24). The onboarding form
7040
+ // reached after EMAIL verification is the same multi-step wizard the
7041
+ // OAuth path hits — a profile form (name, country, company, role) +
7042
+ // a "create your first project" step can need 8-10 rounds alone.
7043
+ // The old 6 starved zilliz's "Set up your account" form: the planner
7044
+ // had filled only name+jobTitle when the budget ran out.
7045
+ maxRounds: task.postVerifyMaxRounds ?? 24,
6636
7046
  steps,
6637
7047
  initialHint: hint,
6638
7048
  ...(task.scopeHint !== undefined ? { scopeHint: task.scopeHint } : {}),
@@ -7046,6 +7456,13 @@ ${formatInventory(input.inventory)}`,
7046
7456
  // persisted (anti-bot/IP rejection) ⇒ oauth_session_not_persisted, not
7047
7457
  // a navigation bug. Generalizes without per-service URLs.
7048
7458
  let consecutiveOauthLoginPageRounds = 0;
7459
+ // Fired once: before declaring the OAuth session dead, reload the page —
7460
+ // an authenticated session whose cookie IS set often shows a transient
7461
+ // login screen (Auth0/WorkOS silent re-auth round-trip, or a slow SPA that
7462
+ // renders the login shell before hydrating the dashboard). A reload lands
7463
+ // the dashboard for those; a genuine callback rejection stays on login
7464
+ // even after reload, so this never masks a real wall.
7465
+ let oauthBounceReloadTried = false;
7049
7466
  let planFailures = 0;
7050
7467
  // 0.8.2-rc.6 — separate counter for upstream-blip retries. Doesn't
7051
7468
  // gate planFailures (so a transient 502 won't push us into the
@@ -7116,6 +7533,18 @@ ${formatInventory(input.inventory)}`,
7116
7533
  // rounds with no inventory change and break out with the proper
7117
7534
  // status before burning the post-verify budget.
7118
7535
  let consecutiveWaits = 0;
7536
+ // Writer-class hung-redirect tracker. A post-OAuth interstitial
7537
+ // (app.writer.com/redirect-auth?…&registered=true) renders a spinner
7538
+ // SHELL — non-zero interactive elements — that never resolves because the
7539
+ // new account's bootstrap (workspace/org provisioning) hangs. The planner
7540
+ // correctly emits `wait`, but the 0-element guard above never fires (the
7541
+ // shell has elements), so it waits out the entire 600s budget (MEASURED
7542
+ // 2026-06-11: writer). Track consecutive waits on the SAME url regardless
7543
+ // of element count; reload once (the redirect usually completes on a fresh
7544
+ // load), then break with a clean terminal reason.
7545
+ let consecutiveSameUrlWaits = 0;
7546
+ let lastWaitUrl = null;
7547
+ let waitReloadTried = false;
7119
7548
  // rc.39 — navigate-loop tracker. Perplexity / Koyeb / Porter all
7120
7549
  // had post-verify loops where the planner emitted `navigate`
7121
7550
  // 5-6 rounds in a row and the URL never changed — the service
@@ -7203,8 +7632,10 @@ ${formatInventory(input.inventory)}`,
7203
7632
  // rejected the run as `missing_round`, and auto-promote silently
7204
7633
  // dropped it. By tracking `capturedRound` separately we get a
7205
7634
  // contiguous 0..N-1 chain regardless of how many planner re-plans
7206
- // happen mid-run.
7207
- let capturedRound = 0;
7635
+ // happen mid-run. Continues from any signup-form preamble rounds the
7636
+ // form-fill phase already captured (captureSignupFormRounds); 0 on the
7637
+ // OAuth path, so this is unchanged for OAuth skills.
7638
+ let capturedRound = this.captureChainRound;
7208
7639
  // 0.8.2-rc.12 — multi-cred-aware loop exit. Track the number of
7209
7640
  // distinct credential keys we've accumulated; if we're in a
7210
7641
  // multi-cred bundle (cloud_name, api_secret, application_id, …)
@@ -7242,6 +7673,34 @@ ${formatInventory(input.inventory)}`,
7242
7673
  // re-doing completed onboarding steps and re-navigating dead URLs.
7243
7674
  const priorActions = [];
7244
7675
  for (let round = 0; round < args.maxRounds; round++) {
7676
+ // Top-of-round credential sweep (MEASURED 2026-06-13: langfuse). The
7677
+ // credential can become visible on the page BETWEEN planner actions —
7678
+ // the keys render in the dashboard after onboarding while the planner
7679
+ // gets distracted re-clicking a promo banner it mis-reads as an error
7680
+ // toast. Re-reading the page every round makes extraction independent of
7681
+ // the planner choosing the right click, so an on-screen key is never
7682
+ // missed into a maxRounds bail. Merge-only (never overwrites a prior
7683
+ // capture); both extractors are best-effort.
7684
+ try {
7685
+ const sweep = await this.extractCredentials();
7686
+ for (const [k, v] of Object.entries(sweep)) {
7687
+ if (credentials[k] === undefined)
7688
+ credentials[k] = v;
7689
+ }
7690
+ }
7691
+ catch {
7692
+ // page mid-render — the early-exit below just won't fire this round
7693
+ }
7694
+ try {
7695
+ const sweepLabeled = await this.extractFromDomProximity();
7696
+ for (const [k, v] of Object.entries(sweepLabeled)) {
7697
+ if (credentials[k] === undefined)
7698
+ credentials[k] = v;
7699
+ }
7700
+ }
7701
+ catch {
7702
+ // DOM-proximity miss is non-fatal
7703
+ }
7245
7704
  const currentCredentialKeyCount = Object.keys(credentials).filter((k) => !NON_CREDENTIAL_KEYS.has(k)).length;
7246
7705
  if (currentCredentialKeyCount > lastCredentialKeyCount) {
7247
7706
  roundsSinceLastNewCredential = 0;
@@ -7531,9 +7990,28 @@ ${formatInventory(input.inventory)}`,
7531
7990
  // without-residential-egress) oauth_session_not_persisted wall.
7532
7991
  if (args.credentials === undefined && isLoginPageUrl(state.url)) {
7533
7992
  consecutiveOauthLoginPageRounds += 1;
7993
+ if (consecutiveOauthLoginPageRounds >= 3 && !oauthBounceReloadTried) {
7994
+ // One reload before giving up. A set session cookie + a transient
7995
+ // login shell (silent re-auth bounce / slow hydration — measured on
7996
+ // the activeloop/galileo/turbopuffer class) lands the dashboard on a
7997
+ // fresh load; a genuine rejection stays on login and bails below.
7998
+ oauthBounceReloadTried = true;
7999
+ args.steps.push(`Post-verify: OAuth run still on a login page (${pathOf(state.url)}) for ` +
8000
+ `${consecutiveOauthLoginPageRounds} rounds — reloading once before bailing ` +
8001
+ `(a set session cookie often lands the dashboard on reload).`);
8002
+ try {
8003
+ await this.browser.goto(originRoot(state.url) ?? state.url);
8004
+ await this.browser.waitForInteractiveDom(5, 15_000).catch(() => undefined);
8005
+ }
8006
+ catch {
8007
+ // reload failed — next login-page round bails below
8008
+ }
8009
+ consecutiveOauthLoginPageRounds = 0;
8010
+ continue;
8011
+ }
7534
8012
  if (consecutiveOauthLoginPageRounds >= 3) {
7535
8013
  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.`);
8014
+ `${consecutiveOauthLoginPageRounds} rounds (incl. a reload) — the OAuth callback never persisted; bailing.`);
7537
8015
  throw new OAuthSessionNotPersistedError(`oauth_session_not_persisted: signed in to ${args.service} via OAuth but the page ` +
7538
8016
  `still presents a login screen (${pathOf(state.url)}) after ` +
7539
8017
  `${consecutiveOauthLoginPageRounds} rounds — the OAuth callback never established a ` +
@@ -7716,6 +8194,29 @@ ${formatInventory(input.inventory)}`,
7716
8194
  }
7717
8195
  lastNavigatedTo = null;
7718
8196
  }
8197
+ // Credential-domain grounding. The OAuth provider (GitHub/GitLab) is
8198
+ // the LOGIN method, never the API-key source — but the planner, told to
8199
+ // "find an API token", sometimes navigates to the provider's own
8200
+ // token-minting settings and tries to create a GitHub PAT as if it were
8201
+ // the service's key (MEASURED 2026-06-11: typesense — the bot went
8202
+ // straight to github.com/settings/tokens and walked into GitHub's
8203
+ // sudo-2FA gate, then mislabeled it the typesense wall). A PAT for
8204
+ // GitHub has nothing to do with the service. Block it and point the
8205
+ // planner back to the service's own dashboard.
8206
+ if (nextStep.kind === "navigate" &&
8207
+ /^https?:\/\/(?:github\.com\/settings\/(?:tokens|personal-access-tokens|apps)|gitlab\.com\/-\/(?:profile\/personal_access_tokens|user_settings))/i.test(nextStep.url) &&
8208
+ args.service.toLowerCase() !== "github" &&
8209
+ args.service.toLowerCase() !== "gitlab") {
8210
+ 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).`);
8211
+ hint =
8212
+ `STOP — ${nextStep.url} is the OAuth PROVIDER's own token page. You signed in ` +
8213
+ `THROUGH that provider, but the ${args.service} API key lives on ${args.service}'s ` +
8214
+ `OWN dashboard, NOT in a GitHub/GitLab personal access token. Do NOT create a ` +
8215
+ `provider PAT. Navigate back to the ${args.service} dashboard and find its API-keys / ` +
8216
+ `tokens / credentials page there (it is often per-project or per-cluster — create the ` +
8217
+ `project/cluster first if none exists).`;
8218
+ continue;
8219
+ }
7719
8220
  // Refuse to re-navigate to a URL already known to 404 — force a
7720
8221
  // click-based re-plan instead of letting the planner re-guess it.
7721
8222
  if (nextStep.kind === "navigate" && deadUrls.has(nextStep.url)) {
@@ -8050,6 +8551,17 @@ ${formatInventory(input.inventory)}`,
8050
8551
  args.steps.push(sameSelector
8051
8552
  ? `Post-verify: no-progress detected — same ${nextStep.kind} on same selector, inventory unchanged. Re-planning instead of re-running.`
8052
8553
  : `Post-verify: no-progress detected — successive click steps with no inventory change. Forcing a non-click action.`);
8554
+ // A click that changed nothing often means an INLINE validation
8555
+ // error is gating submit (e.g. deepseek's "Please enter the
8556
+ // verification code" — red text, not a toast). Surface it + a
8557
+ // forensic snapshot so the stall is diagnosable instead of silent.
8558
+ const stallError = await this.browser.captureTransientAlert(0);
8559
+ let stallHint = "";
8560
+ if (stallError.length > 0) {
8561
+ args.steps.push(`Post-verify: page shows an inline message: "${stallError}"`);
8562
+ 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).`;
8563
+ }
8564
+ await saveDebugSnapshot(this.browser, "post-verify-stuck");
8053
8565
  hint =
8054
8566
  `Your previous ${sameSelector ? `'${nextStep.kind}' on ${JSON.stringify(sel)}` : "click steps"} had NO observable effect — the inventory ` +
8055
8567
  `count is unchanged. The element you targeted is either disabled or gated on ` +
@@ -8061,7 +8573,8 @@ ${formatInventory(input.inventory)}`,
8061
8573
  emptyInputHint +
8062
8574
  defaultedSelectHint +
8063
8575
  customComboboxHint +
8064
- uncheckedBoxHint;
8576
+ uncheckedBoxHint +
8577
+ stallHint;
8065
8578
  prevSignature = signature;
8066
8579
  prevInventorySize = inventory.length;
8067
8580
  continue;
@@ -8149,6 +8662,41 @@ ${formatInventory(input.inventory)}`,
8149
8662
  else {
8150
8663
  consecutiveWaits = 0;
8151
8664
  }
8665
+ // Writer-class hung post-OAuth redirect: consecutive waits on the SAME
8666
+ // url even though the page has elements (a spinner shell). Reload once
8667
+ // at the 4th, break at the 6th — don't burn the whole budget waiting.
8668
+ if (nextStep.kind === "wait") {
8669
+ if (state.url === lastWaitUrl) {
8670
+ consecutiveSameUrlWaits += 1;
8671
+ }
8672
+ else {
8673
+ consecutiveSameUrlWaits = 1;
8674
+ lastWaitUrl = state.url;
8675
+ }
8676
+ if (consecutiveSameUrlWaits === 4 && !waitReloadTried) {
8677
+ waitReloadTried = true;
8678
+ args.steps.push(`Post-verify: ${consecutiveSameUrlWaits} consecutive waits on ${state.url} — reloading once to unstick a hung post-OAuth redirect.`);
8679
+ try {
8680
+ await this.browser.goto(state.url);
8681
+ await this.browser.waitForInteractiveDom(5, 15_000).catch(() => undefined);
8682
+ }
8683
+ catch {
8684
+ // reload failed — next round's wait will reach the break below
8685
+ }
8686
+ continue;
8687
+ }
8688
+ if (consecutiveSameUrlWaits >= 6) {
8689
+ this.lastPostVerifyDoneReason =
8690
+ `post-OAuth interstitial (${state.url}) never resolved after ${consecutiveSameUrlWaits} waits — ` +
8691
+ `likely a hung redirect or onboarding bootstrap for a freshly-created account`;
8692
+ args.steps.push(`Post-verify: wait-loop on ${state.url} (${consecutiveSameUrlWaits} rounds, page has elements but never advances) — breaking out.`);
8693
+ break;
8694
+ }
8695
+ }
8696
+ else {
8697
+ consecutiveSameUrlWaits = 0;
8698
+ lastWaitUrl = null;
8699
+ }
8152
8700
  hint = undefined;
8153
8701
  try {
8154
8702
  if (nextStep.kind === "extract") {
@@ -8383,8 +8931,18 @@ ${formatInventory(input.inventory)}`,
8383
8931
  // shaped regex, so a multi-cred reveal landed nothing
8384
8932
  // unless the explicit extract round re-fired afterward.
8385
8933
  const credentialDeadline = Date.now() + 8000;
8934
+ let alertSeen = "";
8935
+ let alertChecked = false;
8386
8936
  while (Date.now() < credentialDeadline) {
8387
8937
  await this.browser.wait(0.5);
8938
+ // Reuse the first 0.5s settle to grab any transient toast/notification
8939
+ // a submit-like click raised (validation error, rate-limit, "operation
8940
+ // failed") before it auto-dismisses — otherwise a failed submit reads
8941
+ // as a SILENT no-op. MEASURED 2026-06-11 (deepseek Sign-up).
8942
+ if (!alertChecked) {
8943
+ alertChecked = true;
8944
+ alertSeen = await this.browser.captureTransientAlert(0);
8945
+ }
8388
8946
  try {
8389
8947
  const pollExtract = await this.extractCredentials();
8390
8948
  for (const [k, v] of Object.entries(pollExtract)) {
@@ -8414,6 +8972,22 @@ ${formatInventory(input.inventory)}`,
8414
8972
  // Page mid-render — keep polling; next tick may settle.
8415
8973
  }
8416
8974
  }
8975
+ // A click that raised a notification but yielded no key — surface the
8976
+ // toast text so the planner addresses the real error instead of
8977
+ // re-clicking the same dead button into a stuck-loop.
8978
+ if (credentials.api_key === undefined && alertSeen.length > 0) {
8979
+ args.steps.push(`Post-verify: the page showed a notification after the click: "${alertSeen}"`);
8980
+ // Forensic snapshot of the page in its post-click error state.
8981
+ // The before-fill/after-submit snapshots only cover the FIRST
8982
+ // submit; a failure on a post-verify re-submit (e.g. deepseek's
8983
+ // "Submitted failed. Please try again." after the OTP is filled)
8984
+ // was otherwise unobservable. Non-fatal by contract.
8985
+ await saveDebugSnapshot(this.browser, "post-verify-alert");
8986
+ hint =
8987
+ `After your last click the page showed this notification: "${alertSeen}". ` +
8988
+ `It likely explains why the page did not advance — address it (fix the named ` +
8989
+ `field, wait, or choose a different action) rather than repeating the same click.`;
8990
+ }
8417
8991
  }
8418
8992
  else if (nextStep.kind === "fill") {
8419
8993
  await this.browser.type(nextStep.selector, nextStep.value);
@@ -8885,6 +9459,23 @@ Strategy:
8885
9459
  ALSO shows preset choice buttons for the same step (e.g. "Optimize assets",
8886
9460
  "Transform images", "Next"). That input is a placeholder, not a field to
8887
9461
  complete — click a preset option button or "Next" instead.
9462
+ - **MULTI-FIELD PROFILE / ONBOARDING FORM ("Set up your account",
9463
+ "Tell us about yourself").** When the page is a form with SEVERAL fields
9464
+ (first/last name, country/region, company, job title, phone, use-case,
9465
+ cloud region) gating a "Continue" / "Next" / "Submit" button, you must
9466
+ fill EVERY visible empty text input and pick an option for EVERY
9467
+ unselected dropdown — across your one-action-per-turn budget — BEFORE
9468
+ clicking the gating button. Clicking it while ANY required field is
9469
+ empty just re-renders the same page with red "X is required" errors and
9470
+ wastes a round. Consult STEPS ALREADY TAKEN to see which fields you've
9471
+ done and fill a REMAINING empty one each turn; only click Continue once
9472
+ nothing is left empty.
9473
+ - When choosing a dropdown/select option for a profile field (job title,
9474
+ role, use-case, company size, "how did you hear"), prefer a CONCRETE
9475
+ real option (e.g. "Software Engineer", "Startup") — NEVER pick
9476
+ "Other" / "None" / "Prefer not to say". Selecting "Other" SPAWNS an
9477
+ extra required free-text field ("Please specify") that you then also
9478
+ have to fill, costing rounds for no benefit.
8888
9479
  ${loginGuidance}
8889
9480
  - If we're on a "verify your phone" / "verify email" wall, return done (we can't solve those).
8890
9481
  - **EMPTY DASHBOARD — create the first resource.** Many services do NOT expose