@trusty-squire/mcp 0.6.15-rc.8 → 0.7.0

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 (48) hide show
  1. package/dist/bin.js +8 -0
  2. package/dist/bin.js.map +1 -1
  3. package/dist/bot/agent.d.ts +39 -0
  4. package/dist/bot/agent.d.ts.map +1 -1
  5. package/dist/bot/agent.js +1341 -20
  6. package/dist/bot/agent.js.map +1 -1
  7. package/dist/bot/browser.d.ts +13 -0
  8. package/dist/bot/browser.d.ts.map +1 -1
  9. package/dist/bot/browser.js +573 -31
  10. package/dist/bot/browser.js.map +1 -1
  11. package/dist/bot/captcha-solver-2captcha.d.ts +42 -0
  12. package/dist/bot/captcha-solver-2captcha.d.ts.map +1 -0
  13. package/dist/bot/captcha-solver-2captcha.js +144 -0
  14. package/dist/bot/captcha-solver-2captcha.js.map +1 -0
  15. package/dist/bot/index.d.ts +2 -0
  16. package/dist/bot/index.d.ts.map +1 -1
  17. package/dist/bot/index.js +2 -0
  18. package/dist/bot/index.js.map +1 -1
  19. package/dist/bot/llm-client.d.ts +2 -1
  20. package/dist/bot/llm-client.d.ts.map +1 -1
  21. package/dist/bot/llm-client.js +19 -2
  22. package/dist/bot/llm-client.js.map +1 -1
  23. package/dist/bot/notify-api.d.ts +2 -0
  24. package/dist/bot/notify-api.d.ts.map +1 -1
  25. package/dist/bot/notify-api.js +13 -5
  26. package/dist/bot/notify-api.js.map +1 -1
  27. package/dist/bot/promote-to-skill.d.ts +9 -0
  28. package/dist/bot/promote-to-skill.d.ts.map +1 -1
  29. package/dist/bot/promote-to-skill.js +231 -25
  30. package/dist/bot/promote-to-skill.js.map +1 -1
  31. package/dist/bot/read-otp.d.ts +14 -0
  32. package/dist/bot/read-otp.d.ts.map +1 -0
  33. package/dist/bot/read-otp.js +96 -0
  34. package/dist/bot/read-otp.js.map +1 -0
  35. package/dist/bot/redact.d.ts +2 -0
  36. package/dist/bot/redact.d.ts.map +1 -0
  37. package/dist/bot/redact.js +61 -0
  38. package/dist/bot/redact.js.map +1 -0
  39. package/dist/bot/telegram-notify.d.ts +8 -0
  40. package/dist/bot/telegram-notify.d.ts.map +1 -0
  41. package/dist/bot/telegram-notify.js +134 -0
  42. package/dist/bot/telegram-notify.js.map +1 -0
  43. package/dist/skill-cli/cli.js +14 -3
  44. package/dist/skill-cli/cli.js.map +1 -1
  45. package/dist/tools/provision-any.d.ts.map +1 -1
  46. package/dist/tools/provision-any.js +26 -1
  47. package/dist/tools/provision-any.js.map +1 -1
  48. package/package.json +5 -2
@@ -610,33 +610,34 @@ export class BrowserController {
610
610
  }
611
611
  // Humanized typing:
612
612
  // - Click into the field first (moves mouse, generates focus event)
613
- // - pressSequentially with per-character delay 40-110ms baseline
614
- // - Inject occasional "thinking" pauses 200-600ms every ~5-12 chars
613
+ // - pressSequentially focuses ONCE and types each char with a
614
+ // per-key delay. Page-driven focus changes between characters
615
+ // (multi-input OTP forms, auto-advance fields) are honoured —
616
+ // the next char goes to whatever has focus when it fires.
615
617
  //
616
618
  // page.fill() bypasses keydown/keypress/input events entirely — it
617
619
  // sets value via JS. That's a giant red flag to behavior scoring.
618
620
  // pressSequentially emits real key events so the page sees a normal
619
621
  // typing pattern.
622
+ //
623
+ // rc.29 — the prior implementation looped `locator.pressSequentially(
624
+ // ch)` per character, which RE-FOCUSED the locator on every call.
625
+ // For multi-input OTP forms (Porter, Koyeb / WorkOS: 8 inputs each
626
+ // maxlength=1), every character landed in the FIRST input and got
627
+ // discarded after char 1. Switching to a single pressSequentially
628
+ // call lets the browser's auto-advance handler move focus naturally.
620
629
  await this.humanClick(selector);
621
- // Clear any prefilled value (browser autofill, etc.) before typing.
622
630
  const locator = this.page.locator(selector);
623
- await locator.fill("");
624
- let typedSinceLastPause = 0;
625
- let nextPauseAt = rand(5, 12);
626
- for (const ch of text) {
627
- // Per-char delay. Real typing is bursty; we use a slightly
628
- // skewed distribution that occasionally lands a fast char and
629
- // occasionally a slow one.
630
- await locator.pressSequentially(ch, { delay: rand(40, 110) });
631
- typedSinceLastPause += 1;
632
- if (typedSinceLastPause >= nextPauseAt) {
633
- // Brief "thinking" pause — looking at the keyboard, reading
634
- // the label, etc.
635
- await this.sleep(rand(180, 600));
636
- typedSinceLastPause = 0;
637
- nextPauseAt = rand(5, 12);
638
- }
639
- }
631
+ // Clear any prefilled value before typing. Only meaningful for
632
+ // single-input fields; multi-input OTP forms ignore this since
633
+ // each box is its own input.
634
+ await locator.fill("").catch(() => { });
635
+ // Per-key delay matches the prior bursty distribution. The
636
+ // periodic "thinking pause" the prior loop applied is folded into
637
+ // the delay variability — pressSequentially has no built-in pause
638
+ // hook, and over-engineering it added zero observable behavior-
639
+ // score improvement.
640
+ await locator.pressSequentially(text, { delay: rand(40, 110) });
640
641
  }
641
642
  async click(selector) {
642
643
  if (!this.page)
@@ -1031,16 +1032,28 @@ export class BrowserController {
1031
1032
  //
1032
1033
  // Tries option-selector patterns in priority order — each tier
1033
1034
  // targets one combobox-library convention. The text-based final
1034
- // tier catches libraries that ship NO ARIA roles at all (Sentry's
1035
- // permissions picker is the canonical case: it uses `<div>`s with
1036
- // plain text for options and pure JS click handlers).
1035
+ // tier catches libraries that ship NO ARIA roles at all.
1037
1036
  //
1038
1037
  // 1. [role=option] — Radix, Headless UI, React Aria, cmdk
1039
1038
  // 2. [role=menuitem] — ARIA menu pattern (libs that model
1040
1039
  // a dropdown as a menu)
1041
- // 3. [role=listbox] li listbox container without role
1040
+ // 3. [role=menuitemradio] react-select's per-row permission
1041
+ // picker shape (rc.15 — Sentry's
1042
+ // token-create grid). Identical shape
1043
+ // to menuitem for selection purposes,
1044
+ // distinct role string. Without this
1045
+ // tier Sentry's "Team permission =
1046
+ // Admin" never resolves and the loop
1047
+ // burns the post-verify budget.
1048
+ // 4. [id^="react-select-"] — defense-in-depth for any react-
1049
+ // select instance that drops the
1050
+ // role attribute. The id prefix is
1051
+ // baked into the library and is the
1052
+ // most stable signal short of the
1053
+ // role.
1054
+ // 5. [role=listbox] li — listbox container without role
1042
1055
  // attribute on its children
1043
- // 4. text-based (matcher only) — after the trigger click, any newly-
1056
+ // 6. text-based (matcher only) — after the trigger click, any newly-
1044
1057
  // visible element whose text matches
1045
1058
  // the planner-supplied label is
1046
1059
  // almost certainly the option. Only
@@ -1055,6 +1068,8 @@ export class BrowserController {
1055
1068
  const patternSelectors = [
1056
1069
  '[role="option"]:visible',
1057
1070
  '[role="menuitem"]:visible',
1071
+ '[role="menuitemradio"]:visible',
1072
+ '[id^="react-select-"][role*="menu"]:visible',
1058
1073
  '[role="listbox"]:visible li:visible',
1059
1074
  ];
1060
1075
  const triedDescriptors = [];
@@ -1142,10 +1157,25 @@ export class BrowserController {
1142
1157
  // HTML `disabled` attribute AND `aria-disabled="true"` are
1143
1158
  // honored — the latter covers ARIA-styled buttons (Radix, Headless
1144
1159
  // UI) that visually appear interactive but reject input.
1160
+ //
1161
+ // rc.16 — when the poll times out we now THROW instead of silently
1162
+ // proceeding to a no-op click. PostHog's "Create key" submit stays
1163
+ // aria-disabled until both an org/project access option AND a
1164
+ // scopes preset are set; humanClick previously fired a mouse
1165
+ // click at the disabled button (which does nothing), the page
1166
+ // didn't change, and the post-verify no-progress detector
1167
+ // re-planned generically. The planner kept retrying click on the
1168
+ // same button because nothing in its hint named the specific
1169
+ // root cause ("button is disabled — find what precondition is
1170
+ // missing"). Throwing surfaces the disabled state explicitly to
1171
+ // the planner via the executor's existing catch handler, so the
1172
+ // next round's reason includes "click failed: target is
1173
+ // aria-disabled" and the planner pivots to checking other fields.
1145
1174
  {
1146
1175
  const deadline = Date.now() + 6000;
1176
+ let isDisabled = false;
1147
1177
  while (Date.now() < deadline) {
1148
- const isDisabled = await locator
1178
+ isDisabled = await locator
1149
1179
  .first()
1150
1180
  .evaluate((el) => {
1151
1181
  if (el instanceof HTMLButtonElement || el instanceof HTMLInputElement) {
@@ -1160,6 +1190,13 @@ export class BrowserController {
1160
1190
  break;
1161
1191
  await this.sleep(150);
1162
1192
  }
1193
+ if (isDisabled) {
1194
+ throw new Error("target is disabled (HTML disabled or aria-disabled=true) after 6s — " +
1195
+ "the click would no-op. A required precondition is unmet: an empty " +
1196
+ "input, an unselected dropdown, an unchecked agreement checkbox, or " +
1197
+ "a missing preset/permission choice. Do NOT retry this click — pick a " +
1198
+ "different action that fills the missing field first.");
1199
+ }
1163
1200
  }
1164
1201
  // Scroll the element into the viewport BEFORE measuring it. A
1165
1202
  // humanized click is a raw page.mouse.click(x, y) at viewport
@@ -1539,6 +1576,105 @@ export class BrowserController {
1539
1576
  return { variant: "unknown", challengeRendered: false };
1540
1577
  }
1541
1578
  }
1579
+ // Tier 3 captcha-solver support — extract the reCAPTCHA sitekey
1580
+ // from the page so a third-party solver can submit it. Returns
1581
+ // null when no v2 widget is present (Tier 3 only handles v2;
1582
+ // Turnstile + reCAPTCHA v3 are scoring-based and solvers don't
1583
+ // help). Reads from the standard places sites declare it:
1584
+ // 1. <div class="g-recaptcha" data-sitekey="...">
1585
+ // 2. <iframe src="...?k=SITEKEY&..."> (api2/anchor frame)
1586
+ // 3. <script>...sitekey: '...'...</script> via window globals
1587
+ async extractRecaptchaSitekey() {
1588
+ if (!this.page)
1589
+ throw new Error("Browser not started");
1590
+ try {
1591
+ const sitekey = await this.page.evaluate(() => {
1592
+ // 1. div[data-sitekey] — the standard reCAPTCHA v2 anchor.
1593
+ const div = document.querySelector("[data-sitekey], div.g-recaptcha[data-sitekey]");
1594
+ if (div !== null) {
1595
+ const k = div.getAttribute("data-sitekey");
1596
+ if (k !== null && k.length > 10)
1597
+ return k;
1598
+ }
1599
+ // 2. The api2 iframe src carries ?k=SITEKEY.
1600
+ const iframes = Array.from(document.querySelectorAll('iframe[src*="recaptcha/api2"], iframe[src*="recaptcha/enterprise"]'));
1601
+ for (const ifr of iframes) {
1602
+ const url = new URL(ifr.src);
1603
+ const k = url.searchParams.get("k");
1604
+ if (k !== null && k.length > 10)
1605
+ return k;
1606
+ }
1607
+ return null;
1608
+ });
1609
+ return sitekey;
1610
+ }
1611
+ catch {
1612
+ return null;
1613
+ }
1614
+ }
1615
+ // Inject a 2Captcha-resolved token into the page's hidden
1616
+ // g-recaptcha-response textarea AND fire any onSuccess callback
1617
+ // the widget registered with grecaptcha.render(). Without firing
1618
+ // the callback the page often doesn't "see" the token even though
1619
+ // the DOM input is populated.
1620
+ //
1621
+ // Returns true on success, false if no recaptcha widget present.
1622
+ async injectRecaptchaToken(token) {
1623
+ if (!this.page)
1624
+ throw new Error("Browser not started");
1625
+ try {
1626
+ const injected = await this.page.evaluate((tok) => {
1627
+ // 1. Populate every g-recaptcha-response textarea on the page
1628
+ // (some pages render multiple widgets).
1629
+ const inputs = Array.from(document.querySelectorAll('textarea[name="g-recaptcha-response"], textarea[id^="g-recaptcha-response"]'));
1630
+ if (inputs.length === 0)
1631
+ return false;
1632
+ for (const input of inputs) {
1633
+ input.value = tok;
1634
+ input.dispatchEvent(new Event("input", { bubbles: true }));
1635
+ input.dispatchEvent(new Event("change", { bubbles: true }));
1636
+ }
1637
+ // 2. Fire the widget's onSuccess callback if registered. The
1638
+ // callbacks are stored on `___grecaptcha_cfg.clients`; the
1639
+ // exact tree is undocumented and shifts across versions
1640
+ // so a defensive walk is the only reliable way.
1641
+ try {
1642
+ const cfg = window.___grecaptcha_cfg;
1643
+ if (cfg !== undefined && cfg.clients !== undefined) {
1644
+ const fire = (obj) => {
1645
+ if (obj === null || typeof obj !== "object")
1646
+ return;
1647
+ for (const [, v] of Object.entries(obj)) {
1648
+ if (v === null || typeof v !== "object")
1649
+ continue;
1650
+ if ("callback" in v && typeof v.callback === "function") {
1651
+ try {
1652
+ v.callback(tok);
1653
+ }
1654
+ catch {
1655
+ // best-effort — at worst we miss the callback,
1656
+ // but the DOM input is populated which most
1657
+ // sites' server-side validation reads.
1658
+ }
1659
+ }
1660
+ fire(v);
1661
+ }
1662
+ };
1663
+ fire(cfg.clients);
1664
+ }
1665
+ }
1666
+ catch {
1667
+ // grecaptcha not on window — page may use a wrapper
1668
+ // (Stytch, Clerk). DOM injection is still in place.
1669
+ }
1670
+ return true;
1671
+ }, token);
1672
+ return injected;
1673
+ }
1674
+ catch {
1675
+ return false;
1676
+ }
1677
+ }
1542
1678
  // Small mouse wiggle near the current position. Used during prewarm
1543
1679
  // so the page sees pointer events before we navigate away.
1544
1680
  async jitterMouse() {
@@ -1711,6 +1847,359 @@ export class BrowserController {
1711
1847
  return out;
1712
1848
  });
1713
1849
  }
1850
+ // DOM-proximity labeled credential candidates. Walks every visible
1851
+ // input/code/text element looking for credential-shape strings,
1852
+ // pairs each one with its nearest credential-label text in the DOM
1853
+ // tree, and returns the labeled tuples for the multi-cred extractor
1854
+ // to fold into the credentials Record.
1855
+ //
1856
+ // Complements the Phase E planner-quoted extractor — when the
1857
+ // planner's prose doesn't explicitly label values (multi-cred page
1858
+ // where the planner missed one), this DOM-grounded pass picks them
1859
+ // up via the visible labels the page itself renders.
1860
+ //
1861
+ // Returns shape:
1862
+ // { value: "<credential-shape string>",
1863
+ // label: "<the closest matching label text>" | null,
1864
+ // isMasked: true if the value looks like a redacted display
1865
+ // (••••, ****, contains "•" or runs of "*") }
1866
+ //
1867
+ // The caller (agent.ts extractAllLabeledCandidates path) maps label
1868
+ // text to canonical credential keys using the same vocabulary the
1869
+ // Phase E parser uses.
1870
+ async extractLabeledCredentialCandidates() {
1871
+ if (!this.page)
1872
+ throw new Error("Browser not started");
1873
+ return await this.page.evaluate(() => {
1874
+ const LABEL_PHRASES = [
1875
+ // Generic
1876
+ "api key", "api token", "api secret", "secret key", "access key",
1877
+ "access token", "auth token", "bearer token", "personal access token",
1878
+ "client id", "client secret", "client key",
1879
+ // Cloudinary
1880
+ "cloud name", "cloudname",
1881
+ // Algolia
1882
+ "application id", "app id", "admin api key", "search api key",
1883
+ "monitoring api key", "search-only api key",
1884
+ // Twilio
1885
+ "account sid", "auth token",
1886
+ // Stripe
1887
+ "publishable key", "secret key",
1888
+ // AWS
1889
+ "access key id", "secret access key",
1890
+ // OAuth1
1891
+ "consumer key", "consumer secret", "access token secret",
1892
+ // Misc
1893
+ "project api key", "personal api key", "organization id", "org id",
1894
+ "app key", "app secret",
1895
+ ];
1896
+ const isVisible = (el) => {
1897
+ const r = el.getBoundingClientRect();
1898
+ return r.width > 2 && r.height > 2;
1899
+ };
1900
+ const isCredentialShape = (s) => {
1901
+ // Reasonable credential length range
1902
+ if (s.length < 6 || s.length > 256)
1903
+ return false;
1904
+ // Reject pure prose (spaces inside)
1905
+ if (/\s/.test(s))
1906
+ return false;
1907
+ // Must include some entropy markers: digit + letter combo OR
1908
+ // a credential prefix like sk_/pk_/api_/ etc.
1909
+ const hasDigit = /\d/.test(s);
1910
+ const hasLetter = /[A-Za-z]/.test(s);
1911
+ if (!hasDigit && !hasLetter)
1912
+ return false;
1913
+ // Reject pure URL fragments
1914
+ if (/^https?:\/\//i.test(s))
1915
+ return false;
1916
+ // Reject simple words / capitalized phrases
1917
+ if (/^[A-Za-z]+$/.test(s) && s.length < 12)
1918
+ return false;
1919
+ return true;
1920
+ };
1921
+ const isMaskedShape = (s) => {
1922
+ // Common mask glyphs: bullet, asterisk, em-dash spam
1923
+ if (/[•●⬤]{3,}/.test(s))
1924
+ return true;
1925
+ if (/\*{4,}/.test(s))
1926
+ return true;
1927
+ if (/^[•*]+$/.test(s))
1928
+ return true;
1929
+ return false;
1930
+ };
1931
+ // Compute element-center coords for proximity matching.
1932
+ const centerOf = (el) => {
1933
+ const r = el.getBoundingClientRect();
1934
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
1935
+ };
1936
+ const labelHits = [];
1937
+ document.querySelectorAll("body *").forEach((el) => {
1938
+ if (el.tagName === "SCRIPT" || el.tagName === "STYLE")
1939
+ return;
1940
+ if (!isVisible(el))
1941
+ return;
1942
+ // Only consider DIRECT text content — child element text gets
1943
+ // claimed by THOSE elements' own label scans.
1944
+ let direct = "";
1945
+ el.childNodes.forEach((n) => {
1946
+ if (n.nodeType === Node.TEXT_NODE)
1947
+ direct += n.textContent ?? "";
1948
+ });
1949
+ direct = direct.trim().toLowerCase();
1950
+ if (direct.length === 0 || direct.length > 100)
1951
+ return;
1952
+ for (const phrase of LABEL_PHRASES) {
1953
+ if (direct.includes(phrase)) {
1954
+ const c = centerOf(el);
1955
+ labelHits.push({ phrase, x: c.x, y: c.y, el });
1956
+ break; // one label per element is enough
1957
+ }
1958
+ }
1959
+ });
1960
+ // Detect reveal buttons (eye / show / unmask icons) — any visible
1961
+ // button or [role=button] / svg whose aria-label / title / text
1962
+ // matches the reveal vocabulary. We only check WHETHER one exists
1963
+ // near a candidate; the clicker (revealMaskedCredentials below)
1964
+ // does the actual click pass.
1965
+ const REVEAL_PATTERN = /\b(?:reveal|show|unmask|view|toggle|copy)\b/i;
1966
+ const revealButtons = [];
1967
+ document
1968
+ .querySelectorAll('button, [role="button"], a, [aria-label], [title]')
1969
+ .forEach((el) => {
1970
+ if (!isVisible(el))
1971
+ return;
1972
+ const hay = `${el.textContent ?? ""} ${el.getAttribute("aria-label") ?? ""} ${el.getAttribute("title") ?? ""}`;
1973
+ if (!REVEAL_PATTERN.test(hay))
1974
+ return;
1975
+ const c = centerOf(el);
1976
+ revealButtons.push({ x: c.x, y: c.y, el });
1977
+ });
1978
+ // For each candidate, find nearest label by Euclidean distance.
1979
+ const findNearestLabel = (x, y) => {
1980
+ let best = null;
1981
+ for (const lh of labelHits) {
1982
+ const dx = lh.x - x;
1983
+ const dy = lh.y - y;
1984
+ const d = Math.sqrt(dx * dx + dy * dy);
1985
+ // Conservative cap — labels more than 400px away from the
1986
+ // value aren't visually grouped with it. Roughly: a typical
1987
+ // table-row width.
1988
+ if (d > 400)
1989
+ continue;
1990
+ if (best === null || d < best.d)
1991
+ best = { phrase: lh.phrase, d };
1992
+ }
1993
+ return best?.phrase ?? null;
1994
+ };
1995
+ const hasNearbyReveal = (x, y) => {
1996
+ for (const rb of revealButtons) {
1997
+ const dx = rb.x - x;
1998
+ const dy = rb.y - y;
1999
+ // Reveal/copy buttons are usually right next to the value —
2000
+ // 200px is generous.
2001
+ if (Math.sqrt(dx * dx + dy * dy) < 200)
2002
+ return true;
2003
+ }
2004
+ return false;
2005
+ };
2006
+ const seen = new Set();
2007
+ const out = [];
2008
+ const pushCandidate = (value, el) => {
2009
+ const trimmed = value.trim();
2010
+ if (trimmed.length === 0)
2011
+ return;
2012
+ const masked = isMaskedShape(trimmed);
2013
+ if (!masked && !isCredentialShape(trimmed))
2014
+ return;
2015
+ if (seen.has(trimmed))
2016
+ return;
2017
+ seen.add(trimmed);
2018
+ const c = centerOf(el);
2019
+ const label = findNearestLabel(c.x, c.y);
2020
+ const hasReveal = masked ? hasNearbyReveal(c.x, c.y) : false;
2021
+ out.push({
2022
+ value: trimmed,
2023
+ label,
2024
+ isMasked: masked,
2025
+ hasRevealButton: hasReveal,
2026
+ });
2027
+ };
2028
+ // 1. <input> / <textarea> values (visible only).
2029
+ document.querySelectorAll("input, textarea").forEach((el) => {
2030
+ if (el instanceof HTMLInputElement &&
2031
+ (el.type === "hidden" || el.type === "password"))
2032
+ return;
2033
+ if (!isVisible(el))
2034
+ return;
2035
+ const value = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement
2036
+ ? el.value
2037
+ : "";
2038
+ if (value.length > 0)
2039
+ pushCandidate(value, el);
2040
+ });
2041
+ // 2. Direct text content in visible leaf elements.
2042
+ document.querySelectorAll("body *").forEach((el) => {
2043
+ if (el.tagName === "SCRIPT" || el.tagName === "STYLE")
2044
+ return;
2045
+ if (!isVisible(el))
2046
+ return;
2047
+ let direct = "";
2048
+ el.childNodes.forEach((n) => {
2049
+ if (n.nodeType === Node.TEXT_NODE)
2050
+ direct += n.textContent ?? "";
2051
+ });
2052
+ direct = direct.trim();
2053
+ if (direct.length === 0 || direct.length > 256)
2054
+ return;
2055
+ pushCandidate(direct, el);
2056
+ });
2057
+ // 3. Structural containers (code/pre/kbd) where the credential
2058
+ // is interpolated through nested spans.
2059
+ document
2060
+ .querySelectorAll('code, pre, kbd, samp, [role="textbox"]')
2061
+ .forEach((el) => {
2062
+ if (!isVisible(el))
2063
+ return;
2064
+ const full = (el.textContent ?? "").trim();
2065
+ if (full.length === 0 || full.length > 256)
2066
+ return;
2067
+ pushCandidate(full, el);
2068
+ });
2069
+ return out;
2070
+ });
2071
+ }
2072
+ // Click every visible "Reveal" / "Show" / "Eye" / "Copy" button on
2073
+ // the page that sits next to a masked credential display. Used as a
2074
+ // pre-extract pass for services like Cloudinary that hide the
2075
+ // api_secret behind a click-to-reveal icon. Best-effort: failures
2076
+ // don't throw; subsequent extract pass tries whatever surfaced.
2077
+ // Returns the number of buttons successfully clicked.
2078
+ async revealMaskedCredentials() {
2079
+ if (this.page === null)
2080
+ throw new Error("Browser not started");
2081
+ const page = this.page;
2082
+ const probe = await page.evaluate(() => {
2083
+ const isVisible = (el) => {
2084
+ const r = el.getBoundingClientRect();
2085
+ return r.width > 2 && r.height > 2;
2086
+ };
2087
+ // Walk up to the nearest "row-like" ancestor — a <tr>, a <li>,
2088
+ // or any container ≤ 800px wide with limited height. Cloudinary,
2089
+ // Algolia, Twilio all use table rows; clicking the reveal in
2090
+ // ROW X must populate the value in ROW X, not some neighbor row.
2091
+ const rowAncestor = (el) => {
2092
+ let cur = el;
2093
+ for (let i = 0; i < 8 && cur !== null; i++) {
2094
+ if (cur.tagName === "TR" || cur.tagName === "LI")
2095
+ return cur;
2096
+ const r = cur.getBoundingClientRect();
2097
+ if (r.width > 200 && r.width < 900 && r.height < 200)
2098
+ return cur;
2099
+ cur = cur.parentElement;
2100
+ }
2101
+ return el.parentElement;
2102
+ };
2103
+ const masked = [];
2104
+ document.querySelectorAll("body *").forEach((el) => {
2105
+ if (el.tagName === "SCRIPT" || el.tagName === "STYLE")
2106
+ return;
2107
+ if (!isVisible(el))
2108
+ return;
2109
+ let direct = "";
2110
+ el.childNodes.forEach((n) => {
2111
+ if (n.nodeType === Node.TEXT_NODE)
2112
+ direct += n.textContent ?? "";
2113
+ });
2114
+ const t = direct.trim();
2115
+ if (t.length < 3 || t.length > 100)
2116
+ return;
2117
+ if (!/[•●⬤*]{3,}/.test(t) && !/^[•*]+$/.test(t))
2118
+ return;
2119
+ masked.push({ el, row: rowAncestor(el) });
2120
+ });
2121
+ document
2122
+ .querySelectorAll('input[type="password"]')
2123
+ .forEach((el) => {
2124
+ if (!isVisible(el))
2125
+ return;
2126
+ masked.push({ el, row: rowAncestor(el) });
2127
+ });
2128
+ if (masked.length === 0) {
2129
+ return { selectors: [], diagnostic: ["no_masked_displays"] };
2130
+ }
2131
+ // 2. Classify candidate buttons. Prefer SHOW/REVEAL/EYE; fall
2132
+ // back to COPY only when no show button exists in the row.
2133
+ // (Copy generally puts value in clipboard, not in DOM —
2134
+ // which our extractor can't read in headless.)
2135
+ const SHOW_PATTERN = /\b(?:reveal|show|unmask|view|toggle|eye)\b/i;
2136
+ const COPY_PATTERN = /\bcopy\b/i;
2137
+ const collectButtonsInRow = (row) => {
2138
+ const showBtns = [];
2139
+ const copyBtns = [];
2140
+ if (row === null)
2141
+ return { showBtns, copyBtns };
2142
+ row
2143
+ .querySelectorAll('button, [role="button"], a[role="button"], [aria-label], [title]')
2144
+ .forEach((el) => {
2145
+ if (!isVisible(el))
2146
+ return;
2147
+ const hay = `${el.textContent ?? ""} ${el.getAttribute("aria-label") ?? ""} ${el.getAttribute("title") ?? ""} ${el.className ?? ""}`;
2148
+ if (SHOW_PATTERN.test(hay))
2149
+ showBtns.push(el);
2150
+ else if (COPY_PATTERN.test(hay))
2151
+ copyBtns.push(el);
2152
+ });
2153
+ return { showBtns, copyBtns };
2154
+ };
2155
+ const selectorFor = (el) => {
2156
+ const tag = el.tagName.toLowerCase();
2157
+ const all = Array.from(document.querySelectorAll(tag));
2158
+ const idx = all.indexOf(el);
2159
+ return `${tag}:nth-of-type(${idx + 1})`;
2160
+ };
2161
+ const selectors = [];
2162
+ const diagnostic = [];
2163
+ const usedRows = new Set();
2164
+ for (const m of masked) {
2165
+ if (m.row === null)
2166
+ continue;
2167
+ if (usedRows.has(m.row))
2168
+ continue;
2169
+ usedRows.add(m.row);
2170
+ const { showBtns, copyBtns } = collectButtonsInRow(m.row);
2171
+ if (showBtns.length > 0) {
2172
+ const btn = showBtns[0];
2173
+ const sel = selectorFor(btn);
2174
+ selectors.push(sel);
2175
+ const label = (btn.textContent ?? btn.getAttribute("aria-label") ?? btn.getAttribute("title") ?? "").trim().slice(0, 40);
2176
+ diagnostic.push(`row→show:"${label}"→${sel}`);
2177
+ }
2178
+ else if (copyBtns.length > 0) {
2179
+ diagnostic.push(`row→copy_only_no_show_button (copy='${copyBtns.length} found' — skipped, would only populate clipboard not DOM)`);
2180
+ }
2181
+ else {
2182
+ diagnostic.push("row→no_buttons_found");
2183
+ }
2184
+ }
2185
+ return { selectors, diagnostic };
2186
+ });
2187
+ let clicked = 0;
2188
+ for (const sel of probe.selectors) {
2189
+ try {
2190
+ await page.locator(sel).first().click({ timeout: 1500 });
2191
+ clicked += 1;
2192
+ // Reveal click often triggers a fetch (Cloudinary returns the
2193
+ // secret over an XHR before populating the DOM). Wait longer
2194
+ // than the previous 150ms.
2195
+ await this.sleep(800);
2196
+ }
2197
+ catch {
2198
+ // Click failed — best-effort.
2199
+ }
2200
+ }
2201
+ return { clicked, diagnostic: probe.diagnostic };
2202
+ }
1714
2203
  async extractCredentialCandidates() {
1715
2204
  if (!this.page)
1716
2205
  throw new Error("Browser not started");
@@ -1799,17 +2288,58 @@ export class BrowserController {
1799
2288
  // redirect to the real page. Without this, the bot snapshots a
1800
2289
  // 2-element interstitial inventory and bails.
1801
2290
  await this.waitForAntiBotInterstitialToClear(timeoutMs);
2291
+ // rc.33 — extended the element-wait selector to match the broader
2292
+ // inventory walk added in rc.26 (menuitem/option/combobox plus
2293
+ // anchors). Porter and Koyeb's API-tokens pages are nested SPAs
2294
+ // that initially render with NO <input>/<button> — just <a> and
2295
+ // role=button divs. The old selector timed out at 15s on those
2296
+ // pages, the planner saw an empty inventory, and the post-verify
2297
+ // loop burned rounds clicking nothing.
1802
2298
  try {
1803
- await this.page.waitForSelector("input, button", {
1804
- state: "visible",
1805
- timeout: timeoutMs,
1806
- });
2299
+ await this.page.waitForSelector('input, button, textarea, select, a[href], [role="button"], [role="menuitem"]', { state: "visible", timeout: timeoutMs });
1807
2300
  }
1808
2301
  catch {
1809
2302
  // No interactive element appeared in time — let the planner run
1810
2303
  // anyway; it fails cleanly rather than hanging.
1811
2304
  }
1812
2305
  }
2306
+ // rc.33 — wait for the DOM to grow past a minimum interactive-
2307
+ // element count, polling every 500ms up to timeoutMs. The
2308
+ // single-element wait in waitForFormReady is fast-path; this is
2309
+ // for SPAs where DOMContentLoaded fires almost immediately but the
2310
+ // React/Vue/Svelte tree takes 5-15s more to actually render. Used
2311
+ // after navigate() in the post-verify loop so the planner doesn't
2312
+ // see a 0-button page that's still rendering. Best-effort —
2313
+ // returns whenever the count is reached OR the timeout elapses.
2314
+ async waitForInteractiveDom(minElements = 5, timeoutMs = 20_000) {
2315
+ if (!this.page)
2316
+ return;
2317
+ const deadline = Date.now() + timeoutMs;
2318
+ while (Date.now() < deadline) {
2319
+ try {
2320
+ const count = await this.page.evaluate((min) => {
2321
+ const sels = 'input,textarea,select,button,a[href],[role="button"],[role="menuitem"],[role="option"]';
2322
+ const nodes = Array.from(document.querySelectorAll(sels));
2323
+ let visible = 0;
2324
+ for (const n of nodes) {
2325
+ const el = n;
2326
+ const r = el.getBoundingClientRect();
2327
+ if (r.width >= 2 && r.height >= 2)
2328
+ visible++;
2329
+ if (visible >= min)
2330
+ return visible;
2331
+ }
2332
+ return visible;
2333
+ }, minElements);
2334
+ if (count >= minElements)
2335
+ return;
2336
+ }
2337
+ catch {
2338
+ // Page may be mid-navigation — try again on the next tick.
2339
+ }
2340
+ await this.sleep(500);
2341
+ }
2342
+ }
1813
2343
  // Find and click an "Accept"-class button to dismiss any visible
1814
2344
  // cookie/consent banner. Returns the clicked button's text when a
1815
2345
  // dismiss fired, or null when no banner / no clickable affordance
@@ -1961,7 +2491,19 @@ export class BrowserController {
1961
2491
  if (!this.page)
1962
2492
  throw new Error("Browser not started");
1963
2493
  const raw = await this.page.evaluate(() => {
1964
- const SELECTOR = 'input,textarea,select,button,a,[role="button"],[role="checkbox"],[contenteditable=""],[contenteditable="true"]';
2494
+ const SELECTOR =
2495
+ // rc.26 — added Radix/Headless-UI menu + option items so
2496
+ // dropdown contents (Fireworks "Create API Key" → API Key /
2497
+ // Service Account menu, Sentry's per-row permissions) end up
2498
+ // in the planner's inventory.
2499
+ // rc.35 — added [role="link"] (Google account-chooser cards
2500
+ // are <div role="link" data-identifier="…">), and <label>
2501
+ // (Koyeb's onboarding renders each radio choice as a styled
2502
+ // <label> wrapping a sr-only <input type=radio>; the visible
2503
+ // click target is the label, but the bot's inventory selector
2504
+ // didn't catch labels so the planner had no clickable target
2505
+ // matching the visible button text).
2506
+ 'input,textarea,select,button,a,label,[role="button"],[role="link"],[role="checkbox"],[role="menuitem"],[role="menuitemradio"],[role="menuitemcheckbox"],[role="option"],[role="combobox"],[contenteditable=""],[contenteditable="true"]';
1965
2507
  // Collect candidates across the document and every open shadow
1966
2508
  // root. Closed shadow roots are unreachable — accepted.
1967
2509
  const collected = [];