@trusty-squire/mcp 0.8.15 → 0.8.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bot/agent.d.ts +9 -1
- package/dist/bot/agent.d.ts.map +1 -1
- package/dist/bot/agent.js +661 -44
- package/dist/bot/agent.js.map +1 -1
- package/dist/bot/browser.d.ts +2 -2
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +76 -65
- package/dist/bot/browser.js.map +1 -1
- package/dist/bot/promote-to-skill.d.ts +2 -1
- package/dist/bot/promote-to-skill.d.ts.map +1 -1
- package/dist/bot/promote-to-skill.js +26 -0
- package/dist/bot/promote-to-skill.js.map +1 -1
- package/dist/bot/replay-skill.d.ts.map +1 -1
- package/dist/bot/replay-skill.js +237 -32
- package/dist/bot/replay-skill.js.map +1 -1
- package/dist/bot/xvfb.d.ts.map +1 -1
- package/dist/bot/xvfb.js +8 -3
- package/dist/bot/xvfb.js.map +1 -1
- package/dist/install/cli.d.ts +5 -0
- package/dist/install/cli.d.ts.map +1 -1
- package/dist/install/cli.js +33 -8
- package/dist/install/cli.js.map +1 -1
- package/package.json +2 -1
package/dist/bot/agent.js
CHANGED
|
@@ -55,10 +55,48 @@ const VERIFICATION_EXPECTED_PATTERNS = [
|
|
|
55
55
|
"almost there",
|
|
56
56
|
"one more step",
|
|
57
57
|
];
|
|
58
|
-
// Short probe when the post-submit page never
|
|
59
|
-
//
|
|
58
|
+
// Short probe when, even after a settle, the post-submit page still never
|
|
59
|
+
// prompted the user to check their email AND no account-created signal
|
|
60
|
+
// appeared. Legitimate verification mail almost always lands inside a
|
|
60
61
|
// minute; this catches the fast case without 300s of dead air.
|
|
61
62
|
const VERIFICATION_PROBE_SECONDS = 45;
|
|
63
|
+
// Settle window before the SECOND post-submit page read. SPA signups
|
|
64
|
+
// (Postmark, ElevenLabs, Browserbase, Grafana Cloud, …) swap in their
|
|
65
|
+
// "check your email" confirmation screen a beat AFTER submit. Reading the
|
|
66
|
+
// DOM the instant extraction fails races that render and mislabels the
|
|
67
|
+
// run as "no email expected", collapsing the poll to the 45s probe and
|
|
68
|
+
// abandoning mail that was, in fact, on its way.
|
|
69
|
+
const SUBMIT_SETTLE_SECONDS = 3;
|
|
70
|
+
// Poll floor once the form CLEANLY SUBMITTED but the page text stayed
|
|
71
|
+
// inconclusive about an email prompt (no "check your email", but also no
|
|
72
|
+
// hard error/rejection). A clean submit means an account was created, so
|
|
73
|
+
// real verification mail is plausibly inbound; transactional senders on a
|
|
74
|
+
// fresh send (Postmark, SendGrid) routinely take longer than the 45s
|
|
75
|
+
// probe. Polling 120s here — rather than bailing at 45s — is the
|
|
76
|
+
// difference between catching that mail and a false `verification_not_sent`.
|
|
77
|
+
// Still bounded so a genuinely-silent service doesn't hold the run for the
|
|
78
|
+
// full 180s expected-email timeout.
|
|
79
|
+
const SUBMITTED_PROBE_FLOOR_SECONDS = 120;
|
|
80
|
+
// Post-submit page text that means the submit was REJECTED, not accepted —
|
|
81
|
+
// no account was created, so no verification mail is coming and even the
|
|
82
|
+
// 45s probe is wasted. Lets the bot bail immediately instead of polling.
|
|
83
|
+
// Kept conservative: only unambiguous rejection phrasings.
|
|
84
|
+
const SUBMIT_REJECTED_PATTERNS = [
|
|
85
|
+
/\balready\s+(?:registered|exists|in\s+use|taken|have\s+an\s+account)\b/i,
|
|
86
|
+
/\b(?:email|account|username)\s+(?:is\s+)?already\s+(?:registered|taken|in\s+use)\b/i,
|
|
87
|
+
/\bthat\s+email\s+is\s+already\b/i,
|
|
88
|
+
/\ban?\s+account\s+(?:with\s+(?:this|that)\s+email\s+)?already\s+exists\b/i,
|
|
89
|
+
/\bplease\s+(?:try\s+again|correct\s+the\s+errors?)\b/i,
|
|
90
|
+
/\bthis\s+field\s+is\s+required\b/i,
|
|
91
|
+
/\b(?:email|password)\s+cannot\s+be\s+empty\b/i,
|
|
92
|
+
/\binvalid\s+(?:email|password)\b/i,
|
|
93
|
+
];
|
|
94
|
+
// Exported for unit testing. True when the post-submit page reads like a
|
|
95
|
+
// rejected submit (account not created), so the bot should not poll for a
|
|
96
|
+
// verification email that will never arrive.
|
|
97
|
+
export function submitWasRejected(pageText) {
|
|
98
|
+
return SUBMIT_REJECTED_PATTERNS.some((p) => p.test(pageText));
|
|
99
|
+
}
|
|
62
100
|
// T7: page text that means the post-OAuth API key sits behind a
|
|
63
101
|
// billing / payment-method wall. When the OAuth onboarding loop ends
|
|
64
102
|
// without a key and the page reads like this, the run ends
|
|
@@ -174,18 +212,75 @@ const STUCK_LOOP_FALLBACK_PATHS = [
|
|
|
174
212
|
"/settings/tokens",
|
|
175
213
|
"/settings/api-tokens",
|
|
176
214
|
"/settings/account/api/auth-tokens/",
|
|
215
|
+
// 0.8.3-rc.2 — added after the post-OAuth onboarding drain
|
|
216
|
+
// (amplitude/groq/launchdarkly/modal/weaviate/…). These conventions
|
|
217
|
+
// were absent from the generic list and each cost a service its
|
|
218
|
+
// whole post-verify budget: LaunchDarkly hosts keys at
|
|
219
|
+
// /settings/authorization, a cohort of consoles use a bare
|
|
220
|
+
// /settings/access-tokens or /settings/api, and several put the
|
|
221
|
+
// developer surface at /settings/developers. They sit AFTER the
|
|
222
|
+
// historic, more-common paths so we don't regress an existing hit.
|
|
223
|
+
"/settings/authorization",
|
|
224
|
+
"/settings/access-tokens",
|
|
225
|
+
"/settings/developers",
|
|
226
|
+
"/settings/developer",
|
|
227
|
+
"/settings/api",
|
|
177
228
|
"/account/api-keys",
|
|
178
229
|
"/account/api_tokens",
|
|
230
|
+
"/account/api-tokens",
|
|
179
231
|
"/account/keys",
|
|
180
232
|
"/account/tokens",
|
|
233
|
+
"/account/access-tokens",
|
|
181
234
|
"/api-keys",
|
|
182
235
|
"/api_keys",
|
|
236
|
+
"/api-tokens",
|
|
183
237
|
"/keys",
|
|
184
238
|
"/tokens",
|
|
239
|
+
"/access-tokens",
|
|
185
240
|
"/auth-tokens",
|
|
241
|
+
"/developers",
|
|
186
242
|
"/dashboard/api-keys",
|
|
187
243
|
"/dashboard/keys",
|
|
188
244
|
];
|
|
245
|
+
// 0.8.3-rc.2 — curated per-service API-key paths, consulted BEFORE the
|
|
246
|
+
// generic STUCK_LOOP_FALLBACK_PATHS list when the stuck origin belongs
|
|
247
|
+
// to one of these vendors. The generic conventions can't reach these:
|
|
248
|
+
// the path is either non-obvious (LaunchDarkly's /settings/authorization),
|
|
249
|
+
// or the vendor splits the convention differently than the majority
|
|
250
|
+
// (groq keys live at a bare /keys but the planner kept guessing
|
|
251
|
+
// /settings/api-keys, which 404s). Keyed by service SLUG (lowercased,
|
|
252
|
+
// alphanumerics only) so it survives the inbox-alias slug the bot is
|
|
253
|
+
// invoked with, and additionally matched against the stuck URL's host so
|
|
254
|
+
// a curated path is only ever composed onto the vendor it was harvested
|
|
255
|
+
// from. Each entry is ordered most-specific-first.
|
|
256
|
+
//
|
|
257
|
+
// This is deliberately a SMALL map of paths the generic heuristic
|
|
258
|
+
// provably can't derive — not a 12-service URL table. Most services in
|
|
259
|
+
// the onboarding-drain cohort are fixed by the widened generic list
|
|
260
|
+
// above; only the ones whose real path is genuinely un-guessable land
|
|
261
|
+
// here.
|
|
262
|
+
const SERVICE_KEYS_PATHS = {
|
|
263
|
+
// console.groq.com/keys — the planner kept trying /settings/api-keys
|
|
264
|
+
// (404). The bare /keys IS in the generic list but lands deep in the
|
|
265
|
+
// order; pin it first for groq so the first escalation hits.
|
|
266
|
+
groq: ["/keys", "/settings/keys"],
|
|
267
|
+
// app.launchdarkly.com/settings/authorization — LD's access-token UI.
|
|
268
|
+
// /settings/api-keys and /settings/generated-credentials both 404.
|
|
269
|
+
launchdarkly: ["/settings/authorization"],
|
|
270
|
+
// Weaviate keys are issued per-cluster, but the org-level admin page
|
|
271
|
+
// is the closest reachable surface; account-scoped guesses all 404.
|
|
272
|
+
weaviate: ["/account", "/settings/api-keys"],
|
|
273
|
+
// northflank hosts user API keys under the account menu, not a
|
|
274
|
+
// top-level /settings/keys.
|
|
275
|
+
northflank: ["/account/api", "/settings/api"],
|
|
276
|
+
};
|
|
277
|
+
// Normalize a service name to the slug used as a SERVICE_KEYS_PATHS key:
|
|
278
|
+
// lowercased, alphanumerics only. Mirrors guessSignupUrl's slug rule so
|
|
279
|
+
// the inbox-alias-derived service string (e.g. "Groq Cloud") resolves to
|
|
280
|
+
// the same key the map is authored under. Exported for unit testing.
|
|
281
|
+
export function serviceSlug(service) {
|
|
282
|
+
return service.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
283
|
+
}
|
|
189
284
|
// 0.8.2-rc.10 — heuristic for "this account already exists on the
|
|
190
285
|
// service and its API keys are masked, with no path to reveal them."
|
|
191
286
|
// The test identity (methoxine@gmail.com) accumulates state across
|
|
@@ -252,34 +347,120 @@ export function detectExistingAccountNoExtract(input) {
|
|
|
252
347
|
return true;
|
|
253
348
|
return false;
|
|
254
349
|
}
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
350
|
+
// A "mint a fresh key" affordance — a button/link that creates a new
|
|
351
|
+
// API key/token. The label vocabulary is deliberately broad ("create",
|
|
352
|
+
// "generate", "new", "add" paired with a key/token noun) but must be
|
|
353
|
+
// paired with a credential noun so a bare "New project" / "Add member"
|
|
354
|
+
// button on a dashboard isn't mistaken for a key-minting control.
|
|
355
|
+
//
|
|
356
|
+
// Word-boundary-anchored to avoid matching "recreate" / "regenerate
|
|
357
|
+
// password" style false friends — though "regenerate" + a key noun IS
|
|
358
|
+
// a valid mint affordance (rotating a key produces a fresh value), so
|
|
359
|
+
// it's included explicitly.
|
|
360
|
+
const CREATE_KEY_VERB = /\b(?:create|generate|regenerate|new|add|issue|mint)\b/i;
|
|
361
|
+
const CREATE_KEY_NOUN = /\b(?:api[\s_-]*keys?|secret[\s_-]*keys?|access[\s_-]*tokens?|personal[\s_-]*access[\s_-]*tokens?|api[\s_-]*tokens?|auth[\s_-]*tokens?|tokens?|keys?|credentials?)\b/i;
|
|
362
|
+
// A standalone phrase that is unambiguously a key-minting control even
|
|
363
|
+
// without the verb+noun co-occurrence test (some buttons read just
|
|
364
|
+
// "New API key" with the verb folded into "new"). Kept separate so the
|
|
365
|
+
// generic verb/noun pairing can stay strict.
|
|
366
|
+
const CREATE_KEY_PHRASE = /\b(?:create|generate|new|add|issue|mint)\s+(?:a\s+)?(?:new\s+)?(?:api|secret|access|auth|personal\s+access)?\s*(?:keys?|tokens?|credentials?)\b/i;
|
|
367
|
+
// Scan an inventory for the single best "create new key / generate API
|
|
368
|
+
// key / new token" affordance. Returns the matching element or null.
|
|
369
|
+
// Exported for unit tests. Pure — operates on the inventory shape only,
|
|
370
|
+
// no browser access, so it can be unit-tested with synthetic elements.
|
|
371
|
+
export function findCreateKeyAffordance(inventory) {
|
|
372
|
+
const candidates = [];
|
|
373
|
+
for (const el of inventory) {
|
|
374
|
+
// Only buttons / links / role=button are mint controls; an <input>
|
|
375
|
+
// (a text field named "key") is never the create action.
|
|
376
|
+
const isClickable = el.tag === "button" ||
|
|
377
|
+
el.tag === "a" ||
|
|
378
|
+
el.role === "button" ||
|
|
379
|
+
el.role === "link";
|
|
380
|
+
if (!isClickable)
|
|
381
|
+
continue;
|
|
382
|
+
// A non-visible element can't be clicked reliably; skip when the
|
|
383
|
+
// live extractor told us it's hidden (test fixtures that omit
|
|
384
|
+
// `visible` are treated as visible).
|
|
385
|
+
if (el.visible === false)
|
|
386
|
+
continue;
|
|
387
|
+
const haystack = [
|
|
388
|
+
el.visibleText,
|
|
389
|
+
el.ariaLabel,
|
|
390
|
+
el.title,
|
|
391
|
+
el.labelText,
|
|
392
|
+
el.iconLabel,
|
|
393
|
+
]
|
|
394
|
+
.filter((s) => s !== null && s !== undefined)
|
|
395
|
+
.join(" ")
|
|
396
|
+
.trim();
|
|
397
|
+
if (haystack.length === 0)
|
|
398
|
+
continue;
|
|
399
|
+
const phraseHit = CREATE_KEY_PHRASE.test(haystack);
|
|
400
|
+
const verbNounHit = CREATE_KEY_VERB.test(haystack) && CREATE_KEY_NOUN.test(haystack);
|
|
401
|
+
if (!phraseHit && !verbNounHit)
|
|
402
|
+
continue;
|
|
403
|
+
// Score: a full phrase match is the strongest signal; an explicit
|
|
404
|
+
// "api key" / "token" noun beats a bare "key"; in-viewport beats
|
|
405
|
+
// off-screen. Highest score wins so a precise "Create API Key" is
|
|
406
|
+
// preferred over a generic "Add key".
|
|
407
|
+
let score = 0;
|
|
408
|
+
if (phraseHit)
|
|
409
|
+
score += 4;
|
|
410
|
+
if (/\bapi[\s_-]*keys?\b|\bapi[\s_-]*tokens?\b/i.test(haystack))
|
|
411
|
+
score += 2;
|
|
412
|
+
if (el.inViewport === true)
|
|
413
|
+
score += 1;
|
|
414
|
+
candidates.push({ el, score });
|
|
415
|
+
}
|
|
416
|
+
if (candidates.length === 0)
|
|
417
|
+
return null;
|
|
418
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
419
|
+
return candidates[0].el;
|
|
420
|
+
}
|
|
421
|
+
// Pick the next fallback URL to try, keyed against the origin of the
|
|
422
|
+
// currently-stuck URL. The curated SERVICE_KEYS_PATHS for the run's
|
|
423
|
+
// service (when its host matches the stuck origin) are tried FIRST,
|
|
424
|
+
// then the generic STUCK_LOOP_FALLBACK_PATHS. Returns null when every
|
|
425
|
+
// path has already been attempted. Exported for unit tests.
|
|
426
|
+
export function pickStuckLoopFallbackUrl(currentUrl, alreadyTried, service) {
|
|
427
|
+
let parsed;
|
|
260
428
|
try {
|
|
261
|
-
|
|
429
|
+
parsed = new URL(currentUrl);
|
|
262
430
|
}
|
|
263
431
|
catch {
|
|
264
432
|
return null;
|
|
265
433
|
}
|
|
434
|
+
const origin = parsed.origin;
|
|
266
435
|
// Skip a candidate when the current URL's path ALREADY matches it
|
|
267
436
|
// (case-insensitive, trailing-slash tolerant). The planner is stuck
|
|
268
437
|
// ON the page the candidate points to — navigating to the same URL
|
|
269
438
|
// again won't break the cycle, only a different path will.
|
|
270
|
-
const currentPath = (()
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
439
|
+
const currentPath = parsed.pathname.replace(/\/+$/, "").toLowerCase();
|
|
440
|
+
// Compose curated per-service paths first, but only when the stuck
|
|
441
|
+
// origin's host actually belongs to the named service. The slug is
|
|
442
|
+
// a substring of the host for the vendors we curate (groq →
|
|
443
|
+
// console.groq.com, launchdarkly → app.launchdarkly.com, …); this
|
|
444
|
+
// host gate stops a curated path from being composed onto an
|
|
445
|
+
// unrelated origin the bot wandered onto (e.g. an OAuth provider or
|
|
446
|
+
// a redirect to a marketing domain).
|
|
447
|
+
const slug = service !== undefined ? serviceSlug(service) : "";
|
|
448
|
+
const curated = slug !== "" &&
|
|
449
|
+
SERVICE_KEYS_PATHS[slug] !== undefined &&
|
|
450
|
+
parsed.hostname.toLowerCase().includes(slug)
|
|
451
|
+
? SERVICE_KEYS_PATHS[slug]
|
|
452
|
+
: [];
|
|
453
|
+
// Curated paths lead; the generic list follows. De-dup so a path that
|
|
454
|
+
// appears in both (groq's /keys, /settings/keys) isn't offered twice.
|
|
455
|
+
const seen = new Set();
|
|
456
|
+
for (const path of [...curated, ...STUCK_LOOP_FALLBACK_PATHS]) {
|
|
457
|
+
const candidatePath = path.replace(/\/+$/, "").toLowerCase();
|
|
458
|
+
if (seen.has(candidatePath))
|
|
459
|
+
continue;
|
|
460
|
+
seen.add(candidatePath);
|
|
279
461
|
const candidate = `${origin}${path}`;
|
|
280
462
|
if (alreadyTried.has(candidate))
|
|
281
463
|
continue;
|
|
282
|
-
const candidatePath = path.replace(/\/+$/, "").toLowerCase();
|
|
283
464
|
if (candidatePath === currentPath)
|
|
284
465
|
continue;
|
|
285
466
|
return candidate;
|
|
@@ -581,6 +762,21 @@ export function parseSignupPlan(raw, allowedSelectors) {
|
|
|
581
762
|
? { actions, submit_selector: submitSelector, confidence, notes }
|
|
582
763
|
: { actions, submit_selector: submitSelector, confidence };
|
|
583
764
|
}
|
|
765
|
+
// True when a clickSubmit failure is a Playwright visibility/attach
|
|
766
|
+
// timeout rather than a genuine hard error. A timeout means the submit
|
|
767
|
+
// selector resolved at plan-time but was gone by click-time — almost
|
|
768
|
+
// always because an earlier action in the same plan advanced a
|
|
769
|
+
// multi-step SPA (Paddle's "Continue" → next screen), so the right
|
|
770
|
+
// recovery is a re-plan against the new page, not a run-ending
|
|
771
|
+
// submit_failed. Matches Playwright's `locator.waitFor`/`waitForSelector`
|
|
772
|
+
// timeout text; deliberately does NOT match `submit_disabled` (handled
|
|
773
|
+
// separately) or other click errors (genuine failures). Exported for
|
|
774
|
+
// unit testing.
|
|
775
|
+
export function isSubmitTimeout(reason) {
|
|
776
|
+
if (reason.startsWith("submit_disabled"))
|
|
777
|
+
return false;
|
|
778
|
+
return /Timeout \d+ms exceeded/i.test(reason) && /waitfor|waiting for/i.test(reason);
|
|
779
|
+
}
|
|
584
780
|
// Render the element inventory as a compact text block for the
|
|
585
781
|
// planner — one line per element, ending with the verified
|
|
586
782
|
// `selector=` the planner must copy verbatim (F3 T3).
|
|
@@ -1338,6 +1534,48 @@ export function findFirstOAuthButton(inventory, providers) {
|
|
|
1338
1534
|
}
|
|
1339
1535
|
return null;
|
|
1340
1536
|
}
|
|
1537
|
+
// A page can gate the real login UI behind a generic "Sign In to
|
|
1538
|
+
// Continue" interstitial that renders NO provider affordance yet —
|
|
1539
|
+
// Qdrant's session-expiry flow redirects to /logout?aerr=expired whose
|
|
1540
|
+
// only element is a "Sign In to Continue" button; the Google button
|
|
1541
|
+
// lives one click deeper. The OAuth-first scan finds no provider
|
|
1542
|
+
// affordance and was bailing `oauth_required` without ever advancing.
|
|
1543
|
+
// This finds a generic sign-in-ish button to CLICK so the next scan can
|
|
1544
|
+
// see the provider buttons. Strictly gated on sign-in vocabulary so we
|
|
1545
|
+
// never click an arbitrary CTA: a bare "Continue" / "Get started" /
|
|
1546
|
+
// "Go to Dashboard" / 404-recovery / "Join Workspace" button does NOT
|
|
1547
|
+
// match — only text that explicitly reads as advancing into a login.
|
|
1548
|
+
// Caller bounds the number of click-throughs. Returns null when no such
|
|
1549
|
+
// affordance exists (the page is then either genuinely SSO-only, a 404,
|
|
1550
|
+
// or already authenticated). Exported for unit testing.
|
|
1551
|
+
const SIGN_IN_ADVANCE_RE = /\b(?:sign[\s-]?in|log[\s-]?in|continue with email|continue to (?:sign|log)|get started)\b/i;
|
|
1552
|
+
export function findSignInAdvanceButton(inventory, providers) {
|
|
1553
|
+
// If a provider affordance is already present, advancing is pointless
|
|
1554
|
+
// — the caller would have taken the OAuth path. Guard so a page that
|
|
1555
|
+
// has both (e.g. a "Sign in" header link + "Continue with Google")
|
|
1556
|
+
// never routes through this click-through path.
|
|
1557
|
+
if (findFirstOAuthButton(inventory, providers) !== null)
|
|
1558
|
+
return null;
|
|
1559
|
+
for (const e of inventory) {
|
|
1560
|
+
const isButtonish = e.tag === "button" ||
|
|
1561
|
+
e.tag === "a" ||
|
|
1562
|
+
e.role === "button" ||
|
|
1563
|
+
e.type === "submit" ||
|
|
1564
|
+
e.type === "button";
|
|
1565
|
+
if (!isButtonish)
|
|
1566
|
+
continue;
|
|
1567
|
+
const text = `${e.visibleText ?? ""} ${e.ariaLabel ?? ""}`
|
|
1568
|
+
.replace(/\s+/g, " ")
|
|
1569
|
+
.trim();
|
|
1570
|
+
// Sanity-cap: a real sign-in button is short. A long string is a
|
|
1571
|
+
// paragraph / card that happens to contain "sign in".
|
|
1572
|
+
if (text.length === 0 || text.length > MAX_OAUTH_BUTTON_TEXT_CHARS)
|
|
1573
|
+
continue;
|
|
1574
|
+
if (SIGN_IN_ADVANCE_RE.test(text))
|
|
1575
|
+
return e;
|
|
1576
|
+
}
|
|
1577
|
+
return null;
|
|
1578
|
+
}
|
|
1341
1579
|
// Order the OAuth providers the bot may use for a signup, given the
|
|
1342
1580
|
// service's yaml pin (if any) and the providers the persistent profile
|
|
1343
1581
|
// actually has a session for. `findFirstOAuthButton` walks this list in
|
|
@@ -1915,6 +2153,56 @@ export function extractApiKeyFromText(text) {
|
|
|
1915
2153
|
}
|
|
1916
2154
|
return null;
|
|
1917
2155
|
}
|
|
2156
|
+
// Password-manager / autofill UI affordances that render as short
|
|
2157
|
+
// word-tokens on credential pages. A render API-keys page ships a
|
|
2158
|
+
// "Save to 1Password" / "1Password" autofill button next to the real
|
|
2159
|
+
// `rnd_…` key; LastPass, Bitwarden, and Dashlane do the same. These
|
|
2160
|
+
// strings are alphanumeric, often carry a digit ("1Password"), and sit
|
|
2161
|
+
// EARLIER in DOM order than the credential — so the validator-blind
|
|
2162
|
+
// candidate-scan tiers (replay-skill.ts) used to return them as the
|
|
2163
|
+
// "credential" and the downstream length validator then rejected them
|
|
2164
|
+
// (the 0DTW2V66 render skill: `got="1Password" length 9 below min 32`).
|
|
2165
|
+
// They are never credentials; reject them at the candidate layer so the
|
|
2166
|
+
// scan moves on to the real key instead of the right key being shadowed
|
|
2167
|
+
// by a UI word. Matched case-insensitively as a whole token (the
|
|
2168
|
+
// candidates the scan tiers feed in are already whitespace-trimmed
|
|
2169
|
+
// single tokens). Exported for unit testing.
|
|
2170
|
+
const CREDENTIAL_NOISE_TOKENS = [
|
|
2171
|
+
"1password",
|
|
2172
|
+
"lastpass",
|
|
2173
|
+
"bitwarden",
|
|
2174
|
+
"dashlane",
|
|
2175
|
+
"keepass",
|
|
2176
|
+
"keeper",
|
|
2177
|
+
"nordpass",
|
|
2178
|
+
"proton pass",
|
|
2179
|
+
"protonpass",
|
|
2180
|
+
"autofill",
|
|
2181
|
+
"passwords",
|
|
2182
|
+
];
|
|
2183
|
+
// Verb-prefixed UI affordances ("Save to 1Password", "Copy to
|
|
2184
|
+
// clipboard", "Add to vault"). The candidate-scan tiers tokenize on
|
|
2185
|
+
// whitespace so a multi-word affordance rarely survives as one
|
|
2186
|
+
// candidate — but extractText()/innerText passes glue it together, so
|
|
2187
|
+
// guard the leading verbs too.
|
|
2188
|
+
const CREDENTIAL_NOISE_PREFIXES = [
|
|
2189
|
+
"save to ",
|
|
2190
|
+
"copy to ",
|
|
2191
|
+
"add to ",
|
|
2192
|
+
"store in ",
|
|
2193
|
+
];
|
|
2194
|
+
// True when a candidate string is a password-manager / autofill UI
|
|
2195
|
+
// affordance rather than a real credential value. Used by the replay
|
|
2196
|
+
// engine's raw-candidate scan tiers to keep "1Password"-class words
|
|
2197
|
+
// out of the credential slot. Exported for unit testing.
|
|
2198
|
+
export function isCredentialNoiseCandidate(candidate) {
|
|
2199
|
+
const lower = candidate.trim().toLowerCase();
|
|
2200
|
+
if (lower.length === 0)
|
|
2201
|
+
return false;
|
|
2202
|
+
if (CREDENTIAL_NOISE_TOKENS.includes(lower))
|
|
2203
|
+
return true;
|
|
2204
|
+
return CREDENTIAL_NOISE_PREFIXES.some((p) => lower.startsWith(p));
|
|
2205
|
+
}
|
|
1918
2206
|
// Choose which link in a verification email to click. Scores each URL
|
|
1919
2207
|
// by keyword and picks the best — but only if it scored positive.
|
|
1920
2208
|
//
|
|
@@ -2170,6 +2458,12 @@ export class SignupAgent {
|
|
|
2170
2458
|
let progressReplans = 0;
|
|
2171
2459
|
let emptyPlans = 0;
|
|
2172
2460
|
let oauthScanRetries = 0;
|
|
2461
|
+
// Bounded click-throughs of a generic "Sign In to Continue"
|
|
2462
|
+
// interstitial that gates the provider buttons (Qdrant). Capped so
|
|
2463
|
+
// an SSO-only page that keeps re-showing a sign-in button (or a
|
|
2464
|
+
// redirect loop) still terminates at oauth_required.
|
|
2465
|
+
let signInAdvanceClicks = 0;
|
|
2466
|
+
const MAX_SIGN_IN_ADVANCE_CLICKS = 2;
|
|
2173
2467
|
let hint;
|
|
2174
2468
|
// F14 — selectors the planner clicked WITHOUT advancing the page.
|
|
2175
2469
|
// Each no-progress plan records its click selectors here; the next
|
|
@@ -2253,6 +2547,36 @@ export class SignupAgent {
|
|
|
2253
2547
|
"treating as already authenticated, jumping to post-verify navigation");
|
|
2254
2548
|
return { kind: "already_oauth" };
|
|
2255
2549
|
}
|
|
2550
|
+
// The provider buttons can sit one click behind a generic
|
|
2551
|
+
// "Sign In to Continue" interstitial (Qdrant's session-expiry
|
|
2552
|
+
// /logout?aerr=expired redirect renders only that button). The
|
|
2553
|
+
// OAuth scan above found nothing because the real login UI
|
|
2554
|
+
// hasn't been reached yet. Click the sign-in-ish affordance to
|
|
2555
|
+
// advance, reset the async-render retries, and re-scan — bounded
|
|
2556
|
+
// so a page that just keeps showing a sign-in button (genuine
|
|
2557
|
+
// SSO-only / a redirect loop) still terminates at oauth_required
|
|
2558
|
+
// rather than clicking forever.
|
|
2559
|
+
if (signInAdvanceClicks < MAX_SIGN_IN_ADVANCE_CLICKS) {
|
|
2560
|
+
const advance = findSignInAdvanceButton(inventory, oauthCandidates);
|
|
2561
|
+
if (advance !== null) {
|
|
2562
|
+
signInAdvanceClicks += 1;
|
|
2563
|
+
steps.push(`OAuth-first: no provider affordance, but found a generic ` +
|
|
2564
|
+
`sign-in affordance (${JSON.stringify(advance.visibleText ?? advance.ariaLabel ?? "")}) ` +
|
|
2565
|
+
`— clicking it to advance to the real login page ` +
|
|
2566
|
+
`(${signInAdvanceClicks}/${MAX_SIGN_IN_ADVANCE_CLICKS})`);
|
|
2567
|
+
try {
|
|
2568
|
+
await this.browser.click(advance.selector);
|
|
2569
|
+
}
|
|
2570
|
+
catch (err) {
|
|
2571
|
+
steps.push(`OAuth-first: sign-in advance click failed (${err instanceof Error ? err.message : String(err)}) ` +
|
|
2572
|
+
`— falling through to form-fill`);
|
|
2573
|
+
}
|
|
2574
|
+
// Reset the async-render budget for the now-advanced page so
|
|
2575
|
+
// its provider buttons get the same couple of render retries.
|
|
2576
|
+
oauthScanRetries = 0;
|
|
2577
|
+
continue;
|
|
2578
|
+
}
|
|
2579
|
+
}
|
|
2256
2580
|
steps.push("OAuth-first: no usable provider affordance on the page — " +
|
|
2257
2581
|
"falling back to form-fill");
|
|
2258
2582
|
// Dump visible buttons/links so we can see what the OAuth-
|
|
@@ -2539,6 +2863,27 @@ export class SignupAgent {
|
|
|
2539
2863
|
emptyInputHint;
|
|
2540
2864
|
continue;
|
|
2541
2865
|
}
|
|
2866
|
+
// A submit-selector visibility timeout means the element the
|
|
2867
|
+
// planner picked is no longer on the page — typically because an
|
|
2868
|
+
// earlier action in THIS plan advanced a multi-step SPA (Paddle's
|
|
2869
|
+
// signup: a "Continue" click navigates to a new screen, so the
|
|
2870
|
+
// submit selector that resolved at plan-time has vanished by the
|
|
2871
|
+
// time clickSubmit polls for it). The page DID move forward, so
|
|
2872
|
+
// re-plan against the new state rather than failing the whole run
|
|
2873
|
+
// on a stale selector. Bounded by the same progressReplans
|
|
2874
|
+
// headroom as the disabled-submit / post-validation re-plans.
|
|
2875
|
+
if (isSubmitTimeout(reason)) {
|
|
2876
|
+
if (++progressReplans > MAX_PROGRESS_REPLANS) {
|
|
2877
|
+
return { kind: "submit_failed", reason };
|
|
2878
|
+
}
|
|
2879
|
+
steps.push(`⚠ submit selector went stale (${reason.split("\n")[0]}) — the page likely advanced; re-planning`);
|
|
2880
|
+
hint =
|
|
2881
|
+
"The submit button selected last round was no longer present when " +
|
|
2882
|
+
"we tried to click it — an earlier action probably advanced the page. " +
|
|
2883
|
+
"Re-read the now-visible form and plan the next step (pick the submit " +
|
|
2884
|
+
"button that is actually on the current screen).";
|
|
2885
|
+
continue;
|
|
2886
|
+
}
|
|
2542
2887
|
steps.push(`⚠ submit click failed: ${reason}`);
|
|
2543
2888
|
return { kind: "submit_failed", reason };
|
|
2544
2889
|
}
|
|
@@ -3250,10 +3595,25 @@ export class SignupAgent {
|
|
|
3250
3595
|
// Step 7: Email verification + post-verification navigation.
|
|
3251
3596
|
let verificationFailed;
|
|
3252
3597
|
if (credentials.api_key === undefined && credentials.username === undefined) {
|
|
3253
|
-
// S3: read the post-submit page
|
|
3254
|
-
//
|
|
3255
|
-
//
|
|
3256
|
-
|
|
3598
|
+
// S3: read the post-submit page to decide both the no-inbox bail
|
|
3599
|
+
// (M2) and, when an inbox exists, the poll duration. The page is
|
|
3600
|
+
// read up to TWICE: once immediately, then — only if the first
|
|
3601
|
+
// read was inconclusive — again after a short settle, because SPA
|
|
3602
|
+
// signups render the "check your email" screen a beat after submit
|
|
3603
|
+
// and sampling once races that render (the bug behind the
|
|
3604
|
+
// Postmark/ElevenLabs/Browserbase/Grafana false `verification_not_sent`).
|
|
3605
|
+
let postSubmitText = await this.browser.extractText();
|
|
3606
|
+
let expectsEmail = expectsVerificationEmail(postSubmitText);
|
|
3607
|
+
if (!expectsEmail && !submitWasRejected(postSubmitText)) {
|
|
3608
|
+
await this.browser.wait(SUBMIT_SETTLE_SECONDS);
|
|
3609
|
+
postSubmitText = await this.browser.extractText();
|
|
3610
|
+
expectsEmail = expectsVerificationEmail(postSubmitText);
|
|
3611
|
+
}
|
|
3612
|
+
// A clean submit that did NOT visibly reject created an account —
|
|
3613
|
+
// verification mail is plausibly inbound even without a "check
|
|
3614
|
+
// your email" screen. Distinguish that from a rejected submit
|
|
3615
|
+
// (already-registered, validation error) where no mail is coming.
|
|
3616
|
+
const submitRejected = submitWasRejected(postSubmitText);
|
|
3257
3617
|
if (task.inbox === undefined) {
|
|
3258
3618
|
// M2/S3: no inbox to receive a verification email (the SES
|
|
3259
3619
|
// inbound pipeline is mothballed — TODOS M1). If the page is
|
|
@@ -3273,15 +3633,25 @@ export class SignupAgent {
|
|
|
3273
3633
|
}
|
|
3274
3634
|
}
|
|
3275
3635
|
else {
|
|
3276
|
-
// S3: don't blind-wait
|
|
3277
|
-
//
|
|
3278
|
-
//
|
|
3636
|
+
// S3: don't blind-wait, but don't under-poll a clean submit
|
|
3637
|
+
// either. Three cases:
|
|
3638
|
+
// - page says "check your email" → full timeout (mail is
|
|
3639
|
+
// definitely coming).
|
|
3640
|
+
// - submit visibly rejected → 45s probe (no account was
|
|
3641
|
+
// created; no mail is coming).
|
|
3642
|
+
// - inconclusive but submit clean → 120s floor (an account was
|
|
3643
|
+
// created, so transactional mail is plausibly inbound and
|
|
3644
|
+
// can outlast the 45s probe; bounded below the full timeout).
|
|
3279
3645
|
const verificationTimeoutSeconds = expectsEmail
|
|
3280
3646
|
? (task.verificationTimeoutSeconds ?? 180)
|
|
3281
|
-
:
|
|
3647
|
+
: submitRejected
|
|
3648
|
+
? VERIFICATION_PROBE_SECONDS
|
|
3649
|
+
: SUBMITTED_PROBE_FLOOR_SECONDS;
|
|
3282
3650
|
steps.push(expectsEmail
|
|
3283
3651
|
? `Post-submit page asks to check email — polling inbox (up to ${verificationTimeoutSeconds}s)...`
|
|
3284
|
-
:
|
|
3652
|
+
: submitRejected
|
|
3653
|
+
? `Post-submit page shows a rejected submit — short ${verificationTimeoutSeconds}s probe (S3: no account created, no verification email expected)...`
|
|
3654
|
+
: `Post-submit page is inconclusive but submit was clean — polling inbox up to ${verificationTimeoutSeconds}s (S3: an account may have been created and mail can lag)...`);
|
|
3285
3655
|
try {
|
|
3286
3656
|
const email = await this.waitForVerificationEmail(task.inbox, task.email, verificationTimeoutSeconds);
|
|
3287
3657
|
steps.push(`Received: "${email.subject}" from ${email.from_address}`);
|
|
@@ -3325,7 +3695,9 @@ export class SignupAgent {
|
|
|
3325
3695
|
steps.push(`Inbox poll failed: ${detail}`);
|
|
3326
3696
|
verificationFailed = expectsEmail
|
|
3327
3697
|
? `verification_not_sent: form submitted and the page asked to check email, but none arrived in ${verificationTimeoutSeconds}s — the service likely withheld it (anti-abuse) or requires manual signup`
|
|
3328
|
-
:
|
|
3698
|
+
: submitRejected
|
|
3699
|
+
? `verification_not_sent: form submitted but the page reported a rejected submit (already-registered / validation error) and no mail arrived in the ${verificationTimeoutSeconds}s probe — no account was created`
|
|
3700
|
+
: `verification_not_sent: form submitted cleanly but no "check your email" prompt appeared and none arrived in ${verificationTimeoutSeconds}s — the service likely withheld it (fresh-domain anti-abuse) or verifies by another channel (SMS / authenticator)`;
|
|
3329
3701
|
}
|
|
3330
3702
|
}
|
|
3331
3703
|
}
|
|
@@ -4445,6 +4817,206 @@ ${formatInventory(input.inventory)}`,
|
|
|
4445
4817
|
}
|
|
4446
4818
|
return out;
|
|
4447
4819
|
}
|
|
4820
|
+
// Run every visible-credential extraction tier the post-verify loop
|
|
4821
|
+
// uses (legacy regex/clipboard/hidden-input + DOM-proximity labeled),
|
|
4822
|
+
// merging first-wins into a single bundle. Used by attemptMintNewKey
|
|
4823
|
+
// so the freshly-minted key — which may render as a modal value, a
|
|
4824
|
+
// copy-button-only token, or a labeled table row — is caught by
|
|
4825
|
+
// whichever tier fits the vendor's reveal UI.
|
|
4826
|
+
async harvestVisibleCredentials() {
|
|
4827
|
+
const out = {};
|
|
4828
|
+
try {
|
|
4829
|
+
const legacy = await this.extractCredentials();
|
|
4830
|
+
for (const [k, v] of Object.entries(legacy)) {
|
|
4831
|
+
if (out[k] === undefined)
|
|
4832
|
+
out[k] = v;
|
|
4833
|
+
}
|
|
4834
|
+
}
|
|
4835
|
+
catch {
|
|
4836
|
+
// best-effort — fall through to DOM-proximity
|
|
4837
|
+
}
|
|
4838
|
+
try {
|
|
4839
|
+
const labeled = await this.extractFromDomProximity();
|
|
4840
|
+
for (const [k, v] of Object.entries(labeled)) {
|
|
4841
|
+
if (out[k] === undefined)
|
|
4842
|
+
out[k] = v;
|
|
4843
|
+
}
|
|
4844
|
+
}
|
|
4845
|
+
catch {
|
|
4846
|
+
// best-effort
|
|
4847
|
+
}
|
|
4848
|
+
return out;
|
|
4849
|
+
}
|
|
4850
|
+
// SUCCEED on an already-signed-in / existing-account dashboard.
|
|
4851
|
+
//
|
|
4852
|
+
// When the bot lands on an authenticated dashboard whose API-keys
|
|
4853
|
+
// page shows only NAMES of pre-existing keys (values are shown once
|
|
4854
|
+
// at create-time and are unrecoverable), the historic behavior was to
|
|
4855
|
+
// bail with existing_account_no_extract / no_credentials_after_already
|
|
4856
|
+
// _signed_in. That's a usable outcome the bot threw away: a new key is
|
|
4857
|
+
// a perfectly valid credential.
|
|
4858
|
+
//
|
|
4859
|
+
// This routine, given the current (existing-account) state:
|
|
4860
|
+
// 1. Tries to extract a READABLE key right where we are — some
|
|
4861
|
+
// dashboards do show a usable value (a default key on a freshly
|
|
4862
|
+
// reused account, or a reveal affordance the loop hadn't fired).
|
|
4863
|
+
// 2. If the current page isn't a keys page, walks the same
|
|
4864
|
+
// hardcoded keys-path fallbacks the stuck-loop escalation uses,
|
|
4865
|
+
// re-trying the readable-key extract at each.
|
|
4866
|
+
// 3. On a keys page with no readable key, finds a "Create new key /
|
|
4867
|
+
// Generate API key / New token" affordance and CLICKS it, then
|
|
4868
|
+
// harvests the freshly-minted value (modal reveal + copy-button +
|
|
4869
|
+
// clipboard + labeled-row, via harvestVisibleCredentials, after a
|
|
4870
|
+
// short poll for the server round-trip that mints the key).
|
|
4871
|
+
//
|
|
4872
|
+
// Returns the minted/extracted credentials on success, or null when
|
|
4873
|
+
// there is genuinely no way to produce a key for this identity (no
|
|
4874
|
+
// create affordance anywhere — e.g. key creation is paywalled). The
|
|
4875
|
+
// caller then falls through to the honest existing_account bail.
|
|
4876
|
+
//
|
|
4877
|
+
// Best-effort throughout: any browser error degrades to "couldn't
|
|
4878
|
+
// mint" (null) rather than throwing — the existing classifier remains
|
|
4879
|
+
// the safety net.
|
|
4880
|
+
async attemptMintNewKey(steps) {
|
|
4881
|
+
// The set of keys-page URLs we've navigated, so the fallback walk
|
|
4882
|
+
// doesn't revisit one and the create-affordance search doesn't
|
|
4883
|
+
// re-click on a page already shown to lack one.
|
|
4884
|
+
const visitedKeysUrls = new Set();
|
|
4885
|
+
// Attempt readable-extract → create-and-extract on whatever page is
|
|
4886
|
+
// currently loaded. Returns credentials on success, null otherwise.
|
|
4887
|
+
const tryHere = async () => {
|
|
4888
|
+
// (a) A readable key already on the page (or behind a reveal
|
|
4889
|
+
// affordance the post-verify loop hadn't clicked yet).
|
|
4890
|
+
let creds = await this.harvestVisibleCredentials();
|
|
4891
|
+
if (hasAnyExtractedCredential(creds)) {
|
|
4892
|
+
steps.push("Existing-account recovery: a readable key was already present on the keys page — extracted it.");
|
|
4893
|
+
return creds;
|
|
4894
|
+
}
|
|
4895
|
+
// (b) Reveal pass — a masked-but-revealable existing key.
|
|
4896
|
+
try {
|
|
4897
|
+
const revealRes = await this.browser.revealMaskedCredentials();
|
|
4898
|
+
if (revealRes.clicked > 0) {
|
|
4899
|
+
await this.browser.wait(1);
|
|
4900
|
+
creds = await this.harvestVisibleCredentials();
|
|
4901
|
+
if (hasAnyExtractedCredential(creds)) {
|
|
4902
|
+
steps.push(`Existing-account recovery: revealed a masked existing key (clicked ${revealRes.clicked}) and extracted it.`);
|
|
4903
|
+
return creds;
|
|
4904
|
+
}
|
|
4905
|
+
}
|
|
4906
|
+
}
|
|
4907
|
+
catch {
|
|
4908
|
+
// best-effort reveal
|
|
4909
|
+
}
|
|
4910
|
+
// (c) Mint a fresh key. Find + click a create affordance, then
|
|
4911
|
+
// harvest the newly-shown value.
|
|
4912
|
+
const inventory = await this.buildInventory(steps, undefined, 80);
|
|
4913
|
+
const createBtn = findCreateKeyAffordance(inventory);
|
|
4914
|
+
if (createBtn === null)
|
|
4915
|
+
return null;
|
|
4916
|
+
const label = (createBtn.visibleText ??
|
|
4917
|
+
createBtn.ariaLabel ??
|
|
4918
|
+
createBtn.title ??
|
|
4919
|
+
"create key").trim();
|
|
4920
|
+
steps.push(`Existing-account recovery: no readable key — clicking a key-minting affordance ${JSON.stringify(label.slice(0, 40))}.`);
|
|
4921
|
+
try {
|
|
4922
|
+
await this.browser.click(createBtn.selector);
|
|
4923
|
+
}
|
|
4924
|
+
catch (err) {
|
|
4925
|
+
steps.push(`Existing-account recovery: create-key click failed (${err instanceof Error ? err.message : String(err)}).`);
|
|
4926
|
+
return null;
|
|
4927
|
+
}
|
|
4928
|
+
// Poll for the freshly-minted key — minting is a server
|
|
4929
|
+
// round-trip (Render/Mistral/Mailtrap render the value into a
|
|
4930
|
+
// modal after the POST returns). Reuse the modal-reveal poll
|
|
4931
|
+
// budget the click branch uses elsewhere (~8s), early-exiting the
|
|
4932
|
+
// moment any tier surfaces a credential. A confirmation dialog
|
|
4933
|
+
// ("Name your key" → Create) is common; fire the reveal pass each
|
|
4934
|
+
// round so a modal that needs a second confirm-then-show click is
|
|
4935
|
+
// still harvested.
|
|
4936
|
+
const deadline = Date.now() + 8000;
|
|
4937
|
+
while (Date.now() < deadline) {
|
|
4938
|
+
await this.browser.wait(0.5);
|
|
4939
|
+
const minted = await this.harvestVisibleCredentials();
|
|
4940
|
+
if (hasAnyExtractedCredential(minted)) {
|
|
4941
|
+
steps.push("Existing-account recovery: extracted the freshly-minted key.");
|
|
4942
|
+
return minted;
|
|
4943
|
+
}
|
|
4944
|
+
// A two-step create modal: clicking the page-level "Create key"
|
|
4945
|
+
// opened a "name + confirm" dialog. Click a now-visible confirm
|
|
4946
|
+
// affordance once, then keep polling.
|
|
4947
|
+
try {
|
|
4948
|
+
const modalInv = await this.browser.extractInteractiveElements();
|
|
4949
|
+
const confirmBtn = findCreateKeyAffordance(modalInv);
|
|
4950
|
+
if (confirmBtn !== null &&
|
|
4951
|
+
confirmBtn.selector !== createBtn.selector) {
|
|
4952
|
+
await this.browser.click(confirmBtn.selector);
|
|
4953
|
+
}
|
|
4954
|
+
}
|
|
4955
|
+
catch {
|
|
4956
|
+
// best-effort confirm
|
|
4957
|
+
}
|
|
4958
|
+
}
|
|
4959
|
+
// Minted but the value didn't surface — try one last reveal +
|
|
4960
|
+
// harvest (some vendors render the new key masked with a Show
|
|
4961
|
+
// toggle even on first display).
|
|
4962
|
+
try {
|
|
4963
|
+
const revealRes = await this.browser.revealMaskedCredentials();
|
|
4964
|
+
if (revealRes.clicked > 0) {
|
|
4965
|
+
await this.browser.wait(1);
|
|
4966
|
+
const afterReveal = await this.harvestVisibleCredentials();
|
|
4967
|
+
if (hasAnyExtractedCredential(afterReveal)) {
|
|
4968
|
+
steps.push("Existing-account recovery: revealed and extracted the freshly-minted key.");
|
|
4969
|
+
return afterReveal;
|
|
4970
|
+
}
|
|
4971
|
+
}
|
|
4972
|
+
}
|
|
4973
|
+
catch {
|
|
4974
|
+
// best-effort
|
|
4975
|
+
}
|
|
4976
|
+
return null;
|
|
4977
|
+
};
|
|
4978
|
+
// Step 1 — try on the current page first.
|
|
4979
|
+
try {
|
|
4980
|
+
const state = await this.browser.getState();
|
|
4981
|
+
visitedKeysUrls.add(state.url);
|
|
4982
|
+
if (EXISTING_KEY_URL_HINT.test(state.url)) {
|
|
4983
|
+
const here = await tryHere();
|
|
4984
|
+
if (here !== null)
|
|
4985
|
+
return here;
|
|
4986
|
+
}
|
|
4987
|
+
}
|
|
4988
|
+
catch {
|
|
4989
|
+
// best-effort — fall through to the fallback walk
|
|
4990
|
+
}
|
|
4991
|
+
// Step 2 — walk the hardcoded keys-path fallbacks. Even if Step 1
|
|
4992
|
+
// ran (current page WAS a keys page but had no affordance), a
|
|
4993
|
+
// different keys URL on the same origin may carry the create
|
|
4994
|
+
// control (org-scoped vs account-scoped keys pages).
|
|
4995
|
+
for (let i = 0; i < STUCK_LOOP_FALLBACK_PATHS.length; i++) {
|
|
4996
|
+
let currentUrl;
|
|
4997
|
+
try {
|
|
4998
|
+
currentUrl = (await this.browser.getState()).url;
|
|
4999
|
+
}
|
|
5000
|
+
catch {
|
|
5001
|
+
break;
|
|
5002
|
+
}
|
|
5003
|
+
const fallback = pickStuckLoopFallbackUrl(currentUrl, visitedKeysUrls);
|
|
5004
|
+
if (fallback === null)
|
|
5005
|
+
break;
|
|
5006
|
+
visitedKeysUrls.add(fallback);
|
|
5007
|
+
try {
|
|
5008
|
+
await this.browser.goto(fallback);
|
|
5009
|
+
await this.browser.waitForInteractiveDom(5, 15_000);
|
|
5010
|
+
}
|
|
5011
|
+
catch {
|
|
5012
|
+
continue;
|
|
5013
|
+
}
|
|
5014
|
+
const here = await tryHere();
|
|
5015
|
+
if (here !== null)
|
|
5016
|
+
return here;
|
|
5017
|
+
}
|
|
5018
|
+
return null;
|
|
5019
|
+
}
|
|
4448
5020
|
async postVerifyLoop(args) {
|
|
4449
5021
|
let credentials = await this.extractCredentials();
|
|
4450
5022
|
// 0.8.2-rc.15 — also seed DOM-proximity at loop entry. If the
|
|
@@ -5087,7 +5659,7 @@ ${formatInventory(input.inventory)}`,
|
|
|
5087
5659
|
hint = undefined;
|
|
5088
5660
|
continue;
|
|
5089
5661
|
}
|
|
5090
|
-
const fallback = pickStuckLoopFallbackUrl(state.url, triedFallbackUrls);
|
|
5662
|
+
const fallback = pickStuckLoopFallbackUrl(state.url, triedFallbackUrls, args.service);
|
|
5091
5663
|
if (fallback !== null) {
|
|
5092
5664
|
triedFallbackUrls.add(fallback);
|
|
5093
5665
|
args.steps.push(`Post-verify: stuck-loop detected ${stuckFiresAtUrl}x at ${state.url} — escalating to a hardcoded API-key URL: ${fallback}`);
|
|
@@ -5633,24 +6205,69 @@ ${formatInventory(input.inventory)}`,
|
|
|
5633
6205
|
// correctly, the state is just unrecoverable for this identity.
|
|
5634
6206
|
const alreadyClassified = this.lastPostVerifyDoneReason !== null &&
|
|
5635
6207
|
this.lastPostVerifyDoneReason.startsWith("[");
|
|
5636
|
-
|
|
5637
|
-
|
|
5638
|
-
|
|
6208
|
+
const noCredentialYet = credentials.api_key === undefined && credentials.username === undefined;
|
|
6209
|
+
// Distinct from a generic prior classification: ONLY the existing-
|
|
6210
|
+
// account path is recoverable by minting. A [stuck_loop] / [paywall]
|
|
6211
|
+
// / [anti_bot] marker is a different failure the mint flow can't fix,
|
|
6212
|
+
// so leave those alone.
|
|
6213
|
+
const alreadyExistingAccount = this.lastPostVerifyDoneReason !== null &&
|
|
6214
|
+
this.lastPostVerifyDoneReason.startsWith("[existing_account_no_extract]");
|
|
6215
|
+
// The mint flow is appropriate for the existing-account /
|
|
6216
|
+
// already-signed-in category ONLY. A [stuck_loop] / [paywall] /
|
|
6217
|
+
// [anti_bot] marker is a different, non-recoverable failure — leave
|
|
6218
|
+
// those alone. An UNclassified exit reaching here IS the
|
|
6219
|
+
// already-signed-in case (postVerifyLoop only runs on an
|
|
6220
|
+
// authenticated dashboard — `already_oauth` / post-OAuth), so it's
|
|
6221
|
+
// eligible too; the mint flow self-gates by requiring a real keys
|
|
6222
|
+
// page + a create affordance before it acts.
|
|
6223
|
+
const mintEligible = noCredentialYet && (!alreadyClassified || alreadyExistingAccount);
|
|
6224
|
+
if (mintEligible) {
|
|
6225
|
+
// SUCCEED-EVEN-WHEN-ACCOUNT-EXISTS: before bailing, navigate to
|
|
6226
|
+
// the keys page and either extract a readable key or mint a fresh
|
|
6227
|
+
// one. A new key is a valid outcome. attemptMintNewKey returns
|
|
6228
|
+
// null when there is genuinely no create affordance anywhere (key
|
|
6229
|
+
// creation paywalled / no keys page) — then we fall through to the
|
|
6230
|
+
// honest bail.
|
|
6231
|
+
let minted = null;
|
|
5639
6232
|
try {
|
|
5640
|
-
|
|
5641
|
-
const finalText = await this.browser.extractText().catch(() => "");
|
|
5642
|
-
if (detectExistingAccountNoExtract({
|
|
5643
|
-
url: finalState.url,
|
|
5644
|
-
pageText: finalText,
|
|
5645
|
-
lastPlannerReason: this.lastPostVerifyDoneReason ?? "",
|
|
5646
|
-
})) {
|
|
5647
|
-
this.lastPostVerifyDoneReason =
|
|
5648
|
-
`[existing_account_no_extract] at ${finalState.url}; latest planner reason: ${this.lastPostVerifyDoneReason ?? "(none — loop exhausted)"}`;
|
|
5649
|
-
args.steps.push("Post-verify: classified as existing_account_no_extract — masked pre-existing key on an authenticated dashboard.");
|
|
5650
|
-
}
|
|
6233
|
+
minted = await this.attemptMintNewKey(args.steps);
|
|
5651
6234
|
}
|
|
5652
6235
|
catch {
|
|
5653
|
-
// best-effort
|
|
6236
|
+
// best-effort — degrade to the existing classifier below
|
|
6237
|
+
}
|
|
6238
|
+
if (minted !== null && hasAnyExtractedCredential(minted)) {
|
|
6239
|
+
for (const [k, v] of Object.entries(minted)) {
|
|
6240
|
+
if (credentials[k] === undefined)
|
|
6241
|
+
credentials[k] = v;
|
|
6242
|
+
}
|
|
6243
|
+
// Clear any existing-account sentinel — we recovered.
|
|
6244
|
+
if (alreadyExistingAccount)
|
|
6245
|
+
this.lastPostVerifyDoneReason = null;
|
|
6246
|
+
args.steps.push("Post-verify: existing-account / already-signed-in dashboard recovered — minted/extracted a usable key instead of bailing.");
|
|
6247
|
+
return credentials;
|
|
6248
|
+
}
|
|
6249
|
+
// Mint failed. If the masked-pre-existing-key shape is detectable
|
|
6250
|
+
// AND not already flagged by the stuck-loop early-exit, mark the
|
|
6251
|
+
// honest existing_account_no_extract bail so the caller surfaces
|
|
6252
|
+
// the precise status rather than the generic
|
|
6253
|
+
// no_credentials_after_already_signed_in.
|
|
6254
|
+
if (!alreadyExistingAccount) {
|
|
6255
|
+
try {
|
|
6256
|
+
const finalState = await this.browser.getState();
|
|
6257
|
+
const finalText = await this.browser.extractText().catch(() => "");
|
|
6258
|
+
if (detectExistingAccountNoExtract({
|
|
6259
|
+
url: finalState.url,
|
|
6260
|
+
pageText: finalText,
|
|
6261
|
+
lastPlannerReason: this.lastPostVerifyDoneReason ?? "",
|
|
6262
|
+
})) {
|
|
6263
|
+
this.lastPostVerifyDoneReason =
|
|
6264
|
+
`[existing_account_no_extract] at ${finalState.url}; latest planner reason: ${this.lastPostVerifyDoneReason ?? "(none — loop exhausted)"}`;
|
|
6265
|
+
args.steps.push("Post-verify: classified as existing_account_no_extract — pre-existing keys are masked and no key-minting affordance was found.");
|
|
6266
|
+
}
|
|
6267
|
+
}
|
|
6268
|
+
catch {
|
|
6269
|
+
// best-effort classifier — never block returning credentials
|
|
6270
|
+
}
|
|
5654
6271
|
}
|
|
5655
6272
|
}
|
|
5656
6273
|
return credentials;
|