@trusty-squire/mcp 0.8.16 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/bot/agent.d.ts +36 -2
  2. package/dist/bot/agent.d.ts.map +1 -1
  3. package/dist/bot/agent.js +1789 -216
  4. package/dist/bot/agent.js.map +1 -1
  5. package/dist/bot/browser.d.ts +29 -1
  6. package/dist/bot/browser.d.ts.map +1 -1
  7. package/dist/bot/browser.js +796 -48
  8. package/dist/bot/browser.js.map +1 -1
  9. package/dist/bot/captcha-solver-2captcha.d.ts +12 -0
  10. package/dist/bot/captcha-solver-2captcha.d.ts.map +1 -1
  11. package/dist/bot/captcha-solver-2captcha.js +28 -5
  12. package/dist/bot/captcha-solver-2captcha.js.map +1 -1
  13. package/dist/bot/failure-stage.d.ts +11 -0
  14. package/dist/bot/failure-stage.d.ts.map +1 -0
  15. package/dist/bot/failure-stage.js +86 -0
  16. package/dist/bot/failure-stage.js.map +1 -0
  17. package/dist/bot/google-login.d.ts.map +1 -1
  18. package/dist/bot/google-login.js +39 -0
  19. package/dist/bot/google-login.js.map +1 -1
  20. package/dist/bot/index.d.ts +1 -1
  21. package/dist/bot/index.d.ts.map +1 -1
  22. package/dist/bot/index.js +12 -1
  23. package/dist/bot/index.js.map +1 -1
  24. package/dist/bot/llm-client.d.ts +1 -0
  25. package/dist/bot/llm-client.d.ts.map +1 -1
  26. package/dist/bot/llm-client.js +25 -1
  27. package/dist/bot/llm-client.js.map +1 -1
  28. package/dist/bot/oauth-providers.d.ts.map +1 -1
  29. package/dist/bot/oauth-providers.js +13 -3
  30. package/dist/bot/oauth-providers.js.map +1 -1
  31. package/dist/bot/onboarding-capture.d.ts +18 -1
  32. package/dist/bot/onboarding-capture.d.ts.map +1 -1
  33. package/dist/bot/onboarding-capture.js +57 -0
  34. package/dist/bot/onboarding-capture.js.map +1 -1
  35. package/dist/bot/replay-skill.d.ts +1 -0
  36. package/dist/bot/replay-skill.d.ts.map +1 -1
  37. package/dist/bot/replay-skill.js +12 -2
  38. package/dist/bot/replay-skill.js.map +1 -1
  39. package/dist/tools/signup-telemetry.d.ts +2 -2
  40. package/dist/tools/signup-telemetry.d.ts.map +1 -1
  41. package/dist/tools/signup-telemetry.js.map +1 -1
  42. package/package.json +1 -1
package/dist/bot/agent.js CHANGED
@@ -15,7 +15,7 @@ import { sendTelegramHeightenedAuth } from "./telegram-notify.js";
15
15
  import { TwoCaptchaSolver } from "./captcha-solver-2captcha.js";
16
16
  import { redactCredentials } from "./redact.js";
17
17
  import { readOperatorOtp, fromDomainFromUrl } from "./read-otp.js";
18
- import { loggedInProviders, clearProviderLoggedIn } from "./login-state.js";
18
+ import { loggedInProviders, clearProviderLoggedIn, markProviderLoggedIn, } from "./login-state.js";
19
19
  import { saveDebugSnapshot } from "./debug.js";
20
20
  import { captureOnboardingRound } from "./onboarding-capture.js";
21
21
  import { wasRecentlyPrewarmed, recordPrewarmSuccess } from "./prewarm-cache.js";
@@ -570,6 +570,82 @@ export function isGoogleSearchUrl(url) {
570
570
  return false;
571
571
  }
572
572
  }
573
+ // Google's NEWER consent screen (URL form
574
+ // `accounts.google.com/signin/oauth/id?...&part=<opaque-token>`) hides
575
+ // the requested scopes behind the opaque `part=` token — there is no
576
+ // `scope=` query param to read, so extractOAuthScopes() returns null.
577
+ // The only remaining signal is the visible DOM: the consent page lists
578
+ // each requested item as a templated phrase. These pattern sets let us
579
+ // classify that DOM as basic-only vs. reaching beyond identity.
580
+ //
581
+ // BASIC = the openid/email/profile family — the exact thing the
582
+ // URL-readable happy path (scopesAreBasic → auto-approve) already
583
+ // approves without a human. We require a positive basic signal so an
584
+ // empty/ambiguous DOM never counts as basic.
585
+ const GOOGLE_BASIC_CONSENT_PHRASES = [
586
+ // "See your primary Google Account email address"
587
+ /see\s+your\s+primary\s+google\s+account\s+email\s+address/i,
588
+ // generic email-address grant wording
589
+ /\byour\s+(?:primary\s+)?(?:google\s+account\s+)?email\s+address\b/i,
590
+ // "See your personal info, including any personal info you've made
591
+ // publicly available" / "See your public profile"
592
+ /see\s+your\s+personal\s+info/i,
593
+ /your\s+public\s+profile/i,
594
+ // "Associate you with your personal info on Google"
595
+ /associate\s+you\s+with\s+your\s+personal\s+info/i,
596
+ ];
597
+ // Sensitive (non-basic) scope-grant wording. Any hit means the consent
598
+ // reaches beyond identity — never auto-approve. Kept broad on purpose:
599
+ // a false "non-basic" only costs a manual review, but a missed one
600
+ // would auto-approve a sensitive grant.
601
+ const GOOGLE_NON_BASIC_CONSENT_PHRASES = [
602
+ /\bcontacts?\b/i,
603
+ /\bcalendars?\b/i,
604
+ /\b(?:google\s+)?drive\b/i,
605
+ /\byour\s+files?\b/i,
606
+ /\bgmail\b/i,
607
+ /send\s+(?:email|mail|messages)/i,
608
+ /\bspreadsheets?\b/i,
609
+ /\bsheets\b/i,
610
+ /\bphotos\b/i,
611
+ /\byoutube\b/i,
612
+ /\bon\s+your\s+behalf\b/i,
613
+ /\bmanage\s+your\b/i,
614
+ /\bedit\s+your\b/i,
615
+ /\bdelete\s+your\b/i,
616
+ /see\s+and\s+download\s+your/i,
617
+ ];
618
+ // "basic" = the consent DOM lists ONLY openid/email/profile-family
619
+ // grants. See the block comment above for WHY this exists (Google hides
620
+ // scopes behind `part=` in the new consent URL; the visible phrases are
621
+ // the only signal, and a basic-only consent is what the URL-readable
622
+ // path auto-approves anyway). Returns false on ambiguous/empty so the
623
+ // caller keeps its conservative oauth_consent_needs_review abort —
624
+ // this gate only RECOVERS the basic-only case, never widens approval.
625
+ // Exported for unit testing.
626
+ export function googleConsentIsBasicFromDom(bodyText) {
627
+ // Reuse the existing danger scraper as the first backstop — if it
628
+ // flags any sensitive scope-grant phrase, this is not basic-only.
629
+ if (scrapeGoogleScopePhrases(bodyText).length > 0)
630
+ return false;
631
+ const hasNonBasic = GOOGLE_NON_BASIC_CONSENT_PHRASES.some((p) => p.test(bodyText));
632
+ if (hasNonBasic)
633
+ return false;
634
+ // Require a positive basic signal: an empty/ambiguous DOM (no
635
+ // recognizable grant wording) returns false so the caller does not
636
+ // approve blind.
637
+ return GOOGLE_BASIC_CONSENT_PHRASES.some((p) => p.test(bodyText));
638
+ }
639
+ // Detect an image block's media type from its base64 payload's magic bytes.
640
+ // Live screenshots are JPEG (Playwright `type: "jpeg"`), so that's the
641
+ // default — but the eval corpus uses a 1x1 PNG sentinel, and a PNG labeled
642
+ // `image/jpeg` makes the Anthropic premium fallback 400 ("the image appears
643
+ // to be a image/png image"). Base64 of the PNG magic (\x89PNG) is "iVBOR";
644
+ // JPEG (\xFF\xD8\xFF) is "/9j/". Cheap providers tolerate a mislabel; strict
645
+ // ones reject it — so always label what the bytes actually are.
646
+ export function imageMediaType(base64) {
647
+ return base64.startsWith("iVBOR") ? "image/png" : "image/jpeg";
648
+ }
573
649
  // The set of value_kinds the planner is allowed to emit. Kept as a
574
650
  // runtime array so validation and the exhaustive `valueFor` switch
575
651
  // share one source of truth.
@@ -1017,6 +1093,369 @@ export function hostMatchesServiceDomain(hostname, serviceSlug) {
1017
1093
  const normalized = firstLabel.replace(/[^a-z0-9]/g, "");
1018
1094
  return normalized === serviceSlug;
1019
1095
  }
1096
+ // Strip HTML tags + decode the handful of entities that show up in the
1097
+ // copy we key on, then lowercase. We classify on the VISIBLE COPY because
1098
+ // that's the only thing that reliably distinguishes a signup form from a
1099
+ // login form — both have an <input type="password"> and an email field,
1100
+ // so structure alone is ambiguous (the exact bug looksLikeSignupPage
1101
+ // can't see past). The decoded entities matter: "Create&nbsp;account" or
1102
+ // a "Don&apos;t have an account?" link would otherwise hide the
1103
+ // discriminating phrase behind an entity.
1104
+ function stripHtmlToText(html) {
1105
+ return html
1106
+ .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ")
1107
+ .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ")
1108
+ .replace(/<[^>]+>/g, " ")
1109
+ .replace(/&nbsp;/gi, " ")
1110
+ .replace(/&amp;/gi, "&")
1111
+ .replace(/&apos;/gi, "'")
1112
+ .replace(/&#39;/g, "'")
1113
+ .replace(/&quot;/gi, '"')
1114
+ .replace(/\s+/g, " ")
1115
+ .toLowerCase();
1116
+ }
1117
+ // Classify a fetched page as a signup form, a login form, or neither.
1118
+ //
1119
+ // WHY this exists: looksLikeSignupPage() answers "does this page have a
1120
+ // form?" — which a LOGIN page also satisfies (email + password + a
1121
+ // "Continue with Google" button). The discriminator is the COPY, not the
1122
+ // structure: a real email-signup form carries create-account CTA text
1123
+ // ("create account", "sign up", "get started", "register"); a login form
1124
+ // carries "sign in" / "log in" / "welcome back" and lacks the create CTA.
1125
+ // This is the heart of the stale-URL fix — a curated /signup that
1126
+ // silently serves the login SPA classifies as "login" here, which lets
1127
+ // the resolver reject it and probe for the real signup path.
1128
+ export function classifySignupHtml(html, title) {
1129
+ const text = stripHtmlToText(html);
1130
+ const titleLower = (title ?? "").toLowerCase();
1131
+ // 404 / error shell wins regardless of stray form copy — a "not found"
1132
+ // title is the strongest "this isn't the page you wanted" signal.
1133
+ if (titleLower.includes("404") ||
1134
+ titleLower.includes("not found") ||
1135
+ titleLower.includes("page not found")) {
1136
+ return "other";
1137
+ }
1138
+ // A password field is the structural prerequisite for an auth form. We
1139
+ // regex the RAW html (not the stripped text) because attribute values
1140
+ // live inside the tags the stripper removes. Either the input type or a
1141
+ // name="password"/id="password" counts — some SPAs render the field
1142
+ // without an explicit type=password.
1143
+ const hasPassword = /type\s*=\s*["']?password["']?/i.test(html) ||
1144
+ /(?:name|id)\s*=\s*["']?password["']?/i.test(html);
1145
+ // Create-account CTA copy — the signup discriminator. "sign up" is
1146
+ // word-bounded so it matches "sign up" but not "designup"; "get
1147
+ // started" and "register" round out the common variants.
1148
+ const hasSignupCta = /\bcreate (?:an )?account\b/.test(text) ||
1149
+ /\bcreate your account\b/.test(text) ||
1150
+ /\bsign[\s-]?up\b/.test(text) ||
1151
+ /\bget started\b/.test(text) ||
1152
+ /\bregister\b/.test(text);
1153
+ // Generic login copy — present on any sign-IN form.
1154
+ const hasLoginCopy = /\bsign in\b/.test(text) ||
1155
+ /\blog[\s-]?in\b/.test(text) ||
1156
+ /\bwelcome back\b/.test(text);
1157
+ // LOGIN-DOMINANT headings: even when a "Sign up" link sits in the
1158
+ // footer ("Don't have an account? Sign up"), these headings mean the
1159
+ // PRIMARY form is login. Used to veto a false "signup" read.
1160
+ const loginDominant = /\bsign in to your account\b/.test(text) ||
1161
+ /\bwelcome back\b/.test(text) ||
1162
+ /\blog[\s-]?in to\b/.test(text);
1163
+ if (hasPassword && hasSignupCta && !loginDominant) {
1164
+ // Has the form AND advertises account creation, and isn't a login
1165
+ // page that merely links to signup — this is the page we want.
1166
+ return "signup";
1167
+ }
1168
+ // A login-dominant heading wins even when a stray signup link bumped
1169
+ // hasSignupCta (the "Don't have an account? Sign up" footer case).
1170
+ if (loginDominant && hasPassword) {
1171
+ return "login";
1172
+ }
1173
+ if (hasLoginCopy && !hasSignupCta) {
1174
+ // Login copy with no create-account CTA anywhere — a sign-in form.
1175
+ return "login";
1176
+ }
1177
+ // No password field and no clear CTA → marketing page / empty SPA shell
1178
+ // / 404 body. Not a form we can fill.
1179
+ return "other";
1180
+ }
1181
+ // Pull the email address an email-verification wall names ("check your
1182
+ // <addr> inbox", "we sent a link to <addr>"). Returns the first email-shaped
1183
+ // token, or null. Used to poll the RIGHT alias when the wall was reached
1184
+ // without a fresh submit (a pending account may carry an alias from a prior
1185
+ // run, not task.email). Exported for unit tests.
1186
+ export function extractVerifyWallAlias(text) {
1187
+ const re = /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/gi;
1188
+ let m;
1189
+ while ((m = re.exec(text)) !== null) {
1190
+ const addr = m[0];
1191
+ // Reject email-SHAPED asset references — raw HTML carries script/style
1192
+ // srcs like "amplitude-analytics-browser@2.42.4-fe68beca4b18.js" that the
1193
+ // pattern otherwise matches. A real verification alias never ends in a
1194
+ // file extension.
1195
+ if (/\.(?:js|mjs|css|map|png|jpe?g|svg|gif|ico|woff2?|ttf|webp)$/i.test(addr)) {
1196
+ continue;
1197
+ }
1198
+ return addr;
1199
+ }
1200
+ return null;
1201
+ }
1202
+ // Pure: does this post-submit page look like a CONTINUATION step of the same
1203
+ // signup (a dedicated "Create your password" page — amplitude's step 2 — is the
1204
+ // canonical case) rather than a dashboard, a credentials page, or a
1205
+ // verify-your-email screen? Conservative on purpose: requires a VISIBLE, EMPTY
1206
+ // password input the bot still needs to fill AND a create/continue-style submit
1207
+ // control, and the page must NOT read as a verify-your-email screen or a login
1208
+ // form (a "sign in" page also has a password field, but re-filling it with the
1209
+ // run's generated password would just fail). Exported for unit tests.
1210
+ export function isContinuationFormStep(html, inventory) {
1211
+ // A verify-your-email page is finished by the inbox poll, not re-filled.
1212
+ if (expectsVerificationEmail(html))
1213
+ return false;
1214
+ // A login page must not be mistaken for a signup continuation.
1215
+ if (classifySignupHtml(html) === "login")
1216
+ return false;
1217
+ const hasEmptyPassword = inventory.some((e) => e.tag === "input" &&
1218
+ e.type === "password" &&
1219
+ e.visible !== false &&
1220
+ (e.value ?? "") === "");
1221
+ if (!hasEmptyPassword)
1222
+ return false;
1223
+ return inventory.some((e) => {
1224
+ if (e.tag !== "button" && e.type !== "submit")
1225
+ return false;
1226
+ const t = `${e.visibleText ?? ""} ${e.ariaLabel ?? ""}`.toLowerCase();
1227
+ return /\b(?:create|continue|sign[\s-]?up|next|submit|finish|get started|done)\b/.test(t);
1228
+ });
1229
+ }
1230
+ // Find the in-page "create an account" affordance on a LOGIN page that
1231
+ // also advertises signup ("Don't have an account? Sign up for free" —
1232
+ // the amplitude case). After Google OAuth, such a service has signed the
1233
+ // identity in but has no account/org for it, and expects the in-page
1234
+ // signup CTA to be clicked to create one. We surface that element so the
1235
+ // post-OAuth recovery can click it and re-route into the email/password
1236
+ // signup path, instead of re-triggering OAuth in a loop.
1237
+ //
1238
+ // A login page carries BOTH a "Sign in" submit button AND a "Sign up"
1239
+ // link — we want the latter. Returns null when no signup affordance is
1240
+ // present (so callers fall through to the existing re-OAuth path).
1241
+ export function findSignupCtaElement(inventory) {
1242
+ // Signup intent: "sign up" / "sign up for free" / "create (an) account" /
1243
+ // "register" / "get started". Word-bounded so "signup" matches but
1244
+ // "designup" doesn't.
1245
+ const signupIntent = /\b(?:sign[\s-]?up(?:\s+for\s+free)?|create\s+(?:an?\s+)?account|register|get\s+started)\b/i;
1246
+ // OAuth affordances ("Continue with Google", "Sign in with GitHub") —
1247
+ // clicking these re-triggers the OAuth handshake, the exact loop we're
1248
+ // trying to escape. EXCLUDE them even though "sign in with" brushes the
1249
+ // loginIntent regex below.
1250
+ const oauthAffordance = /continue with|sign in with|log ?in with/i;
1251
+ // Pure login affordance ("Sign in" / "Log in") WITHOUT a signup word —
1252
+ // a login page's primary submit button. EXCLUDE it; we want the signup
1253
+ // link sitting next to it, not the sign-in button.
1254
+ const loginIntent = /\b(?:sign[\s-]?in|log[\s-]?in)\b/i;
1255
+ let best = null;
1256
+ for (const el of inventory) {
1257
+ // Only clickable affordances — an <a>, a <button>, or anything with an
1258
+ // explicit button role. A signup CTA is one of these; a bare <div>
1259
+ // label isn't reliably clickable.
1260
+ const isClickable = el.tag === "a" ||
1261
+ el.tag === "button" ||
1262
+ (el.role ?? "").toLowerCase() === "button";
1263
+ if (!isClickable)
1264
+ continue;
1265
+ const label = `${el.visibleText ?? ""} ${el.ariaLabel ?? ""}`.trim();
1266
+ if (label === "")
1267
+ continue;
1268
+ // EXCLUDE OAuth buttons — clicking re-OAuths (the loop we're escaping).
1269
+ if (oauthAffordance.test(label))
1270
+ continue;
1271
+ // Must read as a signup affordance.
1272
+ if (!signupIntent.test(label))
1273
+ continue;
1274
+ // EXCLUDE a pure login button — one whose label reads as sign-IN but
1275
+ // carries no signup word. (signupIntent already matched this element's
1276
+ // own label, so this guard is defensive: it drops anything that is
1277
+ // login-only despite a stray match.)
1278
+ if (loginIntent.test(label) && !signupIntent.test(label))
1279
+ continue;
1280
+ // Prefer an <a>/<button> over a role=button div — a real link/button is
1281
+ // the canonical signup CTA. First clickable match wins; an anchor or
1282
+ // button upgrades a prior role-button-div pick.
1283
+ if (best === null) {
1284
+ best = el;
1285
+ }
1286
+ else if (best.tag !== "a" &&
1287
+ best.tag !== "button" &&
1288
+ (el.tag === "a" || el.tag === "button")) {
1289
+ best = el;
1290
+ }
1291
+ }
1292
+ return best;
1293
+ }
1294
+ // True when a post-OAuth page is a read-only DEMO / sandbox the service drops
1295
+ // new users into (amplitude: app.amplitude.com/analytics/demo) rather than a
1296
+ // real account — there is no API key here, and a real org needs the page's
1297
+ // "Create a free account" CTA. Conservative: a `/demo` URL segment OR explicit
1298
+ // demo copy ("you are currently in the … demo" / "this is a demo"). Exported
1299
+ // for unit tests.
1300
+ export function isSandboxDemoState(url, bodyText) {
1301
+ try {
1302
+ const path = new URL(url).pathname.toLowerCase();
1303
+ if (/(?:^|\/)demo(?:\/|$)/.test(path))
1304
+ return true;
1305
+ }
1306
+ catch {
1307
+ // fall through to the text check
1308
+ }
1309
+ return /you are currently in the .{0,30}demo|this is (?:a|the) .{0,20}demo|viewing (?:the )?demo|demo (?:account|environment|workspace)\b/i.test(bodyText);
1310
+ }
1311
+ // Find the "Create a free account" CTA that escapes a demo/sandbox into the
1312
+ // real signup. Distinct from findSignupCtaElement because the demo phrasing
1313
+ // ("Create a free account") has "free" between "a" and "account", which that
1314
+ // helper's tighter regex doesn't match. Clickable tags only. Exported for
1315
+ // unit tests.
1316
+ export function findCreateAccountCta(inventory) {
1317
+ const re = /create\s+(?:a\s+)?(?:free\s+)?account|sign\s*up\s+for\s+free|get\s+started\s+for\s+free/i;
1318
+ for (const e of inventory) {
1319
+ if (e.tag !== "a" && e.tag !== "button" && e.role !== "button")
1320
+ continue;
1321
+ const text = `${e.visibleText ?? ""} ${e.ariaLabel ?? ""}`.trim();
1322
+ if (re.test(text))
1323
+ return e;
1324
+ }
1325
+ return null;
1326
+ }
1327
+ // Conventional signup paths to probe, in priority order. Small + ordered
1328
+ // on purpose — we want the FIRST real signup form, not a fan-out across
1329
+ // dozens of guesses that each cost a round-trip over a residential
1330
+ // tunnel. "/auth/signup" sits high because it catches the plunk case
1331
+ // (app.useplunk.com/auth/signup 308 → next-app.useplunk.com/auth/signup).
1332
+ const CONVENTIONAL_SIGNUP_PATHS = [
1333
+ "/signup",
1334
+ "/auth/signup",
1335
+ "/sign-up",
1336
+ "/register",
1337
+ "/users/sign_up",
1338
+ "/account/signup",
1339
+ "/join",
1340
+ ];
1341
+ // Host-prefix swaps: dashboards live behind app./console./dashboard./www.,
1342
+ // but the signup form often lives on auth. or the bare apex. Swapping the
1343
+ // leading label widens the probe to those hosts without fanning out
1344
+ // blindly across arbitrary subdomains.
1345
+ const SIGNUP_HOST_PREFIX_SWAPS = [
1346
+ [/^app\./, "auth."],
1347
+ [/^www\./, "auth."],
1348
+ [/^console\./, "auth."],
1349
+ [/^dashboard\./, "auth."],
1350
+ ];
1351
+ // Build the ordered, de-duped candidate URL set for the probe: every
1352
+ // conventional path across (the hint host, the prefix-swapped hosts, and
1353
+ // the bare eTLD+1). The resolver's final domain-safety check guards
1354
+ // against a candidate that ends up redirecting off-domain.
1355
+ function buildSignupCandidates(hint) {
1356
+ const hosts = new Set([hint.hostname]);
1357
+ for (const [from, to] of SIGNUP_HOST_PREFIX_SWAPS) {
1358
+ if (from.test(hint.hostname)) {
1359
+ hosts.add(hint.hostname.replace(from, to));
1360
+ }
1361
+ }
1362
+ const registered = getDomain(hint.hostname);
1363
+ if (registered !== null)
1364
+ hosts.add(registered);
1365
+ const candidates = [];
1366
+ const seen = new Set();
1367
+ // Path-major so each path is tried across all hosts before the next
1368
+ // path — "/signup" everywhere, then "/auth/signup" everywhere, etc.
1369
+ for (const path of CONVENTIONAL_SIGNUP_PATHS) {
1370
+ for (const host of hosts) {
1371
+ const url = `https://${host}${path}`;
1372
+ if (!seen.has(url)) {
1373
+ seen.add(url);
1374
+ candidates.push(url);
1375
+ }
1376
+ }
1377
+ }
1378
+ return candidates;
1379
+ }
1380
+ // Tier A of the signup-URL resolver — the HTTP fast-path. Given a hint URL
1381
+ // (curated YAML or a guess) and an injectable redirect-following fetcher,
1382
+ // return a URL that actually serves a signup FORM, or null if the HTTP
1383
+ // probe can't resolve one (the caller then escalates to the landing-page
1384
+ // CTA or the Google-search fallback).
1385
+ //
1386
+ // `fetchText` is injected so this is unit-testable with a fake — in
1387
+ // production it's bound to BrowserController.fetchText, which egresses
1388
+ // through the same residential proxy + cookie jar as the real navigation,
1389
+ // so a redirect/HTML read here is representative of what the browser would
1390
+ // land on. Pure-ish: no browser, no globals beyond the PSL helper.
1391
+ export async function resolveSignupUrlByProbe(hintUrl, serviceSlug, fetchText, log) {
1392
+ const note = (m) => log?.(m);
1393
+ let hint;
1394
+ try {
1395
+ hint = new URL(hintUrl);
1396
+ }
1397
+ catch {
1398
+ note(`[signup-url] hint ${hintUrl} is not a URL — skipping HTTP probe`);
1399
+ return null;
1400
+ }
1401
+ // Fast path: the hint itself, followed through redirects. A 308 chain
1402
+ // (plunk's app. → next-app.) resolves here for free.
1403
+ const hintRes = await fetchText(hintUrl);
1404
+ if (hintRes !== null && classifySignupHtml(hintRes.bodyText) === "signup") {
1405
+ if (hintRes.finalUrl !== hintUrl) {
1406
+ note(`[signup-url] hint ${hintUrl} redirected to signup ${hintRes.finalUrl}`);
1407
+ }
1408
+ else {
1409
+ note(`[signup-url] hint ${hintUrl} is already a signup form`);
1410
+ }
1411
+ return hintRes.finalUrl;
1412
+ }
1413
+ note(`[signup-url] hint ${hintUrl} did not classify as signup` +
1414
+ (hintRes === null
1415
+ ? " (fetch failed)"
1416
+ : ` (${classifySignupHtml(hintRes.bodyText)})`));
1417
+ // The hint's registered domain (eTLD+1) is the trusted anchor — it's the
1418
+ // curated/guessed signup_url we were told to start from. A conventional-
1419
+ // path candidate is in-bounds when it stays on that SAME registered
1420
+ // domain, which is the robust check: the service SLUG frequently isn't
1421
+ // the domain label (plunk's site is useplunk.com, railway's is
1422
+ // railway.com), so matching the candidate against the slug wrongly
1423
+ // rejected legitimate same-site redirects (plunk app.→next-app.). We keep
1424
+ // a slug match as a secondary allowance for a curated hint that itself
1425
+ // points at a canonical site on a different registered domain.
1426
+ const hintDomain = getDomain(hint.hostname.toLowerCase());
1427
+ // Probe the conventional paths. The first one that BOTH classifies as a
1428
+ // signup form AND stays on the service's own registered domain wins. The
1429
+ // domain check guards against a path that redirects to a third party
1430
+ // (e.g. a generic SSO portal on a different registered domain).
1431
+ for (const candidate of buildSignupCandidates(hint)) {
1432
+ if (candidate === hintUrl)
1433
+ continue; // already tried as the hint
1434
+ const res = await fetchText(candidate);
1435
+ if (res === null)
1436
+ continue;
1437
+ if (classifySignupHtml(res.bodyText) !== "signup")
1438
+ continue;
1439
+ let finalHost;
1440
+ try {
1441
+ finalHost = new URL(res.finalUrl).hostname;
1442
+ }
1443
+ catch {
1444
+ continue;
1445
+ }
1446
+ const finalDomain = getDomain(finalHost.toLowerCase());
1447
+ const sameRegisteredDomain = hintDomain !== null && finalDomain !== null && finalDomain === hintDomain;
1448
+ if (!sameRegisteredDomain && !hostMatchesServiceDomain(finalHost, serviceSlug)) {
1449
+ note(`[signup-url] candidate ${candidate} → ${res.finalUrl} rejected: ` +
1450
+ `off-domain (hint domain ${hintDomain ?? "?"})`);
1451
+ continue;
1452
+ }
1453
+ note(`[signup-url] resolved via probe: ${candidate} → ${res.finalUrl}`);
1454
+ return res.finalUrl;
1455
+ }
1456
+ note(`[signup-url] no conventional signup path resolved for ${hintUrl}`);
1457
+ return null;
1458
+ }
1020
1459
  // BUG-3 GUARD — diagnostic flag for the Inventory snapshot. Stricter
1021
1460
  // than detectAntiBotBlock (no "cf-turnstile" / "recaptcha" raw-HTML
1022
1461
  // matches) because the previous regex false-positive matched legitimate
@@ -1083,6 +1522,39 @@ export function detectAlreadySignedIn(args) {
1083
1522
  (e.type === "email" || e.type === "password" || e.type === "tel"));
1084
1523
  if (hasCredentialInput)
1085
1524
  return false;
1525
+ // Signal 0 — a strong post-login URL path. An onboarding /
1526
+ // getting-started / welcome route is only reachable AFTER you're
1527
+ // authenticated (you cannot see a "you're all set, next steps" wizard
1528
+ // without a session), so the URL alone is conclusive here — unlike the
1529
+ // weaker dashboard paths in Signal 3, no paired creation-CTA is needed.
1530
+ // last9 lands the bot on /v2/organizations/<slug>/getting-started with
1531
+ // its Google session already active; its buttons ("Choose your region",
1532
+ // "You're all set! Next steps", "Upgrade Plan") matched none of the CTA
1533
+ // vocabularies below, so it used to bail `oauth_required` — claiming
1534
+ // "only OAuth/SSO signup, no email/password form" while the bot was in
1535
+ // fact fully signed in. The precondition above already ruled out a
1536
+ // signup chooser (no credential input).
1537
+ // ...UNLESS the page still presents a signup/OAuth chooser (a
1538
+ // "Continue with Google" button or a bare "Sign up"/"Log in"). Some
1539
+ // services route the login chooser through an /onboarding-style URL; if
1540
+ // a provider button is visible, the bot must OAuth via it, not treat the
1541
+ // page as already-authenticated. (PostHog TS-1923.)
1542
+ const hasSignupAffordance = inventory.some((e) => {
1543
+ const t = `${e.visibleText ?? ""} ${e.ariaLabel ?? ""}`
1544
+ .toLowerCase()
1545
+ .replace(/\s+/g, " ")
1546
+ .trim();
1547
+ return (/\b(?:continue with|sign ?up with|sign ?in with|log ?in with|with (?:google|github|gitlab|microsoft|apple))\b/.test(t) || /^(?:sign ?up|sign ?in|log ?in|create (?:an )?account)$/.test(t));
1548
+ });
1549
+ try {
1550
+ if (!hasSignupAffordance &&
1551
+ /\/(?:getting-started|get-started|onboarding|welcome)(?:\/|$)/i.test(new URL(url).pathname)) {
1552
+ return true;
1553
+ }
1554
+ }
1555
+ catch {
1556
+ // malformed URL — fall through to the other signals
1557
+ }
1086
1558
  const visibleTextOf = (e) => `${e.visibleText ?? ""} ${e.ariaLabel ?? ""}`.trim();
1087
1559
  // Signal 1 — strict nav-keyword match (the canonical Sentry-class case).
1088
1560
  const AUTH_KEYWORDS = /^\s*(?:sign out|log out|dashboard|projects|settings|profile|my account|account settings|workspaces)\s*$/i;
@@ -1326,13 +1798,25 @@ export function findOAuthButton(inventory, provider) {
1326
1798
  const href = (e.href ?? "").toLowerCase();
1327
1799
  if (href.length > 0 && hrefRe.test(href))
1328
1800
  return e;
1329
- // 2. Icon-only button — named only by a descendant img/svg. Require
1330
- // the element to be truly icon-only (no own visible text); a
1331
- // populated visibleText means the iconLabel signal is redundant
1332
- // with path 3 below, and accepting it here lets a card wrapper
1333
- // with a stray <img alt="Google"> inside match.
1334
- if (visibleText.length === 0 &&
1335
- keywordRe.test((e.iconLabel ?? "").toLowerCase())) {
1801
+ // 2. Icon-only (logo) button — named only by a descendant img/svg.
1802
+ // Truly-empty visibleText is the clean case. But a logo button whose
1803
+ // <svg> carries a <title>GitHub</title> LEAKS that title into
1804
+ // textContent (northflank renders "GitHubGitHub" doubled, which
1805
+ // also defeats the \bgithub\b match in path 3), so it isn't strictly
1806
+ // empty. Treat it as icon-only too WHEN its visible text is nothing
1807
+ // but the provider name (any number of times): strip every keyword
1808
+ // occurrence and require no residue. A nav link like "GitHub's
1809
+ // Privacy Policy" leaves residue and is correctly rejected. The
1810
+ // iconLabel must still independently name the provider, so a stray
1811
+ // one-word label can't false-positive.
1812
+ const kw = keyword.toLowerCase();
1813
+ const residue = visibleText
1814
+ .toLowerCase()
1815
+ .split(kw)
1816
+ .join("")
1817
+ .replace(/[\s·|/–-]+/g, "");
1818
+ const isLogoOnly = visibleText.length === 0 || residue.length === 0;
1819
+ if (isLogoOnly && keywordRe.test((e.iconLabel ?? "").toLowerCase())) {
1336
1820
  return e;
1337
1821
  }
1338
1822
  // 3. Visible text / accessible label naming the provider + an
@@ -1344,7 +1828,16 @@ export function findOAuthButton(inventory, provider) {
1344
1828
  .trim();
1345
1829
  if (!keywordRe.test(text))
1346
1830
  continue;
1347
- if (/\b(sign|signup|signin|continue|log ?in|connect|auth)\b/.test(text)) {
1831
+ // "with <provider>" is the OAuth-button idiom and is accepted
1832
+ // directly — it survives an SVG accessible name glued to the verb.
1833
+ // elevenlabs renders its button text as "GoogleSign up with Google",
1834
+ // which fuses "sign" into "googlesign" so the bare \bsign\b check
1835
+ // misses, but "with google" still matches. (A blanket camelCase split
1836
+ // can't be used to un-glue it — it would mangle the provider name
1837
+ // itself, e.g. "GitHub" → "Git Hub".)
1838
+ const withProviderRe = new RegExp(`\\bwith ${keyword}\\b`);
1839
+ if (/\b(sign|signup|signin|continue|log ?in|connect|auth)\b/.test(text) ||
1840
+ withProviderRe.test(text)) {
1348
1841
  return e;
1349
1842
  }
1350
1843
  // rc.39 — minimal-label OAuth buttons. Some auth UIs render the
@@ -1424,15 +1917,24 @@ export function isLoginLoopState(url, inventory, provider) {
1424
1917
  // loop-detect path saw the Google button + the login-shaped URL
1425
1918
  // and looped OAuth indefinitely.
1426
1919
  //
1427
- // When BOTH (1) an email/password input is visible AND (2) an
1428
- // OAuth button for the provider we just used is visible, the page
1429
- // is a hybrid form, not a loop. Return null so the caller falls
1430
- // through to the post-verify flow its planner can drive the
1431
- // form-fill, the captcha gate (Cloudflare turnstile shows up as a
1432
- // `check`-shaped checkbox in inventory), and the Continue click
1433
- // the same way the form-fill phase does for non-OAuth signups.
1434
- const hasCredentialInput = inventory.some((e) => e.tag === "input" && (e.type === "email" || e.type === "password"));
1435
- if (hasCredentialInput)
1920
+ // When a PASSWORD input is visible alongside (2) an OAuth button for
1921
+ // the provider we just used, the page is a genuine hybrid
1922
+ // credential-creation form (Clerk/Auth0: email + password [+ turnstile]),
1923
+ // not a loop. Return null so the caller falls through to the
1924
+ // post-verify flow its planner drives the form-fill, the captcha
1925
+ // gate, and the Continue click the same way the form-fill phase does.
1926
+ //
1927
+ // A BARE EMAIL field does NOT count: it's the near-universal "or
1928
+ // continue with email" magic-link/OTP alternative that sits next to
1929
+ // the OAuth buttons on an ordinary login page (groq's /authenticate,
1930
+ // northflank's /login, …). Treating that as a hybrid form suppressed
1931
+ // the login-loop OAuth retry these services REQUIRE — they finalize
1932
+ // the Stytch/WorkOS session only on a second OAuth click — and
1933
+ // stranded them at oauth_session_not_persisted. The email-OTP case
1934
+ // that genuinely needs the planner is caught separately downstream
1935
+ // (detectEmailOtpGate), so narrowing to password here is safe.
1936
+ const hasPasswordInput = inventory.some((e) => e.tag === "input" && e.type === "password");
1937
+ if (hasPasswordInput)
1436
1938
  return null;
1437
1939
  return findOAuthButton(inventory, provider);
1438
1940
  }
@@ -1504,6 +2006,47 @@ export function detectSsoRestriction(pageText) {
1504
2006
  // "Single Sign-On is required", "SSO organization membership".
1505
2007
  return /(?:managed\s+via\s+(?:sso|single\s+sign-?on)|sso[\s-]?managed|sso\s+organization|single\s+sign-?on\s+is\s+required|enforced\s+by\s+(?:sso|saml))/.test(lower);
1506
2008
  }
2009
+ // Google-OAuth-is-LOGIN-ONLY (plunk class). Some services accept Google
2010
+ // only to log an EXISTING account in; they do NOT auto-provision a new
2011
+ // account for a first-time Google identity. The OAuth handshake
2012
+ // completes, then the service bounces back to its login page with an
2013
+ // explicit "no account" message — e.g. plunk lands on
2014
+ // `…/auth/login?message=No%20account%20found%20for%20this%20Google%20account`.
2015
+ //
2016
+ // WHY a dedicated detector: this state otherwise trips
2017
+ // detectManualLoginFallback (it IS a /login form) and aborts as
2018
+ // `oauth_session_not_persisted` — misleading, because nothing dropped
2019
+ // the session; the account simply was never created. The correct
2020
+ // recovery is to abandon OAuth and create the account via the
2021
+ // email/password form. Caller re-routes to form-fill on a true return.
2022
+ //
2023
+ // Conservative by design: matches the URL query AND body text against
2024
+ // CLEAR no-account / must-sign-up phrasing. A normal consent page or a
2025
+ // post-login dashboard (which never carries these phrases) must NOT
2026
+ // match, or we'd wrongly abandon a working OAuth session.
2027
+ export function detectGoogleNoAccount(url, bodyText) {
2028
+ // Inspect the decoded query string (where plunk parks its message)
2029
+ // plus the page body — both lowercased for case-insensitive matching.
2030
+ let query = "";
2031
+ try {
2032
+ const u = new URL(url);
2033
+ query = decodeURIComponent(u.search).toLowerCase();
2034
+ }
2035
+ catch {
2036
+ query = "";
2037
+ }
2038
+ const haystack = `${query}\n${bodyText.toLowerCase()}`;
2039
+ // MEASURED 2026-06-04 (clerk): after Google OAuth, clerk bounces to its
2040
+ // sign-in showing "The External Account was not found" — Google signed
2041
+ // in but no clerk account exists for this identity (same class as plunk's
2042
+ // "No account found"). The added "…not found" / "couldn't find an
2043
+ // account" / "no such account" variants below catch clerk's wording.
2044
+ // Every phrase still requires the word "account" (or "external account"),
2045
+ // so a bare 404 "Page not found" does NOT trip this and abandon a working
2046
+ // OAuth session.
2047
+ 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/;
2048
+ return noAccountPhrase.test(haystack);
2049
+ }
1507
2050
  // (d) Stuck-on-Google-OAuth-screens (Upstash class). After
1508
2051
  // settleAfterOAuth the URL is STILL on accounts.google.com — the
1509
2052
  // handshake didn't redirect through to the service. Most common
@@ -1523,6 +2066,25 @@ export function detectStuckOnGoogleOAuth(url) {
1523
2066
  return false;
1524
2067
  }
1525
2068
  }
2069
+ // Is the current URL an OAuth/SSO CALLBACK route — the redirect target
2070
+ // where the SPA exchanges the provider code for a session? MEASURED
2071
+ // 2026-06-04: clerk's `/sign-in/sso-callback` does a token exchange that
2072
+ // renders even slower than its already-slow dashboard (~15s over the
2073
+ // residential proxy). On a callback route the SPA IS making progress, so
2074
+ // the post-verify hydration loop grants it a larger budget; on every
2075
+ // other route the smaller budget holds (a never-hydrating page must not
2076
+ // burn the run cap). Matches on the pathname only (PSL-safe via URL parse,
2077
+ // try/catch → false for non-URLs).
2078
+ export function isOAuthCallbackRoute(url) {
2079
+ let pathname = "";
2080
+ try {
2081
+ pathname = new URL(url).pathname;
2082
+ }
2083
+ catch {
2084
+ return false;
2085
+ }
2086
+ return /\/sso-callback|\/oauth\/callback|\/auth\/callback|\/callback(?:\/|$)|\/login\/callback/i.test(pathname);
2087
+ }
1526
2088
  // Scan the inventory for the first OAuth affordance among `providers`,
1527
2089
  // in order — the auto-prefer decision passes every provider the
1528
2090
  // profile has a session for. Returns the matched provider + element.
@@ -1581,16 +2143,27 @@ export function findSignInAdvanceButton(inventory, providers) {
1581
2143
  // actually has a session for. `findFirstOAuthButton` walks this list in
1582
2144
  // order and uses the first provider the PAGE offers, so order = preference.
1583
2145
  //
1584
- // KEY RULE: Google goes first whenever the profile has a Google session
1585
- // even over a non-Google pin. Empirically Google's OAuth blocks far less
1586
- // hard than GitHub's: GitHub forces a "Verify your 2FA settings" wall on
1587
- // the /authorize flow that the bot cannot clear, while a warm Google
1588
- // session usually consents in one click (worst case a number-match the
1589
- // operator taps). The pin (or the other session'd provider) stays in the
1590
- // list as the FALLBACK for services that don't render a Google affordance,
1591
- // so nothing regresses — a GitHub-only service still gets GitHub.
2146
+ // RULE 1 respect an explicit pin when its session is warm. The operator
2147
+ // pins a provider for a reason the bot can't see from the page: e.g.
2148
+ // northflank surfaces Google only as on-demand One-Tap (a FedCM widget the
2149
+ // redirect flow can't drive) while its GitHub button is a clean redirect, so
2150
+ // the service is pinned github. Leading with the warm pin honors that, with
2151
+ // the OTHER warm provider kept as a fallback for pages that only render it.
2152
+ // (This became safe once `login` was fixed to establish the session through
2153
+ // the bot's egress proxy — a warm GitHub session no longer dies on an IP
2154
+ // jump, so it doesn't hit the /authorize 2FA wall the way a stale one did.)
2155
+ //
2156
+ // RULE 2 — with NO pin, Google leads when present: empirically its OAuth
2157
+ // blocks less hard than a cold GitHub flow.
1592
2158
  export function orderOAuthCandidates(pinned, loggedIn) {
1593
2159
  if (pinned !== undefined) {
2160
+ if (loggedIn.includes(pinned)) {
2161
+ const others = loggedIn
2162
+ .filter((p) => p !== pinned)
2163
+ .sort((a, b) => (a === "google" ? -1 : b === "google" ? 1 : 0));
2164
+ return [pinned, ...others];
2165
+ }
2166
+ // Pin's session isn't warm — fall back to whatever IS (Google preferred).
1594
2167
  if (pinned !== "google" && loggedIn.includes("google"))
1595
2168
  return ["google", pinned];
1596
2169
  return [pinned];
@@ -2228,12 +2801,174 @@ export function pickVerificationLink(links) {
2228
2801
  const top = scored[0];
2229
2802
  return top !== undefined && top.score > 0 ? top.url : null;
2230
2803
  }
2804
+ // Pick a verification link by its ANCHOR TEXT in the email HTML — the fallback
2805
+ // when pickVerificationLink (which scores the URL) fails because the link is
2806
+ // wrapped in a click-tracker that hides the keyword behind a redirect. MEASURED
2807
+ // on amplitude (2026-06-04): its "Activate account" link is a
2808
+ // u…ct.sendgrid.net/ls/click?upn=… URL (no "activate" in the URL), so the
2809
+ // URL scorer returned null and the bot fell to a false-positive "code" (the
2810
+ // year "2025"). The anchor TEXT still reads "Activate account". Pure + exported
2811
+ // for unit tests.
2812
+ export function pickVerificationLinkFromHtml(bodyHtml) {
2813
+ const anchorRe = /<a\b[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
2814
+ let best = null;
2815
+ let m;
2816
+ while ((m = anchorRe.exec(bodyHtml)) !== null) {
2817
+ const href = (m[1] ?? "").replace(/&amp;/g, "&");
2818
+ if (!/^https?:\/\//i.test(href))
2819
+ continue;
2820
+ const text = (m[2] ?? "")
2821
+ .replace(/<[^>]+>/g, " ")
2822
+ .replace(/&[a-z]+;/gi, " ")
2823
+ .replace(/\s+/g, " ")
2824
+ .trim()
2825
+ .toLowerCase();
2826
+ let score = 0;
2827
+ if (/\b(?:verify|confirm|activate)\b/.test(text))
2828
+ score += 10;
2829
+ if (/verify (?:your )?email|confirm (?:your )?email|activate (?:your )?account|complete (?:your )?sign[\s-]?up/.test(text)) {
2830
+ score += 5;
2831
+ }
2832
+ if (/get started|finish setting up/.test(text))
2833
+ score += 3;
2834
+ if (/unsubscribe|preferences|manage|view (?:in|this) (?:browser|email)|privacy|terms/.test(text)) {
2835
+ score -= 10;
2836
+ }
2837
+ if (score > (best?.score ?? 0))
2838
+ best = { url: href, score };
2839
+ }
2840
+ return best !== null && best.score > 0 ? best.url : null;
2841
+ }
2231
2842
  // Discriminates LLMPair from LLMClient. LLMPair has `primary` (an
2232
2843
  // LLMClient); LLMClient has `createMessage`. They're mutually exclusive
2233
2844
  // shapes so a structural check is reliable.
2234
2845
  function isLLMPair(x) {
2235
2846
  return "primary" in x && typeof x.primary === "object" && x.primary !== null;
2236
2847
  }
2848
+ // True when the last `threshold` executed ACTIONS (click/select/check/
2849
+ // fill — steps meant to mutate the page) each left the page content
2850
+ // UNCHANGED. That is the signature of a broken onboarding wizard that
2851
+ // re-presents itself no matter what the bot clicks (the axiom case,
2852
+ // measured 2026-06-03): the planner keeps correctly reacting to a
2853
+ // visibly-unfilled form, but the click never registers, so without this
2854
+ // the run burns all 24 rounds + LLM budget re-clicking the same card.
2855
+ // Navigates / waits / extracts are excluded — they legitimately don't
2856
+ // change the current DOM (navigate changes URL, wait pauses). Pure +
2857
+ // exported for unit tests.
2858
+ export function isStalledOnActions(effects, threshold = 3) {
2859
+ if (effects.length < threshold)
2860
+ return false;
2861
+ const ACTION_KINDS = new Set(["click", "select", "check", "fill"]);
2862
+ const recent = effects.slice(-threshold);
2863
+ if (!recent.every((e) => ACTION_KINDS.has(e.kind) && e.pageUnchanged)) {
2864
+ return false;
2865
+ }
2866
+ // A genuine stall RE-acts on the SAME element (the planner keeps clicking
2867
+ // one card whose click never registers). Acting on DISTINCT selectors is
2868
+ // PROGRESS through a multi-field wizard — selecting role, then company
2869
+ // size, then a plan doesn't change the inventory, but each is a different
2870
+ // choice (axiom). Only call it stalled when a selector REPEATS (fewer
2871
+ // distinct selectors than actions). All-distinct → let the wizard finish.
2872
+ const selectors = recent.map((e) => e.selector ?? "");
2873
+ const distinct = new Set(selectors).size;
2874
+ // If selectors weren't recorded (older callers pass none), fall back to the
2875
+ // original kind+unchanged behavior so existing tests/paths don't regress.
2876
+ const anyRecorded = recent.some((e) => e.selector !== undefined);
2877
+ if (!anyRecorded)
2878
+ return true;
2879
+ return distinct < threshold;
2880
+ }
2881
+ // True when a URL reads as a login / authentication screen. Service-
2882
+ // agnostic (path-based, no per-service hosts) — used to detect a
2883
+ // non-persisting OAuth session: after a successful OAuth, an
2884
+ // authenticated bot lands on a dashboard, not a login page. Pure +
2885
+ // exported for tests.
2886
+ export function isLoginPageUrl(url) {
2887
+ try {
2888
+ const path = new URL(url).pathname.toLowerCase();
2889
+ if (/(?:^|\/)(?:login|signin|sign-in|authenticate|sso)(?:\/|$)/.test(path)) {
2890
+ return true;
2891
+ }
2892
+ }
2893
+ catch {
2894
+ return false;
2895
+ }
2896
+ // Some providers keep the path stable but flag the failed auth in the
2897
+ // query (amplitude: /login?google-auth-error=…).
2898
+ return /[?&]google-auth-error\b/i.test(url);
2899
+ }
2900
+ // A pre-account route (signup OR login OR register) — the set of paths an
2901
+ // AUTHENTICATED user has no business sitting on. Broader than
2902
+ // isLoginPageUrl (which is tuned for the OAuth-callback-loop detector and
2903
+ // deliberately excludes /signup). Used for the post-OAuth dead-route
2904
+ // escape. Exported for unit tests.
2905
+ export function isSignupOrLoginRoute(url) {
2906
+ try {
2907
+ const path = new URL(url).pathname.toLowerCase();
2908
+ return /(?:^|\/)(?:login|signin|sign-in|sign[_-]?up|signup|register|authenticate|sso)(?:\/|$)/.test(path);
2909
+ }
2910
+ catch {
2911
+ return false;
2912
+ }
2913
+ }
2914
+ // The scheme://host root of a URL (no path/query) — the place a service
2915
+ // redirects an authenticated user to their dashboard. Null on a malformed
2916
+ // URL. Exported for unit tests.
2917
+ export function originRoot(url) {
2918
+ try {
2919
+ return new URL(url).origin + "/";
2920
+ }
2921
+ catch {
2922
+ return null;
2923
+ }
2924
+ }
2925
+ // A modern SPA dashboard often paints a "Connecting…" / "Loading…" shell
2926
+ // (plus the static <noscript> "enable JavaScript" fallback) for a beat
2927
+ // while its JS bundle and websocket finish — especially over a
2928
+ // high-latency residential tunnel. During that window the page has ZERO
2929
+ // interactive elements. northflank's /settings/access-tokens lands on
2930
+ // exactly this shell post-OAuth; the post-verify planner reads the empty
2931
+ // inventory and concludes {"kind":"done","no elements"} ~2s in, abandoning
2932
+ // a page that was about to render the token UI. Detect the shell so the
2933
+ // caller can wait for hydration instead of giving up. Matched ONLY
2934
+ // alongside an empty inventory, so the narrow phrasing here won't swallow
2935
+ // a real dashboard that merely contains the word "loading". Exported for
2936
+ // unit tests.
2937
+ export function isLoadingShellText(text) {
2938
+ // The Google account chooser ("Choose an account to continue to <App>")
2939
+ // carries a stray "Loading" label but is an ACTIONABLE page, not a
2940
+ // hydration shell — the clerk post-verify loop must click the account
2941
+ // card, not idle through the hydration-wait ticks. Veto the shell read
2942
+ // before the generic "loading" match below can fire on it.
2943
+ if (/choose an account/i.test(text))
2944
+ return false;
2945
+ // ONLY transient "still rendering" copy. The <noscript> fallback
2946
+ // ("This application cannot function without JavaScript…") is PERMANENT
2947
+ // in the DOM and was matched here by mistake — it made northflank (whose
2948
+ // noscript text never leaves the body) read as a perpetual loading shell,
2949
+ // so the hydration waits never exited. JS-enabled pages keep that text
2950
+ // forever, so it is not a signal.
2951
+ return /\bconnecting\b|\bloading\b|please wait|getting things ready|initiali[sz]ing/i.test(text);
2952
+ }
2953
+ // Transient "the session is being established RIGHT NOW" copy. MEASURED on
2954
+ // groq (Stytch B2B): after the OAuth callback, /authenticate shows
2955
+ // "Logging in…" then "Creating your organization…" for ~5-7s of async
2956
+ // discovery+org-creation+session calls before redirecting to the dashboard.
2957
+ // Interrupting that window (navigating away, or — worse — re-clicking the
2958
+ // OAuth button) ABORTS the org creation and the session never finalizes,
2959
+ // which is exactly how the bot was failing groq. When this text is present
2960
+ // the bot must WAIT, never act. Generalizes to any async-session auth
2961
+ // (Stytch / WorkOS / Auth0 org provisioning). Exported for unit tests.
2962
+ export function isAuthProcessingText(text) {
2963
+ return /logging in|signing in|creating your organization|creating your account|setting up your account|authenticating|finishing (?:sign|log)|redirecting you|one moment/i.test(text);
2964
+ }
2965
+ // Sentinel returned by runOAuthFlow when the OAuth path is a dead end
2966
+ // that the email/password form-fill path can still recover (Google
2967
+ // login-only services that never created an account — see
2968
+ // detectGoogleNoAccount). runSignup catches it and re-runs the form-fill
2969
+ // path with OAuth-first suppressed. A unique const so it can't collide
2970
+ // with any SignupResult.error string.
2971
+ const OAUTH_FALL_BACK_TO_FORM_FILL = "__fall_back_to_form_fill__";
2237
2972
  export class SignupAgent {
2238
2973
  browser;
2239
2974
  // Per-run counter so a single SignupAgent (which lives one run) can't
@@ -2326,7 +3061,13 @@ export class SignupAgent {
2326
3061
  }
2327
3062
  else if (detected.variant === "recaptcha_v3") {
2328
3063
  this.invisibleCaptcha = { kind: "recaptcha", variant: "recaptcha_v3" };
2329
- steps.push(`${label} captcha: invisible reCAPTCHA v3 badge present recording silent encounter`);
3064
+ // Invisible reCAPTCHA scores in the background, but its token is only
3065
+ // minted when grecaptcha.execute() runs — and a form like amplitude's
3066
+ // REQUIRES that token to submit. Mint it now (passes on our ~1.0
3067
+ // score) so the imminent submit carries a valid g-recaptcha-response,
3068
+ // instead of submitting with an empty token and silently no-op'ing.
3069
+ const minted = await this.browser.triggerInvisibleRecaptcha();
3070
+ steps.push(`${label} captcha: invisible reCAPTCHA v3 — ${minted ? "minted score token via grecaptcha.execute()" : "badge present, token not minted (form may submit it itself)"}`);
2330
3071
  }
2331
3072
  }
2332
3073
  return { found: false, solved: false, blocked: false, kind: "turnstile" };
@@ -2344,7 +3085,15 @@ export class SignupAgent {
2344
3085
  result.kind === "recaptcha" &&
2345
3086
  this.captchaSolver?.isAvailable() === true) {
2346
3087
  const sitekey = await this.browser.extractRecaptchaSitekey();
2347
- if (sitekey !== null) {
3088
+ if (sitekey === null) {
3089
+ // result.kind said "recaptcha" but no key with the reCAPTCHA `6L`
3090
+ // format is on the page — almost always an hCaptcha/Turnstile
3091
+ // widget misbucketed by the host-input heuristic. 2Captcha's
3092
+ // reCAPTCHA endpoint would reject the wrong-provider key
3093
+ // (ERROR_WRONG_GOOGLEKEY); skip it and surface the real shape.
3094
+ steps.push(`${label} captcha: no genuine reCAPTCHA sitekey on page (widget is likely hCaptcha/Turnstile) — skipping 2Captcha`);
3095
+ }
3096
+ else {
2348
3097
  const pageUrl = (await this.browser.getState().catch(() => null))?.url;
2349
3098
  if (pageUrl !== undefined) {
2350
3099
  steps.push(`${label} captcha: Tier 3 — submitting sitekey to 2Captcha (${sitekey.slice(0, 10)}…)`);
@@ -2370,6 +3119,38 @@ export class SignupAgent {
2370
3119
  }
2371
3120
  }
2372
3121
  }
3122
+ // Tier 3 for hCaptcha (plausible). Distinct provider, distinct
3123
+ // 2Captcha method (method=hcaptcha) + a UUID sitekey the reCAPTCHA
3124
+ // `6L` guard rejects — so it needs its own extractor, solver call,
3125
+ // and h-captcha-response injector. Same structure as reCAPTCHA Tier 3.
3126
+ if (!result.solved &&
3127
+ result.kind === "hcaptcha" &&
3128
+ this.captchaSolver?.isAvailable() === true) {
3129
+ const sitekey = await this.browser.extractHcaptchaSitekey();
3130
+ const pageUrl = (await this.browser.getState().catch(() => null))?.url;
3131
+ if (sitekey !== null && pageUrl !== undefined) {
3132
+ steps.push(`${label} captcha: Tier 3 — submitting hCaptcha sitekey to 2Captcha (${sitekey.slice(0, 10)}…)`);
3133
+ const solveRes = await this.captchaSolver.solveHcaptcha({ sitekey, pageUrl });
3134
+ if (solveRes.kind === "ok") {
3135
+ const injected = await this.browser.injectHcaptchaToken(solveRes.token);
3136
+ if (injected) {
3137
+ steps.push(`${label} captcha: Tier 3 hCaptcha solved in ${Math.round(solveRes.durationMs / 1000)}s via 2Captcha`);
3138
+ result = { ...result, solved: true };
3139
+ }
3140
+ else {
3141
+ steps.push(`${label} captcha: Tier 3 hCaptcha token arrived but page injection failed — captcha stays blocked`);
3142
+ }
3143
+ }
3144
+ else {
3145
+ steps.push(`${label} captcha: Tier 3 hCaptcha ${solveRes.kind}` +
3146
+ ("reason" in solveRes ? `: ${solveRes.reason}` : "") +
3147
+ ("durationMs" in solveRes ? ` (${Math.round(solveRes.durationMs / 1000)}s)` : ""));
3148
+ }
3149
+ }
3150
+ else if (sitekey === null) {
3151
+ steps.push(`${label} captcha: hCaptcha widget detected but no sitekey found — cannot Tier-3 solve`);
3152
+ }
3153
+ }
2373
3154
  // rc.32 — forensic snapshot after the captcha attempt. Without
2374
3155
  // this, the only snapshot near the captcha is the pre-fill one
2375
3156
  // taken BEFORE the click, so when a Turnstile fails to solve we
@@ -2445,7 +3226,14 @@ export class SignupAgent {
2445
3226
  // click or a post-submit validation error ("the page advanced")
2446
3227
  // gets more headroom. All bounded by the 15-call LLM breaker + the
2447
3228
  // F2 top-level deadline.
2448
- async planExecuteWithRetry(task, fillValues, steps) {
3229
+ async planExecuteWithRetry(task, fillValues, steps,
3230
+ // When true, suppress the OAuth-first scan entirely and go straight
3231
+ // to form-fill. Set by the re-route after the OAuth path discovered
3232
+ // the Google identity has no account (detectGoogleNoAccount) — the
3233
+ // page still carries a "Continue with Google" button, so without
3234
+ // this the scan would re-pick OAuth and loop right back into the
3235
+ // same no-account bounce. One-shot equivalent of committedToEmailPath.
3236
+ forceFormFill = false) {
2449
3237
  const MAX_ERROR_REPLANS = 2;
2450
3238
  // 0.8.3-rc.1 — widened from 4 to 6 so submit_disabled re-plans
2451
3239
  // get more attempts to identify the gating control. Mailgun's
@@ -2479,7 +3267,7 @@ export class SignupAgent {
2479
3267
  // "Continue with Google" button and reroutes — exactly the
2480
3268
  // regression that produced the Security Code challenge on
2481
3269
  // methoxine's account during the rc.30 Railway run.
2482
- let committedToEmailPath = false;
3270
+ let committedToEmailPath = forceFormFill;
2483
3271
  const oauthCandidates = await this.resolveOAuthCandidates(task, steps);
2484
3272
  for (;;) {
2485
3273
  await this.browser.waitForFormReady();
@@ -2499,6 +3287,50 @@ export class SignupAgent {
2499
3287
  this.browser.getState(),
2500
3288
  this.buildInventory(steps, oauthCandidates),
2501
3289
  ]);
3290
+ // Email-verification WALL reached without a fresh submit — e.g. OAuth
3291
+ // landed on a pending account's "Verify your email — check <addr>" page.
3292
+ // A real signup form still has fields to fill; a wall has only
3293
+ // Open-Gmail / Resend / Return buttons, on which the form-fill planner
3294
+ // stalls. Route to the post-submit inbox-poll + verification-link flow
3295
+ // instead, polling the alias the wall names (which may differ from
3296
+ // task.email when a prior run created the pending account).
3297
+ {
3298
+ // Use the already-fetched state.html (don't call extractText() again —
3299
+ // an extra read would shift queue-backed test mocks and isn't needed:
3300
+ // the verification copy is in the rendered HTML).
3301
+ const wallText = state.html;
3302
+ const hasFillableInput = inventory.some((e) => e.tag === "input" &&
3303
+ (e.type === "email" ||
3304
+ e.type === "text" ||
3305
+ e.type === "password" ||
3306
+ e.type === null) &&
3307
+ e.visible !== false);
3308
+ if (!hasFillableInput && expectsVerificationEmail(wallText)) {
3309
+ const alias = extractVerifyWallAlias(wallText);
3310
+ this.pendingVerificationAlias = alias;
3311
+ steps.push(`Form: email-verification wall (no fields to fill${alias !== null ? `, check ${alias}` : ""}) — ` +
3312
+ `routing to the inbox-poll + verification-link flow.`);
3313
+ // The named link may be stale (a pending account from a prior run);
3314
+ // click "Resend verification email" if present to refresh it.
3315
+ const resend = inventory.find((e) => {
3316
+ if (e.tag !== "button" && e.tag !== "a")
3317
+ return false;
3318
+ const t = `${e.visibleText ?? ""} ${e.ariaLabel ?? ""}`.toLowerCase();
3319
+ return /resend (?:verification )?(?:email|link)|send (?:it )?again/.test(t);
3320
+ });
3321
+ if (resend !== undefined) {
3322
+ try {
3323
+ await this.browser.click(resend.selector);
3324
+ steps.push(`Form: clicked "Resend verification email" to refresh the link.`);
3325
+ await this.browser.wait(2);
3326
+ }
3327
+ catch {
3328
+ // non-fatal — poll for whatever's already in the inbox
3329
+ }
3330
+ }
3331
+ return { kind: "submitted" };
3332
+ }
3333
+ }
2502
3334
  // OAuth-first (T6/T13 + auto-prefer): when the page carries a
2503
3335
  // "Sign in with <provider>" affordance for a provider the bot can
2504
3336
  // use, that button unconditionally outranks any form field — hand
@@ -2527,11 +3359,20 @@ export class SignupAgent {
2527
3359
  }
2528
3360
  // SSO buttons frequently load async — Mistral renders its
2529
3361
  // icon-only provider buttons after the email form. Re-extract
2530
- // a couple of times before giving up on the OAuth path.
2531
- if (oauthScanRetries < 2) {
3362
+ // a couple of times before giving up on the OAuth path. On a
3363
+ // websocket-gated SPA (northflank) the WHOLE page — provider
3364
+ // buttons included — renders only after a ~15s hydration, so a
3365
+ // "Connecting"/loading shell warrants far more patience than the
3366
+ // default 2 retries (otherwise the bot gives up at ~6s and wrongly
3367
+ // falls back to the email-signup path before the GitHub button
3368
+ // even exists).
3369
+ const oauthScanShell = isLoadingShellText(await this.browser.extractText().catch(() => ""));
3370
+ const maxOauthScanRetries = oauthScanShell ? 8 : 2;
3371
+ if (oauthScanRetries < maxOauthScanRetries) {
2532
3372
  oauthScanRetries += 1;
2533
3373
  steps.push(`OAuth-first: no provider affordance yet — waiting for an ` +
2534
- `async render (retry ${oauthScanRetries}/2)`);
3374
+ `async render (retry ${oauthScanRetries}/${maxOauthScanRetries}` +
3375
+ `${oauthScanShell ? ", page still a loading shell" : ""})`);
2535
3376
  await this.browser.wait(3);
2536
3377
  continue;
2537
3378
  }
@@ -2621,7 +3462,7 @@ export class SignupAgent {
2621
3462
  // providers, the situation is recoverable — surface the
2622
3463
  // specific provider to seed.
2623
3464
  const visibleProviders = detectOAuthProvidersInInventory(inventory);
2624
- const haveSessions = loggedInProviders();
3465
+ const haveSessions = await this.effectiveLoggedInProviders();
2625
3466
  const missingProviders = visibleProviders.filter((p) => !haveSessions.includes(p));
2626
3467
  if (missingProviders.length > 0 &&
2627
3468
  // Only surface needs_oauth_provider_session when the user
@@ -2791,6 +3632,17 @@ export class SignupAgent {
2791
3632
  // stuck-tracker so a legitimate later click isn't false-positive
2792
3633
  // rejected.
2793
3634
  lastNoProgressClickSelectors = new Set();
3635
+ // Deterministic agreement-checkbox guard — runs BEFORE the captcha
3636
+ // gate + submit so the form is fully satisfied at submit time. The
3637
+ // LLM planner sometimes skips a required TOS box (amplitude: it
3638
+ // read the box as one of the adjacent card-radios), and when the
3639
+ // service doesn't disable submit for an unchecked box, the click
3640
+ // silently no-ops. This ticks terms/privacy/consent boxes while
3641
+ // never touching marketing opt-ins. Best-effort: never throws.
3642
+ const agreementBoxes = await this.browser.checkRequiredAgreementBoxes();
3643
+ if (agreementBoxes.length > 0) {
3644
+ steps.push(`Form: checked required agreement box(es): [${agreementBoxes.join(", ")}]`);
3645
+ }
2794
3646
  // Captcha gate + submit.
2795
3647
  const preGate = await this.runCaptchaGate("Pre-submit", steps);
2796
3648
  if (preGate.blocked)
@@ -3032,12 +3884,33 @@ export class SignupAgent {
3032
3884
  return false;
3033
3885
  }
3034
3886
  }
3887
+ // Which OAuth providers can the bot actually use right now — the UNION of
3888
+ // the logged-in-providers.json marker (a memo) and a LIVE read of the
3889
+ // browser's cookie jar. The cookie jar is ground truth, so a warm session
3890
+ // is never invisible just because the marker drifted (the GitHub-skipped-
3891
+ // for-Google bug). Self-heals the marker for any live session it was
3892
+ // missing. Falls back to the marker alone if the cookie read fails.
3893
+ async effectiveLoggedInProviders() {
3894
+ const fromMarker = loggedInProviders();
3895
+ let live = [];
3896
+ try {
3897
+ live = await this.browser.detectSessionProviders();
3898
+ }
3899
+ catch {
3900
+ live = [];
3901
+ }
3902
+ for (const p of live) {
3903
+ if (!fromMarker.includes(p))
3904
+ markProviderLoggedIn(p);
3905
+ }
3906
+ return [...new Set([...fromMarker, ...live])];
3907
+ }
3035
3908
  async resolveOAuthCandidates(task, steps) {
3036
3909
  if (task.forceForm === true) {
3037
3910
  steps.push("Force-form: OAuth-first scan suppressed — taking the email/password path");
3038
3911
  return [];
3039
3912
  }
3040
- const ordered = orderOAuthCandidates(task.oauthProvider, loggedInProviders());
3913
+ const ordered = orderOAuthCandidates(task.oauthProvider, await this.effectiveLoggedInProviders());
3041
3914
  if (ordered.length === 0)
3042
3915
  return [];
3043
3916
  const pinNote = task.oauthProvider !== undefined &&
@@ -3106,6 +3979,11 @@ export class SignupAgent {
3106
3979
  // it. Cleared once the loop emits a step that targets the OTP
3107
3980
  // input, so the hint doesn't echo into later unrelated rounds.
3108
3981
  pendingOtpCode = null;
3982
+ // Set when planExecuteWithRetry routes an email-verification WALL (reached
3983
+ // without a fresh submit — e.g. OAuth landed on a pending account's "Verify
3984
+ // your email — check <addr>" page) into the post-submit email flow. The poll
3985
+ // targets this alias (the one the wall names) instead of task.email.
3986
+ pendingVerificationAlias = null;
3109
3987
  // rc.39 — when postVerifyLoop exits because the planner returned
3110
3988
  // `done`, capture the planner's stated reason so the caller can
3111
3989
  // factor it into paywall classification. Koyeb (and similar)
@@ -3197,6 +4075,7 @@ export class SignupAgent {
3197
4075
  system: args.system,
3198
4076
  user: args.userBlocks,
3199
4077
  max_tokens: args.maxTokens,
4078
+ ...(args.temperature !== undefined ? { temperature: args.temperature } : {}),
3200
4079
  });
3201
4080
  this.llmCallCount += 1;
3202
4081
  this.backendsUsed.push(resp.backend);
@@ -3359,6 +4238,30 @@ export class SignupAgent {
3359
4238
  : {}),
3360
4239
  }));
3361
4240
  let signupUrl = guessed;
4241
+ // Tier A — HTTP fast-path signup-URL resolver. Before committing to
4242
+ // the (~6-minute) navigation, probe the candidate over the SAME
4243
+ // proxy via the context request API and confirm it actually serves a
4244
+ // signup FORM (not a login SPA / 404). Curated signup_urls go stale
4245
+ // (plunk's app.useplunk.com/signup now 404s and silently serves the
4246
+ // login page; the real form moved to next-app.useplunk.com/auth/
4247
+ // signup). The probe follows redirects + tries conventional paths
4248
+ // and adopts a better URL when it finds one. Non-Google URLs only —
4249
+ // a Google-search URL is the explicit fallback path, not a hint.
4250
+ if (!isGoogleSearchUrl(signupUrl)) {
4251
+ const serviceSlug = task.service.toLowerCase().replace(/[^a-z0-9]/g, "");
4252
+ const resolved = await resolveSignupUrlByProbe(signupUrl, serviceSlug, (u) => this.browser.fetchText(u), (m) => steps.push(m));
4253
+ if (resolved !== null && resolved !== signupUrl) {
4254
+ steps.push(`[signup-url] resolved ${signupUrl} → ${resolved}`);
4255
+ // A curated URL that the resolver had to move is a stale-YAML
4256
+ // signal worth surfacing in telemetry (curated URLs are
4257
+ // supposed to be the trusted, hand-verified path).
4258
+ if (task.signupUrl !== undefined) {
4259
+ steps.push(`⚠ curated signup_url for ${task.service} looks stale ` +
4260
+ `(${signupUrl}); using ${resolved}`);
4261
+ }
4262
+ signupUrl = resolved;
4263
+ }
4264
+ }
3362
4265
  // Prewarm the target origin before hitting the (often-strict) signup
3363
4266
  // page. Two things this buys us:
3364
4267
  // 1. First-party cookies on the root domain. Cloudflare's
@@ -3381,28 +4284,96 @@ export class SignupAgent {
3381
4284
  // PERF: goto() awaits domcontentloaded; the subsequent
3382
4285
  // waitForFormReady in planExecuteWithRetry handles SPA settle.
3383
4286
  // No need for a blind 2s dwell here.
3384
- // When we *guessed* (no signup_url provided) and the page after
3385
- // load doesn't look like a signup page no inputs, no OAuth
3386
- // affordance, or an obvious 404/error title fall back to the
3387
- // search-and-find-link path. This is the safety net that lets
3388
- // the bot recover from a wrong canonical guess (e.g. a service
3389
- // that uses /register or a non-`.com` TLD).
4287
+ // After load: does the rendered page look like a signup form?
4288
+ // looksLikeSignupPage() can't tell signup from login (both have
4289
+ // email+password), so we ALSO classify the rendered HTML's copy via
4290
+ // classifySignupHtml that's what distinguishes the two.
3390
4291
  //
3391
- // A curated task.signupUrl is trusted as-is (no fallback). Otherwise
3392
- // whether the URL came from a promoted skill, the model, or the
3393
- // .com guess verify it looks like a signup page and fall back to
3394
- // the search-and-find path if not. (A promoted-skill URL is replay-
3395
- // verified, so it passes; an LLM/.com guess that's wrong is recovered
3396
- // here.)
3397
- if (task.signupUrl === undefined && !(await this.looksLikeSignupPage())) {
3398
- steps.push(`${guessed} didn't look like a signup page — searching for the real one`);
3399
- const fallbackSearch = `https://www.google.com/search?q=${encodeURIComponent(`${task.service} signup`)}`;
3400
- await this.browser.goto(fallbackSearch);
3401
- // PERF: domcontentloaded from goto() + findSignupLink reads
3402
- // the DOM itself — no blind dwell needed.
3403
- signupUrl = fallbackSearch;
3404
- }
3405
- if (signupUrl !== guessed || isGoogleSearchUrl(signupUrl)) {
4292
+ // A curated task.signupUrl is no longer trusted blindly: it can land
4293
+ // on a login page (a stale path the SPA reroutes to /login). We
4294
+ // trigger recovery for BOTH guessed and curated URLs but
4295
+ // conservatively for curated ones, to avoid regressing a good
4296
+ // curated URL: recover ONLY when the copy classifies as "login" or
4297
+ // "other" AND looksLikeSignupPage also disagrees. The structural
4298
+ // check is the backstop for an OAuth-only signup page ("Continue
4299
+ // with Google", no email/password copy) that classifySignupHtml
4300
+ // would otherwise read as "other". (A promoted-skill URL is replay-
4301
+ // verified and a guessed URL that's wrong is recovered here too.)
4302
+ let needsRecovery = false;
4303
+ if (task.signupUrl === undefined) {
4304
+ needsRecovery = !(await this.looksLikeSignupPage());
4305
+ }
4306
+ else {
4307
+ const rendered = (await this.browser.getState()).html;
4308
+ const klass = classifySignupHtml(rendered);
4309
+ if (klass !== "signup" && !(await this.looksLikeSignupPage())) {
4310
+ needsRecovery = true;
4311
+ steps.push(`curated signup_url for ${task.service} rendered as "${klass}", not a signup form — attempting recovery`);
4312
+ }
4313
+ }
4314
+ if (needsRecovery) {
4315
+ if (task.signupUrl === undefined) {
4316
+ steps.push(`${signupUrl} didn't look like a signup page — attempting recovery`);
4317
+ }
4318
+ // Tier B — landing-page CTA self-heal. Before the heavyweight
4319
+ // Google-search path, navigate to the site root and click the
4320
+ // highest-scored signup CTA (same scorer the planner uses). This
4321
+ // catches static-host SPAs that serve a 200 empty shell for every
4322
+ // path (so the HTTP probe can't tell signup from login) but DO
4323
+ // render a real "Sign up" CTA once the JS hydrates on the root.
4324
+ const root = originRoot(signupUrl);
4325
+ let recovered = false;
4326
+ if (root !== null) {
4327
+ steps.push(`[signup-url] Tier B: landing-page CTA at ${root}`);
4328
+ try {
4329
+ await this.runPrewarm(root, steps);
4330
+ await this.browser.goto(root);
4331
+ const inventory = await this.browser.extractInteractiveElements();
4332
+ // Score every interactive element's text; pick the best
4333
+ // signup CTA. Providers are driven negative by scoreSignupButton
4334
+ // (we want the email-signup affordance, not an OAuth button).
4335
+ let best = null;
4336
+ for (const el of inventory) {
4337
+ const label = el.visibleText ?? el.ariaLabel ?? el.iconLabel ?? el.title ?? "";
4338
+ if (label.trim().length === 0)
4339
+ continue;
4340
+ const score = scoreSignupButton(label, ["google", "github"]);
4341
+ if (best === null || score > best.score)
4342
+ best = { el, score };
4343
+ }
4344
+ if (best !== null && best.score > 0) {
4345
+ steps.push(`[signup-url] Tier B clicking CTA "${(best.el.visibleText ?? best.el.ariaLabel ?? "").slice(0, 40)}" (score ${best.score})`);
4346
+ await this.browser.click(best.el.selector);
4347
+ const landed = (await this.browser.getState()).html;
4348
+ if (classifySignupHtml(landed) === "signup") {
4349
+ const url = this.browser.currentUrl();
4350
+ steps.push(`[signup-url] Tier B recovered signup page: ${url}`);
4351
+ signupUrl = url;
4352
+ recovered = true;
4353
+ }
4354
+ else {
4355
+ steps.push(`[signup-url] Tier B click did not reach a signup form — falling through to search`);
4356
+ }
4357
+ }
4358
+ else {
4359
+ steps.push(`[signup-url] Tier B found no scoring signup CTA on ${root}`);
4360
+ }
4361
+ }
4362
+ catch (err) {
4363
+ steps.push(`[signup-url] Tier B failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
4364
+ }
4365
+ }
4366
+ // Final fallback — the existing Google-search + findSignupLink
4367
+ // path, unchanged. Only when Tier B didn't recover.
4368
+ if (!recovered) {
4369
+ const fallbackSearch = `https://www.google.com/search?q=${encodeURIComponent(`${task.service} signup`)}`;
4370
+ await this.browser.goto(fallbackSearch);
4371
+ // PERF: domcontentloaded from goto() + findSignupLink reads
4372
+ // the DOM itself — no blind dwell needed.
4373
+ signupUrl = fallbackSearch;
4374
+ }
4375
+ }
4376
+ if (isGoogleSearchUrl(signupUrl)) {
3406
4377
  steps.push("Searching for signup page...");
3407
4378
  const found = await this.findSignupLink(task.service);
3408
4379
  if (found !== null) {
@@ -3420,17 +4391,15 @@ export class SignupAgent {
3420
4391
  // fallback, the bot is sitting on a SERP with no usable
3421
4392
  // destination — abort rather than let the form-fill planner
3422
4393
  // happily fill the Google search box.
3423
- if (isGoogleSearchUrl(signupUrl)) {
3424
- return {
3425
- success: false,
3426
- error: `no_signup_link: searched for ${task.service}'s signup page and ` +
3427
- `found no on-domain candidates. The service likely doesn't have ` +
3428
- `a public self-serve signup, or the bot's domain guard rejected ` +
3429
- `every match. Sign up manually.`,
3430
- steps,
3431
- ...this.resultTail(),
3432
- };
3433
- }
4394
+ return {
4395
+ success: false,
4396
+ error: `no_signup_link: searched for ${task.service}'s signup page and ` +
4397
+ `found no on-domain candidates. The service likely doesn't have ` +
4398
+ `a public self-serve signup, or the bot's domain guard rejected ` +
4399
+ `every match. Sign up manually.`,
4400
+ steps,
4401
+ ...this.resultTail(),
4402
+ };
3434
4403
  }
3435
4404
  }
3436
4405
  // Steps 2-5: plan the form, fill it, submit — via the
@@ -3446,147 +4415,223 @@ export class SignupAgent {
3446
4415
  // `literal` has no fixed value — resolved per-action.
3447
4416
  literal: "",
3448
4417
  };
3449
- const outcome = await this.planExecuteWithRetry(task, fillValues, steps);
3450
- switch (outcome.kind) {
3451
- case "captcha_blocked":
3452
- return {
3453
- success: false,
3454
- error: `captcha_blocked: ${outcome.captchaKind} challenge did not resolve. The site flagged this session.`,
3455
- steps,
3456
- ...this.resultTail(),
3457
- };
3458
- case "submit_failed":
3459
- return {
3460
- success: false,
3461
- error: `submit_failed: could not click the signup button — ${outcome.reason}`,
3462
- steps,
3463
- ...this.resultTail(),
3464
- };
3465
- case "planning_failed":
3466
- return {
3467
- success: false,
3468
- error: `planning_failed: ${outcome.reason}`,
3469
- steps,
3470
- ...this.resultTail(),
3471
- };
3472
- case "oauth_required":
3473
- return {
3474
- success: false,
3475
- error: `oauth_required: ${task.service} offers only OAuth/SSO signup — there is no email/password form to automate.`,
3476
- steps,
3477
- ...this.resultTail(),
3478
- };
3479
- case "needs_oauth_provider_session": {
3480
- // rc.33-task — actionable: name the missing provider so
3481
- // the user runs the right `mcp login` command. When more
3482
- // than one provider is missing, the message lists them and
3483
- // recommends any single one (operator picks).
3484
- const missing = outcome.missingProviders;
3485
- const have = outcome.haveSessions;
3486
- const firstMissing = missing[0];
3487
- const missingLabel = missing
3488
- .map((p) => OAUTH_PROVIDERS[p].label)
3489
- .join(" / ");
3490
- const haveLabel = have.length > 0
3491
- ? have.map((p) => OAUTH_PROVIDERS[p].label).join(", ")
3492
- : "(none)";
3493
- return {
3494
- success: false,
3495
- error: `needs_oauth_provider_session: ${task.service} offers ${missingLabel} OAuth ` +
3496
- `but the bot's chrome profile has no ${missingLabel} session ` +
3497
- `(currently signed in to: ${haveLabel}). ` +
3498
- `Run \`npx @trusty-squire/mcp login --provider=${firstMissing}\` ` +
3499
- `to seed the session, then retry.`,
3500
- steps,
3501
- ...this.resultTail(),
3502
- };
3503
- }
3504
- case "anti_bot_blocked":
3505
- return {
3506
- success: false,
3507
- error: `anti_bot_blocked: ${task.service}'s ${outcome.vendor} anti-bot interstitial would ` +
3508
- `not clear — the bot's IP/fingerprint did not pass ${outcome.vendor}'s server-side ` +
3509
- `risk score. This is a soft block (no challenge to solve); the user should sign up ` +
3510
- `manually.`,
3511
- steps,
3512
- ...this.resultTail(),
3513
- };
3514
- case "oauth":
3515
- // T6/T7 — OAuth-first path. runOAuthFlow drives the consent
3516
- // handshake and post-OAuth onboarding to its own terminal
3517
- // SignupResult; there is no form submit / email verification.
3518
- return await this.runOAuthFlow(task, outcome.selector, outcome.provider, steps);
3519
- case "already_oauth": {
3520
- // F17 — page rendered an authenticated dashboard (a
3521
- // previous OAuth bind already linked the account). Skip
3522
- // consent + form-fill, navigate straight to the API key.
3523
- // Uses the same post-OAuth loop runOAuthFlow uses after a
3524
- // successful handshake.
3525
- let credentials = await this.extractCredentials();
3526
- const skippedPostVerify = credentials.api_key !== undefined;
3527
- if (credentials.api_key === undefined) {
3528
- credentials = await this.postVerifyLoop({
3529
- service: task.service,
3530
- maxRounds: task.postVerifyMaxRounds ?? 24,
4418
+ // `outcome` is re-computed when the OAuth path signals a form-fill
4419
+ // fall-back (Google login-only / no-account, e.g. plunk): the
4420
+ // `case "oauth"` handler re-runs planExecuteWithRetry with OAuth-
4421
+ // first suppressed and loops back through this same switch, so
4422
+ // every terminal case (submitted, planning_failed, …) stays in one
4423
+ // place. Bounded to a single re-route so a service that keeps
4424
+ // bouncing can't spin here.
4425
+ let outcome = await this.planExecuteWithRetry(task, fillValues, steps);
4426
+ let oauthFallbackUsed = false;
4427
+ // Multi-step signup guard (amplitude: email/name step → a dedicated
4428
+ // "Create your password" step). Bounds how many continuation form steps
4429
+ // we'll fill after the first submit before treating the signup as done.
4430
+ let multiStepRounds = 0;
4431
+ const MAX_MULTI_STEP_ROUNDS = 3;
4432
+ dispatch: for (;;) {
4433
+ switch (outcome.kind) {
4434
+ case "captcha_blocked":
4435
+ return {
4436
+ success: false,
4437
+ error: `captcha_blocked: ${outcome.captchaKind} challenge did not resolve. The site flagged this session.`,
3531
4438
  steps,
3532
- ...(task.scopeHint !== undefined ? { scopeHint: task.scopeHint } : {}),
3533
- ...(task.machineToken !== undefined ? { machineToken: task.machineToken } : {}),
3534
- ...(task.apiBase !== undefined ? { apiBase: task.apiBase } : {}),
3535
- });
3536
- }
3537
- if (credentials.api_key !== undefined) {
3538
- // 0.8.3-rc.1 — when extractCredentials short-circuited
3539
- // before postVerifyLoop ran, no captures were written.
3540
- // Emit a synthetic extract round so auto-promote can
3541
- // build a "navigate + extract" skill from this run.
3542
- if (skippedPostVerify) {
3543
- await this.writeFastPathSyntheticCapture(task.service, 0, true);
3544
- }
4439
+ ...this.resultTail(),
4440
+ };
4441
+ case "submit_failed":
3545
4442
  return {
3546
- success: true,
3547
- credentials,
4443
+ success: false,
4444
+ error: `submit_failed: could not click the signup button — ${outcome.reason}`,
4445
+ steps,
4446
+ ...this.resultTail(),
4447
+ };
4448
+ case "planning_failed":
4449
+ return {
4450
+ success: false,
4451
+ error: `planning_failed: ${outcome.reason}`,
4452
+ steps,
4453
+ ...this.resultTail(),
4454
+ };
4455
+ case "oauth_required":
4456
+ return {
4457
+ success: false,
4458
+ error: `oauth_required: ${task.service} offers only OAuth/SSO signup — there is no email/password form to automate.`,
4459
+ steps,
4460
+ ...this.resultTail(),
4461
+ };
4462
+ case "needs_oauth_provider_session": {
4463
+ // rc.33-task — actionable: name the missing provider so
4464
+ // the user runs the right `mcp login` command. When more
4465
+ // than one provider is missing, the message lists them and
4466
+ // recommends any single one (operator picks).
4467
+ const missing = outcome.missingProviders;
4468
+ const have = outcome.haveSessions;
4469
+ const firstMissing = missing[0];
4470
+ const missingLabel = missing
4471
+ .map((p) => OAUTH_PROVIDERS[p].label)
4472
+ .join(" / ");
4473
+ const haveLabel = have.length > 0
4474
+ ? have.map((p) => OAUTH_PROVIDERS[p].label).join(", ")
4475
+ : "(none)";
4476
+ return {
4477
+ success: false,
4478
+ error: `needs_oauth_provider_session: ${task.service} offers ${missingLabel} OAuth ` +
4479
+ `but the bot's chrome profile has no ${missingLabel} session ` +
4480
+ `(currently signed in to: ${haveLabel}). ` +
4481
+ `Run \`npx @trusty-squire/mcp login --provider=${firstMissing}\` ` +
4482
+ `to seed the session, then retry.`,
3548
4483
  steps,
3549
4484
  ...this.resultTail(),
3550
4485
  };
3551
4486
  }
3552
- // 0.8.2-rc.10 — same sentinel-pattern routing the runOAuthFlow
3553
- // path uses. The post-verify loop sets lastPostVerifyDoneReason
3554
- // with [stuck_loop] or [existing_account_no_extract] markers
3555
- // when it bails on a planner-loop or pre-existing-key state;
3556
- // surface those distinctly rather than as the generic
3557
- // no_credentials_after_already_signed_in.
3558
- if (this.lastPostVerifyDoneReason !== null &&
3559
- this.lastPostVerifyDoneReason.startsWith("[stuck_loop]")) {
4487
+ case "anti_bot_blocked":
3560
4488
  return {
3561
4489
  success: false,
3562
- error: `planner_stuck: ${task.service}'s dashboard re-picked the same step repeatedly ` +
3563
- `with no inventory change and the bot's hardcoded API-key URL fallbacks did not ` +
3564
- `advance the page finish the signup manually.`,
4490
+ error: `anti_bot_blocked: ${task.service}'s ${outcome.vendor} anti-bot interstitial would ` +
4491
+ `not clear the bot's IP/fingerprint did not pass ${outcome.vendor}'s server-side ` +
4492
+ `risk score. This is a soft block (no challenge to solve); the user should sign up ` +
4493
+ `manually.`,
3565
4494
  steps,
3566
4495
  ...this.resultTail(),
3567
4496
  };
4497
+ case "oauth": {
4498
+ // T6/T7 — OAuth-first path. runOAuthFlow drives the consent
4499
+ // handshake and post-OAuth onboarding to its own terminal
4500
+ // SignupResult; there is no form submit / email verification.
4501
+ const oauthResult = await this.runOAuthFlow(task, outcome.selector, outcome.provider, steps);
4502
+ // Google login-only / no-account (plunk): OAuth is a dead end
4503
+ // but the email/password form can still create the account.
4504
+ // Re-run the form-fill path ONCE with OAuth-first suppressed
4505
+ // (forceFormFill) — re-navigate to the signup form first since
4506
+ // the OAuth flow left us on the service's /login page — then
4507
+ // loop back through this switch to dispatch the new outcome.
4508
+ if (oauthResult === OAUTH_FALL_BACK_TO_FORM_FILL) {
4509
+ if (oauthFallbackUsed) {
4510
+ // Already fell back once and OAuth came up again — refuse
4511
+ // to ping-pong. Surface the dead end honestly.
4512
+ return {
4513
+ success: false,
4514
+ error: `oauth_required: ${task.service}'s OAuth is login-only (no account for this ` +
4515
+ `identity) and the email/password fall-back did not complete a signup.`,
4516
+ steps,
4517
+ ...this.resultTail(),
4518
+ };
4519
+ }
4520
+ oauthFallbackUsed = true;
4521
+ // If the OAuth recovery already left us ON a signup form (the
4522
+ // amplitude demo-escape clicked "Create a free account" → the real
4523
+ // /signup form), fill it IN PLACE — re-navigating to task.signupUrl
4524
+ // could bounce back to the demo. Otherwise re-navigate (the
4525
+ // login-only / no-account case left us on a /login page).
4526
+ const onSignupFormHtml = (await this.browser.getState().catch(() => null))?.html ?? "";
4527
+ if (classifySignupHtml(onSignupFormHtml) === "signup") {
4528
+ steps.push(`OAuth recovery already on a signup form ` +
4529
+ `(${pathOf(this.browser.currentUrl())}) — filling in place.`);
4530
+ }
4531
+ else {
4532
+ const formUrl = task.signupUrl ?? this.browser.currentUrl();
4533
+ steps.push(`Re-routing to email/password signup at ${formUrl} after OAuth no-account.`);
4534
+ await this.browser.goto(formUrl);
4535
+ }
4536
+ outcome = await this.planExecuteWithRetry(task, fillValues, steps,
4537
+ /* forceFormFill */ true);
4538
+ continue dispatch;
4539
+ }
4540
+ return oauthResult;
3568
4541
  }
3569
- if (this.lastPostVerifyDoneReason !== null &&
3570
- this.lastPostVerifyDoneReason.startsWith("[existing_account_no_extract]")) {
4542
+ case "already_oauth": {
4543
+ // F17 — page rendered an authenticated dashboard (a
4544
+ // previous OAuth bind already linked the account). Skip
4545
+ // consent + form-fill, navigate straight to the API key.
4546
+ // Uses the same post-OAuth loop runOAuthFlow uses after a
4547
+ // successful handshake.
4548
+ let credentials = await this.extractCredentials();
4549
+ const skippedPostVerify = credentials.api_key !== undefined;
4550
+ if (credentials.api_key === undefined) {
4551
+ credentials = await this.postVerifyLoop({
4552
+ service: task.service,
4553
+ maxRounds: task.postVerifyMaxRounds ?? 24,
4554
+ steps,
4555
+ ...(task.scopeHint !== undefined ? { scopeHint: task.scopeHint } : {}),
4556
+ ...(task.machineToken !== undefined ? { machineToken: task.machineToken } : {}),
4557
+ ...(task.apiBase !== undefined ? { apiBase: task.apiBase } : {}),
4558
+ });
4559
+ }
4560
+ if (credentials.api_key !== undefined) {
4561
+ // 0.8.3-rc.1 — when extractCredentials short-circuited
4562
+ // before postVerifyLoop ran, no captures were written.
4563
+ // Emit a synthetic extract round so auto-promote can
4564
+ // build a "navigate + extract" skill from this run.
4565
+ if (skippedPostVerify) {
4566
+ await this.writeFastPathSyntheticCapture(task.service, 0, true);
4567
+ }
4568
+ return {
4569
+ success: true,
4570
+ credentials,
4571
+ steps,
4572
+ ...this.resultTail(),
4573
+ };
4574
+ }
4575
+ // 0.8.2-rc.10 — same sentinel-pattern routing the runOAuthFlow
4576
+ // path uses. The post-verify loop sets lastPostVerifyDoneReason
4577
+ // with [stuck_loop] or [existing_account_no_extract] markers
4578
+ // when it bails on a planner-loop or pre-existing-key state;
4579
+ // surface those distinctly rather than as the generic
4580
+ // no_credentials_after_already_signed_in.
4581
+ if (this.lastPostVerifyDoneReason !== null &&
4582
+ this.lastPostVerifyDoneReason.startsWith("[stuck_loop]")) {
4583
+ return {
4584
+ success: false,
4585
+ error: `planner_stuck: ${task.service}'s dashboard re-picked the same step repeatedly ` +
4586
+ `with no inventory change and the bot's hardcoded API-key URL fallbacks did not ` +
4587
+ `advance the page — finish the signup manually.`,
4588
+ steps,
4589
+ ...this.resultTail(),
4590
+ };
4591
+ }
4592
+ if (this.lastPostVerifyDoneReason !== null &&
4593
+ this.lastPostVerifyDoneReason.startsWith("[existing_account_no_extract]")) {
4594
+ return {
4595
+ success: false,
4596
+ error: `existing_account_no_extract: ${task.service}'s dashboard shows pre-existing API ` +
4597
+ `keys for this identity but the values are masked and unrecoverable — wipe the ` +
4598
+ `test identity's account on ${task.service} or sign in manually and reveal the key.`,
4599
+ steps,
4600
+ ...this.resultTail(),
4601
+ };
4602
+ }
3571
4603
  return {
3572
4604
  success: false,
3573
- error: `existing_account_no_extract: ${task.service}'s dashboard shows pre-existing API ` +
3574
- `keys for this identity but the values are masked and unrecoverable wipe the ` +
3575
- `test identity's account on ${task.service} or sign in manually and reveal the key.`,
4605
+ error: "no_credentials_after_already_signed_in: bot detected an authenticated dashboard " +
4606
+ "but post-OAuth navigation did not surface an API key. Sign in manually and generate the token.",
3576
4607
  steps,
3577
4608
  ...this.resultTail(),
3578
4609
  };
3579
4610
  }
3580
- return {
3581
- success: false,
3582
- error: "no_credentials_after_already_signed_in: bot detected an authenticated dashboard " +
3583
- "but post-OAuth navigation did not surface an API key. Sign in manually and generate the token.",
3584
- steps,
3585
- ...this.resultTail(),
3586
- };
4611
+ case "submitted": {
4612
+ // Multi-step signup: a clean submit can land on ANOTHER form step
4613
+ // (amplitude: a dedicated "Create your password" page) rather than
4614
+ // the dashboard or a verify-email screen. Detect a continuation
4615
+ // form step and run the fill-submit phase again on it, bounded,
4616
+ // before treating the submit as done — otherwise the post-submit
4617
+ // logic below polls the inbox for a verification email the
4618
+ // half-finished signup never triggers. Conservative (a visible
4619
+ // empty password input + a submit control, NOT a login or
4620
+ // check-your-email page), so a genuine email-verification flow
4621
+ // isn't mistaken for a form step.
4622
+ if (multiStepRounds < MAX_MULTI_STEP_ROUNDS) {
4623
+ const stepLabel = await this.detectContinuationFormStep();
4624
+ if (stepLabel !== null) {
4625
+ multiStepRounds += 1;
4626
+ steps.push(`Post-submit: continuation form step detected (${stepLabel}) — ` +
4627
+ `filling + submitting (step ${multiStepRounds + 1}).`);
4628
+ outcome = await this.planExecuteWithRetry(task, fillValues, steps);
4629
+ continue dispatch;
4630
+ }
4631
+ }
4632
+ break dispatch;
4633
+ }
3587
4634
  }
3588
- case "submitted":
3589
- break;
3590
4635
  }
3591
4636
  await saveDebugSnapshot(this.browser, "after-submit");
3592
4637
  // Step 6: Extract creds from page.
@@ -3653,10 +4698,14 @@ export class SignupAgent {
3653
4698
  ? `Post-submit page shows a rejected submit — short ${verificationTimeoutSeconds}s probe (S3: no account created, no verification email expected)...`
3654
4699
  : `Post-submit page is inconclusive but submit was clean — polling inbox up to ${verificationTimeoutSeconds}s (S3: an account may have been created and mail can lag)...`);
3655
4700
  try {
3656
- const email = await this.waitForVerificationEmail(task.inbox, task.email, verificationTimeoutSeconds);
4701
+ const email = await this.waitForVerificationEmail(task.inbox, this.pendingVerificationAlias ?? task.email, verificationTimeoutSeconds);
3657
4702
  steps.push(`Received: "${email.subject}" from ${email.from_address}`);
3658
- if (email.parsed_links.length > 0) {
3659
- const verifyLink = this.pickVerificationLink(Array.from(email.parsed_links));
4703
+ if (email.parsed_links.length > 0 || (email.body_html ?? "") !== "") {
4704
+ // URL-keyword scorer first; if it can't see past a click-tracker
4705
+ // wrapper, fall back to matching the link's ANCHOR TEXT in the
4706
+ // HTML body (amplitude's SendGrid-wrapped "Activate account").
4707
+ const verifyLink = this.pickVerificationLink(Array.from(email.parsed_links)) ??
4708
+ pickVerificationLinkFromHtml(email.body_html ?? "");
3660
4709
  if (verifyLink !== null) {
3661
4710
  steps.push(`Following verification link: ${verifyLink}`);
3662
4711
  await this.browser.goto(verifyLink);
@@ -3682,12 +4731,22 @@ export class SignupAgent {
3682
4731
  });
3683
4732
  }
3684
4733
  }
4734
+ else if (email.parsed_codes.length > 0) {
4735
+ credentials = await this.enterEmailVerificationCode(email.parsed_codes[0] ?? "", task, password, steps);
4736
+ }
3685
4737
  else {
3686
4738
  steps.push("Email had no usable verification link.");
3687
4739
  }
3688
4740
  }
4741
+ else if (email.parsed_codes.length > 0) {
4742
+ // No links at all, but the email carries a numeric code
4743
+ // (plausible: "Enter 4011 to verify your email address").
4744
+ // The signup page transitioned to a code-input step after
4745
+ // submit — type the code in rather than waiting for a link.
4746
+ credentials = await this.enterEmailVerificationCode(email.parsed_codes[0] ?? "", task, password, steps);
4747
+ }
3689
4748
  else {
3690
- steps.push("Email had no parsed links — skipping verification click.");
4749
+ steps.push("Email had no parsed links or codes — skipping verification.");
3691
4750
  }
3692
4751
  }
3693
4752
  catch (err) {
@@ -3768,10 +4827,46 @@ export class SignupAgent {
3768
4827
  // services that don't gate OAuth on Turnstile).
3769
4828
  try {
3770
4829
  const captcha = await this.browser.solveVisibleCaptcha(20_000);
3771
- if (captcha.found) {
3772
- steps.push(captcha.solved
3773
- ? `OAuth: ticked the visible ${captcha.kind ?? "captcha"} checkbox before clicking the ${provider.label} affordance`
3774
- : `OAuth: visible ${captcha.kind ?? "captcha"} present but did not solve in 20s — clicking the ${provider.label} affordance anyway`);
4830
+ if (captcha.found && captcha.solved) {
4831
+ steps.push(`OAuth: ticked the visible ${captcha.kind} checkbox before clicking the ${provider.label} affordance`);
4832
+ }
4833
+ else if (captcha.found && !captcha.solved) {
4834
+ // Tier-2 click-and-wait timed out. For reCAPTCHA v2 this is the
4835
+ // SAME state the form-submit gate (runCaptchaGate) recovers from
4836
+ // by escalating to the third-party solver — mirror that path here
4837
+ // so OAuth-first flows aren't left clicking a Google button that
4838
+ // the service keeps gated behind an unsolved checkbox (replit,
4839
+ // uploadcare). Turnstile is deliberately NOT escalated: Cloudflare
4840
+ // scores at the IP layer, so a solver-issued token is rejected
4841
+ // anyway and only burns the 2Captcha balance.
4842
+ let solvedViaTier3 = false;
4843
+ if (captcha.kind === "recaptcha" && this.captchaSolver?.isAvailable() === true) {
4844
+ const sitekey = await this.browser.extractRecaptchaSitekey();
4845
+ const pageUrl = (await this.browser.getState().catch(() => null))?.url;
4846
+ if (sitekey !== null && pageUrl !== undefined) {
4847
+ steps.push(`OAuth: Tier 3 — submitting reCAPTCHA sitekey to 2Captcha (${sitekey.slice(0, 10)}…)`);
4848
+ const solveRes = await this.captchaSolver.solveRecaptchaV2({ sitekey, pageUrl });
4849
+ if (solveRes.kind === "ok") {
4850
+ const injected = await this.browser.injectRecaptchaToken(solveRes.token);
4851
+ if (injected) {
4852
+ solvedViaTier3 = true;
4853
+ steps.push(`OAuth: Tier 3 solved the reCAPTCHA in ${Math.round(solveRes.durationMs / 1000)}s via 2Captcha — clicking the ${provider.label} affordance`);
4854
+ }
4855
+ else {
4856
+ steps.push(`OAuth: Tier 3 token arrived but page injection failed — clicking the ${provider.label} affordance anyway`);
4857
+ }
4858
+ }
4859
+ else {
4860
+ steps.push(`OAuth: Tier 3 ${solveRes.kind}` +
4861
+ ("reason" in solveRes ? `: ${solveRes.reason}` : "") +
4862
+ ("durationMs" in solveRes ? ` (${Math.round(solveRes.durationMs / 1000)}s)` : "") +
4863
+ ` — clicking the ${provider.label} affordance anyway`);
4864
+ }
4865
+ }
4866
+ }
4867
+ if (!solvedViaTier3) {
4868
+ steps.push(`OAuth: visible ${captcha.kind} present but did not solve in 20s — clicking the ${provider.label} affordance anyway`);
4869
+ }
3775
4870
  }
3776
4871
  }
3777
4872
  catch (err) {
@@ -3779,7 +4874,21 @@ export class SignupAgent {
3779
4874
  steps.push(`OAuth: visible-captcha precheck failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
3780
4875
  }
3781
4876
  steps.push(`OAuth: clicking the ${provider.label} sign-in affordance`);
3782
- await this.browser.startOAuth(oauthSelector);
4877
+ // Google Identity Services (GSI) / FedCM does NOT redirect — clicking the
4878
+ // widget raises a browser-native FedCM dialog or a popup and returns a
4879
+ // JWT to a JS callback. The classic startOAuth waits for a provider
4880
+ // redirect that never comes, so it falsely concludes "signed in" and the
4881
+ // session never persists (northflank). Detect GSI and drive it over CDP.
4882
+ let gsiHandled = false;
4883
+ if (provider.id === "google" && (await this.browser.hasGoogleGsiAffordance())) {
4884
+ const gsi = await this.browser.tryGoogleGsiLogin(oauthSelector);
4885
+ gsiHandled = true;
4886
+ steps.push(`OAuth: Google Identity Services / FedCM widget — resolved via ${gsi.via}` +
4887
+ (gsi.ok ? "" : " (no FedCM dialog or popup appeared — the widget may need a different trigger)"));
4888
+ }
4889
+ if (!gsiHandled) {
4890
+ await this.browser.startOAuth(oauthSelector);
4891
+ }
3783
4892
  await this.browser.wait(3);
3784
4893
  await saveDebugSnapshot(this.browser, "oauth-after-click");
3785
4894
  // Bounded consent walk — handles account-chooser → consent as two
@@ -3806,6 +4915,21 @@ export class SignupAgent {
3806
4915
  await this.browser.wait(1);
3807
4916
  continue;
3808
4917
  }
4918
+ // Google "Choose an account" chooser. Its "…to continue to <app>" copy
4919
+ // matches the consent classifier, but it is an account PICKER — it needs
4920
+ // a card CLICK, not a scope approve. Google shows it before the real
4921
+ // consent right after a fresh relogin (the first OAuth re-confirms the
4922
+ // account). Without handling it here the bot tries to approve, stalls,
4923
+ // and the page flips to needs_login → abort (every Google service fails
4924
+ // until an OAuth is done once). Click the account card and re-read; the
4925
+ // next pass lands on the real consent screen (or back at the service).
4926
+ if (provider.id === "google" &&
4927
+ /\/(?:accountchooser|chooseaccount|oauthchooseaccount)/i.test(url)) {
4928
+ const clicked = await this.tryClickGoogleChooserCard();
4929
+ steps.push(`OAuth: Google account chooser — ${clicked ? "clicked the account card" : "no clickable account card found"}`);
4930
+ await this.browser.wait(2);
4931
+ continue;
4932
+ }
3809
4933
  const authState = provider.classifyAuthState(url, body);
3810
4934
  steps.push(`OAuth: ${provider.label} auth state = ${authState} (url=${url.slice(0, 120)})`);
3811
4935
  if (authState === "not_provider")
@@ -4040,6 +5164,30 @@ export class SignupAgent {
4040
5164
  return this.oauthAbort("oauth_consent_needs_review", `${provider.label} consent page (URL unparseable) lists scope-grant phrases: ` +
4041
5165
  `[${dangerPhrases.join(" | ")}]. Pausing for manual review.`, steps);
4042
5166
  }
5167
+ // Google's newer consent URL hides the scope= param behind an
5168
+ // opaque `part=` token, so extractOAuthScopes() returned null
5169
+ // even on an entirely-basic email/profile consent (measured on
5170
+ // uploadcare). The visible DOM is the only remaining signal: if
5171
+ // it lists ONLY openid/email/profile-family grants (and the
5172
+ // danger scraper above already cleared it), this is exactly the
5173
+ // consent the URL-readable happy path auto-approves — so recover
5174
+ // it here instead of blocking. Anything ambiguous falls through
5175
+ // to the conservative abort below. Mirror the basic-scopes happy
5176
+ // path: set consentAlreadyApproved, advance, handle !advanced.
5177
+ if (provider.id === "google" &&
5178
+ !consentAlreadyApproved &&
5179
+ googleConsentIsBasicFromDom(body)) {
5180
+ steps.push("OAuth: consent scopes unreadable from URL but DOM lists only " +
5181
+ "basic email/profile scopes — auto-approving");
5182
+ consentAlreadyApproved = true;
5183
+ const advanced = await this.browser.advanceOAuthConsent(provider.id);
5184
+ if (!advanced) {
5185
+ return this.oauthAbort("oauth_consent_needs_review", `basic-only consent read from the ${provider.label} DOM but no ` +
5186
+ `approve control found on the consent page — approve it manually.`, steps);
5187
+ }
5188
+ await this.browser.wait(3);
5189
+ continue;
5190
+ }
4043
5191
  // F16 — order matters here. The post-grant intermediate page
4044
5192
  // (after blind-consent approved on iter 1) is also classified
4045
5193
  // as "consent" with unreadable scopes. If we check the blind-
@@ -4115,8 +5263,93 @@ export class SignupAgent {
4115
5263
  // for same-tab redirects) and drive post-OAuth onboarding.
4116
5264
  await this.browser.settleAfterOAuth();
4117
5265
  await this.browser.wait(2);
5266
+ // Token-exchange settle. Stytch/WorkOS-style services (groq) bounce the
5267
+ // OAuth back to a callback page (/authenticate?token=…) and complete an
5268
+ // ASYNC token→session exchange there, THEN redirect to the dashboard.
5269
+ // With a warm Google session the round-trip is near-instant, so the bot
5270
+ // arrives at the callback while the exchange is still in flight — and
5271
+ // acting now (the rc.20 second-click retry, or post-verify navigation)
5272
+ // interrupts it, stranding the run on the login page. Give a callback-
5273
+ // shaped URL a chance to redirect itself away before we touch anything.
5274
+ {
5275
+ // Wait while EITHER the URL is still callback/login-shaped OR the page
5276
+ // shows async-session processing copy ("Creating your organization…").
5277
+ // Budget 24s — MEASURED: Stytch B2B's discovery+org-creation+session
5278
+ // chain takes ~5-7s but varies, and the bot's own page reads add jitter;
5279
+ // a short URL-only wait exits mid-provisioning and the rc.20 retry then
5280
+ // re-clicks OAuth and aborts it. Re-read text each tick.
5281
+ let settled = false;
5282
+ for (let i = 0; i < 12; i++) {
5283
+ const url = this.browser.currentUrl();
5284
+ const text = await this.browser.extractText().catch(() => "");
5285
+ if (!isLoginPageUrl(url) && !isAuthProcessingText(text)) {
5286
+ settled = true;
5287
+ break;
5288
+ }
5289
+ if (i === 0 && isAuthProcessingText(text)) {
5290
+ steps.push("OAuth: session is provisioning (auth-processing screen) — holding, not touching the page.");
5291
+ }
5292
+ await this.browser.wait(2);
5293
+ }
5294
+ const settledUrl = this.browser.currentUrl();
5295
+ steps.push(`OAuth: waited for the callback to settle — now at ${pathOf(settledUrl)}` +
5296
+ (settled ? " (redirected to the app)" : " (still login/processing-shaped)"));
5297
+ }
5298
+ // Dead-route escape. The OAuth often returns to the SIGNUP url it
5299
+ // started from (northflank: app.northflank.com/signup). For an account
5300
+ // that now EXISTS, /signup (and /login, /register…) is a dead route the
5301
+ // SPA can't render — it hangs on a "Connecting" shell forever and the
5302
+ // post-verify planner reads it as "signed out." Navigating to the app
5303
+ // ORIGIN ROOT lets the service redirect an authenticated user to its
5304
+ // real dashboard. Generalizes: a service already on its dashboard has a
5305
+ // non-auth path here and is left alone.
5306
+ if (isSignupOrLoginRoute(this.browser.currentUrl())) {
5307
+ const root = originRoot(this.browser.currentUrl());
5308
+ if (root !== null) {
5309
+ steps.push(`OAuth: post-auth landing is a signup/login route (${pathOf(this.browser.currentUrl())}) — ` +
5310
+ `navigating to the app root (${root}) so the service routes us to the dashboard.`);
5311
+ try {
5312
+ await this.browser.goto(root);
5313
+ await this.browser.wait(2);
5314
+ }
5315
+ catch {
5316
+ // navigation hiccup — the post-verify loop re-reads regardless.
5317
+ }
5318
+ }
5319
+ }
4118
5320
  await saveDebugSnapshot(this.browser, "oauth-post-consent");
4119
5321
  steps.push(`OAuth: signed in via ${provider.label} — driving post-OAuth onboarding to the API key`);
5322
+ // amplitude class — OAuth drops the bot into the service's READ-ONLY DEMO
5323
+ // sandbox (app.amplitude.com/analytics/demo) instead of a real account: it
5324
+ // has NO API key, and the only route to a real org is the prominent
5325
+ // "Create a free account" CTA, which opens the real /signup form. Detect
5326
+ // the demo state and click that CTA, then re-route to form-fill (the
5327
+ // email/name/password form the bot now completes, multi-step password
5328
+ // included). MEASURED 2026-06-04: without this the post-verify loop hunts
5329
+ // the demo for a key that isn't there → oauth_onboarding_failed.
5330
+ {
5331
+ await this.browser.wait(2); // let the post-OAuth redirect settle onto the demo
5332
+ const demoState = await this.browser.getState();
5333
+ const demoText = await this.browser.extractText().catch(() => "");
5334
+ if (isSandboxDemoState(demoState.url, demoText)) {
5335
+ const cta = findCreateAccountCta(await this.browser.extractInteractiveElements());
5336
+ if (cta !== null) {
5337
+ steps.push(`OAuth: landed in ${task.service}'s read-only demo sandbox ` +
5338
+ `(${pathOf(demoState.url)}) — clicking ` +
5339
+ `"${(cta.visibleText ?? "Create a free account").trim()}" to escape into ` +
5340
+ `the real signup form.`);
5341
+ try {
5342
+ await this.browser.click(cta.selector);
5343
+ await this.browser.wait(2);
5344
+ }
5345
+ catch (err) {
5346
+ steps.push(`OAuth: demo-escape click threw (${err instanceof Error ? err.message : String(err)}) — ` +
5347
+ `falling back to form-fill anyway.`);
5348
+ }
5349
+ return OAUTH_FALL_BACK_TO_FORM_FILL;
5350
+ }
5351
+ }
5352
+ }
4120
5353
  // rc.20 — login-loop detection. Services like Groq complete the
4121
5354
  // Google OAuth handshake server-side but redirect back to a
4122
5355
  // login-looking page (/authenticate) where the user has to click
@@ -4138,6 +5371,43 @@ export class SignupAgent {
4138
5371
  const postOAuthState = await this.browser.getState();
4139
5372
  const postOAuthInv = await this.buildInventory(steps, [provider.id]);
4140
5373
  const loopBtn = isLoginLoopState(postOAuthState.url, postOAuthInv, provider.id);
5374
+ // amplitude class — post-OAuth we're STUCK on a login page (the provider
5375
+ // button is still present, or the URL is a login route) that carries an
5376
+ // in-page SIGNUP CTA. Google signed in fine, but the service has no
5377
+ // account/org for this identity and expects us to CREATE one via the
5378
+ // page's "Don't have an account? Sign up for free" link. The naive
5379
+ // loopBtn path below would re-trigger OAuth and loop until
5380
+ // oauth_loop_detected. Instead: click the signup CTA and re-route into
5381
+ // the email/password signup path (same sentinel the detectGoogleNoAccount
5382
+ // gate uses ~40 lines below). CONSERVATIVE: only fires in the STUCK state
5383
+ // (loopBtn or a login URL) and only when the page is NOT already a signup
5384
+ // form, so a dashboard that successfully landed but carries a stray
5385
+ // signup link is untouched, and a service that legitimately needs a
5386
+ // second OAuth click (no signup CTA) falls through. NOTE: gate on
5387
+ // classify !== "signup", NOT === "login": amplitude's Org-Login SSO page
5388
+ // has no password field, so classifySignupHtml returns "other".
5389
+ if ((loopBtn !== null || isLoginPageUrl(postOAuthState.url)) &&
5390
+ classifySignupHtml(postOAuthState.html) !== "signup") {
5391
+ const signupCta = findSignupCtaElement(postOAuthInv);
5392
+ if (signupCta !== null) {
5393
+ const ctaText = (signupCta.visibleText ??
5394
+ signupCta.ariaLabel ??
5395
+ "sign up").trim();
5396
+ steps.push(`Post-OAuth: ${task.service} shows a login page with a signup CTA ("${ctaText}") — ` +
5397
+ `${provider.label} identity has no account; clicking signup to create one.`);
5398
+ try {
5399
+ await this.browser.click(signupCta.selector);
5400
+ await this.browser.wait(2);
5401
+ }
5402
+ catch (err) {
5403
+ steps.push(`Post-OAuth: clicking the signup CTA threw (${err instanceof Error ? err.message : String(err)}) — ` +
5404
+ `falling back to form-fill anyway.`);
5405
+ }
5406
+ // Re-route into the email/password signup path: runSignup catches
5407
+ // this sentinel and re-runs form-fill on the now-signup page.
5408
+ return OAUTH_FALL_BACK_TO_FORM_FILL;
5409
+ }
5410
+ }
4141
5411
  if (loopBtn !== null) {
4142
5412
  steps.push(`Post-OAuth: landed on a login-like page (${pathOf(postOAuthState.url)}) ` +
4143
5413
  `with a ${provider.label} sign-in button still visible — service requires a ` +
@@ -4179,6 +5449,20 @@ export class SignupAgent {
4179
5449
  const gateState = await this.browser.getState();
4180
5450
  const gateText = await this.browser.extractText().catch(() => "");
4181
5451
  const gateInv = postOAuthInv;
5452
+ // (a0) Google-login-only / no-account (plunk class). OAuth
5453
+ // completed but the service bounced back saying this Google
5454
+ // identity has no account (e.g. plunk's
5455
+ // /auth/login?message=No%20account%20found…). MUST run before the
5456
+ // manual-login-fallback gate below — this page IS a /login form, so
5457
+ // detectManualLoginFallback would otherwise swallow it as
5458
+ // oauth_session_not_persisted and abort. The account simply needs
5459
+ // creating via email, so re-route to form-fill instead of bailing.
5460
+ if (detectGoogleNoAccount(gateState.url, gateText)) {
5461
+ steps.push(`OAuth: ${provider.label} sign-in succeeded but ${task.service} has no account for ` +
5462
+ `this identity (login-only OAuth, ${pathOf(gateState.url)}) — abandoning OAuth and ` +
5463
+ `falling back to email/password signup to create the account.`);
5464
+ return OAUTH_FALL_BACK_TO_FORM_FILL;
5465
+ }
4182
5466
  // (a) Manual-login fallback (DigitalOcean, Hyperbolic). Service
4183
5467
  // dropped the OAuth session and rendered a /login form with
4184
5468
  // email + password inputs. Bot can't manually log in.
@@ -4470,6 +5754,9 @@ export class SignupAgent {
4470
5754
  if (screenshot === undefined || screenshot.length === 0)
4471
5755
  return null;
4472
5756
  const reply = await this.callLLM({
5757
+ // Reading a fixed number off a screen has one right answer — no
5758
+ // sampling.
5759
+ temperature: 0,
4473
5760
  system: "You read numbers from Google authentication challenge screens. " +
4474
5761
  "The screen shows a 2-3 digit number the user must tap on their phone " +
4475
5762
  "to verify identity. Reply with ONLY that number. No words, no " +
@@ -4479,7 +5766,7 @@ export class SignupAgent {
4479
5766
  userBlocks: [
4480
5767
  {
4481
5768
  kind: "image",
4482
- media_type: "image/jpeg",
5769
+ media_type: imageMediaType(screenshot),
4483
5770
  data_base64: screenshot,
4484
5771
  },
4485
5772
  {
@@ -4604,7 +5891,7 @@ Output rules:
4604
5891
  7-15 char handle.`;
4605
5892
  const hintLine = input.hint !== undefined ? `\nHint: ${input.hint}` : "";
4606
5893
  const userBlocks = [
4607
- { kind: "image", media_type: "image/jpeg", data_base64: input.screenshot },
5894
+ { kind: "image", media_type: imageMediaType(input.screenshot), data_base64: input.screenshot },
4608
5895
  {
4609
5896
  kind: "text",
4610
5897
  text: `Service: ${input.service}
@@ -4620,6 +5907,9 @@ ${formatInventory(input.inventory)}`,
4620
5907
  system: systemPrompt,
4621
5908
  userBlocks,
4622
5909
  maxTokens: 1500,
5910
+ // Deterministic form-fill picks (same rationale as the post-verify
5911
+ // planner — D2). Removes a run-to-run flakiness source.
5912
+ temperature: 0,
4623
5913
  parse: (raw) => parseSignupPlan(raw, allowed),
4624
5914
  });
4625
5915
  }
@@ -4641,7 +5931,12 @@ ${formatInventory(input.inventory)}`,
4641
5931
  // email rather than "verify", and that broader matcher catches both.
4642
5932
  async waitForVerificationEmail(inbox, alias, totalSeconds) {
4643
5933
  const deadline = Date.now() + totalSeconds * 1000;
4644
- const pattern = /verify|confirm|welcome|activate|complete|finish|set\s*up/i;
5934
+ // `verif` (not `verify`) so the matcher also catches "verification" —
5935
+ // "verification" does NOT contain the substring "verify" (…ifi… vs
5936
+ // …ify), which silently dropped plausible's "4011 is your Plausible
5937
+ // email verification code" and timed the whole signup out. `code` /
5938
+ // `one[- ]?time` / `otp` catch code-based verification subjects too.
5939
+ const pattern = /verif|confirm|welcome|activate|complete|finish|set\s*up|\bcode\b|one[\s-]?time|\botp\b|sign[\s-]?up/i;
4645
5940
  let lastErr = null;
4646
5941
  while (Date.now() < deadline) {
4647
5942
  const remainingSeconds = Math.max(1, Math.floor((deadline - Date.now()) / 1000));
@@ -4665,6 +5960,36 @@ ${formatInventory(input.inventory)}`,
4665
5960
  }
4666
5961
  throw lastErr ?? new Error("verification email did not arrive in time");
4667
5962
  }
5963
+ // Code-based email verification (plausible: "Enter 4011 to verify your
5964
+ // email address"). The signup email carried a numeric code and no
5965
+ // clickable link, and the page transitioned to a code-input step after
5966
+ // submit. Seed the post-verify planner with the code so it fills the
5967
+ // input + clicks Verify, then drives on to the API key. Generalizes to
5968
+ // every service that verifies by emailed code rather than link.
5969
+ async enterEmailVerificationCode(code, task, password, steps) {
5970
+ if (code.length === 0) {
5971
+ steps.push("Verification email exposed a code field but it was empty — skipping.");
5972
+ return {};
5973
+ }
5974
+ steps.push(`Email carries a verification CODE (${code}) and no link — entering it on the page.`);
5975
+ // The post-submit "enter code" view may still be hydrating.
5976
+ await this.browser.waitForFormReady();
5977
+ const hint = `Email verification code retrieved: "${code}". The current page has a ` +
5978
+ `verification-code / OTP input (placeholder like "Code" / "Verification code", ` +
5979
+ `or several single-digit boxes — fill the FIRST and the browser auto-distributes). ` +
5980
+ `Issue {"kind":"fill","selector":"…","value":"${code}"} on it, then NEXT round click ` +
5981
+ `the Verify / Confirm / Continue / Submit button.`;
5982
+ return this.postVerifyLoop({
5983
+ service: task.service,
5984
+ credentials: { email: task.email, password },
5985
+ maxRounds: task.postVerifyMaxRounds ?? 6,
5986
+ steps,
5987
+ initialHint: hint,
5988
+ ...(task.scopeHint !== undefined ? { scopeHint: task.scopeHint } : {}),
5989
+ ...(task.machineToken !== undefined ? { machineToken: task.machineToken } : {}),
5990
+ ...(task.apiBase !== undefined ? { apiBase: task.apiBase } : {}),
5991
+ });
5992
+ }
4668
5993
  // Drive the browser toward the API key after the account exists —
4669
5994
  // used by BOTH the email-verification path and the OAuth path (T9).
4670
5995
  // Each round asks Claude what to do next given the current page; we
@@ -5047,6 +6372,16 @@ ${formatInventory(input.inventory)}`,
5047
6372
  // so the loop can bail with oauth_session_not_persisted instead of
5048
6373
  // thrashing maxRounds and mislabeling it oauth_onboarding_failed.
5049
6374
  let oauthLoginRequests = 0;
6375
+ // Consecutive rounds on an OAuth run where the page is STILL a login /
6376
+ // authenticate screen. The planner usually doesn't return {"kind":
6377
+ // "login"} here — it keeps CLICKING "Sign in with Google" (groq,
6378
+ // northflank, amplitude), so the oauthLoginRequests counter above
6379
+ // never trips. But the structural fact is decisive and service-
6380
+ // agnostic: after OAuth, an authenticated bot is on a dashboard, not a
6381
+ // login page. N consecutive login-page rounds ⇒ the callback never
6382
+ // persisted (anti-bot/IP rejection) ⇒ oauth_session_not_persisted, not
6383
+ // a navigation bug. Generalizes without per-service URLs.
6384
+ let consecutiveOauthLoginPageRounds = 0;
5050
6385
  let planFailures = 0;
5051
6386
  // 0.8.2-rc.6 — separate counter for upstream-blip retries. Doesn't
5052
6387
  // gate planFailures (so a transient 502 won't push us into the
@@ -5063,7 +6398,7 @@ ${formatInventory(input.inventory)}`,
5063
6398
  // truncated (the S3-class trap: the planner sees a key-shaped
5064
6399
  // string and keeps asking to extract it forever), or when the
5065
6400
  // planner's last step was rejected.
5066
- let hint;
6401
+ let hint = args.initialHint;
5067
6402
  // rc.27 — when the email_otp gate handler retrieved a code from
5068
6403
  // the operator's gmail, seed the FIRST round's hint with the
5069
6404
  // code + explicit fill+submit instructions. Cleared after one
@@ -5116,6 +6451,14 @@ ${formatInventory(input.inventory)}`,
5116
6451
  // navigate produced no progress. Inject a hint forcing a CLICK
5117
6452
  // on something visible in the current inventory.
5118
6453
  let prevNavigateFromUrl = null;
6454
+ // Stalled-wizard breaker. Tracks a content signature of the page +
6455
+ // the effect of each executed action, so we can detect an onboarding
6456
+ // wizard that re-presents itself (clicks don't register) and break
6457
+ // out instead of burning every round on it. See isStalledOnActions.
6458
+ let prevContentSig = null;
6459
+ let lastActionKind = null;
6460
+ let lastActionSelector = null;
6461
+ const actionEffects = [];
5119
6462
  // 0.8.2-rc.10 — escalation for the stuck-loop detector.
5120
6463
  //
5121
6464
  // The existing detector injects a re-plan hint when the planner
@@ -5183,6 +6526,10 @@ ${formatInventory(input.inventory)}`,
5183
6526
  // Gate URLs we've already polled the operator's gmail for, so a
5184
6527
  // multi-round wait on the same email-OTP page doesn't re-poll.
5185
6528
  const otpPolledUrls = new Set();
6529
+ // Running summary of the steps the planner has taken, fed back into
6530
+ // each planPostVerifyStep call so the (stateless) planner stops
6531
+ // re-doing completed onboarding steps and re-navigating dead URLs.
6532
+ const priorActions = [];
5186
6533
  for (let round = 0; round < args.maxRounds; round++) {
5187
6534
  const currentCredentialKeyCount = Object.keys(credentials).filter((k) => !NON_CREDENTIAL_KEYS.has(k)).length;
5188
6535
  if (currentCredentialKeyCount > lastCredentialKeyCount) {
@@ -5254,6 +6601,125 @@ ${formatInventory(input.inventory)}`,
5254
6601
  await this.browser.wait(2);
5255
6602
  continue;
5256
6603
  }
6604
+ // clerk class — Google account chooser inside the post-verify loop.
6605
+ // The planner re-clicked "Sign in with Google", which opened
6606
+ // accounts.google.com's chooser (.../accountchooser?...). That page
6607
+ // carries a stray "Loading" label (so the hydration guard below would
6608
+ // burn all its ticks idling) and tryClickGoogleChooserCard is only
6609
+ // wired into runOAuthFlow — so nothing here clicks the account card.
6610
+ // Detect the chooser by URL or its "Choose an account" copy, click
6611
+ // the card to continue OAuth, then skip the rest of this round's
6612
+ // planning (the next round re-reads the post-chooser page).
6613
+ const chooserText = await this.browser.extractText().catch(() => "");
6614
+ if (/accounts\.google\.com\/.*(accountchooser|chooseaccount|oauthchooseaccount)/i.test(state.url) ||
6615
+ /choose an account/i.test(chooserText)) {
6616
+ await this.tryClickGoogleChooserCard();
6617
+ args.steps.push(`Post-verify round ${round}: Google account chooser — clicked the account card to continue OAuth`);
6618
+ await this.browser.wait(2);
6619
+ try {
6620
+ [state, inventory] = await Promise.all([
6621
+ this.browser.getState(),
6622
+ this.buildInventory(args.steps, undefined, 80),
6623
+ ]);
6624
+ }
6625
+ catch {
6626
+ // mid-navigation read after the card click — the next round
6627
+ // re-reads, so just fall through to it.
6628
+ }
6629
+ continue;
6630
+ }
6631
+ // SPA hydration guard. A post-OAuth dashboard (northflank's
6632
+ // /settings/access-tokens, PostHog) can render a "Connecting"/loading
6633
+ // shell while its JS bundle + websocket finish — slow over a
6634
+ // residential tunnel. The shell often carries a stray element or two
6635
+ // (a logo link, the <noscript>), so gating on an EMPTY inventory
6636
+ // misses it; the loading-shell TEXT is the authoritative "not yet
6637
+ // rendered" signal. Wait while that text persists, then proceed with
6638
+ // whatever's there (an honest "still a shell" beats a premature done —
6639
+ // and if the SPA never hydrates, e.g. a blocked websocket, the bound
6640
+ // keeps us from hanging).
6641
+ //
6642
+ // Budget = 6x3s = 18s. MEASURED: a dashboard SPA gated on a websocket
6643
+ // (northflank's wss://platform.northflank.com/websocket) hydrates in
6644
+ // ~12-15s over the tunnel. A larger budget BACKFIRES on a page that
6645
+ // will NEVER hydrate (e.g. an authed user stranded on /signup): the
6646
+ // wait re-runs every round and burns the 600s run cap. The escape for
6647
+ // a never-hydrating route is navigate-to-root post-OAuth, not a longer
6648
+ // wait here.
6649
+ //
6650
+ // ADAPTIVE exception (MEASURED 2026-06-04, clerk): an OAuth/SSO
6651
+ // CALLBACK route does a token exchange that renders even slower than a
6652
+ // plain dashboard — clerk's `/sign-in/sso-callback` outlasts 18s and
6653
+ // the bot bailed at the edge with `oauth_session_not_persisted`. On a
6654
+ // callback route the SPA IS making progress, so 12x3s = 36s of
6655
+ // patience is warranted; everywhere else the 6-tick budget holds so a
6656
+ // genuinely-stuck route still hits the navigate-to-root escape fast.
6657
+ // Read the URL fresh each round (it may redirect off the callback).
6658
+ const HYDRATION_TICKS = isOAuthCallbackRoute(state.url) ? 12 : 6;
6659
+ for (let hydrationWait = 0; hydrationWait < HYDRATION_TICKS &&
6660
+ isLoadingShellText(await this.browser.extractText().catch(() => "")); hydrationWait++) {
6661
+ args.steps.push(`Post-verify round ${round}: ${pathOf(state.url)} is a loading shell ` +
6662
+ `(hydration wait ${hydrationWait + 1}/${HYDRATION_TICKS}) — waiting for the SPA to render`);
6663
+ await this.browser.wait(3);
6664
+ try {
6665
+ [state, inventory] = await Promise.all([
6666
+ this.browser.getState(),
6667
+ this.buildInventory(args.steps, undefined, 80),
6668
+ ]);
6669
+ }
6670
+ catch {
6671
+ // mid-navigation read — keep the prior state/inventory and let
6672
+ // the next hydration tick (or the planner) retry.
6673
+ }
6674
+ }
6675
+ // Stalled-wizard breaker. Build a content signature (URL + each
6676
+ // inventory element's selector + label) and judge whether the
6677
+ // PREVIOUS executed action changed the page. If the last few
6678
+ // page-mutating actions all left the page identical, a wizard is
6679
+ // re-presenting itself and clicking it does nothing — stop here so
6680
+ // we don't waste the remaining rounds + LLM budget. (axiom: 4×
6681
+ // role-card re-clicks that never advanced.)
6682
+ const contentSig = (state.url +
6683
+ "§" +
6684
+ inventory
6685
+ .map((e) => `${e.selector}·${(e.visibleText ?? e.ariaLabel ?? "").slice(0, 24)}`)
6686
+ .join("|")).slice(0, 4000);
6687
+ const pageUnchanged = prevContentSig !== null && contentSig === prevContentSig;
6688
+ if (lastActionKind !== null) {
6689
+ actionEffects.push({ kind: lastActionKind, pageUnchanged, selector: lastActionSelector });
6690
+ }
6691
+ prevContentSig = contentSig;
6692
+ if (isStalledOnActions(actionEffects)) {
6693
+ args.steps.push(`Post-verify: STALLED — the last 3 page-mutating actions left the page ` +
6694
+ `identical (${state.url}). An onboarding wizard is re-presenting itself ` +
6695
+ `(clicks not registering); giving up instead of burning the round budget.`);
6696
+ break;
6697
+ }
6698
+ // Non-persisting-OAuth detector (A5, broadened). On an OAuth run the
6699
+ // bot has ALREADY authenticated before this loop, so landing on a
6700
+ // login page means the callback was rejected. The planner usually
6701
+ // keeps clicking "Sign in with Google" rather than returning a
6702
+ // {"kind":"login"} step, so the oauthLoginRequests counter misses
6703
+ // it — track the structural fact (consecutive login-page rounds)
6704
+ // instead. Generalizes across services (groq/northflank/amplitude)
6705
+ // without per-service URLs; reclassifies these off the misleading
6706
+ // oauth_onboarding_failed label into the truthful (and unwinnable-
6707
+ // without-residential-egress) oauth_session_not_persisted wall.
6708
+ if (args.credentials === undefined && isLoginPageUrl(state.url)) {
6709
+ consecutiveOauthLoginPageRounds += 1;
6710
+ if (consecutiveOauthLoginPageRounds >= 3) {
6711
+ args.steps.push(`Post-verify: OAuth run still on a login page (${pathOf(state.url)}) for ` +
6712
+ `${consecutiveOauthLoginPageRounds} rounds — the OAuth callback never persisted; bailing.`);
6713
+ throw new OAuthSessionNotPersistedError(`oauth_session_not_persisted: signed in to ${args.service} via OAuth but the page ` +
6714
+ `still presents a login screen (${pathOf(state.url)}) after ` +
6715
+ `${consecutiveOauthLoginPageRounds} rounds — the OAuth callback never established a ` +
6716
+ `session (anti-bot / IP rejection of the callback). Not a navigation bug; needs ` +
6717
+ `residential egress or manual signup.`);
6718
+ }
6719
+ }
6720
+ else {
6721
+ consecutiveOauthLoginPageRounds = 0;
6722
+ }
5257
6723
  // Email-OTP gate that surfaced AFTER OAuth (the pre-OAuth signup
5258
6724
  // gate never saw it, so pendingOtpCode is unset). Convex's
5259
6725
  // radar-challenge sends a 6-digit code to the operator's Google
@@ -5301,6 +6767,7 @@ ${formatInventory(input.inventory)}`,
5301
6767
  inventory,
5302
6768
  ...(hint !== undefined ? { hint } : {}),
5303
6769
  ...(args.scopeHint !== undefined ? { scopeHint: args.scopeHint } : {}),
6770
+ ...(priorActions.length > 0 ? { priorActions: priorActions.slice(-10) } : {}),
5304
6771
  });
5305
6772
  }
5306
6773
  catch (err) {
@@ -5361,6 +6828,17 @@ ${formatInventory(input.inventory)}`,
5361
6828
  // GitHub issue, leaking the credential. Redactor patterns mirror
5362
6829
  // tools/archived-harvester/redact.mjs — defense in depth.
5363
6830
  args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: ${nextStep.kind} — ${redactCredentials(nextStep.reason)}`);
6831
+ // Feed this action back into the next round's planner context so it
6832
+ // doesn't loop. Concise: where we were, what we did, why.
6833
+ {
6834
+ const where = state.url.replace(/^https?:\/\//, "").slice(0, 40);
6835
+ const target = "selector" in nextStep && nextStep.selector !== undefined
6836
+ ? ` ${nextStep.selector.slice(0, 24)}`
6837
+ : "url" in nextStep && nextStep.url !== undefined
6838
+ ? ` →${nextStep.url.replace(/^https?:\/\//, "").slice(0, 36)}`
6839
+ : "";
6840
+ priorActions.push(`@${where} ${nextStep.kind}${target}: ${redactCredentials(nextStep.reason).slice(0, 60)}`);
6841
+ }
5364
6842
  // Dump this round's real page state + inventory in the E1
5365
6843
  // eval-corpus format so onboarding adapters can be iterated
5366
6844
  // offline without re-running the rate-limited OAuth handshake.
@@ -5726,6 +7204,14 @@ ${formatInventory(input.inventory)}`,
5726
7204
  prevSignature = null;
5727
7205
  prevInventorySize = inventory.length;
5728
7206
  }
7207
+ // Record the kind of the step we're ABOUT to execute (all re-plan
7208
+ // `continue` guards are behind us here) so next round can judge
7209
+ // whether it changed the page — the stalled-wizard breaker above.
7210
+ lastActionKind = nextStep.kind;
7211
+ lastActionSelector =
7212
+ "selector" in nextStep && typeof nextStep.selector === "string"
7213
+ ? nextStep.selector
7214
+ : null;
5729
7215
  if (nextStep.kind === "done") {
5730
7216
  // When the planner bails because it encountered Google's
5731
7217
  // device-verification challenge mid-post-verify (Algolia +
@@ -6378,7 +7864,23 @@ Schema:
6378
7864
  invent or guess a selector — one not in the inventory is rejected.
6379
7865
  - If the element you want is NOT in the inventory, use {"kind":"navigate"}
6380
7866
  to a likely settings URL instead of guessing a selector.
6381
-
7867
+ ${input.priorActions !== undefined && input.priorActions.length > 0
7868
+ ? `
7869
+ STEPS ALREADY TAKEN this session (most recent last). You plan ONE step
7870
+ at a time and do not otherwise remember earlier rounds — use this list
7871
+ so you do NOT loop:
7872
+ ${input.priorActions.map((a, i) => ` ${i + 1}. ${a}`).join("\n")}
7873
+ - Do NOT repeat a completed onboarding-wizard step. If you already
7874
+ selected a role / company-size / use-case or accepted the terms, that
7875
+ step is DONE — move forward, never back to it.
7876
+ - Do NOT re-issue a {"kind":"navigate"} to a URL that already appears
7877
+ above and did not advance you. If a settings URL errored or bounced
7878
+ you back, try a DIFFERENT path or click a dashboard link instead.
7879
+ - If the last 3+ steps above are the same kind on the same URL with no
7880
+ progress, you are stuck — try a genuinely different action or return
7881
+ {"kind":"done"}.
7882
+ `
7883
+ : ""}
6382
7884
  Strategy:
6383
7885
  - If a FULL, untruncated API key is visible, return {"kind":"extract"}.
6384
7886
  - **MULTI-CREDENTIAL SERVICES** — when the page shows TWO OR MORE
@@ -6390,10 +7892,13 @@ Strategy:
6390
7892
  labels EVERY visible credential in the format
6391
7893
  \`<canonical_label>='<value>'\` (use SINGLE quotes around values).
6392
7894
  The bot's labeled-extractor will pull EACH labeled value into the
6393
- credentials object. Example reason:
6394
- "The Cloudinary API Keys page shows cloud_name='dlq4xgrca' and
6395
- api_key='491741466469613' in the table; api_secret is hidden behind
6396
- a Reveal button."
7895
+ credentials object. Example SHAPE (the bracketed parts are
7896
+ PLACEHOLDERS you MUST substitute the REAL values visible on the
7897
+ CURRENT page; NEVER emit these literal bracket strings or any example
7898
+ values, and never name a service that is not the one you are on):
7899
+ "The API Keys page shows cloud_name='<real cloud_name from this page>'
7900
+ and api_key='<real api_key from this page>' in the table; api_secret
7901
+ is hidden behind a Reveal button."
6397
7902
  Use the standard canonical labels: api_key, api_secret, secret_key,
6398
7903
  publishable_key, access_token, client_id, client_secret, cloud_name,
6399
7904
  application_id, admin_api_key, search_api_key, account_sid,
@@ -6411,10 +7916,11 @@ Strategy:
6411
7916
  behind a Reveal button, return {"kind":"extract"} NOW for the
6412
7917
  visible labels (the bot's labeled extractor folds them into the
6413
7918
  credentials bundle) AND in the same reason field flag the masked
6414
- credential so the bot's automatic reveal pass fires. Example
6415
- reason for Cloudinary: "cloud_name='dlq4xgrca' and
6416
- api_key='491741466469613' are visible in the table; api_secret is
6417
- hidden behind a Reveal button please unmask." The masked
7919
+ credential so the bot's automatic reveal pass fires. Example SHAPE
7920
+ (substitute the REAL values from the current page — the bracketed
7921
+ parts are placeholders, never emit them literally): "cloud_name='<real
7922
+ value>' and api_key='<real value>' are visible in the table;
7923
+ api_secret is hidden behind a Reveal button — please unmask." The masked
6418
7924
  credential's label MUST appear with one of the trigger words
6419
7925
  (masked / hidden / reveal / unmask / bullets / asterisks) so the
6420
7926
  reveal pass triggers. Do this BEFORE attempting any explicit
@@ -6430,9 +7936,17 @@ Strategy:
6430
7936
  capture whatever IS visible (even if just a cloud_name with no
6431
7937
  api_secret) and return the partial bundle to the caller, which is
6432
7938
  more useful than five wasted rounds of clicking a dead reveal.
6433
- - To reach API keys, prefer a {"kind":"navigate"} straight to the
6434
- service's API-keys settings URL note these usually live under the
6435
- user/ACCOUNT settings, not a project or workspace's settings.
7939
+ - To reach API keys, PREFER clicking a visible "API Keys" / "Tokens" /
7940
+ "Developer" / "Settings" link in the INVENTORY (a verified selector) — that
7941
+ always lands on the real page. Only use {"kind":"navigate"} to a GUESSED
7942
+ settings URL when NO such link is in the inventory, and NEVER guess the same
7943
+ URL twice. These pages usually live under user/ACCOUNT settings, not a
7944
+ project or workspace's settings.
7945
+ - **404 RECOVERY.** If the page is a 404 / "not found" / "page doesn't exist"
7946
+ / "we couldn't find" (a guessed URL missed), do NOT retry it or guess
7947
+ another URL. {"kind":"navigate"} to the service's app ROOT/dashboard (the
7948
+ bare origin, e.g. https://app.<service>.com/) and find the API-keys link in
7949
+ the nav from there.
6436
7950
  - **EXCEPT** when the page has a very small inventory (5 or fewer elements)
6437
7951
  and one of them is an onboarding CTA — patterns like "Get started",
6438
7952
  "Continue", "Activate", "Enable API", "Start free trial", "Set up".
@@ -6454,9 +7968,39 @@ Strategy:
6454
7968
  the FIRST card (least committal). After the click, expect a
6455
7969
  "Continue" / "Next" button on the following round — do NOT return
6456
7970
  "done" while a card-radio cluster is still visible.
7971
+ - Do NOT \`fill\` an input whose text is an illustrative EXAMPLE (it reads like
7972
+ a full sentence, e.g. "Optimize images for my eCommerce site…") when the page
7973
+ ALSO shows preset choice buttons for the same step (e.g. "Optimize assets",
7974
+ "Transform images", "Next"). That input is a placeholder, not a field to
7975
+ complete — click a preset option button or "Next" instead.
6457
7976
  ${loginGuidance}
6458
7977
  - If we're on a "verify your phone" / "verify email" wall, return done (we can't solve those).
6459
- - If the page wants the user to create a project/key before showing it, fill the minimum and click create.
7978
+ - **EMPTY DASHBOARD create the first resource.** Many services do NOT expose
7979
+ an API key until you create your first organization / project / cluster /
7980
+ database / service / workspace. If the dashboard shows NO existing resources
7981
+ (an empty state, "Create your first…", "No projects/clusters yet", "Get
7982
+ started by creating…", or just a lone "Create"/"New <resource>"/"+ New" CTA
7983
+ and nothing else useful), CLICK that CTA, then on the following rounds fill
7984
+ the minimal required fields (use a generated name like ts-<random> for
7985
+ name/slug fields, pick the first/free option for plans/regions) and confirm.
7986
+ The API-keys / tokens page appears only AFTER a resource exists. Do NOT
7987
+ return {"kind":"done"} or {"kind":"login"} on an empty dashboard while a
7988
+ create-resource CTA is visible — that is the path forward, not a dead end.
7989
+ - **API-KEYS / TOKENS PAGE — the path to a key is CREATE, not extract.** On any
7990
+ API-keys / tokens / secrets page that offers a "Create" / "Generate" /
7991
+ "New token" / "New key" button, CLICK it to mint a fresh key — this holds
7992
+ whether the page is EMPTY (no keys yet) or LISTS EXISTING keys (masked ••••,
7993
+ with Copy / Reveal / Revoke buttons). An empty page has nothing to read, and
7994
+ existing keys are UNRECOVERABLE (shown once at creation, so their "Copy"
7995
+ button yields only the masked display or a useless id) — so {"kind":"extract"}
7996
+ returns nothing usable here either way. Do NOT extract and do NOT click an
7997
+ existing key's Copy button on this page. Extract ONLY when a COMPLETE,
7998
+ UNMASKED key value is actually displayed on screen (typically the one-time
7999
+ modal right after you click Create).
8000
+ - **Pre-filled fields are DONE — advance, don't re-touch.** If a required
8001
+ onboarding field (first name, company, email) is ALREADY populated, or a
8002
+ required selectable is ALREADY selected, do NOT re-fill/re-select it — click
8003
+ Continue / Next / Submit to move forward. Re-filling a satisfied field loops.
6460
8004
  - For ANY dropdown — native (tag=select) OR a custom combobox (role=combobox / aria-haspopup=listbox, common on modern React apps like Sentry / Stripe / Vercel) — use {"kind":"select"}. "click" on a combobox trigger opens it but does not pick an option; do not click it repeatedly.
6461
8005
  - When you need a SPECIFIC option from the dropdown — e.g. "Project: Read" on Sentry's permissions picker, or a specific region — include "option_text" with the visible label. The executor matches it case-insensitively as a substring. Omit "option_text" when any option is fine (a placeholder country picker).
6462
8006
  - A post-OAuth onboarding form (organization name, region, terms) is normal — fill/select/check its fields and click Continue to advance toward the dashboard; do not return "done" just because it is a form.
@@ -6472,7 +8016,7 @@ ${loginGuidance}
6472
8016
  - On a form with MULTIPLE permission rows (Sentry: Project, Team, Member, Issue, Event, Release, Organization), set EACH ONE before clicking Create. One step per turn — return to this turn-by-turn until every row is set.
6473
8017
  - Round ${input.round + 1} of ${input.maxRounds}. Prefer "done" if you're not making progress.`;
6474
8018
  const userBlocks = [
6475
- { kind: "image", media_type: "image/jpeg", data_base64: input.state.screenshot },
8019
+ { kind: "image", media_type: imageMediaType(input.state.screenshot), data_base64: input.state.screenshot },
6476
8020
  {
6477
8021
  kind: "text",
6478
8022
  text: `Service: ${input.service}
@@ -6493,6 +8037,12 @@ ${formatInventory(input.inventory)}${input.hint !== undefined ? `\n\nIMPORTANT
6493
8037
  system: systemPrompt,
6494
8038
  userBlocks,
6495
8039
  maxTokens: 500,
8040
+ // Deterministic: the same dashboard page must yield the same next step
8041
+ // run-to-run, so the offline eval is a faithful, noise-free signal and
8042
+ // signups don't "randomly" navigate differently (D2 / DESIGN-planner-
8043
+ // navigation-eval.md). The stall-detector + prior-action memory are the
8044
+ // escape from a deterministic loop.
8045
+ temperature: 0,
6496
8046
  parse: (raw) => {
6497
8047
  const step = parsePostVerifyStep(raw, allowed);
6498
8048
  // A `check` must land on a real checkbox/radio — the planner
@@ -6609,6 +8159,29 @@ ${formatInventory(input.inventory)}${input.hint !== undefined ? `\n\nIMPORTANT
6609
8159
  // purpose — a "Continue with Google" / "Login with Google" /
6610
8160
  // icon-only Google button all count when the bot has a
6611
8161
  // provider session).
8162
+ // After a form submit, is the page a CONTINUATION step of the SAME signup
8163
+ // (amplitude's dedicated "Create your password" page is the canonical case)
8164
+ // rather than a dashboard, a credentials page, or a verify-your-email
8165
+ // screen? Returns a short label for the step trail, or null. Reused
8166
+ // fillValues already carry the password, so re-running planExecuteWithRetry
8167
+ // fills it. See isContinuationFormStep for the (conservative) signals.
8168
+ async detectContinuationFormStep() {
8169
+ let html = "";
8170
+ let url = "";
8171
+ let inventory;
8172
+ try {
8173
+ const state = await this.browser.getState();
8174
+ html = state.html;
8175
+ url = state.url;
8176
+ inventory = await this.browser.extractInteractiveElements();
8177
+ }
8178
+ catch {
8179
+ return null;
8180
+ }
8181
+ return isContinuationFormStep(html, inventory)
8182
+ ? `password step at ${pathOf(url)}`
8183
+ : null;
8184
+ }
6612
8185
  async looksLikeSignupPage() {
6613
8186
  const state = await this.browser.getState();
6614
8187
  // 1. URL-path shortcut. If we navigated to a signup-shaped path