@trusty-squire/mcp 0.8.14 → 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 +12 -1
- package/dist/bot/agent.d.ts.map +1 -1
- package/dist/bot/agent.js +715 -56
- 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 +88 -66
- 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
|
|
@@ -145,6 +183,17 @@ export class LLMCallBudgetExceeded extends Error {
|
|
|
145
183
|
this.name = "LLMCallBudgetExceeded";
|
|
146
184
|
}
|
|
147
185
|
}
|
|
186
|
+
// A5 — thrown from postVerifyLoop when an OAuth run keeps landing on a
|
|
187
|
+
// login page (the planner asks to log in ≥2 times): the OAuth callback
|
|
188
|
+
// never established a session, so the post-verify thrash would only end
|
|
189
|
+
// in a mislabeled oauth_onboarding_failed. The OAuth call site catches
|
|
190
|
+
// this and returns the correct oauth_session_not_persisted classification.
|
|
191
|
+
export class OAuthSessionNotPersistedError extends Error {
|
|
192
|
+
constructor(message) {
|
|
193
|
+
super(message);
|
|
194
|
+
this.name = "OAuthSessionNotPersistedError";
|
|
195
|
+
}
|
|
196
|
+
}
|
|
148
197
|
// 0.8.2-rc.10 — common dashboard paths that vendors host their
|
|
149
198
|
// per-account API key UI at. Ordered most-specific first so a
|
|
150
199
|
// fallback navigate doesn't land short of the actual page. Returned
|
|
@@ -163,18 +212,75 @@ const STUCK_LOOP_FALLBACK_PATHS = [
|
|
|
163
212
|
"/settings/tokens",
|
|
164
213
|
"/settings/api-tokens",
|
|
165
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",
|
|
166
228
|
"/account/api-keys",
|
|
167
229
|
"/account/api_tokens",
|
|
230
|
+
"/account/api-tokens",
|
|
168
231
|
"/account/keys",
|
|
169
232
|
"/account/tokens",
|
|
233
|
+
"/account/access-tokens",
|
|
170
234
|
"/api-keys",
|
|
171
235
|
"/api_keys",
|
|
236
|
+
"/api-tokens",
|
|
172
237
|
"/keys",
|
|
173
238
|
"/tokens",
|
|
239
|
+
"/access-tokens",
|
|
174
240
|
"/auth-tokens",
|
|
241
|
+
"/developers",
|
|
175
242
|
"/dashboard/api-keys",
|
|
176
243
|
"/dashboard/keys",
|
|
177
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
|
+
}
|
|
178
284
|
// 0.8.2-rc.10 — heuristic for "this account already exists on the
|
|
179
285
|
// service and its API keys are masked, with no path to reveal them."
|
|
180
286
|
// The test identity (methoxine@gmail.com) accumulates state across
|
|
@@ -241,34 +347,120 @@ export function detectExistingAccountNoExtract(input) {
|
|
|
241
347
|
return true;
|
|
242
348
|
return false;
|
|
243
349
|
}
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
|
|
248
|
-
|
|
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;
|
|
249
428
|
try {
|
|
250
|
-
|
|
429
|
+
parsed = new URL(currentUrl);
|
|
251
430
|
}
|
|
252
431
|
catch {
|
|
253
432
|
return null;
|
|
254
433
|
}
|
|
434
|
+
const origin = parsed.origin;
|
|
255
435
|
// Skip a candidate when the current URL's path ALREADY matches it
|
|
256
436
|
// (case-insensitive, trailing-slash tolerant). The planner is stuck
|
|
257
437
|
// ON the page the candidate points to — navigating to the same URL
|
|
258
438
|
// again won't break the cycle, only a different path will.
|
|
259
|
-
const currentPath = (()
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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);
|
|
268
461
|
const candidate = `${origin}${path}`;
|
|
269
462
|
if (alreadyTried.has(candidate))
|
|
270
463
|
continue;
|
|
271
|
-
const candidatePath = path.replace(/\/+$/, "").toLowerCase();
|
|
272
464
|
if (candidatePath === currentPath)
|
|
273
465
|
continue;
|
|
274
466
|
return candidate;
|
|
@@ -570,6 +762,21 @@ export function parseSignupPlan(raw, allowedSelectors) {
|
|
|
570
762
|
? { actions, submit_selector: submitSelector, confidence, notes }
|
|
571
763
|
: { actions, submit_selector: submitSelector, confidence };
|
|
572
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
|
+
}
|
|
573
780
|
// Render the element inventory as a compact text block for the
|
|
574
781
|
// planner — one line per element, ending with the verified
|
|
575
782
|
// `selector=` the planner must copy verbatim (F3 T3).
|
|
@@ -1327,6 +1534,48 @@ export function findFirstOAuthButton(inventory, providers) {
|
|
|
1327
1534
|
}
|
|
1328
1535
|
return null;
|
|
1329
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
|
+
}
|
|
1330
1579
|
// Order the OAuth providers the bot may use for a signup, given the
|
|
1331
1580
|
// service's yaml pin (if any) and the providers the persistent profile
|
|
1332
1581
|
// actually has a session for. `findFirstOAuthButton` walks this list in
|
|
@@ -1904,6 +2153,56 @@ export function extractApiKeyFromText(text) {
|
|
|
1904
2153
|
}
|
|
1905
2154
|
return null;
|
|
1906
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
|
+
}
|
|
1907
2206
|
// Choose which link in a verification email to click. Scores each URL
|
|
1908
2207
|
// by keyword and picks the best — but only if it scored positive.
|
|
1909
2208
|
//
|
|
@@ -2159,6 +2458,12 @@ export class SignupAgent {
|
|
|
2159
2458
|
let progressReplans = 0;
|
|
2160
2459
|
let emptyPlans = 0;
|
|
2161
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;
|
|
2162
2467
|
let hint;
|
|
2163
2468
|
// F14 — selectors the planner clicked WITHOUT advancing the page.
|
|
2164
2469
|
// Each no-progress plan records its click selectors here; the next
|
|
@@ -2242,6 +2547,36 @@ export class SignupAgent {
|
|
|
2242
2547
|
"treating as already authenticated, jumping to post-verify navigation");
|
|
2243
2548
|
return { kind: "already_oauth" };
|
|
2244
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
|
+
}
|
|
2245
2580
|
steps.push("OAuth-first: no usable provider affordance on the page — " +
|
|
2246
2581
|
"falling back to form-fill");
|
|
2247
2582
|
// Dump visible buttons/links so we can see what the OAuth-
|
|
@@ -2528,6 +2863,27 @@ export class SignupAgent {
|
|
|
2528
2863
|
emptyInputHint;
|
|
2529
2864
|
continue;
|
|
2530
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
|
+
}
|
|
2531
2887
|
steps.push(`⚠ submit click failed: ${reason}`);
|
|
2532
2888
|
return { kind: "submit_failed", reason };
|
|
2533
2889
|
}
|
|
@@ -3239,10 +3595,25 @@ export class SignupAgent {
|
|
|
3239
3595
|
// Step 7: Email verification + post-verification navigation.
|
|
3240
3596
|
let verificationFailed;
|
|
3241
3597
|
if (credentials.api_key === undefined && credentials.username === undefined) {
|
|
3242
|
-
// S3: read the post-submit page
|
|
3243
|
-
//
|
|
3244
|
-
//
|
|
3245
|
-
|
|
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);
|
|
3246
3617
|
if (task.inbox === undefined) {
|
|
3247
3618
|
// M2/S3: no inbox to receive a verification email (the SES
|
|
3248
3619
|
// inbound pipeline is mothballed — TODOS M1). If the page is
|
|
@@ -3262,15 +3633,25 @@ export class SignupAgent {
|
|
|
3262
3633
|
}
|
|
3263
3634
|
}
|
|
3264
3635
|
else {
|
|
3265
|
-
// S3: don't blind-wait
|
|
3266
|
-
//
|
|
3267
|
-
//
|
|
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).
|
|
3268
3645
|
const verificationTimeoutSeconds = expectsEmail
|
|
3269
3646
|
? (task.verificationTimeoutSeconds ?? 180)
|
|
3270
|
-
:
|
|
3647
|
+
: submitRejected
|
|
3648
|
+
? VERIFICATION_PROBE_SECONDS
|
|
3649
|
+
: SUBMITTED_PROBE_FLOOR_SECONDS;
|
|
3271
3650
|
steps.push(expectsEmail
|
|
3272
3651
|
? `Post-submit page asks to check email — polling inbox (up to ${verificationTimeoutSeconds}s)...`
|
|
3273
|
-
:
|
|
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)...`);
|
|
3274
3655
|
try {
|
|
3275
3656
|
const email = await this.waitForVerificationEmail(task.inbox, task.email, verificationTimeoutSeconds);
|
|
3276
3657
|
steps.push(`Received: "${email.subject}" from ${email.from_address}`);
|
|
@@ -3314,7 +3695,9 @@ export class SignupAgent {
|
|
|
3314
3695
|
steps.push(`Inbox poll failed: ${detail}`);
|
|
3315
3696
|
verificationFailed = expectsEmail
|
|
3316
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`
|
|
3317
|
-
:
|
|
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)`;
|
|
3318
3701
|
}
|
|
3319
3702
|
}
|
|
3320
3703
|
}
|
|
@@ -3927,14 +4310,25 @@ export class SignupAgent {
|
|
|
3927
4310
|
// siblings were never extracted. postVerifyLoop's top-of-iter
|
|
3928
4311
|
// early-exit is itself multi-cred-aware (rc.13), so when there's
|
|
3929
4312
|
// nothing more to do, it returns on the first iteration.
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
4313
|
+
try {
|
|
4314
|
+
credentials = await this.postVerifyLoop({
|
|
4315
|
+
service: task.service,
|
|
4316
|
+
maxRounds: task.postVerifyMaxRounds ?? 24,
|
|
4317
|
+
steps,
|
|
4318
|
+
...(task.scopeHint !== undefined ? { scopeHint: task.scopeHint } : {}),
|
|
4319
|
+
...(task.machineToken !== undefined ? { machineToken: task.machineToken } : {}),
|
|
4320
|
+
...(task.apiBase !== undefined ? { apiBase: task.apiBase } : {}),
|
|
4321
|
+
});
|
|
4322
|
+
}
|
|
4323
|
+
catch (err) {
|
|
4324
|
+
// A5 — OAuth bounced back to a login page; classify correctly
|
|
4325
|
+
// (oauth_session_not_persisted) instead of thrashing into
|
|
4326
|
+
// oauth_onboarding_failed.
|
|
4327
|
+
if (err instanceof OAuthSessionNotPersistedError) {
|
|
4328
|
+
return { success: false, error: err.message, steps, ...this.resultTail() };
|
|
4329
|
+
}
|
|
4330
|
+
throw err;
|
|
4331
|
+
}
|
|
3938
4332
|
if (credentials.api_key !== undefined) {
|
|
3939
4333
|
return {
|
|
3940
4334
|
success: true,
|
|
@@ -4423,6 +4817,206 @@ ${formatInventory(input.inventory)}`,
|
|
|
4423
4817
|
}
|
|
4424
4818
|
return out;
|
|
4425
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
|
+
}
|
|
4426
5020
|
async postVerifyLoop(args) {
|
|
4427
5021
|
let credentials = await this.extractCredentials();
|
|
4428
5022
|
// 0.8.2-rc.15 — also seed DOM-proximity at loop entry. If the
|
|
@@ -4446,6 +5040,13 @@ ${formatInventory(input.inventory)}`,
|
|
|
4446
5040
|
// DOM-proximity again, this is just an opportunistic seed.
|
|
4447
5041
|
}
|
|
4448
5042
|
let loginAttempts = 0;
|
|
5043
|
+
// A5 — on an OAuth run the planner asks to `login` only when it SEES a
|
|
5044
|
+
// login page, which after a good OAuth it shouldn't. Repeated asks
|
|
5045
|
+
// mean the OAuth callback never established a session (the page is
|
|
5046
|
+
// still a social/login screen, e.g. groq's /authenticate). Count them
|
|
5047
|
+
// so the loop can bail with oauth_session_not_persisted instead of
|
|
5048
|
+
// thrashing maxRounds and mislabeling it oauth_onboarding_failed.
|
|
5049
|
+
let oauthLoginRequests = 0;
|
|
4449
5050
|
let planFailures = 0;
|
|
4450
5051
|
// 0.8.2-rc.6 — separate counter for upstream-blip retries. Doesn't
|
|
4451
5052
|
// gate planFailures (so a transient 502 won't push us into the
|
|
@@ -5058,7 +5659,7 @@ ${formatInventory(input.inventory)}`,
|
|
|
5058
5659
|
hint = undefined;
|
|
5059
5660
|
continue;
|
|
5060
5661
|
}
|
|
5061
|
-
const fallback = pickStuckLoopFallbackUrl(state.url, triedFallbackUrls);
|
|
5662
|
+
const fallback = pickStuckLoopFallbackUrl(state.url, triedFallbackUrls, args.service);
|
|
5062
5663
|
if (fallback !== null) {
|
|
5063
5664
|
triedFallbackUrls.add(fallback);
|
|
5064
5665
|
args.steps.push(`Post-verify: stuck-loop detected ${stuckFiresAtUrl}x at ${state.url} — escalating to a hardcoded API-key URL: ${fallback}`);
|
|
@@ -5472,10 +6073,23 @@ ${formatInventory(input.inventory)}`,
|
|
|
5472
6073
|
}
|
|
5473
6074
|
else if (nextStep.kind === "login") {
|
|
5474
6075
|
if (args.credentials === undefined) {
|
|
5475
|
-
// OAuth run — no password to give
|
|
5476
|
-
//
|
|
5477
|
-
|
|
5478
|
-
|
|
6076
|
+
// OAuth run — no password to give. A single ask can be a
|
|
6077
|
+
// transient mid-render read, but a SECOND ask means the page
|
|
6078
|
+
// keeps presenting login: the OAuth session didn't persist.
|
|
6079
|
+
// Bail with the right classification (A5) instead of thrashing
|
|
6080
|
+
// the rest of maxRounds and ending in oauth_onboarding_failed.
|
|
6081
|
+
oauthLoginRequests += 1;
|
|
6082
|
+
if (oauthLoginRequests >= 2) {
|
|
6083
|
+
const st = await this.browser.getState().catch(() => null);
|
|
6084
|
+
args.steps.push("Post-verify: planner hit a login page twice on an OAuth run — " +
|
|
6085
|
+
"the OAuth session didn't persist; bailing.");
|
|
6086
|
+
throw new OAuthSessionNotPersistedError(`oauth_session_not_persisted: signed in to ${args.service} via OAuth but the ` +
|
|
6087
|
+
`page still presents a login screen (${st !== null ? pathOf(st.url) : "?"}) — the ` +
|
|
6088
|
+
`OAuth callback never established a session (anti-bot rejection of the callback, ` +
|
|
6089
|
+
`or a service-side session-storage issue). Finish the signup manually.`);
|
|
6090
|
+
}
|
|
6091
|
+
args.steps.push("Post-verify: planner asked to log in on an OAuth run — already " +
|
|
6092
|
+
"authenticated via Google; skipping (1st ask, may be a transient read).");
|
|
5479
6093
|
}
|
|
5480
6094
|
else if (loginAttempts >= 2) {
|
|
5481
6095
|
args.steps.push("Post-verify: already attempted login twice — stopping.");
|
|
@@ -5591,24 +6205,69 @@ ${formatInventory(input.inventory)}`,
|
|
|
5591
6205
|
// correctly, the state is just unrecoverable for this identity.
|
|
5592
6206
|
const alreadyClassified = this.lastPostVerifyDoneReason !== null &&
|
|
5593
6207
|
this.lastPostVerifyDoneReason.startsWith("[");
|
|
5594
|
-
|
|
5595
|
-
|
|
5596
|
-
|
|
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;
|
|
5597
6232
|
try {
|
|
5598
|
-
|
|
5599
|
-
const finalText = await this.browser.extractText().catch(() => "");
|
|
5600
|
-
if (detectExistingAccountNoExtract({
|
|
5601
|
-
url: finalState.url,
|
|
5602
|
-
pageText: finalText,
|
|
5603
|
-
lastPlannerReason: this.lastPostVerifyDoneReason ?? "",
|
|
5604
|
-
})) {
|
|
5605
|
-
this.lastPostVerifyDoneReason =
|
|
5606
|
-
`[existing_account_no_extract] at ${finalState.url}; latest planner reason: ${this.lastPostVerifyDoneReason ?? "(none — loop exhausted)"}`;
|
|
5607
|
-
args.steps.push("Post-verify: classified as existing_account_no_extract — masked pre-existing key on an authenticated dashboard.");
|
|
5608
|
-
}
|
|
6233
|
+
minted = await this.attemptMintNewKey(args.steps);
|
|
5609
6234
|
}
|
|
5610
6235
|
catch {
|
|
5611
|
-
// 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
|
+
}
|
|
5612
6271
|
}
|
|
5613
6272
|
}
|
|
5614
6273
|
return credentials;
|