@trusty-squire/mcp 0.8.2 → 0.8.3-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api-client.d.ts +51 -0
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +25 -0
- package/dist/api-client.js.map +1 -1
- package/dist/bot/agent.d.ts +3 -0
- package/dist/bot/agent.d.ts.map +1 -1
- package/dist/bot/agent.js +479 -24
- package/dist/bot/agent.js.map +1 -1
- package/dist/bot/browser.d.ts +1 -0
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +63 -5
- package/dist/bot/browser.js.map +1 -1
- package/dist/bot/google-login.d.ts +1 -0
- package/dist/bot/google-login.d.ts.map +1 -1
- package/dist/bot/google-login.js +21 -1
- package/dist/bot/google-login.js.map +1 -1
- package/dist/bot/inbox-client.d.ts +3 -0
- package/dist/bot/inbox-client.d.ts.map +1 -1
- package/dist/bot/inbox-client.js +112 -0
- package/dist/bot/inbox-client.js.map +1 -1
- package/dist/bot/near-text-hint.d.ts +4 -0
- package/dist/bot/near-text-hint.d.ts.map +1 -0
- package/dist/bot/near-text-hint.js +72 -0
- package/dist/bot/near-text-hint.js.map +1 -0
- package/dist/bot/promote-to-skill.d.ts.map +1 -1
- package/dist/bot/promote-to-skill.js +365 -49
- package/dist/bot/promote-to-skill.js.map +1 -1
- package/dist/bot/read-otp.d.ts +6 -0
- package/dist/bot/read-otp.d.ts.map +1 -1
- package/dist/bot/read-otp.js +22 -0
- package/dist/bot/read-otp.js.map +1 -1
- package/dist/bot/replay-skill.d.ts.map +1 -1
- package/dist/bot/replay-skill.js +99 -109
- package/dist/bot/replay-skill.js.map +1 -1
- package/dist/install/agents.d.ts +1 -0
- package/dist/install/agents.d.ts.map +1 -1
- package/dist/install/agents.js +32 -0
- package/dist/install/agents.js.map +1 -1
- package/dist/install/cli.d.ts.map +1 -1
- package/dist/install/cli.js +24 -2
- package/dist/install/cli.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +20 -1
- package/dist/server.js.map +1 -1
- package/dist/tools/always-load.d.ts +4 -0
- package/dist/tools/always-load.d.ts.map +1 -0
- package/dist/tools/always-load.js +6 -0
- package/dist/tools/always-load.js.map +1 -0
- package/dist/tools/delete-credential.d.ts +12 -0
- package/dist/tools/delete-credential.d.ts.map +1 -0
- package/dist/tools/delete-credential.js +23 -0
- package/dist/tools/delete-credential.js.map +1 -0
- package/dist/tools/index.d.ts +10 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +17 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/poll-credential-access.d.ts +12 -0
- package/dist/tools/poll-credential-access.d.ts.map +1 -0
- package/dist/tools/poll-credential-access.js +29 -0
- package/dist/tools/poll-credential-access.js.map +1 -0
- package/dist/tools/request-credential.d.ts +69 -0
- package/dist/tools/request-credential.d.ts.map +1 -0
- package/dist/tools/request-credential.js +69 -0
- package/dist/tools/request-credential.js.map +1 -0
- package/dist/tools/rotate-credential.d.ts +15 -0
- package/dist/tools/rotate-credential.d.ts.map +1 -0
- package/dist/tools/rotate-credential.js +29 -0
- package/dist/tools/rotate-credential.js.map +1 -0
- package/dist/tools/store-credential.d.ts +21 -0
- package/dist/tools/store-credential.d.ts.map +1 -0
- package/dist/tools/store-credential.js +50 -0
- package/dist/tools/store-credential.js.map +1 -0
- package/dist/tools/use-credential.d.ts +40 -0
- package/dist/tools/use-credential.d.ts.map +1 -0
- package/dist/tools/use-credential.js +61 -0
- package/dist/tools/use-credential.js.map +1 -0
- package/package.json +1 -1
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
import { createHash } from "node:crypto";
|
|
37
37
|
import { parseSkill, SKILL_SCHEMA_VERSION, } from "@trusty-squire/adapter-sdk";
|
|
38
38
|
import { verifyCaptureChain, } from "./onboarding-capture.js";
|
|
39
|
+
import { filterByNearTextHint } from "./near-text-hint.js";
|
|
39
40
|
/**
|
|
40
41
|
* Synthesizer version. Bumped when the translation logic changes in a
|
|
41
42
|
* way that would produce different output from the same input. The
|
|
@@ -275,7 +276,63 @@ function synthesizeSteps(rounds, runId) {
|
|
|
275
276
|
synthesizer_version: SYNTHESIZER_VERSION,
|
|
276
277
|
};
|
|
277
278
|
}
|
|
278
|
-
|
|
279
|
+
// 0.8.3-rc.1 — strip capture-time retry sequences. When the bot's
|
|
280
|
+
// planner hit a service-side validation error (Baseten / Railway:
|
|
281
|
+
// "name already in use"), it filled the same input AGAIN with a
|
|
282
|
+
// different value and re-clicked submit. The capture chain shows
|
|
283
|
+
// the full trail; at replay time the FIRST submit succeeds because
|
|
284
|
+
// each replay generates a fresh ${TOKEN_NAME}, so the retry fill
|
|
285
|
+
// has no input to find (the form already closed) and the whole
|
|
286
|
+
// skill step-fails.
|
|
287
|
+
//
|
|
288
|
+
// Heuristic: when an input-action step (fill/select/check) at index
|
|
289
|
+
// N targets the same identifying field (label_hint + near_text_hint)
|
|
290
|
+
// as an earlier step at index M, the path M..N-1 is the failed
|
|
291
|
+
// retry path. Drop M..N-1; keep step N onward. Repeat until no
|
|
292
|
+
// retry remains.
|
|
293
|
+
//
|
|
294
|
+
// Why this is safe: a legitimate "fill the same input twice" flow
|
|
295
|
+
// doesn't exist in token-creation pages (the bot's planner never
|
|
296
|
+
// emits "fill name, click somewhere unrelated, fill name again"
|
|
297
|
+
// outside a retry). For confirmation-style flows that DO re-prompt
|
|
298
|
+
// (password confirmation), the two inputs have DIFFERENT
|
|
299
|
+
// label_hints ("Password" vs "Confirm password"), so this pass
|
|
300
|
+
// doesn't fire.
|
|
301
|
+
const trimmed = stripRetrySequences(steps);
|
|
302
|
+
return { kind: "ok", steps: trimmed };
|
|
303
|
+
}
|
|
304
|
+
function stripRetrySequences(steps) {
|
|
305
|
+
// Identity key for retry detection: kind + load-bearing target
|
|
306
|
+
// fields. Two steps with the same identity refer to the same input
|
|
307
|
+
// — the later one supersedes the earlier.
|
|
308
|
+
// `check` PostVerifyStep translates to `click` in the skill, so
|
|
309
|
+
// identity keys here only fire for `fill` and `select`. Click steps
|
|
310
|
+
// are intentionally NOT keyed — same-text-button clicks in a row
|
|
311
|
+
// are legitimate flow steps (e.g. consecutive "Next" buttons in a
|
|
312
|
+
// multi-page wizard would each have text_match="Next").
|
|
313
|
+
const identityKey = (s) => {
|
|
314
|
+
if (s.kind === "fill" || s.kind === "select") {
|
|
315
|
+
return `${s.kind}|${s.label_hint}|${s.near_text_hint ?? ""}`;
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
};
|
|
319
|
+
const out = [...steps];
|
|
320
|
+
for (let i = 1; i < out.length; i++) {
|
|
321
|
+
const curKey = identityKey(out[i]);
|
|
322
|
+
if (curKey === null)
|
|
323
|
+
continue;
|
|
324
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
325
|
+
if (identityKey(out[j]) === curKey) {
|
|
326
|
+
// Drop out[j..i-1] (the failed branch INCLUDING the earlier
|
|
327
|
+
// input action and any intermediate steps that were undone
|
|
328
|
+
// by the retry — typically the failed submit click).
|
|
329
|
+
out.splice(j, i - j);
|
|
330
|
+
i = j; // re-scan from the new position
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return out;
|
|
279
336
|
}
|
|
280
337
|
// 0.8.2-rc.21 — structural equality between two skill steps, ignoring
|
|
281
338
|
// `provenance` (which differs per-round even when intent is identical).
|
|
@@ -344,6 +401,9 @@ function translateStep(observed, inventory, provenance, roundIndex, roundHtml) {
|
|
|
344
401
|
kind: "click",
|
|
345
402
|
text_match: hintResult.hint,
|
|
346
403
|
...(hintResult.role_hint !== undefined ? { role_hint: hintResult.role_hint } : {}),
|
|
404
|
+
...(hintResult.near_text_hint !== undefined
|
|
405
|
+
? { near_text_hint: hintResult.near_text_hint }
|
|
406
|
+
: {}),
|
|
347
407
|
provenance,
|
|
348
408
|
},
|
|
349
409
|
};
|
|
@@ -361,9 +421,21 @@ function translateStep(observed, inventory, provenance, roundIndex, roundHtml) {
|
|
|
361
421
|
// at the credential-creating click because that name now
|
|
362
422
|
// already exists on the service (Railway's silent duplicate-
|
|
363
423
|
// name rejection was the canonical case).
|
|
424
|
+
//
|
|
425
|
+
// 0.8.3-rc.1 — also templatize when the INPUT CONTEXT signals
|
|
426
|
+
// a token/api-key name field, regardless of the captured
|
|
427
|
+
// value's shape. The rc.17 value-shape regex missed Baseten-
|
|
428
|
+
// class captures where a different planner used a name like
|
|
429
|
+
// "ts-random" (no digits in tail) or "ts-agent-x9k2m" (two
|
|
430
|
+
// hyphens) — the synth then baked the literal, and the form's
|
|
431
|
+
// duplicate-name validation kept submit disabled on every
|
|
432
|
+
// replay. Recognising the input by its label/placeholder
|
|
433
|
+
// ("API key name", "production-api-key") closes that gap.
|
|
364
434
|
const literal = observed.value;
|
|
365
435
|
const looksGenerated = /^[a-z]{3,15}-[a-z0-9]{4,12}$/.test(literal);
|
|
366
|
-
const
|
|
436
|
+
const matchedInput = inventory.find((e) => e.selector === observed.selector);
|
|
437
|
+
const inputLooksLikeTokenName = matchedInput !== undefined && looksLikeTokenNameInput(matchedInput);
|
|
438
|
+
const valueTemplate = looksGenerated || inputLooksLikeTokenName ? "${TOKEN_NAME}" : literal;
|
|
367
439
|
return {
|
|
368
440
|
kind: "ok",
|
|
369
441
|
step: {
|
|
@@ -424,6 +496,10 @@ function translateStep(observed, inventory, provenance, roundIndex, roundHtml) {
|
|
|
424
496
|
step: {
|
|
425
497
|
kind: "click",
|
|
426
498
|
text_match: hintResult.hint,
|
|
499
|
+
...(hintResult.role_hint !== undefined ? { role_hint: hintResult.role_hint } : {}),
|
|
500
|
+
...(hintResult.near_text_hint !== undefined
|
|
501
|
+
? { near_text_hint: hintResult.near_text_hint }
|
|
502
|
+
: {}),
|
|
427
503
|
provenance,
|
|
428
504
|
},
|
|
429
505
|
};
|
|
@@ -483,29 +559,78 @@ function resolveClickHint(selector, inventory, roundIndex) {
|
|
|
483
559
|
};
|
|
484
560
|
}
|
|
485
561
|
// Ambiguity check: does this exact hint resolve to more than one
|
|
486
|
-
// element in this round? If yes, the
|
|
487
|
-
//
|
|
488
|
-
//
|
|
562
|
+
// element in this round? If yes, attempt the same near_text_hint
|
|
563
|
+
// disambiguation path that resolveLabelHint uses for fill/select.
|
|
564
|
+
// The baseten-modal case: the modal's "Create API key" submit
|
|
565
|
+
// button collides with the listing's "Create API key" trigger
|
|
566
|
+
// still rendered behind the modal. The modal's preceding inventory
|
|
567
|
+
// (form labels, "Cancel") provides a unique nearby text that pins
|
|
568
|
+
// the modal context — exactly the same shape pickRowDisambiguator
|
|
569
|
+
// already handles for fill/select.
|
|
489
570
|
const duplicates = inventory.filter((e) => pickClickText(e) === hint && e.selector !== selector);
|
|
571
|
+
const role = inferRoleHint(match);
|
|
490
572
|
if (duplicates.length > 0) {
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
`
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
573
|
+
const nearTextHint = pickRowDisambiguator(match, duplicates, inventory);
|
|
574
|
+
if (nearTextHint === null) {
|
|
575
|
+
return {
|
|
576
|
+
kind: "rejected",
|
|
577
|
+
stage: "synthesis",
|
|
578
|
+
error_kind: "ambiguous_text_match",
|
|
579
|
+
message: `Text hint ${JSON.stringify(hint)} matches ${duplicates.length + 1} elements in this round's inventory ` +
|
|
580
|
+
`AND no unique nearby visible text could be found to disambiguate via near_text_hint. ` +
|
|
581
|
+
`Hand-edit the skill with a role_hint or re-capture with a tighter prompt.`,
|
|
582
|
+
offending_round: roundIndex,
|
|
583
|
+
synthesizer_version: SYNTHESIZER_VERSION,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
const result = { kind: "ok", hint, near_text_hint: nearTextHint };
|
|
587
|
+
if (role !== undefined)
|
|
588
|
+
result.role_hint = role;
|
|
589
|
+
return result;
|
|
502
590
|
}
|
|
503
|
-
const role = inferRoleHint(match);
|
|
504
591
|
const result = { kind: "ok", hint };
|
|
505
592
|
if (role !== undefined)
|
|
506
593
|
result.role_hint = role;
|
|
507
594
|
return result;
|
|
508
595
|
}
|
|
596
|
+
// 0.8.3-rc.1 — does this fill target look like an API-key / token
|
|
597
|
+
// NAME field? Used to templatize captured literals as ${TOKEN_NAME}
|
|
598
|
+
// even when the value itself doesn't match the rc.17 shape regex.
|
|
599
|
+
//
|
|
600
|
+
// Conservative on purpose: a plain `<input name="name">` for a person
|
|
601
|
+
// is NOT a token field. We require the placeholder, labelText,
|
|
602
|
+
// ariaLabel, id or name attribute to mention API-key/token semantics
|
|
603
|
+
// — "API key", "token name", "key name", "personal access token", or
|
|
604
|
+
// a placeholder hinting at the format ("production-api-key",
|
|
605
|
+
// "my-api-key"). This catches the canonical token-name inputs across
|
|
606
|
+
// Railway, Baseten, Resend, Vercel, OpenAI, etc. without
|
|
607
|
+
// false-positiving on signup forms' "Name" fields (which lack the
|
|
608
|
+
// surrounding API/token vocabulary).
|
|
609
|
+
function looksLikeTokenNameInput(el) {
|
|
610
|
+
const hay = [
|
|
611
|
+
el.placeholder ?? "",
|
|
612
|
+
el.labelText ?? "",
|
|
613
|
+
el.ariaLabel ?? "",
|
|
614
|
+
el.name ?? "",
|
|
615
|
+
el.id ?? "",
|
|
616
|
+
el.title ?? "",
|
|
617
|
+
]
|
|
618
|
+
.join(" ")
|
|
619
|
+
.toLowerCase();
|
|
620
|
+
// Pattern A: surrounding API-key / token vocabulary.
|
|
621
|
+
if (/api[\s_-]*(?:key|token)|access[\s_-]*token|personal[\s_-]*token/.test(hay)) {
|
|
622
|
+
return true;
|
|
623
|
+
}
|
|
624
|
+
// Pattern B: explicit "<thing> name" where <thing> is token/key/secret.
|
|
625
|
+
if (/\b(?:token|key|secret)[\s_-]*name\b/.test(hay)) {
|
|
626
|
+
return true;
|
|
627
|
+
}
|
|
628
|
+
// Pattern C: well-known placeholder examples that hint at the format.
|
|
629
|
+
if (/production[-_\s]*api[-_\s]*key|my[-_\s]*api[-_\s]*key/.test(hay)) {
|
|
630
|
+
return true;
|
|
631
|
+
}
|
|
632
|
+
return false;
|
|
633
|
+
}
|
|
509
634
|
function pickClickText(el) {
|
|
510
635
|
// Prefer visibleText (what humans read); fall back through ariaLabel,
|
|
511
636
|
// title, and iconLabel for icon-only buttons. iconLabel is the most
|
|
@@ -517,13 +642,57 @@ function pickClickText(el) {
|
|
|
517
642
|
el.title ??
|
|
518
643
|
el.iconLabel ??
|
|
519
644
|
"").trim();
|
|
520
|
-
if (text.length
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
645
|
+
if (text.length > 0) {
|
|
646
|
+
// Truncate exceptionally long text — a 500-char button label is
|
|
647
|
+
// almost certainly a paragraph picked up by the inventory scraper.
|
|
648
|
+
// Cap at 80 chars; the replay engine matches by substring so the
|
|
649
|
+
// first 80 chars are plenty for disambiguation.
|
|
650
|
+
return text.length > 80 ? text.slice(0, 80) : text;
|
|
651
|
+
}
|
|
652
|
+
// 0.8.3-rc.1 — last-resort fallback to stable form attributes when
|
|
653
|
+
// the element has no human-readable text. Targets like mistral's
|
|
654
|
+
// ToS checkbox (`<input name="terms">` with id="_R_75klubsnimdb_")
|
|
655
|
+
// are otherwise unmatchable but have a stable `name`. The replay
|
|
656
|
+
// engine's matchesClickHint / matchesLabelHint were extended to
|
|
657
|
+
// also check `name` and stable `id`, so the synthesized hint pins
|
|
658
|
+
// the right element at replay time.
|
|
659
|
+
const stableAttr = pickStableAttribute(el);
|
|
660
|
+
if (stableAttr !== null)
|
|
661
|
+
return stableAttr;
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
// 0.8.3-rc.1 — pick a stable HTML attribute we can use as a fallback
|
|
665
|
+
// match hint. `name` is preferred (developers set it for form fields
|
|
666
|
+
// and rarely change it across redesigns). `id` is accepted only when
|
|
667
|
+
// it doesn't match common React component-library runtime-ID patterns
|
|
668
|
+
// (react-aria, radix, base-ui, react-aria-utils' `_R_…` / `_r_…`).
|
|
669
|
+
function pickStableAttribute(el) {
|
|
670
|
+
const name = (el.name ?? "").trim();
|
|
671
|
+
if (name.length > 0 && looksStableAttr(name))
|
|
672
|
+
return name;
|
|
673
|
+
const id = (el.id ?? "").trim();
|
|
674
|
+
if (id.length > 0 && looksStableAttr(id) && !looksLikeRuntimeId(id))
|
|
675
|
+
return id;
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
function looksStableAttr(s) {
|
|
679
|
+
// Lower-case alpha-start, alnum+hyphen+underscore+dot, length 2-40.
|
|
680
|
+
// Wider than HTML's strict name rules — some apps put dots in names.
|
|
681
|
+
return /^[a-zA-Z][a-zA-Z0-9_\-.]{1,39}$/.test(s);
|
|
682
|
+
}
|
|
683
|
+
function looksLikeRuntimeId(s) {
|
|
684
|
+
// Common library-generated unstable IDs.
|
|
685
|
+
if (/^react-aria\d+/.test(s))
|
|
686
|
+
return true;
|
|
687
|
+
if (/^radix-/.test(s))
|
|
688
|
+
return true;
|
|
689
|
+
if (/^base-ui-/.test(s))
|
|
690
|
+
return true;
|
|
691
|
+
if (/_R_[a-z0-9]+_?$/i.test(s))
|
|
692
|
+
return true;
|
|
693
|
+
if (/_r_[a-z0-9]+_?$/i.test(s))
|
|
694
|
+
return true;
|
|
695
|
+
return false;
|
|
527
696
|
}
|
|
528
697
|
function inferRoleHint(el) {
|
|
529
698
|
if (el.tag === "button" || el.role === "button")
|
|
@@ -559,18 +728,28 @@ function resolveLabelHint(selector, inventory, roundIndex) {
|
|
|
559
728
|
const comboboxButtonText = match.role === "combobox" && match.tag === "button"
|
|
560
729
|
? match.visibleText ?? null
|
|
561
730
|
: null;
|
|
562
|
-
|
|
731
|
+
let hint = (match.labelText ?? match.placeholder ?? match.ariaLabel ?? comboboxButtonText ?? "")
|
|
563
732
|
.trim();
|
|
564
733
|
if (hint.length === 0) {
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
734
|
+
// 0.8.3-rc.1 — same stable-attribute fallback as pickClickText:
|
|
735
|
+
// form fields routinely have a stable `name` attribute even when
|
|
736
|
+
// their visible labelText/placeholder/ariaLabel are absent
|
|
737
|
+
// (the label may live in a sibling element captured separately).
|
|
738
|
+
const stable = pickStableAttribute(match);
|
|
739
|
+
if (stable !== null) {
|
|
740
|
+
hint = stable;
|
|
741
|
+
}
|
|
742
|
+
else {
|
|
743
|
+
return {
|
|
744
|
+
kind: "rejected",
|
|
745
|
+
stage: "synthesis",
|
|
746
|
+
error_kind: "missing_text_hint",
|
|
747
|
+
message: `Inventory element at ${JSON.stringify(selector)} has no labelText / placeholder / ariaLabel ` +
|
|
748
|
+
`and no stable name/id attribute — cannot synthesize a fill/select label hint.`,
|
|
749
|
+
offending_round: roundIndex,
|
|
750
|
+
synthesizer_version: SYNTHESIZER_VERSION,
|
|
751
|
+
};
|
|
752
|
+
}
|
|
574
753
|
}
|
|
575
754
|
// Ambiguity check — same as click resolver. Mirror the replay
|
|
576
755
|
// engine's rc.8 isFillable filter: only input/textarea/select can
|
|
@@ -582,7 +761,12 @@ function resolveLabelHint(selector, inventory, roundIndex) {
|
|
|
582
761
|
(e.tag === "input" || e.tag === "textarea" || e.tag === "select") &&
|
|
583
762
|
(e.labelText?.trim() === hint ||
|
|
584
763
|
e.placeholder?.trim() === hint ||
|
|
585
|
-
e.ariaLabel?.trim() === hint
|
|
764
|
+
e.ariaLabel?.trim() === hint ||
|
|
765
|
+
// 0.8.3-rc.1 — stable-attribute fallback path: if our hint
|
|
766
|
+
// came from the target's `name`/`id`, a duplicate is any
|
|
767
|
+
// input/textarea/select sharing that attribute value.
|
|
768
|
+
e.name?.trim() === hint ||
|
|
769
|
+
e.id?.trim() === hint));
|
|
586
770
|
if (duplicates.length === 0) {
|
|
587
771
|
return { kind: "ok", hint };
|
|
588
772
|
}
|
|
@@ -644,6 +828,19 @@ function pickRowDisambiguator(target, siblings, inventory) {
|
|
|
644
828
|
siblingPrecedingTexts.add(t);
|
|
645
829
|
}
|
|
646
830
|
}
|
|
831
|
+
// Helper: a candidate hint qualifies only if filterByNearTextHint
|
|
832
|
+
// applied at the FULL inventory level uniquely picks the target.
|
|
833
|
+
// Local proximity isn't sufficient on its own — for the click case,
|
|
834
|
+
// a sibling button's own visibleText can land "near" the target
|
|
835
|
+
// within ±5 entries and still be the wrong hint at replay time
|
|
836
|
+
// (the replay engine scores the same way). Validating with the same
|
|
837
|
+
// function the replay engine uses makes the chosen hint correct
|
|
838
|
+
// by construction.
|
|
839
|
+
const candidates = [target, ...siblings];
|
|
840
|
+
const validates = (hint) => {
|
|
841
|
+
const result = filterByNearTextHint(candidates, hint, inventory);
|
|
842
|
+
return result.length === 1 && result[0].selector === target.selector;
|
|
843
|
+
};
|
|
647
844
|
// Walk backward from target picking the closest preceding visible-
|
|
648
845
|
// text element whose text is unique vs. siblings' preceding texts.
|
|
649
846
|
const start = Math.max(0, targetIdx - WINDOW);
|
|
@@ -655,6 +852,8 @@ function pickRowDisambiguator(target, siblings, inventory) {
|
|
|
655
852
|
continue;
|
|
656
853
|
if (siblingPrecedingTexts.has(text.toLowerCase()))
|
|
657
854
|
continue;
|
|
855
|
+
if (!validates(text))
|
|
856
|
+
continue;
|
|
658
857
|
return text;
|
|
659
858
|
}
|
|
660
859
|
// Backward sweep failed — fall back to a forward sweep within the
|
|
@@ -681,6 +880,8 @@ function pickRowDisambiguator(target, siblings, inventory) {
|
|
|
681
880
|
continue;
|
|
682
881
|
if (siblingFollowingTexts.has(text.toLowerCase()))
|
|
683
882
|
continue;
|
|
883
|
+
if (!validates(text))
|
|
884
|
+
continue;
|
|
684
885
|
return text;
|
|
685
886
|
}
|
|
686
887
|
return null;
|
|
@@ -929,7 +1130,13 @@ function inferVisibility(extractStep, rounds) {
|
|
|
929
1130
|
function validatorForShape(shape, rounds) {
|
|
930
1131
|
switch (shape) {
|
|
931
1132
|
case "uuid":
|
|
932
|
-
|
|
1133
|
+
// 0.8.3 — widened from {36, 36} so we cover cases where shape
|
|
1134
|
+
// inference flagged the credential as "uuid" because the page
|
|
1135
|
+
// had an unrelated UUID-shaped distractor near a non-UUID
|
|
1136
|
+
// credential. Replicate captured at 36 but real keys are 40
|
|
1137
|
+
// chars; widening lets the validator stop rejecting the
|
|
1138
|
+
// actually-correct extract.
|
|
1139
|
+
return { min_length: 32, max_length: 80 };
|
|
933
1140
|
case "prefix:re_":
|
|
934
1141
|
return { min_length: 24, max_length: 64 };
|
|
935
1142
|
case "prefix:sk_live":
|
|
@@ -955,11 +1162,24 @@ function validatorForShape(shape, rounds) {
|
|
|
955
1162
|
// the most-likely value's length. IPInfo's 14-char API token
|
|
956
1163
|
// is the canonical case. Fall back to a wide range if no
|
|
957
1164
|
// value can be inferred (rare).
|
|
958
|
-
|
|
1165
|
+
//
|
|
1166
|
+
// 0.8.3 — clamp the inferred bounds to PLAUSIBLE ranges so a
|
|
1167
|
+
// synthesizer mishap (capturing a 10-char masked stub like
|
|
1168
|
+
// "demo_token" as "the credential") doesn't lock the validator
|
|
1169
|
+
// to a range so tight the real 56-char key can never satisfy it.
|
|
1170
|
+
// Min stays low (services with short keys exist) but max never
|
|
1171
|
+
// drops below 64.
|
|
1172
|
+
return clampOpaqueValidator(inferOpaqueValidatorFromHtml(rounds) ?? { min_length: 8, max_length: 64 });
|
|
959
1173
|
case "username_password":
|
|
960
1174
|
return { min_length: 8, max_length: 256 };
|
|
961
1175
|
}
|
|
962
1176
|
}
|
|
1177
|
+
function clampOpaqueValidator(v) {
|
|
1178
|
+
return {
|
|
1179
|
+
min_length: Math.max(4, v.min_length),
|
|
1180
|
+
max_length: Math.max(64, v.max_length),
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
963
1183
|
// Scan the last round's HTML for short alphanumeric tokens that look
|
|
964
1184
|
// like credentials (digits + letters, no surrounding label glue
|
|
965
1185
|
// detectable). Pick the longest plausible candidate's length to
|
|
@@ -1018,14 +1238,23 @@ function inferShapeHint(extractStep, rounds) {
|
|
|
1018
1238
|
case "uuid_token": {
|
|
1019
1239
|
// uuid_token is the synthesizer's fallback when no recognized
|
|
1020
1240
|
// prefix was found in the HTML. Two sub-cases:
|
|
1021
|
-
// 1. UUID actually present (Railway-
|
|
1022
|
-
//
|
|
1023
|
-
//
|
|
1024
|
-
//
|
|
1025
|
-
//
|
|
1026
|
-
//
|
|
1241
|
+
// 1. UUID actually present near credential context (Railway-
|
|
1242
|
+
// class) → shape "uuid"
|
|
1243
|
+
// 2. UUID present but it's an unrelated session/tracking ID
|
|
1244
|
+
// (IPInfo-class — the dashboard has a hidden analytics
|
|
1245
|
+
// UUID nowhere near the api-token field) → shape "opaque"
|
|
1246
|
+
// so the validator isn't forced to 36/36 and the rc.8
|
|
1247
|
+
// candidate fallback finds the real value.
|
|
1248
|
+
//
|
|
1249
|
+
// 0.8.3-rc.1 — context-scoped scan. The whole-HTML UUID test
|
|
1250
|
+
// mis-tagged IPInfo (its real key is 14-char hex; an unrelated
|
|
1251
|
+
// session UUID elsewhere on the dashboard triggered "uuid"
|
|
1252
|
+
// shape, locking the validator out of the real value's length
|
|
1253
|
+
// range). Require the UUID to appear near credential-context
|
|
1254
|
+
// words (token/key/api/secret) within a small character window
|
|
1255
|
+
// before promoting to the "uuid" shape.
|
|
1027
1256
|
const html = rounds[rounds.length - 1]?.state.html ?? "";
|
|
1028
|
-
if (
|
|
1257
|
+
if (uuidNearCredentialContext(html)) {
|
|
1029
1258
|
return "uuid";
|
|
1030
1259
|
}
|
|
1031
1260
|
return "opaque";
|
|
@@ -1036,8 +1265,98 @@ function inferShapeHint(extractStep, rounds) {
|
|
|
1036
1265
|
// prefix or a UUID. UUID detection is the dominant case for novel
|
|
1037
1266
|
// services since they hit the copy-button path precisely because
|
|
1038
1267
|
// they don't have a recognizable prefix.
|
|
1268
|
+
//
|
|
1269
|
+
// 0.8.3-rc.1 — when the extract step carries a near_text_hint,
|
|
1270
|
+
// narrow the scan to a window around each occurrence of that hint.
|
|
1271
|
+
// The hint points at the copy-button's neighborhood (e.g. "Copy API
|
|
1272
|
+
// key" sits right next to the key value); shape patterns matching
|
|
1273
|
+
// INSIDE that window are far more likely to be the actual
|
|
1274
|
+
// credential than a coincidental match elsewhere on the page.
|
|
1039
1275
|
const lastRound = rounds[rounds.length - 1];
|
|
1040
|
-
const
|
|
1276
|
+
const fullHtml = lastRound.state.html;
|
|
1277
|
+
const nearTextHint = extractStep.kind === "extract_via_copy_button" ||
|
|
1278
|
+
extractStep.kind === "extract_via_copy_button_named"
|
|
1279
|
+
? extractStep.near_text_hint
|
|
1280
|
+
: undefined;
|
|
1281
|
+
const scopedHtml = nearTextHint !== undefined
|
|
1282
|
+
? scopeHtmlAround(fullHtml, nearTextHint)
|
|
1283
|
+
: fullHtml;
|
|
1284
|
+
// Prefer the scoped window: a prefix that hits inside the copy-
|
|
1285
|
+
// button's neighborhood is far more likely correct than one that
|
|
1286
|
+
// happens to appear in nav/footer markup elsewhere.
|
|
1287
|
+
const prefixInScope = detectPrefixShape(scopedHtml);
|
|
1288
|
+
if (prefixInScope !== null)
|
|
1289
|
+
return prefixInScope;
|
|
1290
|
+
// Fall back to whole-HTML prefix detection only when the scoped
|
|
1291
|
+
// window had no hit. This preserves the prior behavior for skills
|
|
1292
|
+
// whose synthesizer-emitted near_text_hint doesn't perfectly land
|
|
1293
|
+
// on the credential's wrapper.
|
|
1294
|
+
if (nearTextHint !== undefined) {
|
|
1295
|
+
const prefixWhole = detectPrefixShape(fullHtml);
|
|
1296
|
+
if (prefixWhole !== null)
|
|
1297
|
+
return prefixWhole;
|
|
1298
|
+
}
|
|
1299
|
+
// UUID-as-shape requires credential-context proximity. A bare UUID
|
|
1300
|
+
// somewhere on the page isn't enough — it must sit next to
|
|
1301
|
+
// token/key/api/secret vocabulary.
|
|
1302
|
+
if (uuidNearCredentialContext(scopedHtml) || uuidNearCredentialContext(fullHtml)) {
|
|
1303
|
+
return "uuid";
|
|
1304
|
+
}
|
|
1305
|
+
return "opaque";
|
|
1306
|
+
}
|
|
1307
|
+
// 0.8.3-rc.1 — true iff a UUID appears within ±200 chars of any
|
|
1308
|
+
// credential-context word in the HTML. Tracking/session UUIDs that
|
|
1309
|
+
// live in script payloads or footers don't satisfy this; the
|
|
1310
|
+
// credential UUID that the dashboard renders next to its label does.
|
|
1311
|
+
function uuidNearCredentialContext(html) {
|
|
1312
|
+
const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g;
|
|
1313
|
+
const CONTEXT = /\b(?:token|api[\s_-]*key|secret|access[\s_-]*key|api[\s_-]*token|personal[\s_-]*access)\b/gi;
|
|
1314
|
+
const WINDOW = 200;
|
|
1315
|
+
// Collect all context offsets first (cheap) and check each UUID
|
|
1316
|
+
// against them. log(n) join would be tighter; n is small enough
|
|
1317
|
+
// for a linear scan to be invisible.
|
|
1318
|
+
const contextOffsets = [];
|
|
1319
|
+
for (const m of html.matchAll(CONTEXT)) {
|
|
1320
|
+
contextOffsets.push(m.index ?? 0);
|
|
1321
|
+
}
|
|
1322
|
+
if (contextOffsets.length === 0)
|
|
1323
|
+
return false;
|
|
1324
|
+
for (const m of html.matchAll(UUID)) {
|
|
1325
|
+
const idx = m.index ?? 0;
|
|
1326
|
+
for (const co of contextOffsets) {
|
|
1327
|
+
if (Math.abs(co - idx) <= WINDOW)
|
|
1328
|
+
return true;
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
return false;
|
|
1332
|
+
}
|
|
1333
|
+
// 0.8.3-rc.1 — extract a window of HTML around each occurrence of
|
|
1334
|
+
// `anchor` so downstream shape-pattern checks only see the
|
|
1335
|
+
// credential's vicinity, not the whole page. Returns the
|
|
1336
|
+
// concatenated windows.
|
|
1337
|
+
function scopeHtmlAround(html, anchor) {
|
|
1338
|
+
if (anchor.length === 0)
|
|
1339
|
+
return html;
|
|
1340
|
+
const WINDOW = 500;
|
|
1341
|
+
const lowerHtml = html.toLowerCase();
|
|
1342
|
+
const lowerAnchor = anchor.toLowerCase();
|
|
1343
|
+
const parts = [];
|
|
1344
|
+
let from = 0;
|
|
1345
|
+
while (from < lowerHtml.length) {
|
|
1346
|
+
const idx = lowerHtml.indexOf(lowerAnchor, from);
|
|
1347
|
+
if (idx === -1)
|
|
1348
|
+
break;
|
|
1349
|
+
const start = Math.max(0, idx - WINDOW);
|
|
1350
|
+
const end = Math.min(html.length, idx + anchor.length + WINDOW);
|
|
1351
|
+
parts.push(html.slice(start, end));
|
|
1352
|
+
from = end;
|
|
1353
|
+
}
|
|
1354
|
+
// If we never matched the anchor (capture-time HTML differs in
|
|
1355
|
+
// ways the resolver normalized over), return the original HTML so
|
|
1356
|
+
// we don't lose the chance to detect a prefix at all.
|
|
1357
|
+
return parts.length === 0 ? html : parts.join("\n");
|
|
1358
|
+
}
|
|
1359
|
+
function detectPrefixShape(html) {
|
|
1041
1360
|
if (/\bre_[a-zA-Z0-9_]{20,}/.test(html))
|
|
1042
1361
|
return "prefix:re_";
|
|
1043
1362
|
if (/\bsk_live_/.test(html))
|
|
@@ -1058,10 +1377,7 @@ function inferShapeHint(extractStep, rounds) {
|
|
|
1058
1377
|
return "prefix:rnd_";
|
|
1059
1378
|
if (/\bsntry[su]_/.test(html))
|
|
1060
1379
|
return "prefix:sntry";
|
|
1061
|
-
|
|
1062
|
-
return "uuid";
|
|
1063
|
-
}
|
|
1064
|
-
return "opaque";
|
|
1380
|
+
return null;
|
|
1065
1381
|
}
|
|
1066
1382
|
// ── OAuth provider detection ─────────────────────────────────────────
|
|
1067
1383
|
function detectOAuthProvider(hint) {
|