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