@trusty-squire/mcp 0.6.15-rc.9 → 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.
- package/dist/bin.js +8 -0
- package/dist/bin.js.map +1 -1
- package/dist/bot/agent.d.ts +39 -0
- package/dist/bot/agent.d.ts.map +1 -1
- package/dist/bot/agent.js +1341 -20
- package/dist/bot/agent.js.map +1 -1
- package/dist/bot/browser.d.ts +13 -0
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +573 -31
- package/dist/bot/browser.js.map +1 -1
- package/dist/bot/captcha-solver-2captcha.d.ts +42 -0
- package/dist/bot/captcha-solver-2captcha.d.ts.map +1 -0
- package/dist/bot/captcha-solver-2captcha.js +144 -0
- package/dist/bot/captcha-solver-2captcha.js.map +1 -0
- package/dist/bot/index.d.ts +2 -0
- package/dist/bot/index.d.ts.map +1 -1
- package/dist/bot/index.js +2 -0
- package/dist/bot/index.js.map +1 -1
- package/dist/bot/llm-client.d.ts +2 -1
- package/dist/bot/llm-client.d.ts.map +1 -1
- package/dist/bot/llm-client.js +19 -2
- package/dist/bot/llm-client.js.map +1 -1
- package/dist/bot/notify-api.d.ts +2 -0
- package/dist/bot/notify-api.d.ts.map +1 -1
- package/dist/bot/notify-api.js +13 -5
- package/dist/bot/notify-api.js.map +1 -1
- package/dist/bot/promote-to-skill.d.ts +9 -0
- package/dist/bot/promote-to-skill.d.ts.map +1 -1
- package/dist/bot/promote-to-skill.js +98 -7
- package/dist/bot/promote-to-skill.js.map +1 -1
- package/dist/bot/read-otp.d.ts +14 -0
- package/dist/bot/read-otp.d.ts.map +1 -0
- package/dist/bot/read-otp.js +96 -0
- package/dist/bot/read-otp.js.map +1 -0
- package/dist/bot/redact.d.ts +2 -0
- package/dist/bot/redact.d.ts.map +1 -0
- package/dist/bot/redact.js +61 -0
- package/dist/bot/redact.js.map +1 -0
- package/dist/bot/telegram-notify.d.ts +8 -0
- package/dist/bot/telegram-notify.d.ts.map +1 -0
- package/dist/bot/telegram-notify.js +134 -0
- package/dist/bot/telegram-notify.js.map +1 -0
- package/dist/skill-cli/cli.js +14 -3
- package/dist/skill-cli/cli.js.map +1 -1
- package/dist/tools/provision-any.d.ts.map +1 -1
- package/dist/tools/provision-any.js +26 -1
- package/dist/tools/provision-any.js.map +1 -1
- package/package.json +5 -2
package/dist/bot/browser.js
CHANGED
|
@@ -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
|
|
614
|
-
//
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
|
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=
|
|
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
|
-
//
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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 = [];
|