@trusty-squire/mcp 0.9.13 → 0.9.14-rc.2
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/api-client.d.ts +28 -0
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +11 -0
- package/dist/api-client.js.map +1 -1
- package/dist/bot/agent.d.ts +7 -1
- package/dist/bot/agent.d.ts.map +1 -1
- package/dist/bot/agent.js +631 -40
- package/dist/bot/agent.js.map +1 -1
- package/dist/bot/browser.d.ts +15 -0
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +858 -84
- package/dist/bot/browser.js.map +1 -1
- package/dist/bot/captcha-solver-2captcha.d.ts +18 -0
- package/dist/bot/captcha-solver-2captcha.d.ts.map +1 -1
- package/dist/bot/captcha-solver-2captcha.js +21 -0
- package/dist/bot/captcha-solver-2captcha.js.map +1 -1
- package/dist/bot/email-code-fetcher.d.ts +5 -0
- package/dist/bot/email-code-fetcher.d.ts.map +1 -0
- package/dist/bot/email-code-fetcher.js +33 -0
- package/dist/bot/email-code-fetcher.js.map +1 -0
- package/dist/bot/inbox-client.d.ts +1 -0
- package/dist/bot/inbox-client.d.ts.map +1 -1
- package/dist/bot/inbox-client.js +55 -15
- package/dist/bot/inbox-client.js.map +1 -1
- package/dist/bot/index.d.ts +2 -1
- package/dist/bot/index.d.ts.map +1 -1
- package/dist/bot/index.js +49 -19
- package/dist/bot/index.js.map +1 -1
- package/dist/bot/promote-to-skill.d.ts +3 -1
- package/dist/bot/promote-to-skill.d.ts.map +1 -1
- package/dist/bot/promote-to-skill.js +122 -7
- package/dist/bot/promote-to-skill.js.map +1 -1
- package/dist/bot/replay-skill.d.ts +18 -0
- package/dist/bot/replay-skill.d.ts.map +1 -1
- package/dist/bot/replay-skill.js +290 -12
- package/dist/bot/replay-skill.js.map +1 -1
- package/dist/bot/signup-lock.d.ts +17 -0
- package/dist/bot/signup-lock.d.ts.map +1 -0
- package/dist/bot/signup-lock.js +174 -0
- package/dist/bot/signup-lock.js.map +1 -0
- package/dist/tools/grant-app-access.d.ts +31 -0
- package/dist/tools/grant-app-access.d.ts.map +1 -0
- package/dist/tools/grant-app-access.js +59 -0
- package/dist/tools/grant-app-access.js.map +1 -0
- package/dist/tools/index.d.ts +2 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +4 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/provision-any.d.ts.map +1 -1
- package/dist/tools/provision-any.js +25 -12
- package/dist/tools/provision-any.js.map +1 -1
- package/dist/tools/store-credential.d.ts +5 -0
- package/dist/tools/store-credential.d.ts.map +1 -1
- package/dist/tools/store-credential.js +13 -2
- package/dist/tools/store-credential.js.map +1 -1
- package/package.json +2 -2
- package/dist/bot/oauth-lock.d.ts +0 -2
- package/dist/bot/oauth-lock.d.ts.map +0 -1
- package/dist/bot/oauth-lock.js +0 -28
- package/dist/bot/oauth-lock.js.map +0 -1
package/dist/bot/replay-skill.js
CHANGED
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
|
|
46
46
|
import { join } from "node:path";
|
|
47
47
|
import { loggedInProviders } from "./login-state.js";
|
|
48
|
-
import { isTruncatedCapture, extractApiKeyFromText, findOAuthButton, isCredentialNoiseCandidate, } from "./agent.js";
|
|
48
|
+
import { isTruncatedCapture, extractApiKeyFromText, findOAuthButton, isCredentialNoiseCandidate, detectAlreadySignedIn, } from "./agent.js";
|
|
49
49
|
import { OAUTH_PROVIDERS, extractOAuthScopes } from "./oauth-providers.js";
|
|
50
50
|
import { scrapeGoogleScopePhrases } from "./google-login.js";
|
|
51
51
|
import { filterByNearTextHint, nearTextHintMatches, } from "./near-text-hint.js";
|
|
@@ -129,6 +129,23 @@ export async function replaySkill(input) {
|
|
|
129
129
|
// marker too, so the verifier doesn't DEMOTE an active skill over it (which
|
|
130
130
|
// was eroding OF#1 — measured: brevo demoted on a returning-user nav click).
|
|
131
131
|
let authedViaOAuth = false;
|
|
132
|
+
// Form-readiness parity with the live bot. Until the FIRST form control
|
|
133
|
+
// fills/selects successfully, an "input absent" on a fill/select is far
|
|
134
|
+
// more likely the SPA signup form still hydrating than a genuinely-absent
|
|
135
|
+
// (already-registered) onboarding field — so we wait + reload + re-validate
|
|
136
|
+
// before treating it as skippable. Once a form control succeeds the form is
|
|
137
|
+
// present, and from then on absent fields keep the account-state skip.
|
|
138
|
+
let reachedForm = false;
|
|
139
|
+
// Post-click settle parity with the live bot. A click can kick off server
|
|
140
|
+
// work BEFORE the SPA navigates (zilliz's onboarding Continue provisions a
|
|
141
|
+
// default org/project/cluster, then routes to the dashboard — several
|
|
142
|
+
// seconds). The live bot's LLM round-trip gave that window for free; the
|
|
143
|
+
// replay engine reads the next inventory ~2s after the click, sees the OLD
|
|
144
|
+
// page, and wrongly skips/fails subsequent steps as "absent". When a step
|
|
145
|
+
// doesn't resolve and the most recent EXECUTED step was a click/navigate,
|
|
146
|
+
// poll re-validation before the skip/fail cascade decides. Iteration-
|
|
147
|
+
// bounded (not wall-clock) so stubbed tests don't spin.
|
|
148
|
+
let lastExecutedWasClick = false;
|
|
132
149
|
for (let i = 0; i < skill.steps.length; i++) {
|
|
133
150
|
const step = skill.steps[i];
|
|
134
151
|
// Dry-mode short circuit: walk every step before the credential-
|
|
@@ -172,7 +189,31 @@ export async function replaySkill(input) {
|
|
|
172
189
|
}
|
|
173
190
|
// Pre-validate: would this step resolve cleanly against the
|
|
174
191
|
// current page? If not, hand to the LLM fallback.
|
|
175
|
-
|
|
192
|
+
let validation = await preValidateStep(step, browser, templateValues);
|
|
193
|
+
// Form-readiness parity: a fill/select that doesn't resolve BEFORE we've
|
|
194
|
+
// reached the form is usually the SPA still hydrating (zilliz /signup
|
|
195
|
+
// renders marketing chrome then the form). Wait for hydration + reload
|
|
196
|
+
// once + re-validate — mirroring the live bot's waitForFormReady +
|
|
197
|
+
// reload-on-shell loop — before the skip/fail cascade decides it's a
|
|
198
|
+
// genuinely-absent (already-registered) field. A fresh signup's form
|
|
199
|
+
// appears; an already-registered one never does and the skip still fires.
|
|
200
|
+
if (!validation.ok &&
|
|
201
|
+
!reachedForm &&
|
|
202
|
+
(step.kind === "fill" || step.kind === "select")) {
|
|
203
|
+
validation = await waitForFormThenRevalidate(step, browser, templateValues);
|
|
204
|
+
}
|
|
205
|
+
if (!validation.ok && lastExecutedWasClick) {
|
|
206
|
+
for (let poll = 0; poll < 6 && !validation.ok; poll++) {
|
|
207
|
+
await browser.wait(2);
|
|
208
|
+
validation = await preValidateStep(step, browser, templateValues);
|
|
209
|
+
}
|
|
210
|
+
// One settle window per click. If the page didn't produce this step's
|
|
211
|
+
// target within it, later steps shouldn't each re-pay the wait — a
|
|
212
|
+
// genuinely-diverged page (returning-user skips) would otherwise
|
|
213
|
+
// crawl through every remaining step at +12s apiece.
|
|
214
|
+
if (!validation.ok)
|
|
215
|
+
lastExecutedWasClick = false;
|
|
216
|
+
}
|
|
176
217
|
let stepToExecute = step;
|
|
177
218
|
if (!validation.ok) {
|
|
178
219
|
const fallbackResult = await tryFallback(step, validation.reason, browser, i, skill, llmFallback, candidatesDir);
|
|
@@ -230,6 +271,25 @@ export async function replaySkill(input) {
|
|
|
230
271
|
skippedOnboardingFill = true;
|
|
231
272
|
continue;
|
|
232
273
|
}
|
|
274
|
+
else if (step.kind === "click_oauth_button" &&
|
|
275
|
+
(await looksAuthenticatedReturningUser(browser))) {
|
|
276
|
+
// Returning-user login-head skip (THE dominant verify failure — measured
|
|
277
|
+
// 2026-06-12: 12/29 fails were "No element matches … for google OAuth
|
|
278
|
+
// button"). The skill was recorded on a FRESH signup, so its head is
|
|
279
|
+
// "click Continue with Google → consent → onboarding". The verifier's
|
|
280
|
+
// operator account already exists, so navigating signup_url lands an
|
|
281
|
+
// AUTHENTICATED dashboard — the provider button is simply gone. That's
|
|
282
|
+
// not rot. detectAlreadySignedIn returns false if a real login chooser
|
|
283
|
+
// (any "Continue with Google" affordance) is present, so a genuinely
|
|
284
|
+
// rotted button still fails below; it returns true only on an actual
|
|
285
|
+
// authenticated app shell. Skip the head and resume at the post-auth
|
|
286
|
+
// credential-fetch tail, in returning-user mode.
|
|
287
|
+
console.error(`[replay] step ${i} (click_oauth_button ${step.provider}) target absent, but the page ` +
|
|
288
|
+
`is an authenticated returning-user session (account already exists) — skipping the ` +
|
|
289
|
+
`login head and resuming at the post-auth credential tail.`);
|
|
290
|
+
authedViaOAuth = true;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
233
293
|
else {
|
|
234
294
|
await maybeDumpReplayDebug(browser, skill, i, validation.reason);
|
|
235
295
|
return {
|
|
@@ -245,7 +305,7 @@ export async function replaySkill(input) {
|
|
|
245
305
|
// the router can decide whether to retry or fall through to the
|
|
246
306
|
// universal bot.
|
|
247
307
|
try {
|
|
248
|
-
const execOutcome = await executeStep(stepToExecute, browser, templateValues, skill);
|
|
308
|
+
const execOutcome = await executeStep(stepToExecute, browser, templateValues, skill, input.fetchEmailCode);
|
|
249
309
|
if (execOutcome.kind === "needs_login") {
|
|
250
310
|
return { kind: "needs_login", provider: execOutcome.provider, stepIndex: i };
|
|
251
311
|
}
|
|
@@ -253,6 +313,22 @@ export async function replaySkill(input) {
|
|
|
253
313
|
// an authenticated returning-user session for the rest of the replay.
|
|
254
314
|
if (stepToExecute.kind === "click_oauth_button")
|
|
255
315
|
authedViaOAuth = true;
|
|
316
|
+
// Track form-readiness across DISTINCT forms. A successful fill/select
|
|
317
|
+
// means the CURRENT form is present; a click/navigate may move us to a
|
|
318
|
+
// NEW page whose form (zilliz's /information onboarding after the OTP)
|
|
319
|
+
// can itself still be hydrating — so re-arm the retry. Without the
|
|
320
|
+
// re-arm, the signup form hydrates but the next form's fields get
|
|
321
|
+
// eagerly skipped as "already registered".
|
|
322
|
+
if (execOutcome.kind === "filled" || execOutcome.kind === "selected") {
|
|
323
|
+
reachedForm = true;
|
|
324
|
+
lastExecutedWasClick = false;
|
|
325
|
+
}
|
|
326
|
+
else if (execOutcome.kind === "clicked" || execOutcome.kind === "navigated") {
|
|
327
|
+
reachedForm = false;
|
|
328
|
+
// Stays true across SKIPPED steps (they don't execute), so a step
|
|
329
|
+
// two slots after the click still gets the settle grace.
|
|
330
|
+
lastExecutedWasClick = true;
|
|
331
|
+
}
|
|
256
332
|
if (execOutcome.kind === "extract_ok") {
|
|
257
333
|
// We extracted a credential successfully. Validate it before
|
|
258
334
|
// declaring victory — the synthesizer's shape inference is a
|
|
@@ -371,6 +447,38 @@ export async function replaySkill(input) {
|
|
|
371
447
|
reason: "Walked entire skill graph without producing a credential.",
|
|
372
448
|
};
|
|
373
449
|
}
|
|
450
|
+
// Wait for an SPA signup form to hydrate, then re-validate the step — the
|
|
451
|
+
// replay-engine analogue of the live bot's waitForFormReady + reload-on-
|
|
452
|
+
// shell loop. A flaky hydrating SPA (zilliz /signup) renders marketing
|
|
453
|
+
// chrome first, so the one-shot post-navigate validation reads a form-less
|
|
454
|
+
// inventory; the bot retries/reloads until the form appears, and so must
|
|
455
|
+
// replay before it concludes a form control is genuinely absent. Bounded:
|
|
456
|
+
// at most three short attempts with one mid-loop reload. Returns the first
|
|
457
|
+
// passing validation, else the last failure (caller then runs its skip/fail
|
|
458
|
+
// cascade). On an already-registered account the form never appears, so
|
|
459
|
+
// this is a bounded no-op and the account-state skip still fires.
|
|
460
|
+
async function waitForFormThenRevalidate(step, browser, templateValues) {
|
|
461
|
+
let v = { ok: false, reason: "form not ready" };
|
|
462
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
463
|
+
await browser.waitForAuthWidgetHydration?.().catch(() => undefined);
|
|
464
|
+
await browser.wait(1.5);
|
|
465
|
+
if (attempt === 1) {
|
|
466
|
+
// One reload to unstick a wedged loading shell (oauthShellReloads).
|
|
467
|
+
try {
|
|
468
|
+
await browser.goto(browser.currentUrl());
|
|
469
|
+
await browser.wait(2);
|
|
470
|
+
await browser.waitForInteractiveDom?.().catch(() => undefined);
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
// navigation hiccup — the next attempt re-validates regardless
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
v = await preValidateStep(step, browser, templateValues);
|
|
477
|
+
if (v.ok)
|
|
478
|
+
return v;
|
|
479
|
+
}
|
|
480
|
+
return v;
|
|
481
|
+
}
|
|
374
482
|
async function preValidateStep(step, browser, templateValues) {
|
|
375
483
|
switch (step.kind) {
|
|
376
484
|
case "navigate": {
|
|
@@ -386,6 +494,15 @@ async function preValidateStep(step, browser, templateValues) {
|
|
|
386
494
|
return { ok: false, reason: `Invalid URL in navigate step: ${step.url}` };
|
|
387
495
|
}
|
|
388
496
|
}
|
|
497
|
+
case "await_email_code": {
|
|
498
|
+
// No meaningful DOM pre-check: the code input is found heuristically
|
|
499
|
+
// at execute time (it may be unlabeled), and the email may not have
|
|
500
|
+
// arrived yet. Accept; the executor polls the inbox and fails cleanly
|
|
501
|
+
// if no code arrives or no input is found. No useful LLM fallback
|
|
502
|
+
// exists for this step (there's no captured selector to substitute).
|
|
503
|
+
void templateValues;
|
|
504
|
+
return { ok: true };
|
|
505
|
+
}
|
|
389
506
|
case "click_oauth_button": {
|
|
390
507
|
const inventory = await browser.extractInteractiveElements();
|
|
391
508
|
const matches = inventory.filter((el) => matchesClickHint(el, step.text_match));
|
|
@@ -550,7 +667,7 @@ async function preValidateStep(step, browser, templateValues) {
|
|
|
550
667
|
}
|
|
551
668
|
case "select": {
|
|
552
669
|
const inventory = await browser.extractInteractiveElements();
|
|
553
|
-
const matches = inventory.filter((el) =>
|
|
670
|
+
const matches = inventory.filter((el) => isSelectTarget(el) && matchesLabelHint(el, step.label_hint));
|
|
554
671
|
if (matches.length === 0) {
|
|
555
672
|
return {
|
|
556
673
|
ok: false,
|
|
@@ -713,7 +830,7 @@ export function labelMatchesHint(label, hint) {
|
|
|
713
830
|
return false;
|
|
714
831
|
return a === b || a.includes(b) || b.includes(a);
|
|
715
832
|
}
|
|
716
|
-
async function executeStep(step, browser, templateValues, skill) {
|
|
833
|
+
async function executeStep(step, browser, templateValues, skill, fetchEmailCode) {
|
|
717
834
|
switch (step.kind) {
|
|
718
835
|
case "navigate": {
|
|
719
836
|
// Rebase a captured per-account subdomain onto the live session's
|
|
@@ -730,6 +847,13 @@ async function executeStep(step, browser, templateValues, skill) {
|
|
|
730
847
|
// content first, with the 2s as a floor for fast/static pages.
|
|
731
848
|
await browser.wait(2);
|
|
732
849
|
await browser.waitForInteractiveDom().catch(() => undefined);
|
|
850
|
+
// Parity with the live bot's waitForFormReady: an SPA signup page can
|
|
851
|
+
// render marketing chrome (so waitForInteractiveDom is satisfied)
|
|
852
|
+
// while the actual auth form is still an async spinner. Without this
|
|
853
|
+
// the replay reads a form-less inventory and skips the email/password
|
|
854
|
+
// fills as "absent" (zilliz /signup). Bounded; no-op once the form
|
|
855
|
+
// is present.
|
|
856
|
+
await browser.waitForAuthWidgetHydration?.().catch(() => undefined);
|
|
733
857
|
// 0.8.2-rc.22 — URL drift detection. When a skill's signup_url
|
|
734
858
|
// assumes the user is authenticated (Railway's /account/tokens
|
|
735
859
|
// captured after OAuth was done in a prior session), the
|
|
@@ -897,21 +1021,53 @@ async function executeStep(step, browser, templateValues, skill) {
|
|
|
897
1021
|
await browser.type(match.selector, value);
|
|
898
1022
|
return { kind: "filled" };
|
|
899
1023
|
}
|
|
1024
|
+
case "await_email_code": {
|
|
1025
|
+
if (fetchEmailCode === undefined) {
|
|
1026
|
+
throw new Error("await_email_code step requires a fetchEmailCode callback, but the " +
|
|
1027
|
+
"caller did not wire inbox access into the replay.");
|
|
1028
|
+
}
|
|
1029
|
+
const alias = templateValues.EMAIL_ALIAS;
|
|
1030
|
+
if (alias === undefined || alias.length === 0) {
|
|
1031
|
+
throw new Error("await_email_code step requires templateValues.EMAIL_ALIAS (the run's " +
|
|
1032
|
+
"inbox alias) to poll for the verification email.");
|
|
1033
|
+
}
|
|
1034
|
+
const code = await fetchEmailCode({ alias });
|
|
1035
|
+
if (code === null || code.length === 0) {
|
|
1036
|
+
throw new Error(`No email verification code arrived for ${alias} within the poll window.`);
|
|
1037
|
+
}
|
|
1038
|
+
const inventory = await browser.extractInteractiveElements();
|
|
1039
|
+
const target = findCodeInput(inventory, step.label_hint);
|
|
1040
|
+
if (target === null) {
|
|
1041
|
+
throw new Error("await_email_code: could not find a verification-code input on the page.");
|
|
1042
|
+
}
|
|
1043
|
+
// browser.type clicks-then-pressSequentially, which auto-distributes
|
|
1044
|
+
// across multi-box single-digit OTP inputs (Porter/Koyeb class) as
|
|
1045
|
+
// well as a single combined box.
|
|
1046
|
+
const otpPageUrl = browser.currentUrl();
|
|
1047
|
+
await browser.type(target.selector, code);
|
|
1048
|
+
// Auto-advance is racy: a keystroke landing during the widget's focus
|
|
1049
|
+
// transition gets dropped by the controlled input, leaving N-1 boxes
|
|
1050
|
+
// filled and the submit disabled (zilliz Verify, observed 2026-06-11).
|
|
1051
|
+
// Read the boxes back and re-type per-box — explicit targeting, no
|
|
1052
|
+
// auto-advance dependency — anything that didn't stick.
|
|
1053
|
+
await fixupOtpDistribution(browser, code, otpPageUrl);
|
|
1054
|
+
return { kind: "filled" };
|
|
1055
|
+
}
|
|
900
1056
|
case "select": {
|
|
901
1057
|
const inventory = await browser.extractInteractiveElements();
|
|
902
1058
|
// 0.8.2-rc.3 — apply near_text_hint filter when present so
|
|
903
1059
|
// Sentry-grid rows land on the right <select>. The original
|
|
904
1060
|
// `inventory.find` would unilaterally pick the first match.
|
|
905
1061
|
//
|
|
906
|
-
// 0.8.2-rc.21 — also restrict to
|
|
907
|
-
// textarea / select). Without this, a Railway-class
|
|
908
|
-
// a `<label for="select-X">` shares labelText with its
|
|
1062
|
+
// 0.8.2-rc.21 — also restrict to select targets (input /
|
|
1063
|
+
// textarea / select / role=combobox). Without this, a Railway-class
|
|
1064
|
+
// form where a `<label for="select-X">` shares labelText with its
|
|
909
1065
|
// `<select id="select-X">` would silently pick the label —
|
|
910
1066
|
// and selectOption(label, …) would then route into the
|
|
911
1067
|
// combobox path and fail because native selects don't reveal
|
|
912
1068
|
// options via DOM patterns. Pre-validation already filters
|
|
913
1069
|
// this way; the executor was lagging.
|
|
914
|
-
const allMatches = inventory.filter((el) =>
|
|
1070
|
+
const allMatches = inventory.filter((el) => isSelectTarget(el) && matchesLabelHint(el, step.label_hint));
|
|
915
1071
|
if (allMatches.length === 0) {
|
|
916
1072
|
throw new Error(`No select matches label_hint=${step.label_hint}`);
|
|
917
1073
|
}
|
|
@@ -1425,6 +1581,11 @@ async function findValidatedCandidate(browser, validator) {
|
|
|
1425
1581
|
try {
|
|
1426
1582
|
const candidates = await browser.extractCredentialCandidates();
|
|
1427
1583
|
for (const cand of candidates) {
|
|
1584
|
+
// Same noise gate the heuristic tiers apply — a password-manager
|
|
1585
|
+
// affordance or consent-widget word that happens to satisfy a
|
|
1586
|
+
// length-only validator must not shadow the real key.
|
|
1587
|
+
if (isCredentialNoiseCandidate(cand))
|
|
1588
|
+
continue;
|
|
1428
1589
|
if (candidateSatisfiesValidatorShape(cand, validator))
|
|
1429
1590
|
return cand;
|
|
1430
1591
|
}
|
|
@@ -1669,16 +1830,24 @@ async function maybeDumpReplayDebug(browser, skill, stepIndex, reason) {
|
|
|
1669
1830
|
.filter((e) => e.visible)
|
|
1670
1831
|
.map((e) => ({
|
|
1671
1832
|
tag: e.tag,
|
|
1833
|
+
type: e.type,
|
|
1672
1834
|
role: e.role,
|
|
1673
1835
|
text: (e.visibleText ?? "").slice(0, 60),
|
|
1674
1836
|
aria: e.ariaLabel,
|
|
1675
1837
|
label: e.labelText,
|
|
1676
1838
|
placeholder: e.placeholder,
|
|
1677
1839
|
href: e.href ?? null,
|
|
1840
|
+
selector: e.selector,
|
|
1841
|
+
// Field state is the diagnostic for "submit stays disabled" failures
|
|
1842
|
+
// (which box is actually empty?). Password values stay redacted.
|
|
1843
|
+
value: e.type === "password" ? (e.value ? "<redacted>" : "") : (e.value ?? null),
|
|
1678
1844
|
}))
|
|
1679
|
-
.filter((e) => e.text || e.aria || e.label || e.placeholder || e.href);
|
|
1845
|
+
.filter((e) => e.text || e.aria || e.label || e.placeholder || e.href || e.value);
|
|
1846
|
+
// Visible page text (toasts, validation errors, "code expired" banners)
|
|
1847
|
+
// — interactive inventory alone can't show WHY a page refused to move.
|
|
1848
|
+
const pageText = (await browser.extractText().catch(() => "")).slice(0, 1500);
|
|
1680
1849
|
const path = `/tmp/replay-debug-${skill.service}-step${stepIndex}.json`;
|
|
1681
|
-
writeFileSync(path, JSON.stringify({ service: skill.service, stepIndex, reason, url: browser.currentUrl(), interesting }, null, 2));
|
|
1850
|
+
writeFileSync(path, JSON.stringify({ service: skill.service, stepIndex, reason, url: browser.currentUrl(), pageText, interesting }, null, 2));
|
|
1682
1851
|
console.error(`[replay-debug] dumped ${path} (${interesting.length} elements)`);
|
|
1683
1852
|
}
|
|
1684
1853
|
catch {
|
|
@@ -1723,7 +1892,9 @@ export function normalizeNavPath(path) {
|
|
|
1723
1892
|
// specified attribute (case-sensitive — these are exact attribute values, not
|
|
1724
1893
|
// display text). The caller requires a UNIQUE match before trusting it.
|
|
1725
1894
|
export function matchesDomHint(el, hint) {
|
|
1726
|
-
if (hint.name === undefined && hint.id === undefined)
|
|
1895
|
+
if (hint.name === undefined && hint.id === undefined && hint.testid === undefined)
|
|
1896
|
+
return false;
|
|
1897
|
+
if (hint.testid !== undefined && (el.testId ?? null) !== hint.testid)
|
|
1727
1898
|
return false;
|
|
1728
1899
|
if (hint.name !== undefined && el.name !== hint.name)
|
|
1729
1900
|
return false;
|
|
@@ -1855,6 +2026,89 @@ function isRuntimeId(id) {
|
|
|
1855
2026
|
function isFillable(el) {
|
|
1856
2027
|
return el.tag === "input" || el.tag === "textarea" || el.tag === "select";
|
|
1857
2028
|
}
|
|
2029
|
+
// A `select` step's target is broader than isFillable: MUI/Radix-class
|
|
2030
|
+
// dropdowns render as a non-input element with role="combobox" (zilliz's
|
|
2031
|
+
// Job Title is a <div id="mui-component-select-jobTitle" role="combobox">).
|
|
2032
|
+
// browser.selectOption already drives those (click + pick option from the
|
|
2033
|
+
// popup — the capture-time path); the replay matcher was the only place
|
|
2034
|
+
// still requiring a native form tag, which made every MUI select look
|
|
2035
|
+
// "absent" and get skipped as account-state onboarding (measured live
|
|
2036
|
+
// 2026-06-11: zilliz replay left Job Title unselected, Continue no-opped,
|
|
2037
|
+
// and the failure surfaced 5 steps later as a bogus returning-user
|
|
2038
|
+
// divergence on "API Keys").
|
|
2039
|
+
function isSelectTarget(el) {
|
|
2040
|
+
return isFillable(el) || el.role === "combobox";
|
|
2041
|
+
}
|
|
2042
|
+
// Locate the verification-code input for an `await_email_code` step.
|
|
2043
|
+
// OTP inputs are frequently UNLABELED (single-digit boxes, headless
|
|
2044
|
+
// inputs) — that's exactly why a `fill` step can't carry them — so the
|
|
2045
|
+
// resolution order is: (1) explicit label_hint when present, (2) an input
|
|
2046
|
+
// whose attributes name it a code field, (3) the first code-shaped input
|
|
2047
|
+
// on the page. (3) is safe because this step only runs at the
|
|
2048
|
+
// verification gate the synthesizer placed it at, where the page is just
|
|
2049
|
+
// the code input(s) + a Verify button. Returns null when no plausible
|
|
2050
|
+
// input exists. Exported for unit tests.
|
|
2051
|
+
export function codeInputCandidates(inventory) {
|
|
2052
|
+
// Code-shaped: a visible text-entry input that is NOT an email/password/
|
|
2053
|
+
// checkbox/radio/etc. (type null/"" covers headless OTP boxes).
|
|
2054
|
+
const TEXT_ENTRY = new Set(["text", "tel", "number", "", "search"]);
|
|
2055
|
+
return inventory.filter((el) => el.tag === "input" &&
|
|
2056
|
+
el.visible !== false &&
|
|
2057
|
+
(el.type === null || TEXT_ENTRY.has(el.type)) &&
|
|
2058
|
+
el.type !== "email" &&
|
|
2059
|
+
el.type !== "password");
|
|
2060
|
+
}
|
|
2061
|
+
// Post-typing readback for an `await_email_code` step. browser.type relies
|
|
2062
|
+
// on the widget's auto-advance to distribute digits across multi-box OTP
|
|
2063
|
+
// inputs; a keystroke that fires during the focus transition is silently
|
|
2064
|
+
// dropped by the controlled input (React setState hasn't moved focus yet),
|
|
2065
|
+
// leaving a box empty and the submit button disabled. Re-read the boxes and
|
|
2066
|
+
// re-type any digit that didn't stick — per-box explicit targeting, so the
|
|
2067
|
+
// corrective pass has no auto-advance dependency. No-ops when the mapping
|
|
2068
|
+
// boxes↔digits isn't unambiguous (extra unrelated inputs on the page) or
|
|
2069
|
+
// when the widget auto-submitted on the last digit (URL changed — the new
|
|
2070
|
+
// page's inputs are NOT OTP boxes). Exported for unit tests.
|
|
2071
|
+
export async function fixupOtpDistribution(browser, code, otpPageUrl) {
|
|
2072
|
+
// Let the widget's controlled-input state settle before reading back.
|
|
2073
|
+
await browser.wait(1);
|
|
2074
|
+
if (browser.currentUrl() !== otpPageUrl)
|
|
2075
|
+
return;
|
|
2076
|
+
const boxes = codeInputCandidates(await browser.extractInteractiveElements());
|
|
2077
|
+
if (boxes.length === 1) {
|
|
2078
|
+
// Single combined input: its value should be the whole code.
|
|
2079
|
+
if ((boxes[0].value ?? "") !== code) {
|
|
2080
|
+
await browser.type(boxes[0].selector, code);
|
|
2081
|
+
}
|
|
2082
|
+
return;
|
|
2083
|
+
}
|
|
2084
|
+
if (boxes.length !== code.length)
|
|
2085
|
+
return;
|
|
2086
|
+
for (let i = 0; i < boxes.length; i++) {
|
|
2087
|
+
if ((boxes[i].value ?? "") === code.charAt(i))
|
|
2088
|
+
continue;
|
|
2089
|
+
console.error(`[replay] await_email_code: OTP box ${i + 1}/${boxes.length} holds ` +
|
|
2090
|
+
`${JSON.stringify(boxes[i].value ?? "")} after auto-advance typing — re-typing it directly.`);
|
|
2091
|
+
await browser.type(boxes[i].selector, code.charAt(i));
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
export function findCodeInput(inventory, labelHint) {
|
|
2095
|
+
const candidates = codeInputCandidates(inventory);
|
|
2096
|
+
if (candidates.length === 0)
|
|
2097
|
+
return null;
|
|
2098
|
+
if (labelHint !== undefined && labelHint.length > 0) {
|
|
2099
|
+
const byLabel = candidates.filter((el) => matchesLabelHint(el, labelHint));
|
|
2100
|
+
if (byLabel.length >= 1)
|
|
2101
|
+
return byLabel[0];
|
|
2102
|
+
}
|
|
2103
|
+
// Word-START boundary only (no trailing \b): "verif" must prefix-match
|
|
2104
|
+
// "verificationCode" / "verification_code", which a trailing \b would
|
|
2105
|
+
// break (it'd require "verif" to be a whole word).
|
|
2106
|
+
const codeRe = /\b(code|otp|verif|pin|one[\s-]?time|2fa|mfa)/i;
|
|
2107
|
+
const byAttr = candidates.filter((el) => codeRe.test(`${el.name ?? ""} ${el.id ?? ""} ${el.placeholder ?? ""} ${el.ariaLabel ?? ""} ${el.labelText ?? ""}`));
|
|
2108
|
+
if (byAttr.length >= 1)
|
|
2109
|
+
return byAttr[0];
|
|
2110
|
+
return candidates[0];
|
|
2111
|
+
}
|
|
1858
2112
|
// rc.24/rc.25 — cascading fill-target disambiguator. Shared by
|
|
1859
2113
|
// preValidate and executeStep so both arrive at the same input when
|
|
1860
2114
|
// a label matches more than once (OpenRouter's "Name" input ships
|
|
@@ -2041,6 +2295,30 @@ function markReturningUser(reason, divergent) {
|
|
|
2041
2295
|
return reason;
|
|
2042
2296
|
return `${reason} [returning-user: authenticated session diverged from fresh-signup capture (onboarding/nav element absent — not rot)]`;
|
|
2043
2297
|
}
|
|
2298
|
+
// True when the current page is an authenticated returning-user app shell — used
|
|
2299
|
+
// to decide whether an ABSENT OAuth-button step is a returning-user login-head
|
|
2300
|
+
// skip (account already exists → no provider button) vs genuine rot. Reuses the
|
|
2301
|
+
// live bot's detectAlreadySignedIn, which is conservative: it returns FALSE if
|
|
2302
|
+
// any login chooser ("Continue with Google", bare "Sign up"/"Log in") or a
|
|
2303
|
+
// credential input is visible, so a genuinely-rotted provider button on a real
|
|
2304
|
+
// login page still fails. A short settle first — the OAuth step is usually step
|
|
2305
|
+
// 0/1 right after goto(signup_url), so the returning-user dashboard may still be
|
|
2306
|
+
// painting.
|
|
2307
|
+
async function looksAuthenticatedReturningUser(browser) {
|
|
2308
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
2309
|
+
const inventory = await browser.extractInteractiveElements();
|
|
2310
|
+
if (detectAlreadySignedIn({ inventory, url: browser.currentUrl() }))
|
|
2311
|
+
return true;
|
|
2312
|
+
// A login chooser IS present (or nothing yet) → not a returning-user skip.
|
|
2313
|
+
// Give a painting dashboard one short beat, then re-check; bail fast
|
|
2314
|
+
// otherwise so a true login page doesn't cost three waits.
|
|
2315
|
+
const hasChooser = inventory.some((e) => /continue with|sign ?in with|log ?in with|sign ?up/i.test(`${e.visibleText ?? ""} ${e.ariaLabel ?? ""}`));
|
|
2316
|
+
if (hasChooser)
|
|
2317
|
+
return false;
|
|
2318
|
+
await browser.wait(2);
|
|
2319
|
+
}
|
|
2320
|
+
return false;
|
|
2321
|
+
}
|
|
2044
2322
|
// True when an absent onboarding FILL is safe to skip: the input is wholly
|
|
2045
2323
|
// absent — the verifier's operator account is already registered, so the
|
|
2046
2324
|
// service skips the signup form (cohere/deepinfra "First name" class) — and
|