@trusty-squire/mcp 0.9.10 → 0.9.12
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 +4 -0
- package/dist/bot/agent.d.ts.map +1 -1
- package/dist/bot/agent.js +360 -29
- package/dist/bot/agent.js.map +1 -1
- package/dist/bot/browser.d.ts +2 -0
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +52 -4
- package/dist/bot/browser.js.map +1 -1
- package/dist/bot/profile.d.ts +2 -0
- package/dist/bot/profile.d.ts.map +1 -1
- package/dist/bot/profile.js +41 -0
- package/dist/bot/profile.js.map +1 -1
- package/dist/bot/promote-to-skill.d.ts +5 -0
- package/dist/bot/promote-to-skill.d.ts.map +1 -1
- package/dist/bot/promote-to-skill.js +43 -0
- package/dist/bot/promote-to-skill.js.map +1 -1
- package/dist/bot/replay-skill.d.ts +4 -0
- package/dist/bot/replay-skill.d.ts.map +1 -1
- package/dist/bot/replay-skill.js +33 -0
- package/dist/bot/replay-skill.js.map +1 -1
- package/dist/install/cli.d.ts.map +1 -1
- package/dist/install/cli.js +10 -0
- package/dist/install/cli.js.map +1 -1
- package/dist/operator-env.d.ts +2 -0
- package/dist/operator-env.d.ts.map +1 -0
- package/dist/operator-env.js +57 -0
- package/dist/operator-env.js.map +1 -0
- package/package.json +1 -1
package/dist/bot/agent.js
CHANGED
|
@@ -1121,6 +1121,28 @@ function stripHtmlToText(html) {
|
|
|
1121
1121
|
.replace(/\s+/g, " ")
|
|
1122
1122
|
.toLowerCase();
|
|
1123
1123
|
}
|
|
1124
|
+
// True when a page is a 404 / route-not-found shell. Used to abort the
|
|
1125
|
+
// hardcoded keys-URL walk early: when guessed keys paths keep 404ing, this
|
|
1126
|
+
// host's routing convention simply isn't in our list, and continuing the
|
|
1127
|
+
// ~25-path walk (each a goto + up-to-15s DOM wait) just burns the run budget.
|
|
1128
|
+
// MEASURED 2026-06-09: axiom/fathom/loops each hit the FULL walk with every
|
|
1129
|
+
// path a 404 and blew the 600s deadline. Matches the three observed shells:
|
|
1130
|
+
// title "Not Found" (fathom), body "404 … page you're looking for doesn't
|
|
1131
|
+
// exist" (loops), body "404 page not found" (axiom).
|
|
1132
|
+
export function looksLike404(title, bodyText) {
|
|
1133
|
+
const hay = `${title} ${bodyText}`.toLowerCase().slice(0, 600);
|
|
1134
|
+
const has404Token = /\b404\b/.test(hay);
|
|
1135
|
+
const notFoundPhrase = /page not found|not found|could ?n[o'’]?t be found|could not be found|does ?n[o'’]?t exist|does not exist|page you[''’]?re looking for/.test(hay);
|
|
1136
|
+
// A bare "404" token, or a clear not-found phrase, is enough — guessed
|
|
1137
|
+
// keys URLs that hit either are dead ends.
|
|
1138
|
+
return has404Token || notFoundPhrase;
|
|
1139
|
+
}
|
|
1140
|
+
// Pull the <title> text out of a raw HTML string (lowercased work is left to
|
|
1141
|
+
// the caller). Empty string when absent. Cheap — no DOM round-trip.
|
|
1142
|
+
export function titleFromHtml(html) {
|
|
1143
|
+
const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html);
|
|
1144
|
+
return m?.[1]?.trim() ?? "";
|
|
1145
|
+
}
|
|
1124
1146
|
// Classify a fetched page as a signup form, a login form, or neither.
|
|
1125
1147
|
//
|
|
1126
1148
|
// WHY this exists: looksLikeSignupPage() answers "does this page have a
|
|
@@ -1715,19 +1737,34 @@ export function detectFormFillIsDashboard(plan) {
|
|
|
1715
1737
|
.join(" ")
|
|
1716
1738
|
.toLowerCase();
|
|
1717
1739
|
// Billing / payment wall — the planner sees a credit-card / billing
|
|
1718
|
-
// form, which is never a signup form.
|
|
1740
|
+
// form, which is never a signup form. (Checked first: a "free trial"
|
|
1741
|
+
// page that ALSO demands a card is a wall, not a path to signup.)
|
|
1719
1742
|
const BILLING_WALL = /\b(?:add (?:a )?(?:credit card|payment method)|enter (?:your )?(?:credit card|payment)|billing (?:information|details)|payment information required)\b/;
|
|
1720
1743
|
if (BILLING_WALL.test(haystack))
|
|
1721
1744
|
return true;
|
|
1745
|
+
// Negative guard — a LOGIN / SIGN-IN page (pre-auth), or a plan that
|
|
1746
|
+
// proposes navigating TO a signup/trial form, is the OPPOSITE of a
|
|
1747
|
+
// logged-in dashboard. MEASURED 2026-06-09: fathom's planner correctly
|
|
1748
|
+
// said "this is a login page, NOT a signup page; click 'Start a free
|
|
1749
|
+
// trial' to reach the actual signup form" — pivoting to key extraction
|
|
1750
|
+
// there is wrong (we're not authenticated; the key page 404s). The
|
|
1751
|
+
// bot must EXECUTE the planner's click toward signup. Runs before the
|
|
1752
|
+
// ambiguous "not a signup" check below so it can't be swallowed.
|
|
1753
|
+
const REACH_SIGNUP = /\blog[\s-]?in page|sign[\s-]?in page|free trial|start a (?:free )?trial|create an? account|navigate to the (?:actual )?sign-?up/;
|
|
1754
|
+
if (REACH_SIGNUP.test(haystack))
|
|
1755
|
+
return false;
|
|
1722
1756
|
// Product-creation form — the planner describes creating a
|
|
1723
1757
|
// database / cluster / instance / deployment / app / project /
|
|
1724
1758
|
// workspace / service, which is post-signup territory.
|
|
1725
1759
|
const PRODUCT_CREATION = /\b(?:create(?:s|d)?|creating|provision(?:s|ed|ing)?)\s+(?:(?:the|a|an|new|your|this)\s+){0,3}(?:database|cluster|instance|deployment|app|service|project|workspace|index|environment|tenant)\b/;
|
|
1726
1760
|
if (PRODUCT_CREATION.test(haystack))
|
|
1727
1761
|
return true;
|
|
1728
|
-
// Explicit "
|
|
1729
|
-
//
|
|
1730
|
-
|
|
1762
|
+
// Explicit "logged in" / "dashboard" statements from the planner. NB:
|
|
1763
|
+
// bare "not a signup" is deliberately NOT here — it's ambiguous (a
|
|
1764
|
+
// LOGIN page is also "not a signup") and false-pivoted fathom into key
|
|
1765
|
+
// extraction. A genuine dashboard says "already signed in" / "logged-in
|
|
1766
|
+
// dashboard", which the REACH_SIGNUP guard above doesn't touch.
|
|
1767
|
+
const EXPLICIT = /\b(?:already\s+(?:signed[\s-]?in|logged[\s-]?in|authenticated)|logged[\s-]?in (?:dashboard|user))\b/;
|
|
1731
1768
|
if (EXPLICIT.test(haystack))
|
|
1732
1769
|
return true;
|
|
1733
1770
|
return false;
|
|
@@ -2043,27 +2080,45 @@ export function detectSsoRestriction(pageText) {
|
|
|
2043
2080
|
// post-login dashboard (which never carries these phrases) must NOT
|
|
2044
2081
|
// match, or we'd wrongly abandon a working OAuth session.
|
|
2045
2082
|
export function detectGoogleNoAccount(url, bodyText) {
|
|
2046
|
-
// Inspect the decoded query string (where plunk parks its message)
|
|
2047
|
-
//
|
|
2083
|
+
// Inspect the decoded query string (where plunk parks its message) and
|
|
2084
|
+
// the URL path (to decide whether a BODY match is trustworthy).
|
|
2048
2085
|
let query = "";
|
|
2086
|
+
let path = "";
|
|
2087
|
+
let urlParsed = false;
|
|
2049
2088
|
try {
|
|
2050
2089
|
const u = new URL(url);
|
|
2051
2090
|
query = decodeURIComponent(u.search).toLowerCase();
|
|
2091
|
+
path = u.pathname.toLowerCase();
|
|
2092
|
+
urlParsed = true;
|
|
2052
2093
|
}
|
|
2053
2094
|
catch {
|
|
2054
2095
|
query = "";
|
|
2096
|
+
path = "";
|
|
2055
2097
|
}
|
|
2056
|
-
const haystack = `${query}\n${bodyText.toLowerCase()}`;
|
|
2057
2098
|
// MEASURED 2026-06-04 (clerk): after Google OAuth, clerk bounces to its
|
|
2058
2099
|
// sign-in showing "The External Account was not found" — Google signed
|
|
2059
2100
|
// in but no clerk account exists for this identity (same class as plunk's
|
|
2060
|
-
// "No account found").
|
|
2061
|
-
// account" / "no such account" variants below catch clerk's wording.
|
|
2062
|
-
// Every phrase still requires the word "account" (or "external account"),
|
|
2063
|
-
// so a bare 404 "Page not found" does NOT trip this and abandon a working
|
|
2064
|
-
// OAuth session.
|
|
2101
|
+
// "No account found").
|
|
2065
2102
|
const noAccountPhrase = /no account found|external account was not found|account (?:was )?not found|no (?:such )?account (?:found|exists)|account (?:doesn['’]?t|does not) exist|couldn['’]?t find (?:an|your) account|no account associated|sign up (?:first|to continue)|create an account|[?&]google-auth-error|register first/;
|
|
2066
|
-
|
|
2103
|
+
// The QUERY string is an unambiguous auth-error redirect param (plunk parks
|
|
2104
|
+
// its message there; some services use ?google-auth-error) — trust it
|
|
2105
|
+
// anywhere.
|
|
2106
|
+
if (noAccountPhrase.test(query))
|
|
2107
|
+
return true;
|
|
2108
|
+
// The BODY phrases ("create an account", "register first", "sign up to
|
|
2109
|
+
// continue", …) ALSO appear as generic CTAs/links on a LOGGED-IN dashboard.
|
|
2110
|
+
// MEASURED 2026-06-09 (together): after a successful Google sign-in the bot
|
|
2111
|
+
// landed on the app root "/", whose body carried such a CTA — and the old
|
|
2112
|
+
// body match abandoned the working session and dead-ended at oauth_required.
|
|
2113
|
+
// A genuine "no account for this identity" message only renders on a
|
|
2114
|
+
// login/sign-in/auth ROUTE, so only honor a body match there. On a dashboard
|
|
2115
|
+
// or app route we keep the session and let post-OAuth navigation extract.
|
|
2116
|
+
// If the URL couldn't be parsed at all, we have no route context to gate on
|
|
2117
|
+
// — fall back to trusting the body (rare, and the phrases are strong).
|
|
2118
|
+
const onAuthRoute = /(?:^|\/)(?:login|signin|sign-in|sign-up|signup|auth|authenticate|sso)(?:\/|$)/.test(path);
|
|
2119
|
+
if (urlParsed && !onAuthRoute)
|
|
2120
|
+
return false;
|
|
2121
|
+
return noAccountPhrase.test(bodyText.toLowerCase());
|
|
2067
2122
|
}
|
|
2068
2123
|
// (d) Stuck-on-Google-OAuth-screens (Upstash class). After
|
|
2069
2124
|
// settleAfterOAuth the URL is STILL on accounts.google.com — the
|
|
@@ -3008,6 +3063,35 @@ function isLLMPair(x) {
|
|
|
3008
3063
|
// Navigates / waits / extracts are excluded — they legitimately don't
|
|
3009
3064
|
// change the current DOM (navigate changes URL, wait pauses). Pure +
|
|
3010
3065
|
// exported for unit tests.
|
|
3066
|
+
// Pick the onboarding field to overwrite with a unique value when a "name
|
|
3067
|
+
// taken" collision stalls the wizard. Prefer a business/org/workspace NAME
|
|
3068
|
+
// field (which commonly drives a derived subdomain — editing the domain
|
|
3069
|
+
// directly gets re-derived away), falling back to a bare subdomain/slug field.
|
|
3070
|
+
// Returns null when no such field is present (then the stall is a genuine
|
|
3071
|
+
// click-not-registering case, not a name collision). Exported for tests.
|
|
3072
|
+
export function pickUniqueNameField(inventory) {
|
|
3073
|
+
const textInputs = inventory.filter((e) => e.tag === "input" && (e.type === null || e.type === "text"));
|
|
3074
|
+
const hay = (e) => `${e.name ?? ""} ${e.id ?? ""} ${e.placeholder ?? ""} ${e.labelText ?? ""} ${e.ariaLabel ?? ""}`.toLowerCase();
|
|
3075
|
+
const NAME_RE = /business[_ -]?name|company[_ -]?name|organi[sz]ation|\borg\b|workspace[_ -]?name|team[_ -]?name|account[_ -]?name|site[_ -]?name|project[_ -]?name|tenant/;
|
|
3076
|
+
const byName = textInputs.find((e) => NAME_RE.test(hay(e)));
|
|
3077
|
+
if (byName !== undefined)
|
|
3078
|
+
return byName;
|
|
3079
|
+
const DOMAIN_RE = /subdomain|\bdomain\b|\bslug\b|workspace[_ ]?url|handle/;
|
|
3080
|
+
const byDomain = textInputs.find((e) => DOMAIN_RE.test(hay(e)));
|
|
3081
|
+
return byDomain ?? null;
|
|
3082
|
+
}
|
|
3083
|
+
// Pick the submit/advance control for an onboarding form. Matches a
|
|
3084
|
+
// type=submit button or the conventional advance verbs. Returns null when no
|
|
3085
|
+
// obvious submit affordance is present.
|
|
3086
|
+
export function pickOnboardingSubmit(inventory) {
|
|
3087
|
+
const buttons = inventory.filter((e) => e.tag === "button" || e.role === "button");
|
|
3088
|
+
const ADVANCE_RE = /^(?:next|continue|submit|create|register|get started|finish|done|save)\b/i;
|
|
3089
|
+
const byText = buttons.find((e) => ADVANCE_RE.test((e.visibleText ?? e.ariaLabel ?? "").trim()));
|
|
3090
|
+
if (byText !== undefined)
|
|
3091
|
+
return byText;
|
|
3092
|
+
const bySubmit = buttons.find((e) => e.type === "submit");
|
|
3093
|
+
return bySubmit ?? buttons[0] ?? null;
|
|
3094
|
+
}
|
|
3011
3095
|
export function isStalledOnActions(effects, threshold = 3) {
|
|
3012
3096
|
if (effects.length < threshold)
|
|
3013
3097
|
return false;
|
|
@@ -4105,6 +4189,7 @@ export class SignupAgent {
|
|
|
4105
4189
|
const page = this.browser.page;
|
|
4106
4190
|
if (page === null || page === undefined)
|
|
4107
4191
|
return false;
|
|
4192
|
+
const urlBefore = page.url();
|
|
4108
4193
|
// First-choice selector: data-identifier on an interactive element.
|
|
4109
4194
|
const candidates = [
|
|
4110
4195
|
'[data-identifier]:visible',
|
|
@@ -4116,12 +4201,27 @@ export class SignupAgent {
|
|
|
4116
4201
|
try {
|
|
4117
4202
|
await loc.waitFor({ state: "visible", timeout: 2_000 });
|
|
4118
4203
|
await loc.click({ timeout: 3_000 });
|
|
4204
|
+
if (process.env.BOT_DEBUG_CHOOSER) {
|
|
4205
|
+
await page.waitForTimeout(1500).catch(() => undefined);
|
|
4206
|
+
console.error(`[chooser-debug] matched sel=${JSON.stringify(sel)} urlBefore=${urlBefore} urlAfter=${page.url()}`);
|
|
4207
|
+
}
|
|
4119
4208
|
return true;
|
|
4120
4209
|
}
|
|
4121
4210
|
catch {
|
|
4122
4211
|
continue;
|
|
4123
4212
|
}
|
|
4124
4213
|
}
|
|
4214
|
+
if (process.env.BOT_DEBUG_CHOOSER) {
|
|
4215
|
+
const dump = await page
|
|
4216
|
+
.evaluate(() => ({
|
|
4217
|
+
url: location.href,
|
|
4218
|
+
buttons: Array.from(document.querySelectorAll('[data-identifier],[role="link"],div[jsaction]'))
|
|
4219
|
+
.slice(0, 12)
|
|
4220
|
+
.map((e) => `${e.tagName}|${(e.getAttribute("data-identifier") ?? "").slice(0, 40)}|${(e.textContent ?? "").trim().slice(0, 50)}`),
|
|
4221
|
+
}))
|
|
4222
|
+
.catch(() => null);
|
|
4223
|
+
console.error(`[chooser-debug] NO selector matched. dump=${JSON.stringify(dump)}`);
|
|
4224
|
+
}
|
|
4125
4225
|
return false;
|
|
4126
4226
|
}
|
|
4127
4227
|
catch {
|
|
@@ -4525,9 +4625,18 @@ export class SignupAgent {
|
|
|
4525
4625
|
}
|
|
4526
4626
|
steps.push(`Navigating to ${signupUrl}`);
|
|
4527
4627
|
await this.browser.goto(signupUrl);
|
|
4528
|
-
//
|
|
4529
|
-
//
|
|
4530
|
-
//
|
|
4628
|
+
// Clear any anti-bot interstitial BEFORE the landing read below.
|
|
4629
|
+
// goto() only awaits domcontentloaded, so a Cloudflare "Verifying you
|
|
4630
|
+
// are human" / "Just a moment…" page is often still up — and the
|
|
4631
|
+
// already-signed-in / classify / OAuth-affordance decisions a few lines
|
|
4632
|
+
// down would then run against the interstitial's 2-element DOM and
|
|
4633
|
+
// misjudge the page. MEASURED 2026-06-09: perplexity's /settings/api
|
|
4634
|
+
// landed on a CF interstitial (inventory = just [Cloudflare, Privacy]),
|
|
4635
|
+
// so the bot saw no OAuth button, classified "other", and bailed
|
|
4636
|
+
// no_signup_link instead of reaching the real login/dashboard. The
|
|
4637
|
+
// later waitForFormReady in planExecuteWithRetry was too late. Best-
|
|
4638
|
+
// effort: a page with no interstitial returns fast.
|
|
4639
|
+
await this.browser.waitForFormReady().catch(() => undefined);
|
|
4531
4640
|
// After load: does the rendered page look like a signup form?
|
|
4532
4641
|
// looksLikeSignupPage() can't tell signup from login (both have
|
|
4533
4642
|
// email+password), so we ALSO classify the rendered HTML's copy via
|
|
@@ -4583,8 +4692,31 @@ export class SignupAgent {
|
|
|
4583
4692
|
else {
|
|
4584
4693
|
const klass = classifySignupHtml(landed.html);
|
|
4585
4694
|
if (klass !== "signup" && !(await this.looksLikeSignupPage())) {
|
|
4586
|
-
|
|
4587
|
-
|
|
4695
|
+
// A "login"-classified page is still a valid signup ENTRY when it
|
|
4696
|
+
// offers OAuth: clicking "Continue with GitHub/Google" auto-
|
|
4697
|
+
// provisions the account on first use, and plenty of modern SPAs
|
|
4698
|
+
// (qdrant: cloud.qdrant.io/login) ship ONE unified page for both
|
|
4699
|
+
// login and signup. Only chase a separate signup page when there's
|
|
4700
|
+
// no OAuth affordance to ride — otherwise recovery falls through to
|
|
4701
|
+
// the Google-search fallback, which Google ERR_ABORTs through the
|
|
4702
|
+
// residential proxy, and the run dies on a page that would have
|
|
4703
|
+
// worked via OAuth.
|
|
4704
|
+
const oauthHere = findFirstOAuthButton(landedInventory, ["google", "github"]);
|
|
4705
|
+
if (process.env.BOT_DEBUG_CHOOSER) {
|
|
4706
|
+
console.error(`[login-entry-debug] ${task.service} klass=${klass} oauthHere=${oauthHere?.provider ?? "none"} ` +
|
|
4707
|
+
`inv(${landedInventory.length})=` +
|
|
4708
|
+
JSON.stringify(landedInventory
|
|
4709
|
+
.slice(0, 20)
|
|
4710
|
+
.map((e) => `${e.tag}|${(e.visibleText ?? e.ariaLabel ?? "").slice(0, 36)}`)));
|
|
4711
|
+
}
|
|
4712
|
+
if (oauthHere !== null) {
|
|
4713
|
+
steps.push(`curated signup_url for ${task.service} rendered as "${klass}", but it offers ` +
|
|
4714
|
+
`${oauthHere.provider} OAuth — treating the login page as the signup entry`);
|
|
4715
|
+
}
|
|
4716
|
+
else {
|
|
4717
|
+
needsRecovery = true;
|
|
4718
|
+
steps.push(`curated signup_url for ${task.service} rendered as "${klass}", not a signup form — attempting recovery`);
|
|
4719
|
+
}
|
|
4588
4720
|
}
|
|
4589
4721
|
}
|
|
4590
4722
|
if (needsRecovery) {
|
|
@@ -5024,6 +5156,26 @@ export class SignupAgent {
|
|
|
5024
5156
|
// dashboard. Previous 3s was over-cautious.
|
|
5025
5157
|
await this.browser.wait(1);
|
|
5026
5158
|
await saveDebugSnapshot(this.browser, "after-verify");
|
|
5159
|
+
// Verify-link SPA bounce (MEASURED 2026-06-09: amplitude). The
|
|
5160
|
+
// emailed link is a click-tracker that redirects to
|
|
5161
|
+
// app.amplitude.com/signup?token=… — the token IS consumed
|
|
5162
|
+
// server-side, but the single-page app still renders the
|
|
5163
|
+
// "check your email" wall until the client re-fetches session
|
|
5164
|
+
// state. The post-verify loop then can't get past it. A single
|
|
5165
|
+
// reload makes the SPA re-read the now-verified session.
|
|
5166
|
+
// Bounded + guarded on the wall still showing, so a service
|
|
5167
|
+
// that verified cleanly pays nothing.
|
|
5168
|
+
try {
|
|
5169
|
+
const afterText = await this.browser.extractText();
|
|
5170
|
+
if (expectsVerificationEmail(afterText)) {
|
|
5171
|
+
steps.push("Verification link landed but the page still shows the email-verify wall — reloading so the SPA re-reads the verified session.");
|
|
5172
|
+
await this.browser.reload();
|
|
5173
|
+
await this.browser.wait(2);
|
|
5174
|
+
}
|
|
5175
|
+
}
|
|
5176
|
+
catch {
|
|
5177
|
+
// best-effort — fall through to extraction regardless
|
|
5178
|
+
}
|
|
5027
5179
|
// Try extracting first — many services drop the API key
|
|
5028
5180
|
// straight onto the landing page after verification.
|
|
5029
5181
|
credentials = await this.extractCredentials();
|
|
@@ -5184,6 +5336,39 @@ export class SignupAgent {
|
|
|
5184
5336
|
}
|
|
5185
5337
|
}
|
|
5186
5338
|
}
|
|
5339
|
+
// hCaptcha Tier-3 — the OAuth-first path historically only escalated
|
|
5340
|
+
// reCAPTCHA, so an hCaptcha-gated OAuth button (MEASURED 2026-06-09:
|
|
5341
|
+
// supabase) timed out at Tier-2 and the bot then clicked a button
|
|
5342
|
+
// still hidden behind the unsolved widget → 20s waitFor crash. The
|
|
5343
|
+
// form-fill gate (runCaptchaGate) already solves hCaptcha via 2Captcha;
|
|
5344
|
+
// mirror it here. Unlike Turnstile, hCaptcha is a real image challenge
|
|
5345
|
+
// 2Captcha can solve, and its token is NOT IP-scored.
|
|
5346
|
+
if (!solvedViaTier3 &&
|
|
5347
|
+
captcha.kind === "hcaptcha" &&
|
|
5348
|
+
this.captchaSolver?.isAvailable() === true) {
|
|
5349
|
+
const sitekey = await this.browser.extractHcaptchaSitekey();
|
|
5350
|
+
const pageUrl = (await this.browser.getState().catch(() => null))?.url;
|
|
5351
|
+
if (sitekey !== null && pageUrl !== undefined) {
|
|
5352
|
+
steps.push(`OAuth: Tier 3 — submitting hCaptcha sitekey to 2Captcha (${sitekey.slice(0, 10)}…)`);
|
|
5353
|
+
const solveRes = await this.captchaSolver.solveHcaptcha({ sitekey, pageUrl });
|
|
5354
|
+
if (solveRes.kind === "ok") {
|
|
5355
|
+
const injected = await this.browser.injectHcaptchaToken(solveRes.token);
|
|
5356
|
+
if (injected) {
|
|
5357
|
+
solvedViaTier3 = true;
|
|
5358
|
+
steps.push(`OAuth: Tier 3 solved the hCaptcha in ${Math.round(solveRes.durationMs / 1000)}s via 2Captcha — clicking the ${provider.label} affordance`);
|
|
5359
|
+
}
|
|
5360
|
+
else {
|
|
5361
|
+
steps.push(`OAuth: Tier 3 hCaptcha token arrived but page injection failed — clicking the ${provider.label} affordance anyway`);
|
|
5362
|
+
}
|
|
5363
|
+
}
|
|
5364
|
+
else {
|
|
5365
|
+
steps.push(`OAuth: Tier 3 hCaptcha ${solveRes.kind}` +
|
|
5366
|
+
("reason" in solveRes ? `: ${solveRes.reason}` : "") +
|
|
5367
|
+
("durationMs" in solveRes ? ` (${Math.round(solveRes.durationMs / 1000)}s)` : "") +
|
|
5368
|
+
` — clicking the ${provider.label} affordance anyway`);
|
|
5369
|
+
}
|
|
5370
|
+
}
|
|
5371
|
+
}
|
|
5187
5372
|
if (!solvedViaTier3) {
|
|
5188
5373
|
steps.push(`OAuth: visible ${captcha.kind} present but did not solve in 20s — clicking the ${provider.label} affordance anyway`);
|
|
5189
5374
|
}
|
|
@@ -5214,6 +5399,25 @@ export class SignupAgent {
|
|
|
5214
5399
|
(gsiHandled
|
|
5215
5400
|
? ""
|
|
5216
5401
|
: " (FedCM dialog/popup did not complete — looking for another provider)"));
|
|
5402
|
+
// Classic-redirect-misrouted-as-GSI recovery (MEASURED 2026-06-08:
|
|
5403
|
+
// netlify). hasGoogleGsiAffordance() returns true whenever the GSI
|
|
5404
|
+
// client *script* is loaded — but many pages load that script next to a
|
|
5405
|
+
// CLASSIC redirect "Sign up with Google" button (netlify renders
|
|
5406
|
+
// button[name="google"]). Routing that button through tryGoogleGsiLogin
|
|
5407
|
+
// clicks it, which fires a normal same-tab redirect to
|
|
5408
|
+
// accounts.google.com; GSI then reports via:"none" (no FedCM dialog, no
|
|
5409
|
+
// popup) even though the OAuth redirect IS now in flight. Without this
|
|
5410
|
+
// check the code below would (a) scan the GOOGLE page for a github
|
|
5411
|
+
// fallback (finds none) and then (b) re-run startOAuth on
|
|
5412
|
+
// button[name="google"] — a button that no longer exists on
|
|
5413
|
+
// accounts.google.com → 20s waitFor timeout → the whole signup dies.
|
|
5414
|
+
// If the page has actually navigated onto a Google auth host, the
|
|
5415
|
+
// redirect already fired: treat it as handled and let the consent loop
|
|
5416
|
+
// below drive it, exactly as it would for any classic redirect OAuth.
|
|
5417
|
+
if (!gsiHandled && detectStuckOnGoogleOAuth(this.browser.currentUrl())) {
|
|
5418
|
+
gsiHandled = true;
|
|
5419
|
+
steps.push("OAuth: the GSI dialog never fired, but the click started a classic Google redirect — continuing the consent flow.");
|
|
5420
|
+
}
|
|
5217
5421
|
}
|
|
5218
5422
|
// GSI/FedCM dead end. Google won't render the FedCM dialog for an
|
|
5219
5423
|
// automated browser (measured: FedCm.enable ok, dialogShown never fires),
|
|
@@ -5222,17 +5426,37 @@ export class SignupAgent {
|
|
|
5222
5426
|
// caller a TRY_NEXT_PROVIDER signal so it re-dispatches via that provider
|
|
5223
5427
|
// instead of dead-ending on Google. meilisearch offers both.
|
|
5224
5428
|
if (gsiAttempted && !gsiHandled) {
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
5229
|
-
|
|
5230
|
-
|
|
5429
|
+
// The GSI click commonly leaves the page mid-SPA-transition (netlify:
|
|
5430
|
+
// the button[name="google"] goes invisible, the page hasn't navigated
|
|
5431
|
+
// anywhere useful), so a single inventory read here can THROW or return
|
|
5432
|
+
// empty — and the old code then fell through to startOAuth(google),
|
|
5433
|
+
// which re-clicked the now-hidden button and hung 20s before dying.
|
|
5434
|
+
// Settle + retry the read a few times so the redirect-provider fallback
|
|
5435
|
+
// (github) actually fires — github is the redirect provider whose
|
|
5436
|
+
// session we keep warm.
|
|
5437
|
+
let alt = null;
|
|
5438
|
+
for (let attempt = 0; attempt < 3 && alt === null; attempt++) {
|
|
5439
|
+
if (attempt > 0)
|
|
5440
|
+
await this.browser.wait(2);
|
|
5441
|
+
try {
|
|
5442
|
+
const inv = await this.browser.extractInteractiveElements();
|
|
5443
|
+
alt = findFirstOAuthButton(inv, ["github"]);
|
|
5444
|
+
}
|
|
5445
|
+
catch {
|
|
5446
|
+
// page still navigating — retry after the settle
|
|
5231
5447
|
}
|
|
5232
5448
|
}
|
|
5233
|
-
|
|
5234
|
-
|
|
5449
|
+
if (alt !== null) {
|
|
5450
|
+
steps.push(`OAuth: Google GSI/FedCM is a dead end for the bot — falling back to ${alt.provider}.`);
|
|
5451
|
+
return { kind: "try_next_provider", selector: alt.button.selector, provider: alt.provider };
|
|
5235
5452
|
}
|
|
5453
|
+
// No redirect provider to hand off to. Re-clicking the Google affordance
|
|
5454
|
+
// (startOAuth below) is pointless — it only re-raises the same dead GSI
|
|
5455
|
+
// dialog, and on netlify-class pages crashes on the hidden button. Skip
|
|
5456
|
+
// it: fall into the consent loop on the current page, which aborts
|
|
5457
|
+
// cleanly (needs_login) instead of a misleading 20s waitFor timeout.
|
|
5458
|
+
gsiHandled = true;
|
|
5459
|
+
steps.push("OAuth: Google GSI/FedCM dead-ended with no redirect provider to fall back to.");
|
|
5236
5460
|
}
|
|
5237
5461
|
// OmniAuth POST-only recovery prep. Capture the affordance's href + the
|
|
5238
5462
|
// page's CSRF token NOW, while we're still on the signin page — the
|
|
@@ -5468,7 +5692,7 @@ export class SignupAgent {
|
|
|
5468
5692
|
if (provider.id === "github" &&
|
|
5469
5693
|
task.machineToken !== undefined &&
|
|
5470
5694
|
task.machineToken.length > 0) {
|
|
5471
|
-
steps.push("GitHub: verify-it's-you challenge — polling operator
|
|
5695
|
+
steps.push("GitHub: verify-it's-you challenge — polling operator inbox for a device-confirmation link (up to 60s)");
|
|
5472
5696
|
try {
|
|
5473
5697
|
const { readGitHubChallengeLink } = await import("./read-otp.js");
|
|
5474
5698
|
const linkResult = await readGitHubChallengeLink({
|
|
@@ -5903,7 +6127,7 @@ export class SignupAgent {
|
|
|
5903
6127
|
otpResult = { code: null, reason: "no_machine_token" };
|
|
5904
6128
|
}
|
|
5905
6129
|
else {
|
|
5906
|
-
steps.push(`Email-OTP gate detected (${pathOf(gateState.url)}) — polling operator
|
|
6130
|
+
steps.push(`Email-OTP gate detected (${pathOf(gateState.url)}) — polling operator inbox for the code` +
|
|
5907
6131
|
(domain !== null ? ` (from_domain=${domain})` : ""));
|
|
5908
6132
|
otpResult = await readOperatorOtp({
|
|
5909
6133
|
machineToken,
|
|
@@ -6726,6 +6950,8 @@ ${formatInventory(input.inventory)}`,
|
|
|
6726
6950
|
// ran (current page WAS a keys page but had no affordance), a
|
|
6727
6951
|
// different keys URL on the same origin may carry the create
|
|
6728
6952
|
// control (org-scoped vs account-scoped keys pages).
|
|
6953
|
+
let consecutive404 = 0;
|
|
6954
|
+
const MAX_CONSECUTIVE_404 = 3;
|
|
6729
6955
|
for (let i = 0; i < STUCK_LOOP_FALLBACK_PATHS.length; i++) {
|
|
6730
6956
|
let currentUrl;
|
|
6731
6957
|
try {
|
|
@@ -6745,6 +6971,28 @@ ${formatInventory(input.inventory)}`,
|
|
|
6745
6971
|
catch {
|
|
6746
6972
|
continue;
|
|
6747
6973
|
}
|
|
6974
|
+
// Abort the walk once guessed keys paths keep 404ing — this host's
|
|
6975
|
+
// convention isn't in our list, so the remaining ~20 paths would all
|
|
6976
|
+
// 404 too and burn the run's 600s budget (axiom/fathom/loops). A real
|
|
6977
|
+
// (non-404) page resets the counter so a host that mixes 404s with a
|
|
6978
|
+
// live keys page is still fully walked.
|
|
6979
|
+
try {
|
|
6980
|
+
const after = await this.browser.getState();
|
|
6981
|
+
const bodyText = await this.browser.extractText().catch(() => "");
|
|
6982
|
+
if (looksLike404(titleFromHtml(after.html), bodyText)) {
|
|
6983
|
+
consecutive404 += 1;
|
|
6984
|
+
if (consecutive404 >= MAX_CONSECUTIVE_404) {
|
|
6985
|
+
steps.push(`Existing-account recovery: ${consecutive404} consecutive 404s on guessed keys URLs — ` +
|
|
6986
|
+
`this host's keys path isn't in our list; aborting the walk.`);
|
|
6987
|
+
break;
|
|
6988
|
+
}
|
|
6989
|
+
continue;
|
|
6990
|
+
}
|
|
6991
|
+
consecutive404 = 0;
|
|
6992
|
+
}
|
|
6993
|
+
catch {
|
|
6994
|
+
// best-effort — fall through to the extract attempt
|
|
6995
|
+
}
|
|
6748
6996
|
const here = await tryHere();
|
|
6749
6997
|
if (here !== null)
|
|
6750
6998
|
return here;
|
|
@@ -6877,6 +7125,11 @@ ${formatInventory(input.inventory)}`,
|
|
|
6877
7125
|
let prevContentSig = null;
|
|
6878
7126
|
let lastActionKind = null;
|
|
6879
7127
|
let lastActionSelector = null;
|
|
7128
|
+
// Fired once: the unique-org-name recovery on a stall whose real cause is
|
|
7129
|
+
// a "name taken" validation (kinde's business_details — the operator's
|
|
7130
|
+
// prior account already used "tsagent", so the pre-filled name collides
|
|
7131
|
+
// and the auto-derived subdomain is rejected, re-presenting the step).
|
|
7132
|
+
let uniqueNameRetried = false;
|
|
6880
7133
|
const actionEffects = [];
|
|
6881
7134
|
// 0.8.2-rc.10 — escalation for the stuck-loop detector.
|
|
6882
7135
|
//
|
|
@@ -6905,6 +7158,17 @@ ${formatInventory(input.inventory)}`,
|
|
|
6905
7158
|
// walking every fallback path.
|
|
6906
7159
|
let prematureDoneFallbacks = 0;
|
|
6907
7160
|
const MAX_PREMATURE_DONE_FALLBACKS = 3;
|
|
7161
|
+
// Navigate budget. A planner that can't find the key page (project-
|
|
7162
|
+
// scoped URLs it can't construct — supabase; or an SPA that ignores
|
|
7163
|
+
// direct navs and stays put — last9) keeps emitting `navigate` round
|
|
7164
|
+
// after round, burning the entire 600s deadline (MEASURED 2026-06-09).
|
|
7165
|
+
// `navigate` is exempt from the stuck-loop detector (it's meant to
|
|
7166
|
+
// change the URL), so cap the TOTAL navigates: a legit dashboard is
|
|
7167
|
+
// reachable in a handful, and past the cap the planner is just guessing.
|
|
7168
|
+
// Past the cap we force non-navigate planning and, if still nothing,
|
|
7169
|
+
// break — converting a 600s hang into a prompt, honest failure.
|
|
7170
|
+
let navigateCount = 0;
|
|
7171
|
+
const MAX_POST_VERIFY_NAVIGATES = 8;
|
|
6908
7172
|
// Dead-URL memory. The planner guesses credential-page URLs
|
|
6909
7173
|
// (e.g. /user/personal_access_tokens/new) that 404; without memory it
|
|
6910
7174
|
// re-guesses the same dead URL round after round — xata and fly each
|
|
@@ -7151,6 +7415,51 @@ ${formatInventory(input.inventory)}`,
|
|
|
7151
7415
|
}
|
|
7152
7416
|
prevContentSig = contentSig;
|
|
7153
7417
|
if (isStalledOnActions(actionEffects)) {
|
|
7418
|
+
// Unique-name-collision recovery (fires ONCE, before giving up). The
|
|
7419
|
+
// stall is often NOT "clicks not registering" but a server-side
|
|
7420
|
+
// validation: an org/business/workspace NAME the operator's prior
|
|
7421
|
+
// account already took, which on many forms drives a derived subdomain
|
|
7422
|
+
// — so editing the domain directly doesn't stick; the NAME field must
|
|
7423
|
+
// change. MEASURED 2026-06-09 (kinde): the pre-filled "tsagent" was
|
|
7424
|
+
// taken, the domain went aria-invalid, and Next re-presented the step.
|
|
7425
|
+
// Detect a taken/unavailable signal, overwrite the name field with a
|
|
7426
|
+
// unique value (which re-derives a unique subdomain), resubmit, re-loop.
|
|
7427
|
+
const bodyLow = (await this.browser.extractText().catch(() => "")).toLowerCase();
|
|
7428
|
+
// NB: leading \b only, NO trailing \b — extractText() glues adjacent
|
|
7429
|
+
// elements without whitespace ("…already taken" + "region" →
|
|
7430
|
+
// "takenregion"), so a trailing boundary made this miss (MEASURED:
|
|
7431
|
+
// kinde, takenSignal=false while the body clearly contained "taken").
|
|
7432
|
+
// The leading \b still rejects mid-word hits like "mistaken"/"overtaken".
|
|
7433
|
+
const takenSignal = /\b(?:taken|already (?:in use|exists|registered|taken)|not available|unavailable|already exists|choose another|try another)/.test(bodyLow);
|
|
7434
|
+
const nameField = takenSignal ? pickUniqueNameField(inventory) : null;
|
|
7435
|
+
if (process.env.BOT_DEBUG_CHOOSER) {
|
|
7436
|
+
console.error(`[uniquename-debug] takenSignal=${takenSignal} nameField=${nameField?.name ?? nameField?.id ?? "null"} ` +
|
|
7437
|
+
`textInputs=${JSON.stringify(inventory
|
|
7438
|
+
.filter((e) => e.tag === "input")
|
|
7439
|
+
.map((e) => `${e.type}|${e.name ?? e.id ?? "?"}`))} bodyHas_taken=${bodyLow.includes("taken")}`);
|
|
7440
|
+
}
|
|
7441
|
+
if (!uniqueNameRetried && nameField !== null) {
|
|
7442
|
+
uniqueNameRetried = true;
|
|
7443
|
+
const unique = `tsq${Math.floor(100000 + Math.random() * 900000)}`;
|
|
7444
|
+
args.steps.push(`Post-verify: stall is a name-taken collision — overwriting ${JSON.stringify(nameField.name ?? nameField.id ?? "name field")} with a unique value and resubmitting.`);
|
|
7445
|
+
try {
|
|
7446
|
+
await this.browser.type(nameField.selector, unique);
|
|
7447
|
+
await this.browser.wait(1);
|
|
7448
|
+
const submit = pickOnboardingSubmit(inventory);
|
|
7449
|
+
if (submit !== null) {
|
|
7450
|
+
await this.browser.click(submit.selector);
|
|
7451
|
+
await this.browser.wait(2);
|
|
7452
|
+
}
|
|
7453
|
+
// Reset the stall window so the resubmit gets a fair shot.
|
|
7454
|
+
actionEffects.length = 0;
|
|
7455
|
+
prevContentSig = null;
|
|
7456
|
+
hint = undefined;
|
|
7457
|
+
continue;
|
|
7458
|
+
}
|
|
7459
|
+
catch (err) {
|
|
7460
|
+
args.steps.push(`Post-verify: unique-name retry failed (${err instanceof Error ? err.message : String(err)}) — falling through.`);
|
|
7461
|
+
}
|
|
7462
|
+
}
|
|
7154
7463
|
// Capture the exact page that defeated the wizard so the N1
|
|
7155
7464
|
// onboarding-wizard class is debuggable post-hoc (post-verify pages
|
|
7156
7465
|
// weren't being snapshotted — imagekit's step-1/3 role-select stall
|
|
@@ -7201,7 +7510,7 @@ ${formatInventory(input.inventory)}`,
|
|
|
7201
7510
|
detectEmailOtpGate(state.url, state.title, await this.browser.extractText().catch(() => ""))) {
|
|
7202
7511
|
otpPolledUrls.add(state.url);
|
|
7203
7512
|
const domain = fromDomainFromUrl(state.url);
|
|
7204
|
-
args.steps.push(`Post-verify round ${round}: post-OAuth email-OTP gate (${pathOf(state.url)}) — polling operator
|
|
7513
|
+
args.steps.push(`Post-verify round ${round}: post-OAuth email-OTP gate (${pathOf(state.url)}) — polling operator inbox for the code` +
|
|
7205
7514
|
(domain !== null ? ` (from_domain=${domain})` : ""));
|
|
7206
7515
|
const otp = await readOperatorOtp({
|
|
7207
7516
|
machineToken: args.machineToken,
|
|
@@ -7402,6 +7711,28 @@ ${formatInventory(input.inventory)}`,
|
|
|
7402
7711
|
continue;
|
|
7403
7712
|
}
|
|
7404
7713
|
if (nextStep.kind === "navigate") {
|
|
7714
|
+
navigateCount += 1;
|
|
7715
|
+
if (navigateCount > MAX_POST_VERIFY_NAVIGATES) {
|
|
7716
|
+
// Over budget: the planner is URL-guessing (supabase project-scoped
|
|
7717
|
+
// keys it can't address; last9's SPA that ignores direct navs). One
|
|
7718
|
+
// more shot CLICKING the current inventory, then give up — don't
|
|
7719
|
+
// burn the rest of the 600s deadline on more dead navigates.
|
|
7720
|
+
const clickable = inventory.filter((e) => e.tag === "button" || e.tag === "a");
|
|
7721
|
+
if (clickable.length > 0 && navigateCount <= MAX_POST_VERIFY_NAVIGATES + 2) {
|
|
7722
|
+
args.steps.push(`Post-verify: navigate budget (${MAX_POST_VERIFY_NAVIGATES}) exhausted — forcing a click on the current page instead of guessing more URLs.`);
|
|
7723
|
+
hint =
|
|
7724
|
+
`You have navigated ${navigateCount} times without reaching an API-key page. ` +
|
|
7725
|
+
`STOP navigating to guessed URLs. CLICK an element from the inventory below ` +
|
|
7726
|
+
`to advance the onboarding/dashboard, or emit 'done' if there is genuinely no ` +
|
|
7727
|
+
`key affordance here.`;
|
|
7728
|
+
continue;
|
|
7729
|
+
}
|
|
7730
|
+
this.lastPostVerifyDoneReason =
|
|
7731
|
+
`[stuck_loop] post-verify exhausted the navigate budget (${navigateCount} navigates) without ` +
|
|
7732
|
+
`reaching a credential page — the key is behind onboarding/URL the planner can't address.`;
|
|
7733
|
+
args.steps.push(`Post-verify: navigate budget exhausted (${navigateCount}) with no credential — breaking out instead of burning the run deadline.`);
|
|
7734
|
+
break;
|
|
7735
|
+
}
|
|
7405
7736
|
prevNavigateFromUrl = state.url;
|
|
7406
7737
|
// Remember where we're going so the next round can blocklist it
|
|
7407
7738
|
// if it 404s.
|