@trusty-squire/mcp 0.6.15-rc.9 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +8 -0
- package/dist/bin.js.map +1 -1
- package/dist/bot/agent.d.ts +39 -0
- package/dist/bot/agent.d.ts.map +1 -1
- package/dist/bot/agent.js +1341 -20
- package/dist/bot/agent.js.map +1 -1
- package/dist/bot/browser.d.ts +13 -0
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +573 -31
- package/dist/bot/browser.js.map +1 -1
- package/dist/bot/captcha-solver-2captcha.d.ts +42 -0
- package/dist/bot/captcha-solver-2captcha.d.ts.map +1 -0
- package/dist/bot/captcha-solver-2captcha.js +144 -0
- package/dist/bot/captcha-solver-2captcha.js.map +1 -0
- package/dist/bot/index.d.ts +2 -0
- package/dist/bot/index.d.ts.map +1 -1
- package/dist/bot/index.js +2 -0
- package/dist/bot/index.js.map +1 -1
- package/dist/bot/llm-client.d.ts +2 -1
- package/dist/bot/llm-client.d.ts.map +1 -1
- package/dist/bot/llm-client.js +19 -2
- package/dist/bot/llm-client.js.map +1 -1
- package/dist/bot/notify-api.d.ts +2 -0
- package/dist/bot/notify-api.d.ts.map +1 -1
- package/dist/bot/notify-api.js +13 -5
- package/dist/bot/notify-api.js.map +1 -1
- package/dist/bot/promote-to-skill.d.ts +9 -0
- package/dist/bot/promote-to-skill.d.ts.map +1 -1
- package/dist/bot/promote-to-skill.js +98 -7
- package/dist/bot/promote-to-skill.js.map +1 -1
- package/dist/bot/read-otp.d.ts +14 -0
- package/dist/bot/read-otp.d.ts.map +1 -0
- package/dist/bot/read-otp.js +96 -0
- package/dist/bot/read-otp.js.map +1 -0
- package/dist/bot/redact.d.ts +2 -0
- package/dist/bot/redact.d.ts.map +1 -0
- package/dist/bot/redact.js +61 -0
- package/dist/bot/redact.js.map +1 -0
- package/dist/bot/telegram-notify.d.ts +8 -0
- package/dist/bot/telegram-notify.d.ts.map +1 -0
- package/dist/bot/telegram-notify.js +134 -0
- package/dist/bot/telegram-notify.js.map +1 -0
- package/dist/skill-cli/cli.js +14 -3
- package/dist/skill-cli/cli.js.map +1 -1
- package/dist/tools/provision-any.d.ts.map +1 -1
- package/dist/tools/provision-any.js +26 -1
- package/dist/tools/provision-any.js.map +1 -1
- package/package.json +5 -2
package/dist/bot/agent.js
CHANGED
|
@@ -11,6 +11,10 @@ import { rankAndCapInventory, scoreSignupButton } from "./browser.js";
|
|
|
11
11
|
import { OAUTH_PROVIDERS, extractOAuthScopes, isGitHubDismissible2faSetup, GITHUB_DISMISSIBLE_2FA_SKIP_TEXT, } from "./oauth-providers.js";
|
|
12
12
|
import { extractGoogleNumberMatch, scrapeGoogleScopePhrases } from "./google-login.js";
|
|
13
13
|
import { notifyHeightenedAuth } from "./notify-api.js";
|
|
14
|
+
import { sendTelegramHeightenedAuth } from "./telegram-notify.js";
|
|
15
|
+
import { TwoCaptchaSolver } from "./captcha-solver-2captcha.js";
|
|
16
|
+
import { redactCredentials } from "./redact.js";
|
|
17
|
+
import { readOperatorOtp, fromDomainFromUrl } from "./read-otp.js";
|
|
14
18
|
import { loggedInProviders, clearProviderLoggedIn } from "./login-state.js";
|
|
15
19
|
import { saveDebugSnapshot } from "./debug.js";
|
|
16
20
|
import { captureOnboardingRound } from "./onboarding-capture.js";
|
|
@@ -75,8 +79,20 @@ const ONBOARDING_PAYWALL_PATTERNS = [
|
|
|
75
79
|
/\benter\s+your\s+card\b/i,
|
|
76
80
|
/\benter\s+your\s+payment\b/i,
|
|
77
81
|
/\benter\s+payment\s+details\b/i,
|
|
82
|
+
/\bconnect\s+a(?:\s+valid)?\s+payment\s+method\b/i,
|
|
83
|
+
/\byour\s+(?:free\s+)?trial\s+(?:is\s+)?ending\b/i,
|
|
78
84
|
/\bupgrade\s+your\s+plan\s+to\b/i,
|
|
79
85
|
/\bstart\s+your\s+paid\s+plan\b/i,
|
|
86
|
+
// rc.39 — Koyeb-class. Cover the variants the post-verify planner
|
|
87
|
+
// produces in its `done` reason when it gives up on a billing wall:
|
|
88
|
+
// "requires credit card payment", "credit card verification wall",
|
|
89
|
+
// "payment wall", "Pro plan payment required", "complete billing".
|
|
90
|
+
/\brequir(?:es?|ing)\s+(?:a\s+)?credit\s+card\b/i,
|
|
91
|
+
/\b(?:credit\s+card|payment)\s+wall\b/i,
|
|
92
|
+
/\bcredit\s+card\s+verification\b/i,
|
|
93
|
+
/\b(?:plan\s+|account\s+)?payment\s+required\b/i,
|
|
94
|
+
/\bcomplet(?:e|ing)\s+(?:billing|payment)\b/i,
|
|
95
|
+
/\bbilling\s+setup\s+(?:is\s+)?required\b/i,
|
|
80
96
|
];
|
|
81
97
|
// Negators that, if they appear in the ~30 characters immediately
|
|
82
98
|
// before a paywall pattern match, flip its meaning from a demand
|
|
@@ -206,13 +222,22 @@ function extractJsonObject(raw) {
|
|
|
206
222
|
// Tolerate models that wrap their reply in markdown fences.
|
|
207
223
|
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]+?)\s*```/);
|
|
208
224
|
const candidate = fenced !== null && fenced[1] !== undefined ? fenced[1] : trimmed;
|
|
209
|
-
|
|
210
|
-
|
|
225
|
+
// F7 / 0.6.15-rc.11 — stack-based first-balanced-object extraction.
|
|
226
|
+
// The previous regex `\{[\s\S]*\}` was greedy and matched from the
|
|
227
|
+
// first `{` to the LAST `}` in the string. When an LLM emitted
|
|
228
|
+
// multiple JSON objects (e.g. {"kind":"fill",…}\n{"kind":"click",…}),
|
|
229
|
+
// the greedy match spanned both and JSON.parse failed with
|
|
230
|
+
// "Unexpected non-whitespace character after JSON at position N".
|
|
231
|
+
// The stack walker finds the first balanced `{…}` block respecting
|
|
232
|
+
// string-literal boundaries, so a single object always parses cleanly
|
|
233
|
+
// even when the model appends trailing prose or extra objects.
|
|
234
|
+
const objText = extractFirstBalancedObject(candidate);
|
|
235
|
+
if (objText === null) {
|
|
211
236
|
throw new Error(`no JSON object in reply: ${raw.slice(0, 200)}`);
|
|
212
237
|
}
|
|
213
238
|
let parsed;
|
|
214
239
|
try {
|
|
215
|
-
parsed = JSON.parse(
|
|
240
|
+
parsed = JSON.parse(objText);
|
|
216
241
|
}
|
|
217
242
|
catch (err) {
|
|
218
243
|
throw new Error(`JSON.parse failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -228,6 +253,47 @@ function extractJsonObject(raw) {
|
|
|
228
253
|
obj[k] = v;
|
|
229
254
|
return obj;
|
|
230
255
|
}
|
|
256
|
+
// Find the first balanced `{ … }` block in `s`, respecting string
|
|
257
|
+
// literals and escapes. Returns the substring (inclusive of braces) or
|
|
258
|
+
// null when no balanced block exists. Tolerates trailing text after
|
|
259
|
+
// the closing brace (which is the whole reason we need it — the
|
|
260
|
+
// previous greedy regex couldn't).
|
|
261
|
+
function extractFirstBalancedObject(s) {
|
|
262
|
+
const open = s.indexOf("{");
|
|
263
|
+
if (open < 0)
|
|
264
|
+
return null;
|
|
265
|
+
let depth = 0;
|
|
266
|
+
let inString = false;
|
|
267
|
+
let escape = false;
|
|
268
|
+
for (let i = open; i < s.length; i++) {
|
|
269
|
+
const c = s[i];
|
|
270
|
+
if (escape) {
|
|
271
|
+
escape = false;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (inString) {
|
|
275
|
+
if (c === "\\") {
|
|
276
|
+
escape = true;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (c === '"')
|
|
280
|
+
inString = false;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (c === '"') {
|
|
284
|
+
inString = true;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (c === "{")
|
|
288
|
+
depth++;
|
|
289
|
+
else if (c === "}") {
|
|
290
|
+
depth--;
|
|
291
|
+
if (depth === 0)
|
|
292
|
+
return s.slice(open, i + 1);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
231
297
|
// Narrow `unknown` to a non-null object map.
|
|
232
298
|
function asObject(value, context) {
|
|
233
299
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
@@ -335,6 +401,31 @@ export function parseSignupPlan(raw, allowedSelectors) {
|
|
|
335
401
|
// with "Email" both in the body CTA and the footer made the planner
|
|
336
402
|
// pick the wrong one and loop. Single-occurrence text is rendered
|
|
337
403
|
// without the landmark tag to keep the inventory terse.
|
|
404
|
+
//
|
|
405
|
+
// rc.17 — split keyboard-shortcut suffixes out of button labels.
|
|
406
|
+
// Resend / Linear / Notion-style buttons render the shortcut hint
|
|
407
|
+
// glued to the label without a separator: "AddCtrl↩", "CancelEsc",
|
|
408
|
+
// "Save⌘↩". The planner reads "AddCtrl↩" and gets confused — the
|
|
409
|
+
// Resend trace showed the planner clicking "All domains" thinking
|
|
410
|
+
// it was the Add button (because "AddCtrl↩" didn't pattern-match
|
|
411
|
+
// "Add" cleanly). Exported for unit testing.
|
|
412
|
+
export function splitKeyboardShortcut(text) {
|
|
413
|
+
// Trailing keyboard hint = optional modifier (Ctrl/⌘/Cmd/Shift/Alt/
|
|
414
|
+
// Opt/Option/Meta), optional `+`, then one of: arrow-return symbols,
|
|
415
|
+
// named keys (Enter/Esc/Tab/Space/Return), or a single letter / Fn key.
|
|
416
|
+
// The suffix MUST start at a word boundary OR a transition from
|
|
417
|
+
// lowercase to uppercase ("AddCtrl" — Add↑↓Ctrl). Anchored to end-
|
|
418
|
+
// of-string.
|
|
419
|
+
const re = /(?<=[a-z])(Ctrl|⌘|Cmd|Shift|Alt|Opt(?:ion)?|Meta)?\+?(?:[↩⏎⌫⌦⇧⌘⎋]|Enter|Esc|Tab|Space|Return|F\d{1,2})$/;
|
|
420
|
+
const m = re.exec(text);
|
|
421
|
+
if (m === null)
|
|
422
|
+
return { label: text, shortcut: null };
|
|
423
|
+
const label = text.slice(0, m.index).trim();
|
|
424
|
+
if (label.length < 2)
|
|
425
|
+
return { label: text, shortcut: null };
|
|
426
|
+
const shortcut = m[0];
|
|
427
|
+
return { label, shortcut };
|
|
428
|
+
}
|
|
338
429
|
export function formatInventory(inventory) {
|
|
339
430
|
if (inventory.length === 0)
|
|
340
431
|
return "(no interactive elements found on the page)";
|
|
@@ -411,7 +502,10 @@ export function formatInventory(inventory) {
|
|
|
411
502
|
e.tag !== "textarea" &&
|
|
412
503
|
e.tag !== "select" &&
|
|
413
504
|
e.visibleText !== null) {
|
|
414
|
-
|
|
505
|
+
const { label, shortcut } = splitKeyboardShortcut(e.visibleText);
|
|
506
|
+
bits.push(`text=${JSON.stringify(label)}`);
|
|
507
|
+
if (shortcut !== null)
|
|
508
|
+
bits.push(`shortcut=${JSON.stringify(shortcut)}`);
|
|
415
509
|
}
|
|
416
510
|
if (e.inConsentWidget)
|
|
417
511
|
bits.push("[cookie-consent — avoid]");
|
|
@@ -618,14 +712,28 @@ export function detectAlreadySignedIn(args) {
|
|
|
618
712
|
let dashboardyPath = false;
|
|
619
713
|
try {
|
|
620
714
|
const parsed = new URL(url);
|
|
715
|
+
// rc.37 — widened the dashboard-path allowlist after the rc.35
|
|
716
|
+
// sweep showed Upstash's post-OAuth landing was /redis (the
|
|
717
|
+
// product-segment route, not a generic /dashboard). Added
|
|
718
|
+
// /redis, /kafka, /vector, /cluster, /databases?, /instances?,
|
|
719
|
+
// /apps?, /deployments?, /services? — all common product-name
|
|
720
|
+
// routes that almost always indicate authenticated state.
|
|
621
721
|
dashboardyPath =
|
|
622
|
-
/\/(?:new|dashboard|projects?|account|settings|workspace|home)(?:\/|$)/i.test(parsed.pathname) && !/\/(?:signup|sign-up|register|login|sign-in|signin)/i.test(parsed.pathname);
|
|
722
|
+
/\/(?:new|dashboard|projects?|account|settings|workspace|home|redis|kafka|vector|cluster|databases?|instances?|apps?|deployments?|services?)(?:\/|$)/i.test(parsed.pathname) && !/\/(?:signup|sign-up|register|login|sign-in|signin)/i.test(parsed.pathname);
|
|
623
723
|
}
|
|
624
724
|
catch {
|
|
625
725
|
// Malformed URL — skip URL signal.
|
|
626
726
|
}
|
|
627
727
|
if (dashboardyPath) {
|
|
628
|
-
|
|
728
|
+
// rc.37 — widened the creation-CTA vocabulary to include the
|
|
729
|
+
// dashboard-y "Create <product-noun>" pattern. Upstash's
|
|
730
|
+
// dashboard CTA reads "Create Database"; Convex / Neon /
|
|
731
|
+
// PlanetScale / similar all use this shape ("Create cluster",
|
|
732
|
+
// "Create instance", "Create deployment"). Without this the
|
|
733
|
+
// bot's F17 already-signed-in path fell through to form-fill
|
|
734
|
+
// and the planner clicked the CTA thinking it was a signup
|
|
735
|
+
// submit button.
|
|
736
|
+
const CREATION_CTA = /^\s*(?:\+\s*)?(?:new\s+(?:project|workspace|team|app|site|deployment|api\s*key|database|cluster|instance|service)|create(?:\s+(?:new|a|project|workspace|database|cluster|instance|deployment|app|service|index|environment))?)/i;
|
|
629
737
|
if (inventory.some((e) => {
|
|
630
738
|
const t = e.visibleText ?? e.ariaLabel ?? "";
|
|
631
739
|
return CREATION_CTA.test(t.trim());
|
|
@@ -635,6 +743,46 @@ export function detectAlreadySignedIn(args) {
|
|
|
635
743
|
}
|
|
636
744
|
return false;
|
|
637
745
|
}
|
|
746
|
+
// rc.39 — companion to detectAlreadySignedIn for the form-fill stage.
|
|
747
|
+
// The URL-based dashboard check above conservatively excludes /sign-up,
|
|
748
|
+
// /signup, /register paths — but services like PlanetScale and Turso
|
|
749
|
+
// serve a logged-in create-database / billing-wall form at /sign-up
|
|
750
|
+
// when the user has an active session. The form-fill planner reliably
|
|
751
|
+
// describes what it sees: "create the database on this PS-5 plan",
|
|
752
|
+
// "Add credit card", "database name". Match those phrases in the
|
|
753
|
+
// planner's notes and action reasons to pivot to post-verify instead
|
|
754
|
+
// of fooling the form-fill loop into clicking a "create database"
|
|
755
|
+
// button it mistakes for a signup submit.
|
|
756
|
+
//
|
|
757
|
+
// Patterns are intentionally conservative — they must mention an
|
|
758
|
+
// authenticated-state product/billing noun, not just any verb. A
|
|
759
|
+
// regular signup form's planner output ("name field", "submit
|
|
760
|
+
// button") shouldn't match.
|
|
761
|
+
export function detectFormFillIsDashboard(plan) {
|
|
762
|
+
const haystack = [
|
|
763
|
+
plan.notes ?? "",
|
|
764
|
+
...plan.actions.map((a) => a.reason ?? ""),
|
|
765
|
+
]
|
|
766
|
+
.join(" ")
|
|
767
|
+
.toLowerCase();
|
|
768
|
+
// Billing / payment wall — the planner sees a credit-card / billing
|
|
769
|
+
// form, which is never a signup form.
|
|
770
|
+
const BILLING_WALL = /\b(?:add (?:a )?(?:credit card|payment method)|enter (?:your )?(?:credit card|payment)|billing (?:information|details)|payment information required)\b/;
|
|
771
|
+
if (BILLING_WALL.test(haystack))
|
|
772
|
+
return true;
|
|
773
|
+
// Product-creation form — the planner describes creating a
|
|
774
|
+
// database / cluster / instance / deployment / app / project /
|
|
775
|
+
// workspace / service, which is post-signup territory.
|
|
776
|
+
const PRODUCT_CREATION = /\b(?:create(?:s|d)?|creating|provision(?:s|ed|ing)?)\s+(?:(?:the|a|an|new|your|this)\s+){0,3}(?:database|cluster|instance|deployment|app|service|project|workspace|index|environment|tenant)\b/;
|
|
777
|
+
if (PRODUCT_CREATION.test(haystack))
|
|
778
|
+
return true;
|
|
779
|
+
// Explicit "not a signup" / "logged in" / "dashboard" statements
|
|
780
|
+
// from the planner.
|
|
781
|
+
const EXPLICIT = /\b(?:not\s+(?:a\s+)?(?:sign-?up|signup)|already\s+(?:signed[\s-]?in|logged[\s-]?in|authenticated)|logged[\s-]?in (?:dashboard|user))\b/;
|
|
782
|
+
if (EXPLICIT.test(haystack))
|
|
783
|
+
return true;
|
|
784
|
+
return false;
|
|
785
|
+
}
|
|
638
786
|
// True when the page has no fillable text input AND no button that
|
|
639
787
|
// reads as an email-signup option — a genuinely OAuth/SSO-only
|
|
640
788
|
// service with no form to automate (F3 Issue 4).
|
|
@@ -686,6 +834,19 @@ export function isOauthOnlyChooser(inventory) {
|
|
|
686
834
|
// or "Google's Privacy Policy" out.
|
|
687
835
|
// Returns null when the page has no such affordance — the planner then
|
|
688
836
|
// falls back to form-fill. Exported for unit testing.
|
|
837
|
+
//
|
|
838
|
+
// rc.12 — sanity-cap the element's own visible text. A real sign-in
|
|
839
|
+
// button is short ("Continue with Google" = 19 chars, "Sign in with
|
|
840
|
+
// GitHub" = 19). When the element's visibleText runs longer than the
|
|
841
|
+
// cap below, it is wrapping unrelated content — typically a marketing
|
|
842
|
+
// card with a small provider logo nested inside. The OpenRouter case:
|
|
843
|
+
// an <a> wrapping a model card whose textContent reads "anthropic/
|
|
844
|
+
// claude-opus-4.7Model routing visualization…" and whose descendant
|
|
845
|
+
// tree contains an <img alt="Google"> for a tiny G icon. The iconLabel
|
|
846
|
+
// path then fired against the wrong element. Capping at 60 chars also
|
|
847
|
+
// gates path 2 to truly icon-only elements (no own visible text) so a
|
|
848
|
+
// card wrapper with one stray <img alt> can never match.
|
|
849
|
+
const MAX_OAUTH_BUTTON_TEXT_CHARS = 60;
|
|
689
850
|
export function findOAuthButton(inventory, provider) {
|
|
690
851
|
const keyword = OAUTH_PROVIDERS[provider].buttonKeyword;
|
|
691
852
|
const keywordRe = new RegExp(`\\b${keyword}\\b`);
|
|
@@ -699,17 +860,26 @@ export function findOAuthButton(inventory, provider) {
|
|
|
699
860
|
e.type === "button";
|
|
700
861
|
if (!isButtonish)
|
|
701
862
|
continue;
|
|
863
|
+
const visibleText = (e.visibleText ?? "").trim();
|
|
864
|
+
if (visibleText.length > MAX_OAUTH_BUTTON_TEXT_CHARS)
|
|
865
|
+
continue;
|
|
702
866
|
// 1. An <a> whose href routes through the provider's OAuth endpoint.
|
|
703
867
|
const href = (e.href ?? "").toLowerCase();
|
|
704
868
|
if (href.length > 0 && hrefRe.test(href))
|
|
705
869
|
return e;
|
|
706
|
-
// 2. Icon-only button — named only by a descendant img/svg.
|
|
707
|
-
|
|
870
|
+
// 2. Icon-only button — named only by a descendant img/svg. Require
|
|
871
|
+
// the element to be truly icon-only (no own visible text); a
|
|
872
|
+
// populated visibleText means the iconLabel signal is redundant
|
|
873
|
+
// with path 3 below, and accepting it here lets a card wrapper
|
|
874
|
+
// with a stray <img alt="Google"> inside match.
|
|
875
|
+
if (visibleText.length === 0 &&
|
|
876
|
+
keywordRe.test((e.iconLabel ?? "").toLowerCase())) {
|
|
708
877
|
return e;
|
|
878
|
+
}
|
|
709
879
|
// 3. Visible text / accessible label naming the provider + an
|
|
710
880
|
// auth verb. The auth verb requirement rejects nav and policy
|
|
711
881
|
// links that merely mention the provider.
|
|
712
|
-
const text = `${
|
|
882
|
+
const text = `${visibleText} ${e.ariaLabel ?? ""} ${e.labelText ?? ""}`
|
|
713
883
|
.toLowerCase()
|
|
714
884
|
.replace(/\s+/g, " ")
|
|
715
885
|
.trim();
|
|
@@ -718,9 +888,140 @@ export function findOAuthButton(inventory, provider) {
|
|
|
718
888
|
if (/\b(sign|signup|signin|continue|log ?in|connect|auth)\b/.test(text)) {
|
|
719
889
|
return e;
|
|
720
890
|
}
|
|
891
|
+
// rc.39 — minimal-label OAuth buttons. Some auth UIs render the
|
|
892
|
+
// provider as a bare keyword button: just "GitHub" or just "Google"
|
|
893
|
+
// (Turso, several Stytch / Clerk / Auth0 templates). When the
|
|
894
|
+
// VISIBLE text is essentially nothing but the provider keyword,
|
|
895
|
+
// accept it — no auth-verb required. The keyword regex already
|
|
896
|
+
// ensured the provider name is present; the length cap MAX_OAUTH_
|
|
897
|
+
// BUTTON_TEXT_CHARS (60) ensures it's still buttonish, not a
|
|
898
|
+
// paragraph that happens to mention the provider.
|
|
899
|
+
const stripped = visibleText.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
|
900
|
+
if (stripped === keyword || stripped === `with ${keyword}`) {
|
|
901
|
+
return e;
|
|
902
|
+
}
|
|
721
903
|
}
|
|
722
904
|
return null;
|
|
723
905
|
}
|
|
906
|
+
// rc.20 — true when the post-OAuth landing page LOOKS like the same
|
|
907
|
+
// login/auth UI the bot just OAuth'd from AND still surfaces an OAuth
|
|
908
|
+
// affordance for the same provider. Services like Groq complete the
|
|
909
|
+
// Google handshake server-side but bounce the user back to a
|
|
910
|
+
// /authenticate page that requires one more click of the provider
|
|
911
|
+
// button to actually finalize the session. Returns the OAuth button
|
|
912
|
+
// to click (so the caller can pass it to startOAuth) or null when the
|
|
913
|
+
// page is past that gate.
|
|
914
|
+
//
|
|
915
|
+
// Login-shaped path patterns: /login, /signin, /sign-in, /signup,
|
|
916
|
+
// /sign-up, /auth, /authenticate, /authorize. Excludes /callback
|
|
917
|
+
// (genuinely transient) and dashboard-shaped paths.
|
|
918
|
+
export function isLoginLoopState(url, inventory, provider) {
|
|
919
|
+
let path;
|
|
920
|
+
try {
|
|
921
|
+
path = new URL(url).pathname.toLowerCase();
|
|
922
|
+
}
|
|
923
|
+
catch {
|
|
924
|
+
return null;
|
|
925
|
+
}
|
|
926
|
+
const loginPath = /\/(?:login|signin|sign-in|signup|sign-up|auth|authenticate|authorize)(?:\/|$)/.test(path);
|
|
927
|
+
if (!loginPath)
|
|
928
|
+
return null;
|
|
929
|
+
// Defense in depth: if the page also shows authenticated-state
|
|
930
|
+
// markers (Sign out / Dashboard / billing widget) it isn't really
|
|
931
|
+
// a login loop — the OAuth-buttons are decoration. detectAlreadySignedIn
|
|
932
|
+
// returns false when ANY credential input is visible, but here we're
|
|
933
|
+
// checking the inverse — markers despite the login path.
|
|
934
|
+
if (detectAlreadySignedIn({ inventory, url }))
|
|
935
|
+
return null;
|
|
936
|
+
return findOAuthButton(inventory, provider);
|
|
937
|
+
}
|
|
938
|
+
// Path-only formatter for step trail entries. Same parse semantics as
|
|
939
|
+
// isLoginLoopState — best-effort, returns "(unparseable)" on failure.
|
|
940
|
+
export function pathOf(url) {
|
|
941
|
+
try {
|
|
942
|
+
return new URL(url).pathname;
|
|
943
|
+
}
|
|
944
|
+
catch {
|
|
945
|
+
return "(unparseable)";
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
// (a) Manual-login fallback. DigitalOcean + Hyperbolic completed the
|
|
949
|
+
// Google OAuth handshake on Google's side but the service didn't
|
|
950
|
+
// honour the callback — the bot lands back on a manual /login page
|
|
951
|
+
// with email + password inputs. Distinct from the rc.20 login-loop
|
|
952
|
+
// (which assumes the page still surfaces OAuth provider buttons).
|
|
953
|
+
export function detectManualLoginFallback(url, inventory) {
|
|
954
|
+
let path;
|
|
955
|
+
try {
|
|
956
|
+
path = new URL(url).pathname.toLowerCase();
|
|
957
|
+
}
|
|
958
|
+
catch {
|
|
959
|
+
return false;
|
|
960
|
+
}
|
|
961
|
+
const loginPath = /\/(?:login|signin|sign-in|signup|sign-up|authenticate)(?:\/|$)/.test(path);
|
|
962
|
+
if (!loginPath)
|
|
963
|
+
return false;
|
|
964
|
+
// Manual-login signal: email + password input pair on the page.
|
|
965
|
+
const hasEmail = inventory.some((e) => e.tag === "input" && e.type === "email");
|
|
966
|
+
const hasPassword = inventory.some((e) => e.tag === "input" && e.type === "password");
|
|
967
|
+
return hasEmail && hasPassword;
|
|
968
|
+
}
|
|
969
|
+
// (b) Email-OTP wall. Porter, Koyeb (both WorkOS-backed) send a 6-
|
|
970
|
+
// or 8-digit code to the operator's email and gate further access
|
|
971
|
+
// behind it. Signal: URL path or title literal "email-verification"
|
|
972
|
+
// + a single short-numeric input in the inventory.
|
|
973
|
+
export function detectEmailOtpGate(url, title, pageText) {
|
|
974
|
+
let path;
|
|
975
|
+
try {
|
|
976
|
+
path = new URL(url).pathname.toLowerCase();
|
|
977
|
+
}
|
|
978
|
+
catch {
|
|
979
|
+
path = "";
|
|
980
|
+
}
|
|
981
|
+
if (/email[-_]verification|verify[-_]email|email[-_]code|otp/.test(path)) {
|
|
982
|
+
return true;
|
|
983
|
+
}
|
|
984
|
+
const titleLower = title.toLowerCase();
|
|
985
|
+
if (titleLower.includes("verify your email") ||
|
|
986
|
+
titleLower.includes("email verification")) {
|
|
987
|
+
return true;
|
|
988
|
+
}
|
|
989
|
+
// Page-text fallback for services that route OTP gates through
|
|
990
|
+
// generic URLs (the bot has the rendered body anyway). Conservative
|
|
991
|
+
// phrasing — must include a "we sent … code … to" or "enter …
|
|
992
|
+
// code … sent" shape with a bounded gap.
|
|
993
|
+
const lower = pageText.toLowerCase();
|
|
994
|
+
return (/we sent[^.]{0,60}\bcode\b[^.]{0,40}to\b/.test(lower) ||
|
|
995
|
+
/enter[^.]{0,40}\bcode\b[^.]{0,40}\b(?:sent|email)/.test(lower));
|
|
996
|
+
}
|
|
997
|
+
// (c) SSO restriction (Fly.io class). Service rejects token-creation
|
|
998
|
+
// for accounts whose org enforces SSO/SAML. Signal: phrase fragments
|
|
999
|
+
// that explicitly name SSO/SAML/Single Sign-On as the blocker.
|
|
1000
|
+
export function detectSsoRestriction(pageText) {
|
|
1001
|
+
const lower = pageText.toLowerCase();
|
|
1002
|
+
// Common phrasings observed: "managed via SSO", "SSO-managed",
|
|
1003
|
+
// "Single Sign-On is required", "SSO organization membership".
|
|
1004
|
+
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);
|
|
1005
|
+
}
|
|
1006
|
+
// (d) Stuck-on-Google-OAuth-screens (Upstash class). After
|
|
1007
|
+
// settleAfterOAuth the URL is STILL on accounts.google.com — the
|
|
1008
|
+
// handshake didn't redirect through to the service. Most common
|
|
1009
|
+
// shape: Clerk-mediated OAuth (Upstash's auth.upstash.com → Google
|
|
1010
|
+
// account chooser) where the chooser uses a clickable card the
|
|
1011
|
+
// post-verify planner can't reliably target, and the bot loops
|
|
1012
|
+
// trying. Defining trait: hostname accounts.google.com (or
|
|
1013
|
+
// accounts.googleusercontent.com) at the post-OAuth gate.
|
|
1014
|
+
export function detectStuckOnGoogleOAuth(url) {
|
|
1015
|
+
try {
|
|
1016
|
+
const h = new URL(url).hostname.toLowerCase();
|
|
1017
|
+
return (h === "accounts.google.com" ||
|
|
1018
|
+
h === "accounts.googleusercontent.com" ||
|
|
1019
|
+
h.endsWith(".accounts.google.com"));
|
|
1020
|
+
}
|
|
1021
|
+
catch {
|
|
1022
|
+
return false;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
724
1025
|
// Scan the inventory for the first OAuth affordance among `providers`,
|
|
725
1026
|
// in order — the auto-prefer decision passes every provider the
|
|
726
1027
|
// profile has a session for. Returns the matched provider + element.
|
|
@@ -917,8 +1218,187 @@ export function extractQuotedTokenFromReason(reason, pageText) {
|
|
|
917
1218
|
if (pageText.includes(candidate))
|
|
918
1219
|
return candidate;
|
|
919
1220
|
}
|
|
1221
|
+
// rc.36 — unquoted UUID fallback. Upstash's API key dialog shows a
|
|
1222
|
+
// bare UUID (`b7dd0ff0-2497-4dc8-a793-8261a38e0339`) and the
|
|
1223
|
+
// planner quotes it WITHOUT surrounding quote marks ("The full API
|
|
1224
|
+
// key b7dd0ff0-… is visible"). The verbatim-in-page check is the
|
|
1225
|
+
// safety net — random UUIDs sprinkled across dashboards (trace IDs,
|
|
1226
|
+
// project IDs) only false-positive if the SAME UUID appears in the
|
|
1227
|
+
// planner's reason. Keyword guard ('api key' / 'token' / 'secret'
|
|
1228
|
+
// / 'credential' within 50 chars of the UUID) keeps unrelated
|
|
1229
|
+
// UUIDs (project IDs, member IDs in a sidebar) from matching.
|
|
1230
|
+
const uuidRe = /\b([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/gi;
|
|
1231
|
+
const keywordRe = /\b(?:api[\s_-]?(?:key|token)|access[\s_-]?token|secret|credential)\b/i;
|
|
1232
|
+
for (const m of reason.matchAll(uuidRe)) {
|
|
1233
|
+
const candidate = m[1] ?? m[0];
|
|
1234
|
+
if (!pageText.includes(candidate))
|
|
1235
|
+
continue;
|
|
1236
|
+
const start = Math.max(0, m.index - 50);
|
|
1237
|
+
const end = Math.min(reason.length, m.index + candidate.length + 50);
|
|
1238
|
+
const window = reason.slice(start, end);
|
|
1239
|
+
if (keywordRe.test(window))
|
|
1240
|
+
return candidate;
|
|
1241
|
+
}
|
|
920
1242
|
return null;
|
|
921
1243
|
}
|
|
1244
|
+
// Phase E — multi-credential planner-prose parser. When a service
|
|
1245
|
+
// exposes several distinct credentials on the same page (Cloudinary:
|
|
1246
|
+
// cloud_name + api_key + api_secret; Algolia: application_id +
|
|
1247
|
+
// admin_api_key + search_api_key; Twilio: account_sid + auth_token;
|
|
1248
|
+
// Stripe: publishable_key + secret_key), the post-verify planner is
|
|
1249
|
+
// instructed (Phase E prompt update) to label each value explicitly
|
|
1250
|
+
// in its extract reason. This parser pulls those labels + values out
|
|
1251
|
+
// and returns them as { [label]: value }.
|
|
1252
|
+
//
|
|
1253
|
+
// The label vocabulary is whitelisted to known credential-shaped
|
|
1254
|
+
// names so the parser doesn't false-match prose like "the
|
|
1255
|
+
// dashboard_url is …" or "the project_name is …". Anything outside
|
|
1256
|
+
// the whitelist is dropped to keep credentials objects clean.
|
|
1257
|
+
//
|
|
1258
|
+
// Returns empty record when nothing parsed. Caller folds the result
|
|
1259
|
+
// into the credentials dict; falls back to single-cred extraction
|
|
1260
|
+
// when this returns empty.
|
|
1261
|
+
export function extractAllLabeledTokensFromReason(reason, pageText) {
|
|
1262
|
+
// Whitelist of credential labels we recognize. Snake_case canonical;
|
|
1263
|
+
// the matcher tolerates the LLM emitting hyphenated or PascalCase
|
|
1264
|
+
// variants. Each entry maps a normalized form back to the canonical
|
|
1265
|
+
// snake_case used in the credentials Record.
|
|
1266
|
+
const LABEL_ALIASES = {
|
|
1267
|
+
api_key: "api_key",
|
|
1268
|
+
apikey: "api_key",
|
|
1269
|
+
api_token: "api_key",
|
|
1270
|
+
apitoken: "api_key",
|
|
1271
|
+
access_token: "access_token",
|
|
1272
|
+
accesstoken: "access_token",
|
|
1273
|
+
api_secret: "api_secret",
|
|
1274
|
+
apisecret: "api_secret",
|
|
1275
|
+
secret_key: "secret_key",
|
|
1276
|
+
secretkey: "secret_key",
|
|
1277
|
+
publishable_key: "publishable_key",
|
|
1278
|
+
publishablekey: "publishable_key",
|
|
1279
|
+
client_id: "client_id",
|
|
1280
|
+
clientid: "client_id",
|
|
1281
|
+
client_secret: "client_secret",
|
|
1282
|
+
clientsecret: "client_secret",
|
|
1283
|
+
cloud_name: "cloud_name",
|
|
1284
|
+
cloudname: "cloud_name",
|
|
1285
|
+
application_id: "application_id",
|
|
1286
|
+
applicationid: "application_id",
|
|
1287
|
+
app_id: "application_id",
|
|
1288
|
+
appid: "application_id",
|
|
1289
|
+
admin_api_key: "admin_api_key",
|
|
1290
|
+
adminapikey: "admin_api_key",
|
|
1291
|
+
search_api_key: "search_api_key",
|
|
1292
|
+
searchapikey: "search_api_key",
|
|
1293
|
+
monitoring_api_key: "monitoring_api_key",
|
|
1294
|
+
account_sid: "account_sid",
|
|
1295
|
+
accountsid: "account_sid",
|
|
1296
|
+
auth_token: "auth_token",
|
|
1297
|
+
authtoken: "auth_token",
|
|
1298
|
+
sandbox_secret: "sandbox_secret",
|
|
1299
|
+
sandboxsecret: "sandbox_secret",
|
|
1300
|
+
org_id: "org_id",
|
|
1301
|
+
orgid: "org_id",
|
|
1302
|
+
organization_id: "org_id",
|
|
1303
|
+
consumer_key: "consumer_key",
|
|
1304
|
+
consumer_secret: "consumer_secret",
|
|
1305
|
+
access_token_secret: "access_token_secret",
|
|
1306
|
+
project_api_key: "project_api_key",
|
|
1307
|
+
personal_api_key: "personal_api_key",
|
|
1308
|
+
app_key: "app_key",
|
|
1309
|
+
appkey: "app_key",
|
|
1310
|
+
};
|
|
1311
|
+
const out = {};
|
|
1312
|
+
// Build the label-alternation from the whitelist keys. Restricting
|
|
1313
|
+
// the regex to KNOWN labels avoids the greedy-match-eats-real-label
|
|
1314
|
+
// bug (without this, "shows: application_id" would match as
|
|
1315
|
+
// label='shows' / value='application_id' and consume the real
|
|
1316
|
+
// 'application_id' that follows). Longer aliases first so the
|
|
1317
|
+
// regex prefers `admin_api_key` over `api_key` at the same start.
|
|
1318
|
+
const labelKeys = Object.keys(LABEL_ALIASES).sort((a, b) => b.length - a.length);
|
|
1319
|
+
const labelAlt = labelKeys.map(escapeRegex).join("|");
|
|
1320
|
+
// Hyphen variants — the LLM sometimes emits `cloud-name` instead of
|
|
1321
|
+
// `cloud_name`. Replace _ with [-_] inside each alternative.
|
|
1322
|
+
const labelAltLoose = labelAlt.replace(/_/g, "[-_]");
|
|
1323
|
+
// Two patterns:
|
|
1324
|
+
//
|
|
1325
|
+
// (A) Strict QUOTED form — `label='value'` / `label="value"` /
|
|
1326
|
+
// `label:'value'` etc. Trusts the value as credential-shape
|
|
1327
|
+
// because the planner was instructed (Phase E prompt) to quote.
|
|
1328
|
+
//
|
|
1329
|
+
// (B) Prose `label is value` form — required for natural-language
|
|
1330
|
+
// extracts but DANGEROUS. The Cloudinary trace produced
|
|
1331
|
+
// "api_secret is hidden behind asterisks" — the prose-pattern
|
|
1332
|
+
// greedily captured `hidden` as the value, then the
|
|
1333
|
+
// anti-hallucination check passed (the word "hidden" was in
|
|
1334
|
+
// pageText/reason). Mitigations: (1) require the value to LOOK
|
|
1335
|
+
// credential-shape (mixed alpha+digit, ≥16 chars, OR a known
|
|
1336
|
+
// credential prefix); (2) hard-reject a curated set of common
|
|
1337
|
+
// English status words that look label-like in extract prose.
|
|
1338
|
+
const quotedRe = new RegExp(`\\b(${labelAltLoose})\\b\\s*[=:]\\s*['"\`]([A-Za-z0-9_\\-]{4,80})['"\`]`, "gi");
|
|
1339
|
+
for (const m of reason.matchAll(quotedRe)) {
|
|
1340
|
+
const rawLabel = (m[1] ?? "").toLowerCase().replace(/-/g, "_");
|
|
1341
|
+
const normalized = rawLabel.replace(/_+/g, "_");
|
|
1342
|
+
const canonical = LABEL_ALIASES[normalized];
|
|
1343
|
+
const value = m[2];
|
|
1344
|
+
if (canonical === undefined || value === undefined)
|
|
1345
|
+
continue;
|
|
1346
|
+
if (!pageText.includes(value))
|
|
1347
|
+
continue;
|
|
1348
|
+
if (out[canonical] === undefined)
|
|
1349
|
+
out[canonical] = value;
|
|
1350
|
+
}
|
|
1351
|
+
// English status words that show up in planner prose alongside
|
|
1352
|
+
// a credential label but are NEVER the credential value itself.
|
|
1353
|
+
// Each is a literal lowercase comparison after value-lowercase.
|
|
1354
|
+
const PROSE_BLACKLIST = new Set([
|
|
1355
|
+
"hidden", "masked", "shown", "visible", "available", "missing",
|
|
1356
|
+
"unavailable", "redacted", "obscured", "concealed", "secret",
|
|
1357
|
+
"true", "false", "null", "none", "empty", "unset", "undefined",
|
|
1358
|
+
"displayed", "revealed", "asterisks", "bullets", "dots", "stars",
|
|
1359
|
+
"blurred", "encrypted",
|
|
1360
|
+
]);
|
|
1361
|
+
const looksCredentialShape = (v) => {
|
|
1362
|
+
if (v.length >= 16)
|
|
1363
|
+
return true; // long-enough tokens are presumed real
|
|
1364
|
+
if (/^[A-Za-z]+$/.test(v))
|
|
1365
|
+
return false; // pure word → suspect
|
|
1366
|
+
if (/^\d{10,}$/.test(v))
|
|
1367
|
+
return true; // long all-digit (Cloudinary api_key)
|
|
1368
|
+
if (/[_\-]/.test(v) && /[a-z]/i.test(v) && /\d/.test(v))
|
|
1369
|
+
return true; // mixed
|
|
1370
|
+
if (/^[a-z]+_[A-Za-z0-9]/i.test(v))
|
|
1371
|
+
return true; // prefix_ style (sk_…, npm_…)
|
|
1372
|
+
if (/\d/.test(v) && /[A-Za-z]/.test(v))
|
|
1373
|
+
return true; // alphanumeric mix
|
|
1374
|
+
return false; // pure short word → reject as suspect
|
|
1375
|
+
};
|
|
1376
|
+
// Same separator vocab as quoted, plus optional quotes around the
|
|
1377
|
+
// value. The credential-shape + blacklist guards run on the
|
|
1378
|
+
// captured (possibly-unquoted) value.
|
|
1379
|
+
const proseRe = new RegExp(`\\b(${labelAltLoose})\\b\\s*(?:[=:]|\\b(?:is|are)\\b)\\s*['"\`]?([A-Za-z0-9_\\-]{4,80})['"\`]?`, "gi");
|
|
1380
|
+
for (const m of reason.matchAll(proseRe)) {
|
|
1381
|
+
const rawLabel = (m[1] ?? "").toLowerCase().replace(/-/g, "_");
|
|
1382
|
+
const normalized = rawLabel.replace(/_+/g, "_");
|
|
1383
|
+
const canonical = LABEL_ALIASES[normalized];
|
|
1384
|
+
const value = m[2];
|
|
1385
|
+
if (canonical === undefined || value === undefined)
|
|
1386
|
+
continue;
|
|
1387
|
+
if (out[canonical] !== undefined)
|
|
1388
|
+
continue; // quoted-form already won
|
|
1389
|
+
if (PROSE_BLACKLIST.has(value.toLowerCase()))
|
|
1390
|
+
continue;
|
|
1391
|
+
if (!looksCredentialShape(value))
|
|
1392
|
+
continue;
|
|
1393
|
+
if (!pageText.includes(value))
|
|
1394
|
+
continue;
|
|
1395
|
+
out[canonical] = value;
|
|
1396
|
+
}
|
|
1397
|
+
return out;
|
|
1398
|
+
}
|
|
1399
|
+
function escapeRegex(s) {
|
|
1400
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1401
|
+
}
|
|
922
1402
|
export function extractApiKeyFromText(text) {
|
|
923
1403
|
const prefixed = [
|
|
924
1404
|
/\bre_[a-zA-Z0-9_]{20,}\b/, // Resend (key body contains underscores)
|
|
@@ -934,6 +1414,57 @@ export function extractApiKeyFromText(text) {
|
|
|
934
1414
|
/\bSG\.[a-zA-Z0-9_\-]{20,}\.[a-zA-Z0-9_\-]{20,}\b/, // SendGrid
|
|
935
1415
|
/\brnd_[a-zA-Z0-9]{20,}\b/, // Render
|
|
936
1416
|
/\bsntry[su]_[A-Za-z0-9_=\-]{20,}/, // Sentry org/user auth token
|
|
1417
|
+
// Neon serverless Postgres. Modal renders `napi_<48-char-alnum>` and
|
|
1418
|
+
// also shows a truncated `napi_xxx…` in the visible text below the
|
|
1419
|
+
// input field. Without the prefix here, the bot saw the truncated
|
|
1420
|
+
// display, isTruncatedCapture rejected the partial value, every
|
|
1421
|
+
// pass returned null, and the planner gave up despite the full key
|
|
1422
|
+
// being in the input field's `value` attribute. rc.14 — surfaced
|
|
1423
|
+
// during the harvester rc.13 pass on Neon.
|
|
1424
|
+
/\bnapi_[a-zA-Z0-9]{30,80}\b/, // Neon
|
|
1425
|
+
// Replicate API tokens. `r8_<40-char alnum>` per their docs. Shown
|
|
1426
|
+
// in the table row after Create. The post-verify loop iterates,
|
|
1427
|
+
// adds rows, but extractCredentials returned null every round
|
|
1428
|
+
// until rc.20 because no regex matched. Added defensively after
|
|
1429
|
+
// the rc.13 verification pass showed Replicate burning the full
|
|
1430
|
+
// 12-round budget filling-creating tokens nobody could extract.
|
|
1431
|
+
/\br8_[a-zA-Z0-9]{30,60}\b/, // Replicate
|
|
1432
|
+
// rc.23 — added after the post-rc.22 registry-snapshot review of
|
|
1433
|
+
// 200 failed signups. Each pattern matches a token shape the
|
|
1434
|
+
// bot's planner had already QUOTED in its `reason` field (i.e.
|
|
1435
|
+
// the credential was visible on the page, just not in a shape
|
|
1436
|
+
// any prior regex recognised). The redact.{ts,mjs} pattern set
|
|
1437
|
+
// stays in lockstep with these.
|
|
1438
|
+
/\bpscale_tkn_[A-Za-z0-9]{30,60}\b/, // PlanetScale Service Token
|
|
1439
|
+
/\bsbp_[a-zA-Z0-9]{30,80}\b/, // Supabase Personal Access Token
|
|
1440
|
+
// Baseten: `<6-12 alnum>.<30+ alnum>`. The dot separator + length
|
|
1441
|
+
// bounds on both sides distinguish it from version strings (too
|
|
1442
|
+
// short on either side). rc.35 — relaxed the prefix to mixed-case
|
|
1443
|
+
// after the rc.33 broad sweep showed a Baseten key whose prefix
|
|
1444
|
+
// had uppercase letters: `HP9tFTtm.txDl4vv7ayYsTwx9dQea47ylRdN4Brk3`.
|
|
1445
|
+
/\b[A-Za-z0-9]{6,12}\.[A-Za-z0-9]{30,50}\b/, // Baseten
|
|
1446
|
+
// Qdrant Cloud: `<UUID>|<55-char opaque>` — a literal pipe between
|
|
1447
|
+
// a key id and the secret body. Unique enough that no false-
|
|
1448
|
+
// positive guard is needed.
|
|
1449
|
+
//
|
|
1450
|
+
// rc.34/rc.36 — extended the secret-body character class to
|
|
1451
|
+
// include underscore + hyphen. rc.35 broad sweep surfaced
|
|
1452
|
+
// another Qdrant shape with mid-body hyphens:
|
|
1453
|
+
// `<UUID>|e8L7oyi-5fHa327u7x-IQN6WivtPlpIVjT-giIsrXDZW7P-8i2G9Pw`.
|
|
1454
|
+
// [A-Za-z0-9_-] covers both observed shapes; the {30,80} length
|
|
1455
|
+
// bound + UUID prefix keep false-positive risk near zero.
|
|
1456
|
+
/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\|[A-Za-z0-9_\-]{30,80}\b/i, // Qdrant
|
|
1457
|
+
// JWT (eyJ...eyJ...sig) — Convex's API "token" is a JWT. Other
|
|
1458
|
+
// services may emit JWTs as bearer secrets too. Three-segment
|
|
1459
|
+
// base64url with literal dots. Conservative bounds — under 20
|
|
1460
|
+
// chars per segment is almost never a real JWT.
|
|
1461
|
+
/\beyJ[A-Za-z0-9_\-]{20,}\.eyJ[A-Za-z0-9_\-]{20,}\.[A-Za-z0-9_\-]{20,}\b/, // JWT
|
|
1462
|
+
// Zeabur's API key — `sk-<28-40 lowercase alnum>`. Shorter than
|
|
1463
|
+
// OpenAI legacy (which is 40+ mixed-case). The lowercase-only
|
|
1464
|
+
// character class differentiates from OpenAI legacy so this
|
|
1465
|
+
// pattern only fires on Zeabur-style keys. Surfaced from the
|
|
1466
|
+
// rc.23 snapshot review.
|
|
1467
|
+
/\bsk-[a-z0-9]{28,38}\b/, // Zeabur
|
|
937
1468
|
// OpenRouter, Anthropic, OpenAI — these are the dominant
|
|
938
1469
|
// OAuth-completed-then-copy-needed services. Specific-prefix
|
|
939
1470
|
// patterns first so a labeled-pattern fallback isn't load-
|
|
@@ -1072,11 +1603,49 @@ export class SignupAgent {
|
|
|
1072
1603
|
steps.push(`${label} captcha gate skipped — session already captcha-blocked (${kind}).`);
|
|
1073
1604
|
return { found: true, solved: false, blocked: true, kind };
|
|
1074
1605
|
}
|
|
1075
|
-
|
|
1606
|
+
let result = await this.browser.solveVisibleCaptcha();
|
|
1076
1607
|
if (!result.found) {
|
|
1077
1608
|
return { found: false, solved: false, blocked: false, kind: "turnstile" };
|
|
1078
1609
|
}
|
|
1079
1610
|
steps.push(`${label} captcha (${result.kind}): ${result.solved ? "solved" : "NOT solved (timeout)"}`);
|
|
1611
|
+
// Tier 3 — when Tier 2 click-and-wait times out on a reCAPTCHA v2
|
|
1612
|
+
// image challenge AND TWOCAPTCHA_API_KEY is configured, fall
|
|
1613
|
+
// through to the third-party solver. Reads sitekey from the
|
|
1614
|
+
// page, submits to 2Captcha, polls for the token (~30-90s),
|
|
1615
|
+
// injects into the hidden g-recaptcha-response textarea + fires
|
|
1616
|
+
// the widget's onSuccess callback. Turnstile is intentionally
|
|
1617
|
+
// skipped — Cloudflare's challenge scores at the IP layer; a
|
|
1618
|
+
// solver-issued token gets rejected anyway.
|
|
1619
|
+
if (!result.solved &&
|
|
1620
|
+
result.kind === "recaptcha" &&
|
|
1621
|
+
this.captchaSolver?.isAvailable() === true) {
|
|
1622
|
+
const sitekey = await this.browser.extractRecaptchaSitekey();
|
|
1623
|
+
if (sitekey !== null) {
|
|
1624
|
+
const pageUrl = (await this.browser.getState().catch(() => null))?.url;
|
|
1625
|
+
if (pageUrl !== undefined) {
|
|
1626
|
+
steps.push(`${label} captcha: Tier 3 — submitting sitekey to 2Captcha (${sitekey.slice(0, 10)}…)`);
|
|
1627
|
+
const solveRes = await this.captchaSolver.solveRecaptchaV2({
|
|
1628
|
+
sitekey,
|
|
1629
|
+
pageUrl,
|
|
1630
|
+
});
|
|
1631
|
+
if (solveRes.kind === "ok") {
|
|
1632
|
+
const injected = await this.browser.injectRecaptchaToken(solveRes.token);
|
|
1633
|
+
if (injected) {
|
|
1634
|
+
steps.push(`${label} captcha: Tier 3 solved in ${Math.round(solveRes.durationMs / 1000)}s via 2Captcha`);
|
|
1635
|
+
result = { ...result, solved: true };
|
|
1636
|
+
}
|
|
1637
|
+
else {
|
|
1638
|
+
steps.push(`${label} captcha: Tier 3 token arrived but page injection failed — captcha stays blocked`);
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
else {
|
|
1642
|
+
steps.push(`${label} captcha: Tier 3 ${solveRes.kind}` +
|
|
1643
|
+
("reason" in solveRes ? `: ${solveRes.reason}` : "") +
|
|
1644
|
+
("durationMs" in solveRes ? ` (${Math.round(solveRes.durationMs / 1000)}s)` : ""));
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1080
1649
|
// rc.32 — forensic snapshot after the captcha attempt. Without
|
|
1081
1650
|
// this, the only snapshot near the captcha is the pre-fill one
|
|
1082
1651
|
// taken BEFORE the click, so when a Turnstile fails to solve we
|
|
@@ -1329,6 +1898,19 @@ export class SignupAgent {
|
|
|
1329
1898
|
}
|
|
1330
1899
|
steps.push(`Plan: ${plan.actions.length} action(s), confidence=${plan.confidence}` +
|
|
1331
1900
|
(plan.notes !== undefined ? ` — ${plan.notes}` : ""));
|
|
1901
|
+
// rc.39 — PlanetScale-class detection. The form-fill planner
|
|
1902
|
+
// sometimes lands on a logged-in product page (PlanetScale's
|
|
1903
|
+
// /sign-up redirects authenticated users to a create-database
|
|
1904
|
+
// form; similar for Turso, Cockroach, etc.). detectAlreadySignedIn
|
|
1905
|
+
// missed the URL signal because the path is /sign-up. But the
|
|
1906
|
+
// planner's notes / action reasons describe the page accurately:
|
|
1907
|
+
// "create the database on this PS-5 plan", "Add credit card",
|
|
1908
|
+
// "database name field". Pivot to post-verify navigation rather
|
|
1909
|
+
// than blindly filling the create-product form.
|
|
1910
|
+
if (detectFormFillIsDashboard(plan)) {
|
|
1911
|
+
steps.push("Form-fill planner described a logged-in product/billing page (not a signup form) — pivoting to post-verify navigation");
|
|
1912
|
+
return { kind: "already_oauth" };
|
|
1913
|
+
}
|
|
1332
1914
|
// F14 — stuck-detection: if the plan picks ONLY click selectors
|
|
1333
1915
|
// we already tried in the previous round without page progress,
|
|
1334
1916
|
// it's a planner loop. Fail planning_failed with the offending
|
|
@@ -1561,6 +2143,45 @@ export class SignupAgent {
|
|
|
1561
2143
|
// clear `needs_login` the host agent can act on, vs. a silent 45-second
|
|
1562
2144
|
// form-fill timeout. Better failure mode wins; the original gate was
|
|
1563
2145
|
// protecting the bot from the wrong loss.
|
|
2146
|
+
// rc.28 — click the first plausible account card on a Google
|
|
2147
|
+
// account-chooser page. Returns true on a successful click, false
|
|
2148
|
+
// when no card was identified. Used by the rc.25 stuck-on-chooser
|
|
2149
|
+
// post-OAuth gate to forward the flow off accounts.google.com
|
|
2150
|
+
// instead of immediately aborting.
|
|
2151
|
+
//
|
|
2152
|
+
// Google's chooser markup is consistent across surfaces: each
|
|
2153
|
+
// account renders as a clickable container with a data-identifier
|
|
2154
|
+
// attribute equal to the account's email, plus role="link" or
|
|
2155
|
+
// jsaction. The fallback is any element whose visible text
|
|
2156
|
+
// contains an @ — accounts always show their email.
|
|
2157
|
+
async tryClickGoogleChooserCard() {
|
|
2158
|
+
try {
|
|
2159
|
+
const page = this.browser.page;
|
|
2160
|
+
if (page === null || page === undefined)
|
|
2161
|
+
return false;
|
|
2162
|
+
// First-choice selector: data-identifier on an interactive element.
|
|
2163
|
+
const candidates = [
|
|
2164
|
+
'[data-identifier]:visible',
|
|
2165
|
+
'[role="link"]:has-text("@")',
|
|
2166
|
+
'div[jsaction]:has-text("@")',
|
|
2167
|
+
];
|
|
2168
|
+
for (const sel of candidates) {
|
|
2169
|
+
const loc = page.locator(sel).first();
|
|
2170
|
+
try {
|
|
2171
|
+
await loc.waitFor({ state: "visible", timeout: 2_000 });
|
|
2172
|
+
await loc.click({ timeout: 3_000 });
|
|
2173
|
+
return true;
|
|
2174
|
+
}
|
|
2175
|
+
catch {
|
|
2176
|
+
continue;
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
return false;
|
|
2180
|
+
}
|
|
2181
|
+
catch {
|
|
2182
|
+
return false;
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
1564
2185
|
async resolveOAuthCandidates(task, steps) {
|
|
1565
2186
|
if (task.oauthProvider !== undefined) {
|
|
1566
2187
|
return [task.oauthProvider];
|
|
@@ -1637,6 +2258,36 @@ export class SignupAgent {
|
|
|
1637
2258
|
// Set per-task in signup(). Lets the uploader know which service
|
|
1638
2259
|
// was being provisioned without threading it through every call.
|
|
1639
2260
|
currentService = "";
|
|
2261
|
+
// rc.27 — set when the email_otp_required gate handler successfully
|
|
2262
|
+
// fetched a code from the operator's gmail. Consumed by the next
|
|
2263
|
+
// post-verify round's planner prompt as a hint so the planner can
|
|
2264
|
+
// fill the verification input without burning rounds discovering
|
|
2265
|
+
// it. Cleared once the loop emits a step that targets the OTP
|
|
2266
|
+
// input, so the hint doesn't echo into later unrelated rounds.
|
|
2267
|
+
pendingOtpCode = null;
|
|
2268
|
+
// rc.39 — when postVerifyLoop exits because the planner returned
|
|
2269
|
+
// `done`, capture the planner's stated reason so the caller can
|
|
2270
|
+
// factor it into paywall classification. Koyeb (and similar)
|
|
2271
|
+
// shows a billing wall whose page text doesn't match the
|
|
2272
|
+
// ONBOARDING_PAYWALL_PATTERNS regex set, but the planner accurately
|
|
2273
|
+
// reasons about it in the done reason ("requires credit card payment
|
|
2274
|
+
// to access the platform"). Mixing that into the paywall check
|
|
2275
|
+
// upgrades these from oauth_onboarding_failed → onboarding_blocked.
|
|
2276
|
+
lastPostVerifyDoneReason = null;
|
|
2277
|
+
// Stashed at signup() entry so deep postVerifyLoop code (the
|
|
2278
|
+
// heightened-auth detector below) can fire notifyHeightenedAuth
|
|
2279
|
+
// without threading task through every method. Mirrors the
|
|
2280
|
+
// currentService pattern.
|
|
2281
|
+
currentMachineToken = undefined;
|
|
2282
|
+
currentApiBase = undefined;
|
|
2283
|
+
// Once per signup — fire the heightened-auth notifier at most one
|
|
2284
|
+
// time even if the planner returns done with a challenge phrasing
|
|
2285
|
+
// multiple times before the loop fully exits.
|
|
2286
|
+
heightenedAuthFired = false;
|
|
2287
|
+
// Tier 3 captcha solver (2Captcha). Constructed eagerly so the
|
|
2288
|
+
// isAvailable() check in runCaptchaGate is cheap; opt-in via the
|
|
2289
|
+
// TWOCAPTCHA_API_KEY env var read at construction.
|
|
2290
|
+
captchaSolver;
|
|
1640
2291
|
constructor(browser, llm, opts = {}) {
|
|
1641
2292
|
this.browser = browser;
|
|
1642
2293
|
if (llm === undefined) {
|
|
@@ -1658,6 +2309,7 @@ export class SignupAgent {
|
|
|
1658
2309
|
if (opts.roundUploader !== undefined) {
|
|
1659
2310
|
this.roundUploader = opts.roundUploader;
|
|
1660
2311
|
}
|
|
2312
|
+
this.captchaSolver = opts.captchaSolver ?? new TwoCaptchaSolver();
|
|
1661
2313
|
}
|
|
1662
2314
|
// Read-only view of how many calls landed on which backend. Exported
|
|
1663
2315
|
// through SignupResult.llm_backends so tests and ops can verify the
|
|
@@ -1773,6 +2425,14 @@ export class SignupAgent {
|
|
|
1773
2425
|
// deep inside postVerifyLoop after a failed extract) can label
|
|
1774
2426
|
// the snapshot without us threading task through every method.
|
|
1775
2427
|
this.currentService = task.service;
|
|
2428
|
+
this.lastPostVerifyDoneReason = null;
|
|
2429
|
+
// Stash for the post-verify heightened-auth notifier — the
|
|
2430
|
+
// detection point is deep inside postVerifyLoop where it sees
|
|
2431
|
+
// the planner's `done` reason naming a Google challenge. Same
|
|
2432
|
+
// path runOAuthFlow uses for the in-handshake case.
|
|
2433
|
+
this.currentMachineToken = task.machineToken;
|
|
2434
|
+
this.currentApiBase = task.apiBase;
|
|
2435
|
+
this.heightenedAuthFired = false;
|
|
1776
2436
|
const rawTimeout = Number(process.env.UNIVERSAL_BOT_RUN_TIMEOUT_MS);
|
|
1777
2437
|
const timeoutMs = Number.isFinite(rawTimeout) && rawTimeout > 0 ? rawTimeout : 600_000;
|
|
1778
2438
|
let timer;
|
|
@@ -2232,6 +2892,16 @@ export class SignupAgent {
|
|
|
2232
2892
|
service: task.service,
|
|
2233
2893
|
digit: String(matchNum),
|
|
2234
2894
|
windowSeconds: 120,
|
|
2895
|
+
machineToken: task.machineToken,
|
|
2896
|
+
apiBase: task.apiBase,
|
|
2897
|
+
});
|
|
2898
|
+
// rc.18 — opt-in Telegram fallback. Bypasses the email
|
|
2899
|
+
// path (which collapses to Sent only when GMAIL_USER ==
|
|
2900
|
+
// account.email). No-op without TELEGRAM_BOT_TOKEN env.
|
|
2901
|
+
void sendTelegramHeightenedAuth({
|
|
2902
|
+
service: task.service,
|
|
2903
|
+
digit: String(matchNum),
|
|
2904
|
+
windowSeconds: 120,
|
|
2235
2905
|
});
|
|
2236
2906
|
}
|
|
2237
2907
|
else {
|
|
@@ -2247,6 +2917,13 @@ export class SignupAgent {
|
|
|
2247
2917
|
service: task.service,
|
|
2248
2918
|
digit: null,
|
|
2249
2919
|
windowSeconds: 120,
|
|
2920
|
+
machineToken: task.machineToken,
|
|
2921
|
+
apiBase: task.apiBase,
|
|
2922
|
+
});
|
|
2923
|
+
void sendTelegramHeightenedAuth({
|
|
2924
|
+
service: task.service,
|
|
2925
|
+
digit: null,
|
|
2926
|
+
windowSeconds: 120,
|
|
2250
2927
|
});
|
|
2251
2928
|
}
|
|
2252
2929
|
// Either way (number found or not), the user can still
|
|
@@ -2404,6 +3081,190 @@ export class SignupAgent {
|
|
|
2404
3081
|
await this.browser.wait(2);
|
|
2405
3082
|
await saveDebugSnapshot(this.browser, "oauth-post-consent");
|
|
2406
3083
|
steps.push(`OAuth: signed in via ${provider.label} — driving post-OAuth onboarding to the API key`);
|
|
3084
|
+
// rc.20 — login-loop detection. Services like Groq complete the
|
|
3085
|
+
// Google OAuth handshake server-side but redirect back to a
|
|
3086
|
+
// login-looking page (/authenticate) where the user has to click
|
|
3087
|
+
// "Continue with Google" ONE MORE TIME to actually finalize the
|
|
3088
|
+
// session. The post-verify planner sees the same OAuth buttons it
|
|
3089
|
+
// saw on the original signup page, picks `click` on the provider
|
|
3090
|
+
// affordance, and the click triggers a popup-based re-OAuth that
|
|
3091
|
+
// the planner-driven post-verify loop doesn't follow — so each
|
|
3092
|
+
// iteration sees the same page text and the bot burns the round
|
|
3093
|
+
// budget.
|
|
3094
|
+
//
|
|
3095
|
+
// Detect: post-OAuth URL path matches a known login-shaped pattern
|
|
3096
|
+
// (/login, /signin, /authenticate, ...) AND the inventory still
|
|
3097
|
+
// carries an OAuth affordance for the SAME provider we just used.
|
|
3098
|
+
// Recovery: re-invoke runOAuthFlow ONCE with the new selector —
|
|
3099
|
+
// the second handshake completes the session and lands on the
|
|
3100
|
+
// dashboard. Bounded to one retry so a service that genuinely
|
|
3101
|
+
// never finalizes can't trap us in a loop.
|
|
3102
|
+
const postOAuthState = await this.browser.getState();
|
|
3103
|
+
const postOAuthInv = await this.buildInventory(steps, [provider.id]);
|
|
3104
|
+
const loopBtn = isLoginLoopState(postOAuthState.url, postOAuthInv, provider.id);
|
|
3105
|
+
if (loopBtn !== null) {
|
|
3106
|
+
steps.push(`Post-OAuth: landed on a login-like page (${pathOf(postOAuthState.url)}) ` +
|
|
3107
|
+
`with a ${provider.label} sign-in button still visible — service requires a ` +
|
|
3108
|
+
`second click to finalize the session. Re-triggering OAuth once.`);
|
|
3109
|
+
try {
|
|
3110
|
+
await this.browser.startOAuth(loopBtn.selector);
|
|
3111
|
+
await this.browser.wait(3);
|
|
3112
|
+
await saveDebugSnapshot(this.browser, "oauth-loop-retry");
|
|
3113
|
+
await this.browser.settleAfterOAuth();
|
|
3114
|
+
await this.browser.wait(2);
|
|
3115
|
+
steps.push(`Post-OAuth: re-OAuth completed (url=${pathOf(this.browser.currentUrl())}).`);
|
|
3116
|
+
}
|
|
3117
|
+
catch (err) {
|
|
3118
|
+
steps.push(`Post-OAuth: re-OAuth retry threw (${err instanceof Error ? err.message : String(err)}) — ` +
|
|
3119
|
+
`continuing to post-verify loop anyway.`);
|
|
3120
|
+
}
|
|
3121
|
+
// After the retry, if we're STILL on a login-like page with the
|
|
3122
|
+
// same provider button visible, the service has trapped us. Abort
|
|
3123
|
+
// with a specific error rather than re-running the loop.
|
|
3124
|
+
const retryState = await this.browser.getState();
|
|
3125
|
+
const retryInv = await this.browser.extractInteractiveElements();
|
|
3126
|
+
if (isLoginLoopState(retryState.url, retryInv, provider.id) !== null) {
|
|
3127
|
+
return {
|
|
3128
|
+
success: false,
|
|
3129
|
+
error: `oauth_loop_detected: signed in via ${provider.label} twice but ${task.service} ` +
|
|
3130
|
+
`keeps redirecting to a login page (${pathOf(retryState.url)}). The service may ` +
|
|
3131
|
+
`require manual completion of an onboarding step before its OAuth session ` +
|
|
3132
|
+
`finalizes — finish the signup manually.`,
|
|
3133
|
+
steps,
|
|
3134
|
+
...this.resultTail(),
|
|
3135
|
+
};
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
3138
|
+
// rc.24 — three additional post-OAuth gates surfaced from the
|
|
3139
|
+
// registry-snapshot review. Each is a state the bot definitively
|
|
3140
|
+
// cannot pass; emit a precise terminal error rather than burn the
|
|
3141
|
+
// post-verify budget.
|
|
3142
|
+
{
|
|
3143
|
+
const gateState = await this.browser.getState();
|
|
3144
|
+
const gateText = await this.browser.extractText().catch(() => "");
|
|
3145
|
+
const gateInv = postOAuthInv;
|
|
3146
|
+
// (a) Manual-login fallback (DigitalOcean, Hyperbolic). Service
|
|
3147
|
+
// dropped the OAuth session and rendered a /login form with
|
|
3148
|
+
// email + password inputs. Bot can't manually log in.
|
|
3149
|
+
if (detectManualLoginFallback(gateState.url, gateInv)) {
|
|
3150
|
+
return {
|
|
3151
|
+
success: false,
|
|
3152
|
+
error: `oauth_session_not_persisted: signed in via ${provider.label} but ${task.service} ` +
|
|
3153
|
+
`dropped the OAuth callback and rendered a manual /login form (${pathOf(gateState.url)}). ` +
|
|
3154
|
+
`Finish the signup manually — this typically indicates anti-bot rejection of the ` +
|
|
3155
|
+
`OAuth callback or a service-side session-storage issue.`,
|
|
3156
|
+
steps,
|
|
3157
|
+
...this.resultTail(),
|
|
3158
|
+
};
|
|
3159
|
+
}
|
|
3160
|
+
// (b) Email-OTP wall (Porter, Koyeb / WorkOS-backed). Code went
|
|
3161
|
+
// to the operator's gmail. rc.27 — instead of immediately
|
|
3162
|
+
// aborting, try reading the OTP from the operator's inbox via
|
|
3163
|
+
// POST /v1/inbox/poll-operator-otp. If a code arrives, push a
|
|
3164
|
+
// step trail hint and continue to the post-verify loop —
|
|
3165
|
+
// the planner sees the hint and fills the input.
|
|
3166
|
+
if (detectEmailOtpGate(gateState.url, gateState.title, gateText)) {
|
|
3167
|
+
const domain = fromDomainFromUrl(gateState.url);
|
|
3168
|
+
const machineToken = task.machineToken;
|
|
3169
|
+
let otpResult;
|
|
3170
|
+
if (machineToken === undefined ||
|
|
3171
|
+
machineToken.length === 0) {
|
|
3172
|
+
otpResult = { code: null, reason: "no_machine_token" };
|
|
3173
|
+
}
|
|
3174
|
+
else {
|
|
3175
|
+
steps.push(`Email-OTP gate detected (${pathOf(gateState.url)}) — polling operator gmail for the code` +
|
|
3176
|
+
(domain !== null ? ` (from_domain=${domain})` : ""));
|
|
3177
|
+
otpResult = await readOperatorOtp({
|
|
3178
|
+
machineToken,
|
|
3179
|
+
...(task.apiBase !== undefined ? { apiBase: task.apiBase } : {}),
|
|
3180
|
+
...(domain !== null ? { fromDomain: domain } : {}),
|
|
3181
|
+
maxWaitSeconds: 90,
|
|
3182
|
+
});
|
|
3183
|
+
}
|
|
3184
|
+
if (otpResult.code !== null) {
|
|
3185
|
+
// rc.27 — log the code's existence + length but never the
|
|
3186
|
+
// digits. The planner gets the digits via a system-prompt
|
|
3187
|
+
// hint passed through scopeHint-style on the next round.
|
|
3188
|
+
steps.push(`Email-OTP retrieved (${otpResult.code.length} digits, ending …${otpResult.code.slice(-2)}) — continuing into post-verify so the planner can fill the verification input.`);
|
|
3189
|
+
this.pendingOtpCode = otpResult.code;
|
|
3190
|
+
// Fall through to extract + postVerifyLoop normally.
|
|
3191
|
+
}
|
|
3192
|
+
else {
|
|
3193
|
+
return {
|
|
3194
|
+
success: false,
|
|
3195
|
+
error: `email_otp_required: ${task.service} sent a verification code but the bot ` +
|
|
3196
|
+
`couldn't fetch it from the operator's gmail (reason=${otpResult.reason}). ` +
|
|
3197
|
+
`Finish the signup manually by entering the OTP from the email.`,
|
|
3198
|
+
steps,
|
|
3199
|
+
...this.resultTail(),
|
|
3200
|
+
};
|
|
3201
|
+
}
|
|
3202
|
+
}
|
|
3203
|
+
// (c) SSO restriction (Fly.io). Org SSO blocks programmatic
|
|
3204
|
+
// token creation — the page explicitly says so.
|
|
3205
|
+
if (detectSsoRestriction(gateText)) {
|
|
3206
|
+
return {
|
|
3207
|
+
success: false,
|
|
3208
|
+
error: `sso_restricted: ${task.service} requires SSO/SAML for token creation. The bot ` +
|
|
3209
|
+
`cannot complete an SSO handshake. Finish via your organization's SSO portal.`,
|
|
3210
|
+
steps,
|
|
3211
|
+
...this.resultTail(),
|
|
3212
|
+
};
|
|
3213
|
+
}
|
|
3214
|
+
// (d) Stuck on Google OAuth screens (Upstash class). Bot
|
|
3215
|
+
// signed in via Google but the OAuth flow didn't redirect off
|
|
3216
|
+
// accounts.google.com — usually a Clerk-mediated chooser the
|
|
3217
|
+
// post-verify planner can't navigate.
|
|
3218
|
+
//
|
|
3219
|
+
// rc.27 → rc.28 — instead of aborting immediately, try to
|
|
3220
|
+
// click an account card on the chooser. Google's chooser
|
|
3221
|
+
// renders each account as `<div data-identifier="email@…"
|
|
3222
|
+
// role="link" jsaction="…">` (or similar accountchooser shape).
|
|
3223
|
+
// Picking the first visible card forwards the flow off the
|
|
3224
|
+
// chooser. If the click doesn't move us off accounts.google.com
|
|
3225
|
+
// within a few seconds, abort with oauth_stuck_on_chooser.
|
|
3226
|
+
if (detectStuckOnGoogleOAuth(gateState.url)) {
|
|
3227
|
+
steps.push(`Post-OAuth: stuck on Google account chooser (${pathOf(gateState.url)}). ` +
|
|
3228
|
+
`Trying to click an account card.`);
|
|
3229
|
+
const clicked = await this.tryClickGoogleChooserCard();
|
|
3230
|
+
if (clicked) {
|
|
3231
|
+
await this.browser.wait(3);
|
|
3232
|
+
await saveDebugSnapshot(this.browser, "oauth-chooser-click");
|
|
3233
|
+
const afterUrl = this.browser.currentUrl();
|
|
3234
|
+
steps.push(`Post-OAuth: chooser card clicked — now at ${pathOf(afterUrl)} ` +
|
|
3235
|
+
`(host=${(() => { try {
|
|
3236
|
+
return new URL(afterUrl).hostname;
|
|
3237
|
+
}
|
|
3238
|
+
catch {
|
|
3239
|
+
return "?";
|
|
3240
|
+
} })()})`);
|
|
3241
|
+
// If the click moved us off accounts.google.com, fall
|
|
3242
|
+
// through to the post-verify loop normally.
|
|
3243
|
+
if (!detectStuckOnGoogleOAuth(afterUrl)) {
|
|
3244
|
+
// continue to extract + postVerifyLoop
|
|
3245
|
+
}
|
|
3246
|
+
else {
|
|
3247
|
+
return {
|
|
3248
|
+
success: false,
|
|
3249
|
+
error: `oauth_stuck_on_chooser: clicked an account card on the chooser but the URL ` +
|
|
3250
|
+
`stayed on accounts.google.com (${pathOf(afterUrl)}). Finish the signup manually.`,
|
|
3251
|
+
steps,
|
|
3252
|
+
...this.resultTail(),
|
|
3253
|
+
};
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
3256
|
+
else {
|
|
3257
|
+
return {
|
|
3258
|
+
success: false,
|
|
3259
|
+
error: `oauth_stuck_on_chooser: ${task.service}'s Google OAuth flow did not redirect off ` +
|
|
3260
|
+
`accounts.google.com (${pathOf(gateState.url)}) and no clickable account card was ` +
|
|
3261
|
+
`found on the chooser. Finish the signup manually.`,
|
|
3262
|
+
steps,
|
|
3263
|
+
...this.resultTail(),
|
|
3264
|
+
};
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
3267
|
+
}
|
|
2407
3268
|
let credentials = await this.extractCredentials();
|
|
2408
3269
|
if (credentials.api_key === undefined) {
|
|
2409
3270
|
credentials = await this.postVerifyLoop({
|
|
@@ -2423,8 +3284,15 @@ export class SignupAgent {
|
|
|
2423
3284
|
}
|
|
2424
3285
|
// No API key. Distinguish a billing/card wall (onboarding_blocked)
|
|
2425
3286
|
// from a generic navigation miss — never grep-loop a paid wall.
|
|
3287
|
+
// rc.39 — fold the planner's `done` reason into the text we
|
|
3288
|
+
// grep. Some services (Koyeb) gate API issuance on a billing
|
|
3289
|
+
// confirmation whose visible text doesn't match the regex set
|
|
3290
|
+
// but whose planner reason clearly describes the wall.
|
|
2426
3291
|
const finalText = await this.browser.extractText().catch(() => "");
|
|
2427
|
-
|
|
3292
|
+
const paywallCheckText = this.lastPostVerifyDoneReason !== null
|
|
3293
|
+
? `${finalText}\n${this.lastPostVerifyDoneReason}`
|
|
3294
|
+
: finalText;
|
|
3295
|
+
if (isAtPaywall(paywallCheckText)) {
|
|
2428
3296
|
return {
|
|
2429
3297
|
success: false,
|
|
2430
3298
|
error: `onboarding_blocked: ${task.service}'s API key sits behind a billing or ` +
|
|
@@ -2433,6 +3301,24 @@ export class SignupAgent {
|
|
|
2433
3301
|
...this.resultTail(),
|
|
2434
3302
|
};
|
|
2435
3303
|
}
|
|
3304
|
+
// rc.39 — anti-bot interstitial that survived the post-OAuth
|
|
3305
|
+
// landing. Turso's GitHub SSO callback runs a Cloudflare check
|
|
3306
|
+
// that never clears for our Chromium fingerprint; the planner's
|
|
3307
|
+
// done reason / wait reasons name the vendor explicitly. Classify
|
|
3308
|
+
// as anti_bot_blocked so the operator sees an accurate status
|
|
3309
|
+
// (and the harvester routes it the same way as the form-fill-
|
|
3310
|
+
// phase anti-bot detector does).
|
|
3311
|
+
const ANTI_BOT_REASON = /\b(?:cloudflare\b.*?(?:verification|challenge|check)|just\s+a\s+moment|verifying\s+you\s+are\s+human|0\s+interactive\s+elements)/i;
|
|
3312
|
+
if (this.lastPostVerifyDoneReason !== null &&
|
|
3313
|
+
ANTI_BOT_REASON.test(this.lastPostVerifyDoneReason)) {
|
|
3314
|
+
return {
|
|
3315
|
+
success: false,
|
|
3316
|
+
error: `anti_bot_blocked: ${task.service}'s post-OAuth landing is gated by an anti-bot ` +
|
|
3317
|
+
`interstitial (Cloudflare or similar) the bot cannot clear — finish the signup manually.`,
|
|
3318
|
+
steps,
|
|
3319
|
+
...this.resultTail(),
|
|
3320
|
+
};
|
|
3321
|
+
}
|
|
2436
3322
|
return {
|
|
2437
3323
|
success: false,
|
|
2438
3324
|
error: `oauth_onboarding_failed: signed in to ${task.service} via ${provider.label} but ` +
|
|
@@ -2595,6 +3481,42 @@ ${formatInventory(input.inventory)}`,
|
|
|
2595
3481
|
// Drive the browser toward the API key after the account exists —
|
|
2596
3482
|
// used by BOTH the email-verification path and the OAuth path (T9).
|
|
2597
3483
|
// Each round asks Claude what to do next given the current page; we
|
|
3484
|
+
// Heightened-auth detector for the POST-VERIFY path. runOAuthFlow
|
|
3485
|
+
// catches Google's device-verification challenge during the OAuth
|
|
3486
|
+
// handshake itself; this catches the case where the planner
|
|
3487
|
+
// bumped into the same challenge mid-post-verify (Algolia, etc.).
|
|
3488
|
+
// Patterns match the planner's natural-language done-reason; the
|
|
3489
|
+
// number-match is extracted with the same shape the bot's
|
|
3490
|
+
// existing extractGoogleNumberMatch expects ("tap 71", "number 42").
|
|
3491
|
+
// Fire-and-forget; idempotent per-signup via heightenedAuthFired.
|
|
3492
|
+
maybeFirePostVerifyHeightenedAuth(reason, service, steps) {
|
|
3493
|
+
if (this.heightenedAuthFired)
|
|
3494
|
+
return false;
|
|
3495
|
+
const CHALLENGE_PATTERNS = /\b(?:device verification|security challenge|2[- ]?step|2fa|number(?: match)?(?: on (?:your |the )?(?:phone|screen|device))?|tap \d+|tap the number|confirm.{0,15}sign[- ]?in|verify it'?s you)\b/i;
|
|
3496
|
+
if (!CHALLENGE_PATTERNS.test(reason))
|
|
3497
|
+
return false;
|
|
3498
|
+
const digitMatch = reason.match(/\btap (\d{1,3})\b|\bnumber (\d{1,3})\b/i);
|
|
3499
|
+
const digit = digitMatch !== null ? (digitMatch[1] ?? digitMatch[2] ?? null) : null;
|
|
3500
|
+
this.heightenedAuthFired = true;
|
|
3501
|
+
const msg = digit !== null
|
|
3502
|
+
? `Google challenge detected mid-post-verify: tap ${digit} on your phone — 2 minute window`
|
|
3503
|
+
: `Google challenge detected mid-post-verify (number extractor missed it — read the planner reason): ${reason.slice(0, 200)}`;
|
|
3504
|
+
console.error(`[universal-bot] ${msg}`);
|
|
3505
|
+
steps.push(`Post-verify: ${msg}`);
|
|
3506
|
+
void notifyHeightenedAuth({
|
|
3507
|
+
service,
|
|
3508
|
+
digit,
|
|
3509
|
+
windowSeconds: 120,
|
|
3510
|
+
machineToken: this.currentMachineToken,
|
|
3511
|
+
apiBase: this.currentApiBase,
|
|
3512
|
+
});
|
|
3513
|
+
void sendTelegramHeightenedAuth({
|
|
3514
|
+
service,
|
|
3515
|
+
digit,
|
|
3516
|
+
windowSeconds: 120,
|
|
3517
|
+
});
|
|
3518
|
+
return true;
|
|
3519
|
+
}
|
|
2598
3520
|
// stop when Claude says "done" or when we extract a credential.
|
|
2599
3521
|
// Bounded by maxRounds so a confused agent can't burn the context.
|
|
2600
3522
|
//
|
|
@@ -2603,6 +3525,76 @@ ${formatInventory(input.inventory)}`,
|
|
|
2603
3525
|
// with the just-created account (SendPulse). On the OAuth path it is
|
|
2604
3526
|
// absent: there is no password, and the Google session already
|
|
2605
3527
|
// authenticated the user — a `login` step is then a no-op.
|
|
3528
|
+
// Tier 4 — DOM-proximity labeled extraction. Walks the page's
|
|
3529
|
+
// visible DOM via the BrowserController helper, pairs each
|
|
3530
|
+
// credential-shape string with the nearest credential-label text,
|
|
3531
|
+
// returns the canonical-key → value map. Used as a fallback after
|
|
3532
|
+
// the Phase E planner-quoted path when the planner mentioned only
|
|
3533
|
+
// one of several visible credentials.
|
|
3534
|
+
async extractFromDomProximity() {
|
|
3535
|
+
// Vocabulary matches the LABEL_ALIASES used by Phase E so the
|
|
3536
|
+
// canonical keys stay consistent across paths.
|
|
3537
|
+
const LABEL_TO_KEY = {
|
|
3538
|
+
"api key": "api_key",
|
|
3539
|
+
"api token": "api_key",
|
|
3540
|
+
"api secret": "api_secret",
|
|
3541
|
+
"secret key": "secret_key",
|
|
3542
|
+
"publishable key": "publishable_key",
|
|
3543
|
+
"access key": "access_key_id",
|
|
3544
|
+
"access key id": "access_key_id",
|
|
3545
|
+
"access token": "access_token",
|
|
3546
|
+
"bearer token": "access_token",
|
|
3547
|
+
"personal access token": "access_token",
|
|
3548
|
+
"auth token": "auth_token",
|
|
3549
|
+
"client id": "client_id",
|
|
3550
|
+
"client secret": "client_secret",
|
|
3551
|
+
"client key": "client_id",
|
|
3552
|
+
"cloud name": "cloud_name",
|
|
3553
|
+
"cloudname": "cloud_name",
|
|
3554
|
+
"application id": "application_id",
|
|
3555
|
+
"app id": "application_id",
|
|
3556
|
+
"admin api key": "admin_api_key",
|
|
3557
|
+
"search api key": "search_api_key",
|
|
3558
|
+
"search-only api key": "search_api_key",
|
|
3559
|
+
"monitoring api key": "monitoring_api_key",
|
|
3560
|
+
"account sid": "account_sid",
|
|
3561
|
+
"secret access key": "secret_access_key",
|
|
3562
|
+
"consumer key": "consumer_key",
|
|
3563
|
+
"consumer secret": "consumer_secret",
|
|
3564
|
+
"access token secret": "access_token_secret",
|
|
3565
|
+
"project api key": "project_api_key",
|
|
3566
|
+
"personal api key": "personal_api_key",
|
|
3567
|
+
"organization id": "org_id",
|
|
3568
|
+
"org id": "org_id",
|
|
3569
|
+
"app key": "app_key",
|
|
3570
|
+
"app secret": "app_secret",
|
|
3571
|
+
};
|
|
3572
|
+
let labeled = [];
|
|
3573
|
+
try {
|
|
3574
|
+
labeled = await this.browser.extractLabeledCredentialCandidates();
|
|
3575
|
+
}
|
|
3576
|
+
catch {
|
|
3577
|
+
return {};
|
|
3578
|
+
}
|
|
3579
|
+
const out = {};
|
|
3580
|
+
for (const c of labeled) {
|
|
3581
|
+
// Skip masked values — even if we have a label, the on-DOM
|
|
3582
|
+
// string is bullets, not the real credential. The reveal pass
|
|
3583
|
+
// ran before this so anything still masked is genuinely
|
|
3584
|
+
// unreachable.
|
|
3585
|
+
if (c.isMasked)
|
|
3586
|
+
continue;
|
|
3587
|
+
if (c.label === null)
|
|
3588
|
+
continue;
|
|
3589
|
+
const canonical = LABEL_TO_KEY[c.label];
|
|
3590
|
+
if (canonical === undefined)
|
|
3591
|
+
continue;
|
|
3592
|
+
if (out[canonical] !== undefined)
|
|
3593
|
+
continue; // first-wins
|
|
3594
|
+
out[canonical] = c.value;
|
|
3595
|
+
}
|
|
3596
|
+
return out;
|
|
3597
|
+
}
|
|
2606
3598
|
async postVerifyLoop(args) {
|
|
2607
3599
|
let credentials = await this.extractCredentials();
|
|
2608
3600
|
let loginAttempts = 0;
|
|
@@ -2614,6 +3606,20 @@ ${formatInventory(input.inventory)}`,
|
|
|
2614
3606
|
// string and keeps asking to extract it forever), or when the
|
|
2615
3607
|
// planner's last step was rejected.
|
|
2616
3608
|
let hint;
|
|
3609
|
+
// rc.27 — when the email_otp gate handler retrieved a code from
|
|
3610
|
+
// the operator's gmail, seed the FIRST round's hint with the
|
|
3611
|
+
// code + explicit fill+submit instructions. Cleared after one
|
|
3612
|
+
// round so it doesn't echo into unrelated downstream rounds.
|
|
3613
|
+
if (this.pendingOtpCode !== null) {
|
|
3614
|
+
hint =
|
|
3615
|
+
`Operator email-OTP retrieved from gmail: code is "${this.pendingOtpCode}". ` +
|
|
3616
|
+
`The current page is an email-verification gate. Find the SINGLE visible OTP/code input ` +
|
|
3617
|
+
`on this page (it usually has placeholder text like "Code", "Verification code", or a ` +
|
|
3618
|
+
`numeric placeholder; some sites render 6 individual digit inputs — in that case fill the ` +
|
|
3619
|
+
`FIRST one and the browser auto-distributes). Issue {"kind":"fill", "selector":"…", ` +
|
|
3620
|
+
`"value":"${this.pendingOtpCode}"} on it. NEXT round, click the Verify/Continue/Submit button.`;
|
|
3621
|
+
this.pendingOtpCode = null;
|
|
3622
|
+
}
|
|
2617
3623
|
// Failed-extract counter. The stuck-loop detector below exempts
|
|
2618
3624
|
// `extract` on the theory that "extract is its own progress signal"
|
|
2619
3625
|
// — true when extract succeeds, FALSE when it returns no key. A
|
|
@@ -2634,6 +3640,24 @@ ${formatInventory(input.inventory)}`,
|
|
|
2634
3640
|
// and inject a forced "no-progress" hint on the second repeat.
|
|
2635
3641
|
let prevSignature = null;
|
|
2636
3642
|
let prevInventorySize = -1;
|
|
3643
|
+
// rc.39 — wait-loop tracker. Turso's GitHub OAuth handshake
|
|
3644
|
+
// succeeds, then the SSO-callback page stays empty (0 elements)
|
|
3645
|
+
// while a Cloudflare verification widget runs that never clears
|
|
3646
|
+
// for this Chromium fingerprint. The planner kept emitting wait
|
|
3647
|
+
// for all 12 rounds; the run timed out as oauth_onboarding_failed.
|
|
3648
|
+
// Better classification: anti-bot block. Track consecutive wait
|
|
3649
|
+
// rounds with no inventory change and break out with the proper
|
|
3650
|
+
// status before burning the post-verify budget.
|
|
3651
|
+
let consecutiveWaits = 0;
|
|
3652
|
+
// rc.39 — navigate-loop tracker. Perplexity / Koyeb / Porter all
|
|
3653
|
+
// had post-verify loops where the planner emitted `navigate`
|
|
3654
|
+
// 5-6 rounds in a row and the URL never changed — the service
|
|
3655
|
+
// silently redirected each attempt back to the same onboarding
|
|
3656
|
+
// page. Track the URL state observed BEFORE each navigate; if
|
|
3657
|
+
// two consecutive navigates fire from the same URL, the previous
|
|
3658
|
+
// navigate produced no progress. Inject a hint forcing a CLICK
|
|
3659
|
+
// on something visible in the current inventory.
|
|
3660
|
+
let prevNavigateFromUrl = null;
|
|
2637
3661
|
for (let round = 0; round < args.maxRounds; round++) {
|
|
2638
3662
|
if (credentials.api_key !== undefined || credentials.username !== undefined) {
|
|
2639
3663
|
args.steps.push(`Post-verify: credentials found on round ${round}.`);
|
|
@@ -2699,7 +3723,13 @@ ${formatInventory(input.inventory)}`,
|
|
|
2699
3723
|
"and never the whole line.";
|
|
2700
3724
|
continue;
|
|
2701
3725
|
}
|
|
2702
|
-
|
|
3726
|
+
// rc.22 — redact tokens before pushing to the step trail.
|
|
3727
|
+
// The planner's reason field sometimes quotes the actual API
|
|
3728
|
+
// value it just observed ("The full API token 'sbp_xxx' is
|
|
3729
|
+
// visible…"); the harvester then posts step trails to a public
|
|
3730
|
+
// GitHub issue, leaking the credential. Redactor patterns mirror
|
|
3731
|
+
// tools/archived-harvester/redact.mjs — defense in depth.
|
|
3732
|
+
args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: ${nextStep.kind} — ${redactCredentials(nextStep.reason)}`);
|
|
2703
3733
|
// Dump this round's real page state + inventory in the E1
|
|
2704
3734
|
// eval-corpus format so onboarding adapters can be iterated
|
|
2705
3735
|
// offline without re-running the rate-limited OAuth handshake.
|
|
@@ -2742,6 +3772,44 @@ ${formatInventory(input.inventory)}`,
|
|
|
2742
3772
|
}
|
|
2743
3773
|
})();
|
|
2744
3774
|
}
|
|
3775
|
+
// rc.39 — navigate-loop detector. Perplexity/Koyeb/Porter spun
|
|
3776
|
+
// 5+ rounds of `navigate` because each navigate landed back at
|
|
3777
|
+
// the same URL — the service redirected past the requested URL.
|
|
3778
|
+
// If THIS plan is also navigate and the URL we're observing now
|
|
3779
|
+
// is the same one we navigated FROM last round, the previous
|
|
3780
|
+
// navigate produced no progress. Inject a hint forcing a click.
|
|
3781
|
+
if (nextStep.kind === "navigate" && prevNavigateFromUrl === state.url) {
|
|
3782
|
+
const candidateClicks = inventory
|
|
3783
|
+
.filter((e) => (e.tag === "button" ||
|
|
3784
|
+
e.tag === "a" ||
|
|
3785
|
+
e.role === "button" ||
|
|
3786
|
+
e.role === "link") &&
|
|
3787
|
+
e.interactedThisRun !== true)
|
|
3788
|
+
.slice(0, 8)
|
|
3789
|
+
.map((e) => {
|
|
3790
|
+
const label = e.visibleText ?? e.ariaLabel ?? e.labelText ?? "(no label)";
|
|
3791
|
+
return ` - ${JSON.stringify(label)} → selector=${e.selector}`;
|
|
3792
|
+
});
|
|
3793
|
+
args.steps.push(`Post-verify: navigate did not advance the page (URL still ${state.url}) — forcing a click on an inventory element.`);
|
|
3794
|
+
hint =
|
|
3795
|
+
`Your last 'navigate' to a guessed URL did NOT advance the page — the service ` +
|
|
3796
|
+
`redirected you back to ${state.url}. STOP navigating and CLICK an element ` +
|
|
3797
|
+
`from the current inventory below. The page is gating you behind an onboarding ` +
|
|
3798
|
+
`CTA (e.g. "Get started", "Continue", "Activate") or a setup step that must be ` +
|
|
3799
|
+
`clicked before the API console becomes reachable.` +
|
|
3800
|
+
(candidateClicks.length > 0
|
|
3801
|
+
? `\n\nClickable elements you haven't tried:\n${candidateClicks.join("\n")}`
|
|
3802
|
+
: "");
|
|
3803
|
+
// Don't execute this navigate — re-plan with the hint.
|
|
3804
|
+
prevNavigateFromUrl = null;
|
|
3805
|
+
continue;
|
|
3806
|
+
}
|
|
3807
|
+
if (nextStep.kind === "navigate") {
|
|
3808
|
+
prevNavigateFromUrl = state.url;
|
|
3809
|
+
}
|
|
3810
|
+
else {
|
|
3811
|
+
prevNavigateFromUrl = null;
|
|
3812
|
+
}
|
|
2745
3813
|
// Stuck-loop detector. Re-planning steps (done/extract/login/
|
|
2746
3814
|
// wait/navigate) are exempt: extract is its own progress signal,
|
|
2747
3815
|
// navigate intentionally changes the URL not the current DOM,
|
|
@@ -2815,6 +3883,47 @@ ${formatInventory(input.inventory)}`,
|
|
|
2815
3883
|
const defaultedSelectHint = defaultedSelects.length > 0
|
|
2816
3884
|
? `\n\nVisible DEFAULTED dropdowns on this page (value="" — React form-state likely treats these as UNTOUCHED, which silently fails submit):\n${defaultedSelects.join("\n")}\n\nIssue {"kind":"select", "option_text":"…"} to commit a choice. Even if the default visible label ("No workspace", "None") is what you want, you MUST emit the select step to register it with the form's state.`
|
|
2817
3885
|
: "";
|
|
3886
|
+
// rc.20 — also enumerate custom combobox triggers (Cohere's
|
|
3887
|
+
// role picker, Fireworks's survey, similar). These render as
|
|
3888
|
+
// <button role="combobox"> or as <button> with placeholder-
|
|
3889
|
+
// shaped text ("Select your role", "Choose a country") and
|
|
3890
|
+
// gate a submit-disabled state that the planner can't see.
|
|
3891
|
+
// Surface them so the planner emits `select` instead of
|
|
3892
|
+
// re-clicking the dead submit. Tightly scoped: must be a
|
|
3893
|
+
// button-shaped element with role=combobox OR placeholder-
|
|
3894
|
+
// shaped visible text, and NOT touched this run.
|
|
3895
|
+
const SELECT_PROMPT_TEXT = /^(?:select|choose|pick)\b|^select an?\b|\bselect\.{3}|\bchoose\.{3}/i;
|
|
3896
|
+
const customComboboxes = inventory
|
|
3897
|
+
.filter((e) => (e.role === "combobox" ||
|
|
3898
|
+
(e.tag === "button" &&
|
|
3899
|
+
SELECT_PROMPT_TEXT.test((e.visibleText ?? "").trim()))) &&
|
|
3900
|
+
e.interactedThisRun !== true)
|
|
3901
|
+
.slice(0, 5)
|
|
3902
|
+
.map((e) => {
|
|
3903
|
+
const label = e.labelText ?? e.ariaLabel ?? e.name ?? e.visibleText ?? "(no label)";
|
|
3904
|
+
return ` - ${JSON.stringify(label)} → selector=${e.selector}`;
|
|
3905
|
+
});
|
|
3906
|
+
const customComboboxHint = customComboboxes.length > 0
|
|
3907
|
+
? `\n\nVisible custom comboboxes / placeholder-state pickers that you haven't touched yet:\n${customComboboxes.join("\n")}\n\nIssue {"kind":"select", "option_text":"…"} with a sensible option_text — these are commonly the gate on a submit-disabled state.`
|
|
3908
|
+
: "";
|
|
3909
|
+
// rc.20 — and enumerate unchecked agreement checkboxes.
|
|
3910
|
+
// Mistral's TOS, GitHub-app sign-up, many onboarding forms
|
|
3911
|
+
// gate submit on a checkbox that isn't yet ticked.
|
|
3912
|
+
const uncheckedBoxes = inventory
|
|
3913
|
+
.filter((e) => e.tag === "input" &&
|
|
3914
|
+
e.type === "checkbox" &&
|
|
3915
|
+
// We can't read the actual `checked` from the inventory
|
|
3916
|
+
// shape, but interactedThisRun is set after a successful
|
|
3917
|
+
// `check` step. Show checkboxes the bot hasn't touched.
|
|
3918
|
+
e.interactedThisRun !== true)
|
|
3919
|
+
.slice(0, 5)
|
|
3920
|
+
.map((e) => {
|
|
3921
|
+
const label = e.labelText ?? e.ariaLabel ?? e.name ?? e.placeholder ?? "(no label)";
|
|
3922
|
+
return ` - ${JSON.stringify(label)} → selector=${e.selector}`;
|
|
3923
|
+
});
|
|
3924
|
+
const uncheckedBoxHint = uncheckedBoxes.length > 0
|
|
3925
|
+
? `\n\nVisible checkboxes you haven't ticked yet (often a TOS / agreement gate):\n${uncheckedBoxes.join("\n")}\n\nIssue {"kind":"check"} on any that look like agreements / required confirmations.`
|
|
3926
|
+
: "";
|
|
2818
3927
|
args.steps.push(sameSelector
|
|
2819
3928
|
? `Post-verify: no-progress detected — same ${nextStep.kind} on same selector, inventory unchanged. Re-planning instead of re-running.`
|
|
2820
3929
|
: `Post-verify: no-progress detected — successive click steps with no inventory change. Forcing a non-click action.`);
|
|
@@ -2827,7 +3936,9 @@ ${formatInventory(input.inventory)}`,
|
|
|
2827
3936
|
`any unticked checkbox, {"kind":"select"} on any unselected dropdown, or ` +
|
|
2828
3937
|
`{"kind":"done"} if there is genuinely nothing to do.` +
|
|
2829
3938
|
emptyInputHint +
|
|
2830
|
-
defaultedSelectHint
|
|
3939
|
+
defaultedSelectHint +
|
|
3940
|
+
customComboboxHint +
|
|
3941
|
+
uncheckedBoxHint;
|
|
2831
3942
|
prevSignature = signature;
|
|
2832
3943
|
prevInventorySize = inventory.length;
|
|
2833
3944
|
continue;
|
|
@@ -2842,8 +3953,43 @@ ${formatInventory(input.inventory)}`,
|
|
|
2842
3953
|
prevSignature = null;
|
|
2843
3954
|
prevInventorySize = inventory.length;
|
|
2844
3955
|
}
|
|
2845
|
-
if (nextStep.kind === "done")
|
|
3956
|
+
if (nextStep.kind === "done") {
|
|
3957
|
+
// When the planner bails because it encountered Google's
|
|
3958
|
+
// device-verification challenge mid-post-verify (Algolia +
|
|
3959
|
+
// similar redirect their `signup_url` to a sign-in page,
|
|
3960
|
+
// OAuth handoff happens AFTER runOAuthFlow already exited),
|
|
3961
|
+
// fire the heightened-auth notifier AND wait the 2-minute
|
|
3962
|
+
// window for the operator to tap their phone. After the
|
|
3963
|
+
// wait, re-read the page state and continue post-verify —
|
|
3964
|
+
// Google's challenge typically clears within seconds of the
|
|
3965
|
+
// tap and the dashboard becomes accessible.
|
|
3966
|
+
if (this.maybeFirePostVerifyHeightenedAuth(nextStep.reason, args.service, args.steps)) {
|
|
3967
|
+
args.steps.push("Post-verify: waiting 120s for the operator to clear Google's device challenge");
|
|
3968
|
+
await this.browser.wait(120);
|
|
3969
|
+
// Re-loop — next iteration's waitForFormReady + inventory
|
|
3970
|
+
// read see the post-challenge dashboard.
|
|
3971
|
+
continue;
|
|
3972
|
+
}
|
|
3973
|
+
this.lastPostVerifyDoneReason = nextStep.reason;
|
|
2846
3974
|
break;
|
|
3975
|
+
}
|
|
3976
|
+
// rc.39 — wait-loop break. The planner is asking us to wait
|
|
3977
|
+
// round after round on an empty page (Turso's Cloudflare SSO
|
|
3978
|
+
// callback). Cap at three consecutive waits with zero inventory
|
|
3979
|
+
// and surface the empty-page reason so the caller can classify.
|
|
3980
|
+
if (nextStep.kind === "wait" && inventory.length === 0) {
|
|
3981
|
+
consecutiveWaits += 1;
|
|
3982
|
+
if (consecutiveWaits >= 3) {
|
|
3983
|
+
this.lastPostVerifyDoneReason =
|
|
3984
|
+
`post-OAuth landing rendered 0 interactive elements for ${consecutiveWaits} rounds — ` +
|
|
3985
|
+
`most recent planner reason: ${nextStep.reason}`;
|
|
3986
|
+
args.steps.push(`Post-verify: wait-loop on an empty page (${consecutiveWaits} consecutive rounds, 0 elements) — breaking out.`);
|
|
3987
|
+
break;
|
|
3988
|
+
}
|
|
3989
|
+
}
|
|
3990
|
+
else {
|
|
3991
|
+
consecutiveWaits = 0;
|
|
3992
|
+
}
|
|
2847
3993
|
hint = undefined;
|
|
2848
3994
|
try {
|
|
2849
3995
|
if (nextStep.kind === "extract") {
|
|
@@ -2855,10 +4001,82 @@ ${formatInventory(input.inventory)}`,
|
|
|
2855
4001
|
// quotes the value. Accept it IF it's also present
|
|
2856
4002
|
// verbatim in the visible page text — that's the
|
|
2857
4003
|
// anti-hallucination guardrail.
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
4004
|
+
// rc.38 — verify the planner-quoted value against both
|
|
4005
|
+
// visible text AND every input's `value` attribute. The
|
|
4006
|
+
// rc.37 Upstash retest showed the bot quoting a bare UUID
|
|
4007
|
+
// it observed in a create-key modal whose UUID lived in
|
|
4008
|
+
// an <input readonly value="…"> — textContent doesn't
|
|
4009
|
+
// include input values, so the verbatim-in-page check
|
|
4010
|
+
// rejected a real credential. Concatenating input values
|
|
4011
|
+
// closes the gap without weakening the anti-hallucination
|
|
4012
|
+
// guarantee (the candidate still has to appear SOMEWHERE
|
|
4013
|
+
// verifiable on the page).
|
|
4014
|
+
const [pageText, inputValues] = await Promise.all([
|
|
4015
|
+
this.browser.extractText().catch(() => ""),
|
|
4016
|
+
this.browser.extractAllInputValues().catch(() => []),
|
|
4017
|
+
]);
|
|
4018
|
+
const verifySource = pageText + "\n" + inputValues.join("\n");
|
|
4019
|
+
// Phase E — multi-cred-aware extraction. Try the labeled
|
|
4020
|
+
// multi-credential parser FIRST. If the planner labeled
|
|
4021
|
+
// 2+ distinct credentials in its reason, fold them all
|
|
4022
|
+
// into the credentials Record. If the parser found at
|
|
4023
|
+
// least one new value (cloud_name, api_secret, etc. —
|
|
4024
|
+
// anything beyond the single api_key the legacy path
|
|
4025
|
+
// captures), prefer this. Falls through to the single-
|
|
4026
|
+
// value extractQuotedTokenFromReason when no labeled
|
|
4027
|
+
// tokens parsed (single-cred services, ad-hoc planner
|
|
4028
|
+
// prose without explicit labels).
|
|
4029
|
+
const labeled = extractAllLabeledTokensFromReason(nextStep.reason, verifySource);
|
|
4030
|
+
const labeledKeys = Object.keys(labeled);
|
|
4031
|
+
if (labeledKeys.length >= 2 || (labeledKeys.length === 1 && labeled["api_key"] === undefined)) {
|
|
4032
|
+
credentials = { ...credentials, ...labeled };
|
|
4033
|
+
const summary = labeledKeys
|
|
4034
|
+
.map((k) => `${k}=${labeled[k].slice(0, 4)}…${labeled[k].slice(-4)}`)
|
|
4035
|
+
.join(", ");
|
|
4036
|
+
args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: extracted ${labeledKeys.length} labeled credential(s) ` +
|
|
4037
|
+
`via Phase E parser (${summary})`);
|
|
4038
|
+
// When the planner's reason explicitly flags a masked
|
|
4039
|
+
// credential ("api_secret is masked", "hidden behind
|
|
4040
|
+
// asterisks", "click Reveal to show"), Phase E only
|
|
4041
|
+
// captured the visible values — try to reveal + extract
|
|
4042
|
+
// the rest on the same round before continuing. Without
|
|
4043
|
+
// this, the loop returns success with a partial bundle
|
|
4044
|
+
// and never tries the reveal click.
|
|
4045
|
+
const MASKED_HINT = /\b(?:masked|hidden|bullets?|asterisks?|••+|\*{3,}|reveal|unmask)\b/i;
|
|
4046
|
+
if (MASKED_HINT.test(nextStep.reason)) {
|
|
4047
|
+
try {
|
|
4048
|
+
const revealRes = await this.browser.revealMaskedCredentials();
|
|
4049
|
+
args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: reveal pass clicked=${revealRes.clicked} diagnostic=[${revealRes.diagnostic.join("; ")}]`);
|
|
4050
|
+
if (revealRes.clicked > 0) {
|
|
4051
|
+
const labeledAfter = await this.extractFromDomProximity();
|
|
4052
|
+
const newKeys = Object.keys(labeledAfter).filter((k) => credentials[k] === undefined);
|
|
4053
|
+
if (newKeys.length > 0) {
|
|
4054
|
+
for (const k of newKeys)
|
|
4055
|
+
credentials[k] = labeledAfter[k];
|
|
4056
|
+
args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: post-reveal DOM-proximity extracted ${newKeys.length} more (${newKeys.join(", ")})`);
|
|
4057
|
+
}
|
|
4058
|
+
else {
|
|
4059
|
+
// Surface ALL labeled candidates we found, so
|
|
4060
|
+
// we can see whether the value is on-page but
|
|
4061
|
+
// mislabeled vs. genuinely not surfaced.
|
|
4062
|
+
const allLabeled = await this.browser.extractLabeledCredentialCandidates();
|
|
4063
|
+
const summary = allLabeled
|
|
4064
|
+
.filter((c) => !c.isMasked)
|
|
4065
|
+
.slice(0, 8)
|
|
4066
|
+
.map((c) => `${c.value.slice(0, 6)}…(${c.value.length}ch)/${c.label ?? "no-label"}`)
|
|
4067
|
+
.join(", ");
|
|
4068
|
+
args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: post-reveal had ${allLabeled.length} candidates; visible: ${summary}`);
|
|
4069
|
+
}
|
|
4070
|
+
}
|
|
4071
|
+
}
|
|
4072
|
+
catch (err) {
|
|
4073
|
+
args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: reveal pass error (${err instanceof Error ? err.message : String(err)})`);
|
|
4074
|
+
}
|
|
4075
|
+
}
|
|
4076
|
+
consecutiveFailedExtracts = 0;
|
|
4077
|
+
continue;
|
|
4078
|
+
}
|
|
4079
|
+
const quoted = extractQuotedTokenFromReason(nextStep.reason, verifySource);
|
|
2862
4080
|
if (quoted !== null) {
|
|
2863
4081
|
credentials = { ...credentials, api_key: quoted };
|
|
2864
4082
|
args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: extracted token via ` +
|
|
@@ -2866,6 +4084,38 @@ ${formatInventory(input.inventory)}`,
|
|
|
2866
4084
|
consecutiveFailedExtracts = 0;
|
|
2867
4085
|
continue;
|
|
2868
4086
|
}
|
|
4087
|
+
// Tier 4 — DOM-proximity labeled credential extraction.
|
|
4088
|
+
// Run BEFORE bailing the extract. Walks the visible DOM,
|
|
4089
|
+
// finds credential-shape strings, pairs each with its
|
|
4090
|
+
// nearest credential-label text by Euclidean center
|
|
4091
|
+
// distance. Catches multi-cred pages where the planner
|
|
4092
|
+
// mentioned ONE value but the DOM shows several (the
|
|
4093
|
+
// planner's narrative-style extract reason missed the
|
|
4094
|
+
// sibling labels). Also tries to unmask hidden secrets
|
|
4095
|
+
// first by clicking visible Reveal/Eye/Copy buttons.
|
|
4096
|
+
try {
|
|
4097
|
+
await this.browser.revealMaskedCredentials();
|
|
4098
|
+
}
|
|
4099
|
+
catch {
|
|
4100
|
+
// Best-effort; never block the extract pass on a
|
|
4101
|
+
// reveal-click failure.
|
|
4102
|
+
}
|
|
4103
|
+
const labeledFromDom = await this.extractFromDomProximity();
|
|
4104
|
+
const newKeys = Object.keys(labeledFromDom).filter((k) => credentials[k] === undefined);
|
|
4105
|
+
if (newKeys.length > 0) {
|
|
4106
|
+
for (const k of newKeys)
|
|
4107
|
+
credentials[k] = labeledFromDom[k];
|
|
4108
|
+
const summary = newKeys
|
|
4109
|
+
.map((k) => {
|
|
4110
|
+
const v = labeledFromDom[k];
|
|
4111
|
+
return `${k}=${v.slice(0, 4)}…${v.slice(-4)}`;
|
|
4112
|
+
})
|
|
4113
|
+
.join(", ");
|
|
4114
|
+
args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: extracted ${newKeys.length} labeled credential(s) ` +
|
|
4115
|
+
`via DOM-proximity fallback (${summary})`);
|
|
4116
|
+
consecutiveFailedExtracts = 0;
|
|
4117
|
+
continue;
|
|
4118
|
+
}
|
|
2869
4119
|
consecutiveFailedExtracts += 1;
|
|
2870
4120
|
// Best-effort diagnostic upload: when extract returns
|
|
2871
4121
|
// null despite the planner asserting a credential is
|
|
@@ -2936,7 +4186,31 @@ ${formatInventory(input.inventory)}`,
|
|
|
2936
4186
|
}
|
|
2937
4187
|
else if (nextStep.kind === "click") {
|
|
2938
4188
|
await this.browser.click(nextStep.selector);
|
|
2939
|
-
|
|
4189
|
+
// F7 / 0.6.15-rc.11 — Modal-based credential reveals
|
|
4190
|
+
// (OpenRouter, Anthropic, OpenAI Create-Key flows) render
|
|
4191
|
+
// the new key into a modal AFTER a server round-trip — the
|
|
4192
|
+
// prior blind 2s wait was racing the API response. The
|
|
4193
|
+
// implicit-extract that follows this branch then found
|
|
4194
|
+
// nothing, the round ended, and by the next round the modal
|
|
4195
|
+
// had auto-closed. Poll up to 8s for the key to appear in
|
|
4196
|
+
// the post-action DOM. Early-exit as soon as
|
|
4197
|
+
// extractCredentials returns one — typical happy path on
|
|
4198
|
+
// services without modal-delay returns in <1s. Saves both
|
|
4199
|
+
// time (no overshoot wait) and correctness (catches the
|
|
4200
|
+
// modal-render race).
|
|
4201
|
+
const credentialDeadline = Date.now() + 8000;
|
|
4202
|
+
let pollExtract = {};
|
|
4203
|
+
while (Date.now() < credentialDeadline) {
|
|
4204
|
+
await this.browser.wait(0.5);
|
|
4205
|
+
try {
|
|
4206
|
+
pollExtract = await this.extractCredentials();
|
|
4207
|
+
if (pollExtract.api_key !== undefined)
|
|
4208
|
+
break;
|
|
4209
|
+
}
|
|
4210
|
+
catch {
|
|
4211
|
+
// Page mid-render — keep polling; next tick may settle.
|
|
4212
|
+
}
|
|
4213
|
+
}
|
|
2940
4214
|
}
|
|
2941
4215
|
else if (nextStep.kind === "fill") {
|
|
2942
4216
|
await this.browser.type(nextStep.selector, nextStep.value);
|
|
@@ -2979,7 +4253,15 @@ ${formatInventory(input.inventory)}`,
|
|
|
2979
4253
|
}
|
|
2980
4254
|
else if (nextStep.kind === "navigate") {
|
|
2981
4255
|
await this.browser.goto(nextStep.url);
|
|
2982
|
-
//
|
|
4256
|
+
// rc.33 — wait for the SPA to actually render before the
|
|
4257
|
+
// next round reads inventory. waitForFormReady (called at
|
|
4258
|
+
// the top of the next round) handles the basic "DOM
|
|
4259
|
+
// parsed" signal, but Porter / Koyeb's API-tokens pages
|
|
4260
|
+
// load over 5-15 seconds — the planner-driven post-verify
|
|
4261
|
+
// loop was running on the empty initial shell and burning
|
|
4262
|
+
// rounds clicking nothing. Wait for at least 5 interactive
|
|
4263
|
+
// elements (Porter's tokens page has 20+ once rendered).
|
|
4264
|
+
await this.browser.waitForInteractiveDom(5, 20_000);
|
|
2983
4265
|
}
|
|
2984
4266
|
else if (nextStep.kind === "wait") {
|
|
2985
4267
|
await this.browser.wait(Math.min(nextStep.seconds, 15));
|
|
@@ -3150,14 +4432,53 @@ Schema:
|
|
|
3150
4432
|
|
|
3151
4433
|
Strategy:
|
|
3152
4434
|
- If a FULL, untruncated API key is visible, return {"kind":"extract"}.
|
|
4435
|
+
- **MULTI-CREDENTIAL SERVICES** — when the page shows TWO OR MORE
|
|
4436
|
+
DISTINCT labeled credentials (e.g. Cloudinary shows cloud_name +
|
|
4437
|
+
api_key + api_secret; Algolia shows application_id + admin_api_key +
|
|
4438
|
+
search_api_key; Twilio shows account_sid + auth_token; Stripe shows
|
|
4439
|
+
publishable_key + secret_key; AWS shows access_key_id +
|
|
4440
|
+
secret_access_key) — return ONE {"kind":"extract"} step whose reason
|
|
4441
|
+
labels EVERY visible credential in the format
|
|
4442
|
+
\`<canonical_label>='<value>'\` (use SINGLE quotes around values).
|
|
4443
|
+
The bot's labeled-extractor will pull EACH labeled value into the
|
|
4444
|
+
credentials object. Example reason:
|
|
4445
|
+
"The Cloudinary API Keys page shows cloud_name='dlq4xgrca' and
|
|
4446
|
+
api_key='491741466469613' in the table; api_secret is hidden behind
|
|
4447
|
+
a Reveal button."
|
|
4448
|
+
Use the standard canonical labels: api_key, api_secret, secret_key,
|
|
4449
|
+
publishable_key, access_token, client_id, client_secret, cloud_name,
|
|
4450
|
+
application_id, admin_api_key, search_api_key, account_sid,
|
|
4451
|
+
auth_token, app_key, org_id, consumer_key, consumer_secret,
|
|
4452
|
+
access_token_secret, project_api_key, personal_api_key.
|
|
4453
|
+
If only ONE credential is visible, omit the labels and quote the
|
|
4454
|
+
value as before — the single-cred fallback handles that path.
|
|
3153
4455
|
- A key shown masked or truncated (with "...", dots, or "•") is NOT
|
|
3154
4456
|
extractable — its full value is shown only once, at creation. Do NOT
|
|
3155
4457
|
return "extract" for a masked key, and do not return "extract" twice
|
|
3156
4458
|
in a row. Instead click "Create API Key" / "New API Key" / "Generate"
|
|
3157
4459
|
to make a fresh key, then extract its full value.
|
|
4460
|
+
- **REVEAL-CLICK BEFORE EXTRACT** — when a credential is shown masked
|
|
4461
|
+
(•••••, asterisks, dots) AND there is a VISIBLE "Show", "Reveal",
|
|
4462
|
+
"Eye", or eye-icon button NEXT TO IT (typically same row in a
|
|
4463
|
+
credentials table — Cloudinary, Twilio, Stripe all follow this
|
|
4464
|
+
pattern for api_secret / auth_token / secret_key), emit a CLICK
|
|
4465
|
+
on that show/reveal button FIRST. Do NOT return extract on the same
|
|
4466
|
+
round as the masked display — the masked text would be parsed as
|
|
4467
|
+
the value. Next round the value will be visible and your extract
|
|
4468
|
+
step can quote it. The bot's reveal-pass is a fallback; explicit
|
|
4469
|
+
clicks via the planner are more reliable because you can see the
|
|
4470
|
+
exact button in the screenshot.
|
|
3158
4471
|
- To reach API keys, prefer a {"kind":"navigate"} straight to the
|
|
3159
4472
|
service's API-keys settings URL — note these usually live under the
|
|
3160
4473
|
user/ACCOUNT settings, not a project or workspace's settings.
|
|
4474
|
+
- **EXCEPT** when the page has a very small inventory (5 or fewer elements)
|
|
4475
|
+
and one of them is an onboarding CTA — patterns like "Get started",
|
|
4476
|
+
"Continue", "Activate", "Enable API", "Start free trial", "Set up".
|
|
4477
|
+
These are gating CTAs that unlock the API console; CLICKING them
|
|
4478
|
+
is required, navigate will just redirect you back. If you've already
|
|
4479
|
+
emitted a navigate once on this URL and the URL did not change on the
|
|
4480
|
+
next round, that is the signal — the service is gating the route. Click
|
|
4481
|
+
the visible CTA instead.
|
|
3161
4482
|
- Otherwise click a dashboard menu link like "API Keys" / "Tokens" /
|
|
3162
4483
|
"Developer" / "Settings" — using its inventory selector.
|
|
3163
4484
|
- If there's an onboarding modal or a "Skip" link blocking, dismiss it.
|