@trusty-squire/mcp 0.9.3 → 0.9.5
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/README.md +15 -21
- package/dist/bot/agent.d.ts +8 -0
- package/dist/bot/agent.d.ts.map +1 -1
- package/dist/bot/agent.js +436 -71
- package/dist/bot/agent.js.map +1 -1
- package/dist/bot/browser.d.ts +3 -0
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +59 -0
- package/dist/bot/browser.js.map +1 -1
- package/dist/bot/promote-to-skill.d.ts +2 -0
- package/dist/bot/promote-to-skill.d.ts.map +1 -1
- package/dist/bot/promote-to-skill.js +34 -0
- package/dist/bot/promote-to-skill.js.map +1 -1
- package/dist/bot/replay-skill.d.ts +3 -0
- package/dist/bot/replay-skill.d.ts.map +1 -1
- package/dist/bot/replay-skill.js +119 -2
- package/dist/bot/replay-skill.js.map +1 -1
- package/package.json +1 -1
package/dist/bot/agent.js
CHANGED
|
@@ -1195,6 +1195,17 @@ export function extractVerifyWallAlias(text) {
|
|
|
1195
1195
|
if (/\.(?:js|mjs|css|map|png|jpe?g|svg|gif|ico|woff2?|ttf|webp)$/i.test(addr)) {
|
|
1196
1196
|
continue;
|
|
1197
1197
|
}
|
|
1198
|
+
// Reject RFC 2606 reserved-for-documentation domains. A docs/sample email
|
|
1199
|
+
// like "check amy@example.com" rendered on a dashboard is never a real
|
|
1200
|
+
// verification target — polling it 404s as unknown_alias and yields a
|
|
1201
|
+
// false verification_not_sent. (Anthropic's console shows amy@example.com
|
|
1202
|
+
// in a sample.) Covers example.{com,net,org} + the .example/.test/
|
|
1203
|
+
// .invalid/.localhost reserved suffixes.
|
|
1204
|
+
const domain = addr.slice(addr.indexOf("@") + 1).toLowerCase();
|
|
1205
|
+
if (/^example\.(?:com|net|org)$/.test(domain) ||
|
|
1206
|
+
/\.(?:example|test|invalid|localhost)$/.test(domain)) {
|
|
1207
|
+
continue;
|
|
1208
|
+
}
|
|
1198
1209
|
return addr;
|
|
1199
1210
|
}
|
|
1200
1211
|
return null;
|
|
@@ -2143,32 +2154,40 @@ export function findSignInAdvanceButton(inventory, providers) {
|
|
|
2143
2154
|
// actually has a session for. `findFirstOAuthButton` walks this list in
|
|
2144
2155
|
// order and uses the first provider the PAGE offers, so order = preference.
|
|
2145
2156
|
//
|
|
2146
|
-
// RULE 1 —
|
|
2147
|
-
//
|
|
2148
|
-
//
|
|
2149
|
-
//
|
|
2150
|
-
// the
|
|
2151
|
-
//
|
|
2152
|
-
//
|
|
2153
|
-
//
|
|
2154
|
-
// jump, so it doesn't hit the /authorize 2FA wall the way a stale one did.)
|
|
2157
|
+
// RULE 1 — Google leads whenever its session is warm, pin or not. Empirically
|
|
2158
|
+
// Google's OAuth blocks far less hard than GitHub's, which hits an UNCLEARABLE
|
|
2159
|
+
// forced-2FA "Verify 2FA now" wall on the /authorize step regardless of session
|
|
2160
|
+
// warmth or egress IP (MEASURED 2026-06-07: porter + deepinfra were pinned
|
|
2161
|
+
// github, hit the wall and aborted, while their own signin pages also offered a
|
|
2162
|
+
// clean Google button the bot should have taken). This makes the code match the
|
|
2163
|
+
// long-stated intent in resolveOAuthCandidates ("Google blocks less hard, so it
|
|
2164
|
+
// leads") — which RULE 1 previously contradicted by leading with the pin.
|
|
2155
2165
|
//
|
|
2156
|
-
//
|
|
2157
|
-
//
|
|
2166
|
+
// A `github` pin still works for its real purpose: when a service's Google is
|
|
2167
|
+
// One-Tap/FedCM-only (no redirect button the flow can drive — northflank),
|
|
2168
|
+
// findFirstOAuthButton finds no Google button and falls through to GitHub even
|
|
2169
|
+
// though Google leads here. So a pin only decides ORDER when Google is NOT warm.
|
|
2170
|
+
//
|
|
2171
|
+
// RULE 2 — with no warm Google session, honor an explicit pin, else whatever IS
|
|
2172
|
+
// warm.
|
|
2158
2173
|
export function orderOAuthCandidates(pinned, loggedIn) {
|
|
2174
|
+
if (loggedIn.includes("google")) {
|
|
2175
|
+
const rest = loggedIn.filter((p) => p !== "google");
|
|
2176
|
+
// A non-Google pin sits right behind Google. Keep it even when its own
|
|
2177
|
+
// session is cold, as a trailing fallback so a page that only offers that
|
|
2178
|
+
// provider still gets attempted (a cold attempt → needs_login, which tells
|
|
2179
|
+
// the operator to log in — better than silently dropping to form-fill).
|
|
2180
|
+
if (pinned !== undefined && pinned !== "google") {
|
|
2181
|
+
return ["google", pinned, ...rest.filter((p) => p !== pinned)];
|
|
2182
|
+
}
|
|
2183
|
+
return ["google", ...rest];
|
|
2184
|
+
}
|
|
2159
2185
|
if (pinned !== undefined) {
|
|
2160
|
-
if (loggedIn.includes(pinned))
|
|
2161
|
-
|
|
2162
|
-
.filter((p) => p !== pinned)
|
|
2163
|
-
.sort((a, b) => (a === "google" ? -1 : b === "google" ? 1 : 0));
|
|
2164
|
-
return [pinned, ...others];
|
|
2165
|
-
}
|
|
2166
|
-
// Pin's session isn't warm — fall back to whatever IS (Google preferred).
|
|
2167
|
-
if (pinned !== "google" && loggedIn.includes("google"))
|
|
2168
|
-
return ["google", pinned];
|
|
2186
|
+
if (loggedIn.includes(pinned))
|
|
2187
|
+
return [pinned, ...loggedIn.filter((p) => p !== pinned)];
|
|
2169
2188
|
return [pinned];
|
|
2170
2189
|
}
|
|
2171
|
-
return [...loggedIn]
|
|
2190
|
+
return [...loggedIn];
|
|
2172
2191
|
}
|
|
2173
2192
|
// Parse a post-verify step. When `allowedSelectors` is supplied, a
|
|
2174
2193
|
// `click`/`fill` selector that is not in the page inventory is a
|
|
@@ -2593,6 +2612,46 @@ export function isMultiCredBundle(creds) {
|
|
|
2593
2612
|
}
|
|
2594
2613
|
return false;
|
|
2595
2614
|
}
|
|
2615
|
+
// DOM-label phrase → canonical credential key. Shared by
|
|
2616
|
+
// extractFromDomProximity (which harvests VALUES) and
|
|
2617
|
+
// countPresentedCredentialLabels (which counts how many distinct
|
|
2618
|
+
// credentials a page PRESENTS, masked included). Kept in lockstep with
|
|
2619
|
+
// the Phase E LABEL_ALIASES vocabulary.
|
|
2620
|
+
const DOM_LABEL_TO_KEY = {
|
|
2621
|
+
"api key": "api_key",
|
|
2622
|
+
"api token": "api_key",
|
|
2623
|
+
"api secret": "api_secret",
|
|
2624
|
+
"secret key": "secret_key",
|
|
2625
|
+
"publishable key": "publishable_key",
|
|
2626
|
+
"access key": "access_key_id",
|
|
2627
|
+
"access key id": "access_key_id",
|
|
2628
|
+
"access token": "access_token",
|
|
2629
|
+
"bearer token": "access_token",
|
|
2630
|
+
"personal access token": "access_token",
|
|
2631
|
+
"auth token": "auth_token",
|
|
2632
|
+
"client id": "client_id",
|
|
2633
|
+
"client secret": "client_secret",
|
|
2634
|
+
"client key": "client_id",
|
|
2635
|
+
"cloud name": "cloud_name",
|
|
2636
|
+
cloudname: "cloud_name",
|
|
2637
|
+
"application id": "application_id",
|
|
2638
|
+
"app id": "application_id",
|
|
2639
|
+
"admin api key": "admin_api_key",
|
|
2640
|
+
"search api key": "search_api_key",
|
|
2641
|
+
"search-only api key": "search_api_key",
|
|
2642
|
+
"monitoring api key": "monitoring_api_key",
|
|
2643
|
+
"account sid": "account_sid",
|
|
2644
|
+
"secret access key": "secret_access_key",
|
|
2645
|
+
"consumer key": "consumer_key",
|
|
2646
|
+
"consumer secret": "consumer_secret",
|
|
2647
|
+
"access token secret": "access_token_secret",
|
|
2648
|
+
"project api key": "project_api_key",
|
|
2649
|
+
"personal api key": "personal_api_key",
|
|
2650
|
+
"organization id": "org_id",
|
|
2651
|
+
"org id": "org_id",
|
|
2652
|
+
"app key": "app_key",
|
|
2653
|
+
"app secret": "app_secret",
|
|
2654
|
+
};
|
|
2596
2655
|
export function extractApiKeyFromText(text) {
|
|
2597
2656
|
const prefixed = [
|
|
2598
2657
|
/\bre_[a-zA-Z0-9_]{20,}\b/, // Resend (key body contains underscores)
|
|
@@ -2776,6 +2835,20 @@ export function isCredentialNoiseCandidate(candidate) {
|
|
|
2776
2835
|
return true;
|
|
2777
2836
|
return CREDENTIAL_NOISE_PREFIXES.some((p) => lower.startsWith(p));
|
|
2778
2837
|
}
|
|
2838
|
+
// True when a URL is a documentation / help / reference page rather than a
|
|
2839
|
+
// product surface. Such pages render REALISTIC sample credentials (Anthropic's
|
|
2840
|
+
// platform.claude.com/docs/.../get-started shows ANTHROPIC_API_KEY='sk-ant-
|
|
2841
|
+
// api03-...') that match the real key shape but are NOT user credentials. A
|
|
2842
|
+
// real minted key lives on the console / settings, never under /docs — so the
|
|
2843
|
+
// extractor refuses to read a credential while on a docs page, which keeps the
|
|
2844
|
+
// post-verify loop navigating toward the real keys page instead of false-
|
|
2845
|
+
// succeeding on a sample. Exported for unit testing.
|
|
2846
|
+
export function isDocumentationUrl(url) {
|
|
2847
|
+
const u = url.toLowerCase();
|
|
2848
|
+
return (/^https?:\/\/docs?\.[^/]+/.test(u) || // docs.x.com / doc.x.com host
|
|
2849
|
+
/\/docs?\//.test(u) || // /docs/ or /doc/ path
|
|
2850
|
+
/\/(?:help|reference|api-reference|guides?|tutorials?)\//.test(u));
|
|
2851
|
+
}
|
|
2779
2852
|
// Choose which link in a verification email to click. Scores each URL
|
|
2780
2853
|
// by keyword and picks the best — but only if it scored positive.
|
|
2781
2854
|
//
|
|
@@ -2839,6 +2912,60 @@ export function pickVerificationLinkFromHtml(bodyHtml) {
|
|
|
2839
2912
|
}
|
|
2840
2913
|
return best !== null && best.score > 0 ? best.url : null;
|
|
2841
2914
|
}
|
|
2915
|
+
// Last-resort verification-CODE extraction from an email body, for the
|
|
2916
|
+
// passwordless "we emailed you a code" flow (axiom: "Axiom sign-in
|
|
2917
|
+
// verification code") when the inbox parser's parsed_codes came back empty.
|
|
2918
|
+
// Without this the bot bailed "no usable verification link" on a code-only
|
|
2919
|
+
// email — treating a routine code flow as a dead end. Conservative: prefers a
|
|
2920
|
+
// 4-8 digit run next to a code/verify keyword, then a space/dash-grouped code,
|
|
2921
|
+
// then a standalone 6-digit number (the most common verification length).
|
|
2922
|
+
// Returns null when nothing code-shaped is found so the caller still bails
|
|
2923
|
+
// honestly rather than typing garbage. Exported for unit testing.
|
|
2924
|
+
export function extractCodeFromEmailBody(email) {
|
|
2925
|
+
const text = [
|
|
2926
|
+
email.subject ?? "",
|
|
2927
|
+
email.body_text ?? "",
|
|
2928
|
+
(email.body_html ?? "").replace(/<[^>]+>/g, " "),
|
|
2929
|
+
].join("\n");
|
|
2930
|
+
// 1) A code sitting next to a verification keyword — the strongest signal.
|
|
2931
|
+
const kw = text.match(/(?:verification code|sign[\s-]?in code|one[\s-]?time(?:\s+(?:code|password))?|security code|your code|confirmation code|code is|enter(?:\s+this)?\s+code)\b[^0-9]{0,40}([0-9]{4,8})\b/i);
|
|
2932
|
+
if (kw?.[1] !== undefined)
|
|
2933
|
+
return kw[1];
|
|
2934
|
+
// 2) A grouped code ("123-456" / "1234 5678").
|
|
2935
|
+
const grouped = text.match(/(?<![0-9])([0-9]{3,4}[ -][0-9]{3,4})(?![0-9])/);
|
|
2936
|
+
if (grouped?.[1] !== undefined)
|
|
2937
|
+
return grouped[1].replace(/[ -]/g, "");
|
|
2938
|
+
// 3) A standalone 6-digit number (most verification codes).
|
|
2939
|
+
const six = text.match(/(?<![0-9])([0-9]{6})(?![0-9])/);
|
|
2940
|
+
if (six?.[1] !== undefined)
|
|
2941
|
+
return six[1];
|
|
2942
|
+
return null;
|
|
2943
|
+
}
|
|
2944
|
+
// True when the page is an email verification-CODE entry gate: a single
|
|
2945
|
+
// code-style input (name/id/placeholder/label ~ code/token/otp/verification),
|
|
2946
|
+
// NO email/password/tel field still to fill, and verify/code copy in the body.
|
|
2947
|
+
// axiom-class passwordless ("Send Code to Email" lands here). Distinct from the
|
|
2948
|
+
// no-fields verification WALL: this page HAS an input, but it's a CODE field,
|
|
2949
|
+
// so the form-fill planner would otherwise type an empty literal into it and
|
|
2950
|
+
// loop. The caller returns "submitted" to route to the inbox-poll + code-entry
|
|
2951
|
+
// path (the code was emailed to our alias). Exported for unit testing.
|
|
2952
|
+
export function isVerificationCodeGate(inventory, pageText) {
|
|
2953
|
+
const inputs = inventory.filter((e) => e.tag === "input" && e.visible !== false);
|
|
2954
|
+
// Any email/password/tel field still present → still a signup form, not a
|
|
2955
|
+
// pure code gate.
|
|
2956
|
+
if (inputs.some((e) => e.type === "email" || e.type === "password" || e.type === "tel")) {
|
|
2957
|
+
return false;
|
|
2958
|
+
}
|
|
2959
|
+
const codeRe = /\b(?:code|token|otp|verification|verify|one[\s-]?time|2fa|mfa)\b/i;
|
|
2960
|
+
const hasCodeInput = inputs.some((e) => {
|
|
2961
|
+
const hay = `${e.name ?? ""} ${e.id ?? ""} ${e.placeholder ?? ""} ${e.ariaLabel ?? ""} ${e.labelText ?? ""}`;
|
|
2962
|
+
return codeRe.test(hay);
|
|
2963
|
+
});
|
|
2964
|
+
if (!hasCodeInput)
|
|
2965
|
+
return false;
|
|
2966
|
+
const t = pageText.toLowerCase();
|
|
2967
|
+
return /verification code|enter (?:the |your )?code|code is required|verify and continue|we (?:sent|emailed)|check your email|one[\s-]?time (?:code|password)|sign[\s-]?in code/.test(t);
|
|
2968
|
+
}
|
|
2842
2969
|
// Discriminates LLMPair from LLMClient. LLMPair has `primary` (an
|
|
2843
2970
|
// LLMClient); LLMClient has `createMessage`. They're mutually exclusive
|
|
2844
2971
|
// shapes so a structural check is reliable.
|
|
@@ -3305,8 +3432,27 @@ export class SignupAgent {
|
|
|
3305
3432
|
e.type === "password" ||
|
|
3306
3433
|
e.type === null) &&
|
|
3307
3434
|
e.visible !== false);
|
|
3308
|
-
|
|
3309
|
-
|
|
3435
|
+
// Only enter the inbox-poll flow when the named alias is one we can
|
|
3436
|
+
// actually POLL — on our own inbox domain (task.email's domain, which
|
|
3437
|
+
// also covers a prior run's alias). A logged-in dashboard often shows
|
|
3438
|
+
// the operator's real email (e.g. a personal gmail) or a docs sample
|
|
3439
|
+
// next to "check your email" copy; polling those 404s as
|
|
3440
|
+
// unknown_alias and yields a false verification_not_sent. A wall that
|
|
3441
|
+
// names NO address (generic "check your email") still fires and polls
|
|
3442
|
+
// task.email — that's the common legitimate case. (Already-
|
|
3443
|
+
// authenticated dashboards are routed straight to key extraction
|
|
3444
|
+
// before the form-fill loop, so they never reach this detector.)
|
|
3445
|
+
const wallAlias = extractVerifyWallAlias(wallText);
|
|
3446
|
+
const ourInboxDomain = task.email
|
|
3447
|
+
.slice(task.email.indexOf("@") + 1)
|
|
3448
|
+
.toLowerCase();
|
|
3449
|
+
const aliasPollable = wallAlias === null ||
|
|
3450
|
+
wallAlias.slice(wallAlias.indexOf("@") + 1).toLowerCase() ===
|
|
3451
|
+
ourInboxDomain;
|
|
3452
|
+
if (!hasFillableInput &&
|
|
3453
|
+
expectsVerificationEmail(wallText) &&
|
|
3454
|
+
aliasPollable) {
|
|
3455
|
+
const alias = wallAlias;
|
|
3310
3456
|
this.pendingVerificationAlias = alias;
|
|
3311
3457
|
steps.push(`Form: email-verification wall (no fields to fill${alias !== null ? `, check ${alias}` : ""}) — ` +
|
|
3312
3458
|
`routing to the inbox-poll + verification-link flow.`);
|
|
@@ -3331,6 +3477,19 @@ export class SignupAgent {
|
|
|
3331
3477
|
return { kind: "submitted" };
|
|
3332
3478
|
}
|
|
3333
3479
|
}
|
|
3480
|
+
// Email verification-CODE gate (axiom-class passwordless). The no-fields
|
|
3481
|
+
// wall above misses it because this page HAS an input — but it's a CODE
|
|
3482
|
+
// field, not email/password, and the code was emailed to our alias.
|
|
3483
|
+
// Return "submitted" so the post-submit inbox-poll + code-entry path
|
|
3484
|
+
// (extractCodeFromEmailBody → enterEmailVerificationCode) handles it,
|
|
3485
|
+
// instead of the planner typing an empty literal into the code field and
|
|
3486
|
+
// looping. Gated on committedToEmailPath — a code gate only appears after
|
|
3487
|
+
// the email was submitted.
|
|
3488
|
+
if (committedToEmailPath && isVerificationCodeGate(inventory, state.html)) {
|
|
3489
|
+
this.pendingVerificationAlias = this.pendingVerificationAlias ?? task.email;
|
|
3490
|
+
steps.push("Form: email verification-CODE gate detected — routing to the inbox-poll + code-entry flow.");
|
|
3491
|
+
return { kind: "submitted" };
|
|
3492
|
+
}
|
|
3334
3493
|
// OAuth-first (T6/T13 + auto-prefer): when the page carries a
|
|
3335
3494
|
// "Sign in with <provider>" affordance for a provider the bot can
|
|
3336
3495
|
// use, that button unconditionally outranks any form field — hand
|
|
@@ -4300,12 +4459,44 @@ export class SignupAgent {
|
|
|
4300
4459
|
// would otherwise read as "other". (A promoted-skill URL is replay-
|
|
4301
4460
|
// verified and a guessed URL that's wrong is recovered here too.)
|
|
4302
4461
|
let needsRecovery = false;
|
|
4303
|
-
|
|
4462
|
+
// Set when the landing page is an already-authenticated dashboard — we
|
|
4463
|
+
// then route STRAIGHT to key extraction (the already_oauth dispatch
|
|
4464
|
+
// case) and skip the form-fill loop entirely. The loop's own
|
|
4465
|
+
// already-signed-in check runs on a ranked+capped inventory that drops
|
|
4466
|
+
// the low-ranked nav markers detectAlreadySignedIn keys on, so it
|
|
4467
|
+
// unreliably misses dashboards (anthropic) and the verification-wall
|
|
4468
|
+
// detector false-fires first. The full-inventory check below is the
|
|
4469
|
+
// reliable signal; act on it here, not in the loop.
|
|
4470
|
+
let alreadyAuthenticated = false;
|
|
4471
|
+
// Before any recovery: are we ALREADY authenticated for this service?
|
|
4472
|
+
// The operator's own session can be bound to the service (e.g.
|
|
4473
|
+
// Anthropic, where the bot rides the operator's account), so the
|
|
4474
|
+
// landing page is a dashboard with no signup CTA. Recovery would then
|
|
4475
|
+
// chase a non-existent signup page — Tier B finds no CTA, the Google
|
|
4476
|
+
// fallback finds no on-domain match — and bail `no_signup_link`
|
|
4477
|
+
// ("the service likely doesn't have a public self-serve signup"),
|
|
4478
|
+
// which is flatly wrong: we're simply logged in. Skip recovery and let
|
|
4479
|
+
// planExecuteWithRetry's OAuth-first scan detect the authenticated
|
|
4480
|
+
// state (already_oauth) and jump straight to key extraction — the same
|
|
4481
|
+
// post-verify path runOAuthFlow uses after a successful handshake.
|
|
4482
|
+
// detectAlreadySignedIn's precondition (no email/password/tel input
|
|
4483
|
+
// visible) makes this safe: a real signup/login page short-circuits to
|
|
4484
|
+
// false before any dashboard marker is considered.
|
|
4485
|
+
const landed = await this.browser.getState();
|
|
4486
|
+
const landedInventory = await this.browser.extractInteractiveElements();
|
|
4487
|
+
if (detectAlreadySignedIn({
|
|
4488
|
+
inventory: landedInventory,
|
|
4489
|
+
url: landed.url,
|
|
4490
|
+
})) {
|
|
4491
|
+
steps.push(`${task.service}: already authenticated (dashboard markers, no signup CTA) — ` +
|
|
4492
|
+
`skipping signup, routing straight to key extraction`);
|
|
4493
|
+
alreadyAuthenticated = true;
|
|
4494
|
+
}
|
|
4495
|
+
else if (task.signupUrl === undefined) {
|
|
4304
4496
|
needsRecovery = !(await this.looksLikeSignupPage());
|
|
4305
4497
|
}
|
|
4306
4498
|
else {
|
|
4307
|
-
const
|
|
4308
|
-
const klass = classifySignupHtml(rendered);
|
|
4499
|
+
const klass = classifySignupHtml(landed.html);
|
|
4309
4500
|
if (klass !== "signup" && !(await this.looksLikeSignupPage())) {
|
|
4310
4501
|
needsRecovery = true;
|
|
4311
4502
|
steps.push(`curated signup_url for ${task.service} rendered as "${klass}", not a signup form — attempting recovery`);
|
|
@@ -4394,9 +4585,12 @@ export class SignupAgent {
|
|
|
4394
4585
|
return {
|
|
4395
4586
|
success: false,
|
|
4396
4587
|
error: `no_signup_link: searched for ${task.service}'s signup page and ` +
|
|
4397
|
-
`found no on-domain candidates.
|
|
4398
|
-
`
|
|
4399
|
-
`
|
|
4588
|
+
`found no on-domain candidates. Likely causes: you already have an ` +
|
|
4589
|
+
`account and the bot landed on a logged-in dashboard with no signup ` +
|
|
4590
|
+
`CTA (the already-authenticated detector didn't recognize this ` +
|
|
4591
|
+
`dashboard's markers); the service has no public self-serve signup; ` +
|
|
4592
|
+
`or the bot's domain guard rejected every match. Sign up manually, ` +
|
|
4593
|
+
`or extract the key from the existing session.`,
|
|
4400
4594
|
steps,
|
|
4401
4595
|
...this.resultTail(),
|
|
4402
4596
|
};
|
|
@@ -4422,7 +4616,14 @@ export class SignupAgent {
|
|
|
4422
4616
|
// every terminal case (submitted, planning_failed, …) stays in one
|
|
4423
4617
|
// place. Bounded to a single re-route so a service that keeps
|
|
4424
4618
|
// bouncing can't spin here.
|
|
4425
|
-
|
|
4619
|
+
// Already authenticated (full-inventory check above): skip the form-fill
|
|
4620
|
+
// loop and hand straight to the already_oauth case, which extracts /
|
|
4621
|
+
// mints the key from the existing session. Going through the loop would
|
|
4622
|
+
// re-detect on a capped inventory (missing the nav markers), false-fire
|
|
4623
|
+
// the verification-wall detector, and never reach key extraction.
|
|
4624
|
+
let outcome = alreadyAuthenticated
|
|
4625
|
+
? { kind: "already_oauth" }
|
|
4626
|
+
: await this.planExecuteWithRetry(task, fillValues, steps);
|
|
4426
4627
|
let oauthFallbackUsed = false;
|
|
4427
4628
|
// Multi-step signup guard (amplitude: email/name step → a dedicated
|
|
4428
4629
|
// "Create your password" step). Bounds how many continuation form steps
|
|
@@ -4735,7 +4936,17 @@ export class SignupAgent {
|
|
|
4735
4936
|
credentials = await this.enterEmailVerificationCode(email.parsed_codes[0] ?? "", task, password, steps);
|
|
4736
4937
|
}
|
|
4737
4938
|
else {
|
|
4738
|
-
|
|
4939
|
+
// No link and the inbox parser found no code — last-resort
|
|
4940
|
+
// scan the email body ourselves for a verification code
|
|
4941
|
+
// (passwordless "we emailed you a code" flow, e.g. axiom).
|
|
4942
|
+
const bodyCode = extractCodeFromEmailBody(email);
|
|
4943
|
+
if (bodyCode !== null) {
|
|
4944
|
+
steps.push(`Email had no link but carried a verification code (…${bodyCode.slice(-2)}) — entering it.`);
|
|
4945
|
+
credentials = await this.enterEmailVerificationCode(bodyCode, task, password, steps);
|
|
4946
|
+
}
|
|
4947
|
+
else {
|
|
4948
|
+
steps.push("Email had no usable verification link or code.");
|
|
4949
|
+
}
|
|
4739
4950
|
}
|
|
4740
4951
|
}
|
|
4741
4952
|
else if (email.parsed_codes.length > 0) {
|
|
@@ -4886,6 +5097,21 @@ export class SignupAgent {
|
|
|
4886
5097
|
steps.push(`OAuth: Google Identity Services / FedCM widget — resolved via ${gsi.via}` +
|
|
4887
5098
|
(gsi.ok ? "" : " (no FedCM dialog or popup appeared — the widget may need a different trigger)"));
|
|
4888
5099
|
}
|
|
5100
|
+
// OmniAuth POST-only recovery prep. Capture the affordance's href + the
|
|
5101
|
+
// page's CSRF token NOW, while we're still on the signin page — the
|
|
5102
|
+
// "Authentication passthru" page a GET-click lands on is bare (no token).
|
|
5103
|
+
// See the typesense root-cause (2026-06-07): Rails/OmniAuth 2.0 is
|
|
5104
|
+
// POST-only; the GitHub button is a GET <a href="/users/auth/github">
|
|
5105
|
+
// upgraded to POST by page JS, and the bot's GET-click hit the passthru.
|
|
5106
|
+
const omniauthHref = typeof this.browser.getElementAttribute === "function"
|
|
5107
|
+
? await this.browser
|
|
5108
|
+
.getElementAttribute(oauthSelector, "href")
|
|
5109
|
+
.catch(() => null)
|
|
5110
|
+
: null;
|
|
5111
|
+
const omniauthToken = typeof this.browser.getMetaCsrfToken === "function"
|
|
5112
|
+
? await this.browser.getMetaCsrfToken().catch(() => null)
|
|
5113
|
+
: null;
|
|
5114
|
+
let omniauthPostTried = false;
|
|
4889
5115
|
if (!gsiHandled) {
|
|
4890
5116
|
await this.browser.startOAuth(oauthSelector);
|
|
4891
5117
|
}
|
|
@@ -4932,8 +5158,37 @@ export class SignupAgent {
|
|
|
4932
5158
|
}
|
|
4933
5159
|
const authState = provider.classifyAuthState(url, body);
|
|
4934
5160
|
steps.push(`OAuth: ${provider.label} auth state = ${authState} (url=${url.slice(0, 120)})`);
|
|
4935
|
-
if (authState === "not_provider")
|
|
5161
|
+
if (authState === "not_provider") {
|
|
5162
|
+
// OmniAuth 2.0 POST-only recovery. The provider button can be a GET
|
|
5163
|
+
// <a href="/.../auth/<provider>"> that page-JS upgrades to a POST; if
|
|
5164
|
+
// the bot's click hit the default GET, Rails/OmniAuth answers
|
|
5165
|
+
// "Not found. Authentication passthru." and OAuth never started — the
|
|
5166
|
+
// bot then misreads it as "signed in" and bails. Detect that bare
|
|
5167
|
+
// passthru page and re-initiate via POST with the signin page's CSRF
|
|
5168
|
+
// token (proven to 302 to the provider). MEASURED 2026-06-07: typesense.
|
|
5169
|
+
const onOmniAuthPassthru = /authentication passthru|not found/i.test(body) &&
|
|
5170
|
+
/\/auth\/[a-z0-9_-]+\/?$/i.test(url);
|
|
5171
|
+
if (onOmniAuthPassthru &&
|
|
5172
|
+
!omniauthPostTried &&
|
|
5173
|
+
omniauthToken !== null &&
|
|
5174
|
+
omniauthHref !== null) {
|
|
5175
|
+
omniauthPostTried = true;
|
|
5176
|
+
const action = new URL(omniauthHref, url).toString();
|
|
5177
|
+
steps.push(`OAuth: ${provider.label} endpoint is POST-only (OmniAuth GET passthru) — ` +
|
|
5178
|
+
`re-initiating via POST with the page CSRF token`);
|
|
5179
|
+
try {
|
|
5180
|
+
await this.browser.submitPostForm(action, {
|
|
5181
|
+
authenticity_token: omniauthToken,
|
|
5182
|
+
});
|
|
5183
|
+
await this.browser.wait(3);
|
|
5184
|
+
continue; // re-read state — should now be on the provider's page
|
|
5185
|
+
}
|
|
5186
|
+
catch (err) {
|
|
5187
|
+
steps.push(`OAuth: OmniAuth POST recovery failed (${err instanceof Error ? err.message : String(err)})`);
|
|
5188
|
+
}
|
|
5189
|
+
}
|
|
4936
5190
|
break; // flow left the provider — back on the service
|
|
5191
|
+
}
|
|
4937
5192
|
if (authState === "challenge") {
|
|
4938
5193
|
// rc.26 — always capture forensic state at the moment the
|
|
4939
5194
|
// challenge is detected. Before this, snapshots fired only at
|
|
@@ -6081,41 +6336,7 @@ ${formatInventory(input.inventory)}`,
|
|
|
6081
6336
|
async extractFromDomProximity() {
|
|
6082
6337
|
// Vocabulary matches the LABEL_ALIASES used by Phase E so the
|
|
6083
6338
|
// canonical keys stay consistent across paths.
|
|
6084
|
-
const LABEL_TO_KEY =
|
|
6085
|
-
"api key": "api_key",
|
|
6086
|
-
"api token": "api_key",
|
|
6087
|
-
"api secret": "api_secret",
|
|
6088
|
-
"secret key": "secret_key",
|
|
6089
|
-
"publishable key": "publishable_key",
|
|
6090
|
-
"access key": "access_key_id",
|
|
6091
|
-
"access key id": "access_key_id",
|
|
6092
|
-
"access token": "access_token",
|
|
6093
|
-
"bearer token": "access_token",
|
|
6094
|
-
"personal access token": "access_token",
|
|
6095
|
-
"auth token": "auth_token",
|
|
6096
|
-
"client id": "client_id",
|
|
6097
|
-
"client secret": "client_secret",
|
|
6098
|
-
"client key": "client_id",
|
|
6099
|
-
"cloud name": "cloud_name",
|
|
6100
|
-
"cloudname": "cloud_name",
|
|
6101
|
-
"application id": "application_id",
|
|
6102
|
-
"app id": "application_id",
|
|
6103
|
-
"admin api key": "admin_api_key",
|
|
6104
|
-
"search api key": "search_api_key",
|
|
6105
|
-
"search-only api key": "search_api_key",
|
|
6106
|
-
"monitoring api key": "monitoring_api_key",
|
|
6107
|
-
"account sid": "account_sid",
|
|
6108
|
-
"secret access key": "secret_access_key",
|
|
6109
|
-
"consumer key": "consumer_key",
|
|
6110
|
-
"consumer secret": "consumer_secret",
|
|
6111
|
-
"access token secret": "access_token_secret",
|
|
6112
|
-
"project api key": "project_api_key",
|
|
6113
|
-
"personal api key": "personal_api_key",
|
|
6114
|
-
"organization id": "org_id",
|
|
6115
|
-
"org id": "org_id",
|
|
6116
|
-
"app key": "app_key",
|
|
6117
|
-
"app secret": "app_secret",
|
|
6118
|
-
};
|
|
6339
|
+
const LABEL_TO_KEY = DOM_LABEL_TO_KEY;
|
|
6119
6340
|
let labeled = [];
|
|
6120
6341
|
try {
|
|
6121
6342
|
labeled = await this.browser.extractLabeledCredentialCandidates();
|
|
@@ -6142,6 +6363,30 @@ ${formatInventory(input.inventory)}`,
|
|
|
6142
6363
|
}
|
|
6143
6364
|
return out;
|
|
6144
6365
|
}
|
|
6366
|
+
// Count the DISTINCT credentials the current page PRESENTS — masked
|
|
6367
|
+
// ones included. This detects a multi-cred page BEFORE every value is
|
|
6368
|
+
// captured, so the post-verify loop can stay open to harvest the 2nd/
|
|
6369
|
+
// 3rd key instead of exiting the moment the first surfaces ("stops at
|
|
6370
|
+
// one"). Uses the DOM-proximity harvester's labels (which fire even on
|
|
6371
|
+
// masked/bullet'd values) mapped to canonical keys; values are NOT read
|
|
6372
|
+
// here, only the label set. Best-effort → 0 on any browser error.
|
|
6373
|
+
async countPresentedCredentialLabels() {
|
|
6374
|
+
try {
|
|
6375
|
+
const cands = await this.browser.extractLabeledCredentialCandidates();
|
|
6376
|
+
const canon = new Set();
|
|
6377
|
+
for (const c of cands) {
|
|
6378
|
+
if (c.label === null)
|
|
6379
|
+
continue;
|
|
6380
|
+
const key = DOM_LABEL_TO_KEY[c.label];
|
|
6381
|
+
if (key !== undefined)
|
|
6382
|
+
canon.add(key);
|
|
6383
|
+
}
|
|
6384
|
+
return canon.size;
|
|
6385
|
+
}
|
|
6386
|
+
catch {
|
|
6387
|
+
return 0;
|
|
6388
|
+
}
|
|
6389
|
+
}
|
|
6145
6390
|
// Run every visible-credential extraction tier the post-verify loop
|
|
6146
6391
|
// uses (legacy regex/clipboard/hidden-input + DOM-proximity labeled),
|
|
6147
6392
|
// merging first-wins into a single bundle. Used by attemptMintNewKey
|
|
@@ -6433,6 +6678,16 @@ ${formatInventory(input.inventory)}`,
|
|
|
6433
6678
|
// and inject a forced "no-progress" hint on the second repeat.
|
|
6434
6679
|
let prevSignature = null;
|
|
6435
6680
|
let prevInventorySize = -1;
|
|
6681
|
+
// Selectors the planner has CLICKED while the inventory count has held
|
|
6682
|
+
// steady. A multi-step onboarding wizard (axiom: role → company-size →
|
|
6683
|
+
// plan) advances by clicking distinct radio-style cards that flip an
|
|
6684
|
+
// aria-checked but add/remove no elements, so inventory.length never
|
|
6685
|
+
// moves — and the kind-level stuck detector below would false-positive
|
|
6686
|
+
// on the 2nd DISTINCT selection. We exempt a brand-new selector (wizard
|
|
6687
|
+
// progress) and only call it stuck once a selector REPEATS (the Railway
|
|
6688
|
+
// Create→Focus→Create cycle). Reset whenever the inventory count changes
|
|
6689
|
+
// (genuine page mutation → fresh wizard step / new page).
|
|
6690
|
+
let clickSelectorsSinceInventoryChange = new Set();
|
|
6436
6691
|
// rc.39 — wait-loop tracker. Turso's GitHub OAuth handshake
|
|
6437
6692
|
// succeeds, then the SSO-callback page stays empty (0 elements)
|
|
6438
6693
|
// while a Cloudflare verification widget runs that never clears
|
|
@@ -6479,6 +6734,13 @@ ${formatInventory(input.inventory)}`,
|
|
|
6479
6734
|
let stuckFiresAtUrl = 0;
|
|
6480
6735
|
let lastStuckFireUrl = null;
|
|
6481
6736
|
const triedFallbackUrls = new Set();
|
|
6737
|
+
// Premature-done guard budget. When the planner gives up (`done`)
|
|
6738
|
+
// with zero credentials captured, we navigate to an unvisited
|
|
6739
|
+
// canonical keys URL and re-plan — bounded so a service that
|
|
6740
|
+
// genuinely has no self-serve key doesn't burn the whole run budget
|
|
6741
|
+
// walking every fallback path.
|
|
6742
|
+
let prematureDoneFallbacks = 0;
|
|
6743
|
+
const MAX_PREMATURE_DONE_FALLBACKS = 3;
|
|
6482
6744
|
// Dead-URL memory. The planner guesses credential-page URLs
|
|
6483
6745
|
// (e.g. /user/personal_access_tokens/new) that 404; without memory it
|
|
6484
6746
|
// re-guesses the same dead URL round after round — xata and fly each
|
|
@@ -6523,6 +6785,15 @@ ${formatInventory(input.inventory)}`,
|
|
|
6523
6785
|
// surveyed the labeled credentials surface.
|
|
6524
6786
|
const seedHadCredential = credentials.api_key !== undefined || credentials.username !== undefined;
|
|
6525
6787
|
let plannerExtractEmitted = false;
|
|
6788
|
+
// 2026-06-07 — "stops at one" fix. The legacy loop-exit treated a run
|
|
6789
|
+
// as single-cred based on what was ALREADY captured (isMultiCredBundle),
|
|
6790
|
+
// so a page with 3 credentials whose 1st surfaced first — siblings still
|
|
6791
|
+
// masked or missed on the first harvest pass — exited before the rest
|
|
6792
|
+
// were caught. Set once the page is observed to PRESENT >=2 distinct
|
|
6793
|
+
// credentials (masked included); the loop-exit then holds open (bounded
|
|
6794
|
+
// by roundsSinceLastNewCredential) so the reveal pass + DOM harvest get
|
|
6795
|
+
// more rounds to capture the siblings.
|
|
6796
|
+
let pageOffersMultiCred = false;
|
|
6526
6797
|
// Gate URLs we've already polled the operator's gmail for, so a
|
|
6527
6798
|
// multi-round wait on the same email-OTP page doesn't re-poll.
|
|
6528
6799
|
const otpPolledUrls = new Set();
|
|
@@ -6547,7 +6818,7 @@ ${formatInventory(input.inventory)}`,
|
|
|
6547
6818
|
// when the api_key came from the pre-loop seed and the
|
|
6548
6819
|
// planner hasn't yet emitted an explicit extract step. In
|
|
6549
6820
|
// that case we let the planner run until extract fires.
|
|
6550
|
-
const inMultiCredMode = isMultiCredBundle(credentials);
|
|
6821
|
+
const inMultiCredMode = isMultiCredBundle(credentials) || pageOffersMultiCred;
|
|
6551
6822
|
const haveOnlySeedCredentials = seedHadCredential && !plannerExtractEmitted;
|
|
6552
6823
|
if (!inMultiCredMode &&
|
|
6553
6824
|
(credentials.api_key !== undefined || credentials.username !== undefined) &&
|
|
@@ -6586,6 +6857,24 @@ ${formatInventory(input.inventory)}`,
|
|
|
6586
6857
|
// widened (the "API Keys"/"Settings" links must survive ranking).
|
|
6587
6858
|
// Reading state can still race a navigation — a transient throw
|
|
6588
6859
|
// burns the round rather than crashing the whole run.
|
|
6860
|
+
// Dismiss any cookie/consent banner BEFORE reading the page or
|
|
6861
|
+
// planning a click. A consent overlay (Google "Accept all", GDPR
|
|
6862
|
+
// banners) intercepts pointer events, so the planner's clicks land
|
|
6863
|
+
// on the banner instead of the dashboard and the loop stalls / loops /
|
|
6864
|
+
// times out. MEASURED 2026-06-07: meilisearch reached /settings/keys
|
|
6865
|
+
// but sat behind an Accept-All overlay and ran out the 600s clock.
|
|
6866
|
+
// The form-fill loop already dismisses banners every round; the
|
|
6867
|
+
// post-verify loop never did. Best-effort — a dismiss failure must
|
|
6868
|
+
// not burn the round.
|
|
6869
|
+
try {
|
|
6870
|
+
const dismissed = await this.browser.dismissConsentBanner();
|
|
6871
|
+
if (dismissed !== null) {
|
|
6872
|
+
args.steps.push(`Post-verify round ${round}: dismissed consent banner ("${dismissed}")`);
|
|
6873
|
+
}
|
|
6874
|
+
}
|
|
6875
|
+
catch {
|
|
6876
|
+
// best-effort
|
|
6877
|
+
}
|
|
6589
6878
|
let state;
|
|
6590
6879
|
let inventory;
|
|
6591
6880
|
try {
|
|
@@ -6952,6 +7241,13 @@ ${formatInventory(input.inventory)}`,
|
|
|
6952
7241
|
// the same selector AND the inventory size matches the prior
|
|
6953
7242
|
// round's — strong evidence the previous step did nothing.
|
|
6954
7243
|
const repeatableKinds = new Set(["click", "fill", "select", "check", "scroll"]);
|
|
7244
|
+
// An inventory-count change means the page genuinely mutated — a fresh
|
|
7245
|
+
// wizard step or a new page. Reset the per-stable-run click-selector
|
|
7246
|
+
// memory so distinct clicks on the NEW state aren't judged against the
|
|
7247
|
+
// old one.
|
|
7248
|
+
if (inventory.length !== prevInventorySize) {
|
|
7249
|
+
clickSelectorsSinceInventoryChange = new Set();
|
|
7250
|
+
}
|
|
6955
7251
|
if (repeatableKinds.has(nextStep.kind)) {
|
|
6956
7252
|
const sel = "selector" in nextStep ? (nextStep.selector ?? "<none>") : "<none>";
|
|
6957
7253
|
const signature = `${nextStep.kind}|${sel}`;
|
|
@@ -6962,10 +7258,21 @@ ${formatInventory(input.inventory)}`,
|
|
|
6962
7258
|
// (planner cycles through Create, Focus-input, Create again,
|
|
6963
7259
|
// …). When that happens, force a non-click action.
|
|
6964
7260
|
const sameSelector = signature === prevSignature && inventory.length === prevInventorySize;
|
|
7261
|
+
// A brand-new click selector (never clicked since the inventory last
|
|
7262
|
+
// changed) is wizard PROGRESS, not a cycle — selecting role, then
|
|
7263
|
+
// company-size, then a plan flips aria-checked without moving the
|
|
7264
|
+
// element count (axiom). Only treat repeated clicks as stuck: the
|
|
7265
|
+
// selector has already been clicked in this stable-inventory run.
|
|
7266
|
+
const clickSelectorIsRepeat = nextStep.kind === "click" &&
|
|
7267
|
+
clickSelectorsSinceInventoryChange.has(sel);
|
|
6965
7268
|
const stuckOnKind = nextStep.kind === "click" &&
|
|
6966
7269
|
prevSignature !== null &&
|
|
6967
7270
|
prevSignature.startsWith("click|") &&
|
|
6968
|
-
inventory.length === prevInventorySize
|
|
7271
|
+
inventory.length === prevInventorySize &&
|
|
7272
|
+
clickSelectorIsRepeat;
|
|
7273
|
+
if (nextStep.kind === "click") {
|
|
7274
|
+
clickSelectorsSinceInventoryChange.add(sel);
|
|
7275
|
+
}
|
|
6969
7276
|
if (sameSelector || stuckOnKind) {
|
|
6970
7277
|
const emptyInputs = inventory
|
|
6971
7278
|
.filter((e) => (e.tag === "input" || e.tag === "textarea") &&
|
|
@@ -7044,8 +7351,16 @@ ${formatInventory(input.inventory)}`,
|
|
|
7044
7351
|
// Mistral's TOS, GitHub-app sign-up, many onboarding forms
|
|
7045
7352
|
// gate submit on a checkbox that isn't yet ticked.
|
|
7046
7353
|
const uncheckedBoxes = inventory
|
|
7047
|
-
.filter((e) =>
|
|
7048
|
-
|
|
7354
|
+
.filter((e) =>
|
|
7355
|
+
// Native <input type=checkbox> OR a custom ARIA checkbox
|
|
7356
|
+
// (<button role="checkbox">, <div role="checkbox">). The
|
|
7357
|
+
// input-only filter missed meilisearch's required agreement,
|
|
7358
|
+
// which renders as <button role="checkbox"> — so the planner
|
|
7359
|
+
// was never told to tick it and "Next" stayed disabled. The
|
|
7360
|
+
// check() executor already handles role=checkbox (it clicks +
|
|
7361
|
+
// verifies aria-checked).
|
|
7362
|
+
((e.tag === "input" && e.type === "checkbox") ||
|
|
7363
|
+
e.role === "checkbox") &&
|
|
7049
7364
|
// We can't read the actual `checked` from the inventory
|
|
7050
7365
|
// shape, but interactedThisRun is set after a successful
|
|
7051
7366
|
// `check` step. Show checkboxes the bot hasn't touched.
|
|
@@ -7229,6 +7544,34 @@ ${formatInventory(input.inventory)}`,
|
|
|
7229
7544
|
// read see the post-challenge dashboard.
|
|
7230
7545
|
continue;
|
|
7231
7546
|
}
|
|
7547
|
+
// Premature-done guard. The planner sometimes concludes "nothing
|
|
7548
|
+
// to extract" on an authenticated dashboard whose API keys live on
|
|
7549
|
+
// a settings/API-keys page it never visited — render's case: an
|
|
7550
|
+
// empty SERVICES list ("no services created yet") is NOT the same
|
|
7551
|
+
// as "no API keys", which sit under Account Settings. Before
|
|
7552
|
+
// accepting `done` with zero credentials captured, navigate to an
|
|
7553
|
+
// unvisited canonical keys URL (same fallback list the stuck-loop
|
|
7554
|
+
// escalation uses). Bounded by triedFallbackUrls — once every
|
|
7555
|
+
// candidate is exhausted, `done` is honored.
|
|
7556
|
+
const capturedCredCount = Object.keys(credentials).filter((k) => !NON_CREDENTIAL_KEYS.has(k)).length;
|
|
7557
|
+
if (capturedCredCount === 0 && prematureDoneFallbacks < MAX_PREMATURE_DONE_FALLBACKS) {
|
|
7558
|
+
const fallback = pickStuckLoopFallbackUrl(state.url, triedFallbackUrls, args.service);
|
|
7559
|
+
if (fallback !== null) {
|
|
7560
|
+
prematureDoneFallbacks += 1;
|
|
7561
|
+
triedFallbackUrls.add(fallback);
|
|
7562
|
+
args.steps.push(`Post-verify: planner emitted done with no credential captured — ` +
|
|
7563
|
+
`navigating to an unvisited API-keys URL before giving up: ${fallback}`);
|
|
7564
|
+
try {
|
|
7565
|
+
await this.browser.goto(fallback);
|
|
7566
|
+
await this.browser.waitForInteractiveDom(5, 15_000);
|
|
7567
|
+
}
|
|
7568
|
+
catch (err) {
|
|
7569
|
+
args.steps.push(`Post-verify: premature-done fallback navigate failed (${err instanceof Error ? err.message : String(err)}) — continuing.`);
|
|
7570
|
+
}
|
|
7571
|
+
hint = undefined;
|
|
7572
|
+
continue;
|
|
7573
|
+
}
|
|
7574
|
+
}
|
|
7232
7575
|
this.lastPostVerifyDoneReason = nextStep.reason;
|
|
7233
7576
|
break;
|
|
7234
7577
|
}
|
|
@@ -7358,6 +7701,18 @@ ${formatInventory(input.inventory)}`,
|
|
|
7358
7701
|
// best-effort; never abort an extract pass on DOM-proximity
|
|
7359
7702
|
// failure (page mid-navigation etc).
|
|
7360
7703
|
}
|
|
7704
|
+
// "Stops at one" guard: does THIS page present >=2 distinct
|
|
7705
|
+
// credentials (masked included)? If so, hold the loop open past
|
|
7706
|
+
// the first key so the reveal pass + DOM harvest get more rounds
|
|
7707
|
+
// to capture the siblings — even when only one value is in hand
|
|
7708
|
+
// right now. Bounded downstream by roundsSinceLastNewCredential.
|
|
7709
|
+
if (!pageOffersMultiCred) {
|
|
7710
|
+
const presented = await this.countPresentedCredentialLabels();
|
|
7711
|
+
if (presented >= 2) {
|
|
7712
|
+
pageOffersMultiCred = true;
|
|
7713
|
+
args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: page presents ${presented} distinct credentials — holding the loop open to harvest all (not just the first).`);
|
|
7714
|
+
}
|
|
7715
|
+
}
|
|
7361
7716
|
// Anything found across all tiers? hasMultiCredCredentials
|
|
7362
7717
|
// also catches non-api_key labels (cloud_name, application_id).
|
|
7363
7718
|
if (hasAnyExtractedCredential(credentials)) {
|
|
@@ -8250,6 +8605,16 @@ ${formatInventory(input.inventory)}${input.hint !== undefined ? `\n\nIMPORTANT
|
|
|
8250
8605
|
const credentials = {};
|
|
8251
8606
|
let apiKey = null;
|
|
8252
8607
|
let truncatedHit = null;
|
|
8608
|
+
// Never trust a credential read off a documentation page — it's a
|
|
8609
|
+
// realistic SAMPLE (Anthropic's /docs get-started shows a shape-valid
|
|
8610
|
+
// sk-ant-api03-... example). Returning empty keeps the post-verify loop
|
|
8611
|
+
// navigating to the real keys console instead of false-succeeding here.
|
|
8612
|
+
const curUrl = typeof this.browser.currentUrl === "function"
|
|
8613
|
+
? this.browser.currentUrl()
|
|
8614
|
+
: "";
|
|
8615
|
+
if (typeof curUrl === "string" && isDocumentationUrl(curUrl)) {
|
|
8616
|
+
return credentials;
|
|
8617
|
+
}
|
|
8253
8618
|
for (const candidate of await this.browser.extractCredentialCandidates()) {
|
|
8254
8619
|
const hit = extractApiKeyFromText(candidate);
|
|
8255
8620
|
if (hit === null)
|