@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.
- package/dist/api-client.d.ts +28 -0
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +11 -0
- package/dist/api-client.js.map +1 -1
- package/dist/bot/agent.d.ts +7 -1
- package/dist/bot/agent.d.ts.map +1 -1
- package/dist/bot/agent.js +631 -40
- package/dist/bot/agent.js.map +1 -1
- package/dist/bot/browser.d.ts +15 -0
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +858 -84
- package/dist/bot/browser.js.map +1 -1
- package/dist/bot/captcha-solver-2captcha.d.ts +18 -0
- package/dist/bot/captcha-solver-2captcha.d.ts.map +1 -1
- package/dist/bot/captcha-solver-2captcha.js +21 -0
- package/dist/bot/captcha-solver-2captcha.js.map +1 -1
- package/dist/bot/email-code-fetcher.d.ts +5 -0
- package/dist/bot/email-code-fetcher.d.ts.map +1 -0
- package/dist/bot/email-code-fetcher.js +33 -0
- package/dist/bot/email-code-fetcher.js.map +1 -0
- package/dist/bot/inbox-client.d.ts +1 -0
- package/dist/bot/inbox-client.d.ts.map +1 -1
- package/dist/bot/inbox-client.js +55 -15
- package/dist/bot/inbox-client.js.map +1 -1
- package/dist/bot/index.d.ts +2 -1
- package/dist/bot/index.d.ts.map +1 -1
- package/dist/bot/index.js +49 -19
- package/dist/bot/index.js.map +1 -1
- package/dist/bot/promote-to-skill.d.ts +3 -1
- package/dist/bot/promote-to-skill.d.ts.map +1 -1
- package/dist/bot/promote-to-skill.js +122 -7
- package/dist/bot/promote-to-skill.js.map +1 -1
- package/dist/bot/replay-skill.d.ts +18 -0
- package/dist/bot/replay-skill.d.ts.map +1 -1
- package/dist/bot/replay-skill.js +290 -12
- package/dist/bot/replay-skill.js.map +1 -1
- package/dist/bot/signup-lock.d.ts +17 -0
- package/dist/bot/signup-lock.d.ts.map +1 -0
- package/dist/bot/signup-lock.js +174 -0
- package/dist/bot/signup-lock.js.map +1 -0
- package/dist/tools/grant-app-access.d.ts +31 -0
- package/dist/tools/grant-app-access.d.ts.map +1 -0
- package/dist/tools/grant-app-access.js +59 -0
- package/dist/tools/grant-app-access.js.map +1 -0
- package/dist/tools/index.d.ts +2 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +4 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/provision-any.d.ts.map +1 -1
- package/dist/tools/provision-any.js +25 -12
- package/dist/tools/provision-any.js.map +1 -1
- package/dist/tools/store-credential.d.ts +5 -0
- package/dist/tools/store-credential.d.ts.map +1 -1
- package/dist/tools/store-credential.js +13 -2
- package/dist/tools/store-credential.js.map +1 -1
- package/package.json +2 -2
- package/dist/bot/oauth-lock.d.ts +0 -2
- package/dist/bot/oauth-lock.d.ts.map +0 -1
- package/dist/bot/oauth-lock.js +0 -28
- 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
|
-
|
|
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
|
-
|
|
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_
|
|
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_
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
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
|
-
|
|
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:
|
|
6190
|
-
`Trying
|
|
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
|
-
|
|
6196
|
-
|
|
6197
|
-
|
|
6198
|
-
|
|
6199
|
-
|
|
6200
|
-
|
|
6201
|
-
|
|
6202
|
-
|
|
6203
|
-
|
|
6204
|
-
|
|
6205
|
-
if (
|
|
6206
|
-
|
|
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: `
|
|
6212
|
-
`
|
|
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
|
-
|
|
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(
|
|
6223
|
-
`
|
|
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
|
-
|
|
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?…®istered=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
|
-
|
|
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
|