@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.
Files changed (77) hide show
  1. package/dist/api-client.d.ts +51 -0
  2. package/dist/api-client.d.ts.map +1 -1
  3. package/dist/api-client.js +25 -0
  4. package/dist/api-client.js.map +1 -1
  5. package/dist/bot/agent.d.ts +3 -0
  6. package/dist/bot/agent.d.ts.map +1 -1
  7. package/dist/bot/agent.js +479 -24
  8. package/dist/bot/agent.js.map +1 -1
  9. package/dist/bot/browser.d.ts +1 -0
  10. package/dist/bot/browser.d.ts.map +1 -1
  11. package/dist/bot/browser.js +63 -5
  12. package/dist/bot/browser.js.map +1 -1
  13. package/dist/bot/google-login.d.ts +1 -0
  14. package/dist/bot/google-login.d.ts.map +1 -1
  15. package/dist/bot/google-login.js +21 -1
  16. package/dist/bot/google-login.js.map +1 -1
  17. package/dist/bot/inbox-client.d.ts +3 -0
  18. package/dist/bot/inbox-client.d.ts.map +1 -1
  19. package/dist/bot/inbox-client.js +112 -0
  20. package/dist/bot/inbox-client.js.map +1 -1
  21. package/dist/bot/near-text-hint.d.ts +4 -0
  22. package/dist/bot/near-text-hint.d.ts.map +1 -0
  23. package/dist/bot/near-text-hint.js +72 -0
  24. package/dist/bot/near-text-hint.js.map +1 -0
  25. package/dist/bot/promote-to-skill.d.ts.map +1 -1
  26. package/dist/bot/promote-to-skill.js +365 -49
  27. package/dist/bot/promote-to-skill.js.map +1 -1
  28. package/dist/bot/read-otp.d.ts +6 -0
  29. package/dist/bot/read-otp.d.ts.map +1 -1
  30. package/dist/bot/read-otp.js +22 -0
  31. package/dist/bot/read-otp.js.map +1 -1
  32. package/dist/bot/replay-skill.d.ts.map +1 -1
  33. package/dist/bot/replay-skill.js +99 -109
  34. package/dist/bot/replay-skill.js.map +1 -1
  35. package/dist/install/agents.d.ts +1 -0
  36. package/dist/install/agents.d.ts.map +1 -1
  37. package/dist/install/agents.js +32 -0
  38. package/dist/install/agents.js.map +1 -1
  39. package/dist/install/cli.d.ts.map +1 -1
  40. package/dist/install/cli.js +24 -2
  41. package/dist/install/cli.js.map +1 -1
  42. package/dist/server.d.ts.map +1 -1
  43. package/dist/server.js +20 -1
  44. package/dist/server.js.map +1 -1
  45. package/dist/tools/always-load.d.ts +4 -0
  46. package/dist/tools/always-load.d.ts.map +1 -0
  47. package/dist/tools/always-load.js +6 -0
  48. package/dist/tools/always-load.js.map +1 -0
  49. package/dist/tools/delete-credential.d.ts +12 -0
  50. package/dist/tools/delete-credential.d.ts.map +1 -0
  51. package/dist/tools/delete-credential.js +23 -0
  52. package/dist/tools/delete-credential.js.map +1 -0
  53. package/dist/tools/index.d.ts +10 -1
  54. package/dist/tools/index.d.ts.map +1 -1
  55. package/dist/tools/index.js +17 -1
  56. package/dist/tools/index.js.map +1 -1
  57. package/dist/tools/poll-credential-access.d.ts +12 -0
  58. package/dist/tools/poll-credential-access.d.ts.map +1 -0
  59. package/dist/tools/poll-credential-access.js +29 -0
  60. package/dist/tools/poll-credential-access.js.map +1 -0
  61. package/dist/tools/request-credential.d.ts +69 -0
  62. package/dist/tools/request-credential.d.ts.map +1 -0
  63. package/dist/tools/request-credential.js +69 -0
  64. package/dist/tools/request-credential.js.map +1 -0
  65. package/dist/tools/rotate-credential.d.ts +15 -0
  66. package/dist/tools/rotate-credential.d.ts.map +1 -0
  67. package/dist/tools/rotate-credential.js +29 -0
  68. package/dist/tools/rotate-credential.js.map +1 -0
  69. package/dist/tools/store-credential.d.ts +21 -0
  70. package/dist/tools/store-credential.d.ts.map +1 -0
  71. package/dist/tools/store-credential.js +50 -0
  72. package/dist/tools/store-credential.js.map +1 -0
  73. package/dist/tools/use-credential.d.ts +40 -0
  74. package/dist/tools/use-credential.d.ts.map +1 -0
  75. package/dist/tools/use-credential.js +61 -0
  76. package/dist/tools/use-credential.js.map +1 -0
  77. 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
- return { kind: "ok", steps };
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 valueTemplate = looksGenerated ? "${TOKEN_NAME}" : literal;
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 replay engine will need a
487
- // disambiguator we don't have. Reject so the operator can either
488
- // re-capture with a better-scoped page or hand-edit the skill.
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
- return {
492
- kind: "rejected",
493
- stage: "synthesis",
494
- error_kind: "ambiguous_text_match",
495
- message: `Text hint ${JSON.stringify(hint)} matches ${duplicates.length + 1} elements in this round's inventory. ` +
496
- `Cannot uniquely identify the click target by text. Either the page genuinely has multiple ` +
497
- `same-named controls (skill needs hand-editing with a role_hint or near_text_hint) or the ` +
498
- `capture saw a transient state with duplicate labels.`,
499
- offending_round: roundIndex,
500
- synthesizer_version: SYNTHESIZER_VERSION,
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 === 0)
521
- return null;
522
- // Truncate exceptionally long text a 500-char button label is
523
- // almost certainly a paragraph picked up by the inventory scraper.
524
- // Cap at 80 chars; the replay engine matches by substring so the
525
- // first 80 chars are plenty for disambiguation.
526
- return text.length > 80 ? text.slice(0, 80) : text;
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
- const hint = (match.labelText ?? match.placeholder ?? match.ariaLabel ?? comboboxButtonText ?? "")
731
+ let hint = (match.labelText ?? match.placeholder ?? match.ariaLabel ?? comboboxButtonText ?? "")
563
732
  .trim();
564
733
  if (hint.length === 0) {
565
- return {
566
- kind: "rejected",
567
- stage: "synthesis",
568
- error_kind: "missing_text_hint",
569
- message: `Inventory element at ${JSON.stringify(selector)} has no labelText / placeholder / ariaLabel — ` +
570
- `cannot synthesize a fill/select label hint.`,
571
- offending_round: roundIndex,
572
- synthesizer_version: SYNTHESIZER_VERSION,
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
- return { min_length: 36, max_length: 36 };
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
- return inferOpaqueValidatorFromHtml(rounds) ?? { min_length: 8, max_length: 64 };
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-class) → shape "uuid"
1022
- // 2. No UUID present (IPInfo-class opaque short token) →
1023
- // shape "opaque" so the validator isn't forced to 36/36
1024
- // and inferOpaqueValidatorFromHtml picks the observed
1025
- // length range. The replay engine's rc.8 candidate
1026
- // fallback then uses that validator to find the value.
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 (/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/.test(html)) {
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 html = lastRound.state.html;
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
- if (/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/.test(html)) {
1062
- return "uuid";
1063
- }
1064
- return "opaque";
1380
+ return null;
1065
1381
  }
1066
1382
  // ── OAuth provider detection ─────────────────────────────────────────
1067
1383
  function detectOAuthProvider(hint) {