@trusty-squire/mcp 0.1.18 → 0.2.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 (46) hide show
  1. package/dist/bin.js +2 -2
  2. package/dist/bin.js.map +1 -1
  3. package/dist/bot/agent.d.ts +15 -1
  4. package/dist/bot/agent.d.ts.map +1 -1
  5. package/dist/bot/agent.js +531 -52
  6. package/dist/bot/agent.js.map +1 -1
  7. package/dist/bot/browser.d.ts +15 -3
  8. package/dist/bot/browser.d.ts.map +1 -1
  9. package/dist/bot/browser.js +281 -56
  10. package/dist/bot/browser.js.map +1 -1
  11. package/dist/bot/google-login.d.ts +18 -0
  12. package/dist/bot/google-login.d.ts.map +1 -0
  13. package/dist/bot/google-login.js +379 -0
  14. package/dist/bot/google-login.js.map +1 -0
  15. package/dist/bot/index.d.ts +5 -0
  16. package/dist/bot/index.d.ts.map +1 -1
  17. package/dist/bot/index.js +14 -0
  18. package/dist/bot/index.js.map +1 -1
  19. package/dist/bot/llm-client.d.ts.map +1 -1
  20. package/dist/bot/llm-client.js +19 -12
  21. package/dist/bot/llm-client.js.map +1 -1
  22. package/dist/bot/oauth-lock.d.ts +2 -0
  23. package/dist/bot/oauth-lock.d.ts.map +1 -0
  24. package/dist/bot/oauth-lock.js +28 -0
  25. package/dist/bot/oauth-lock.js.map +1 -0
  26. package/dist/bot/oauth-providers.d.ts +16 -0
  27. package/dist/bot/oauth-providers.d.ts.map +1 -0
  28. package/dist/bot/oauth-providers.js +100 -0
  29. package/dist/bot/oauth-providers.js.map +1 -0
  30. package/dist/bot/onboarding-capture.d.ts +17 -0
  31. package/dist/bot/onboarding-capture.d.ts.map +1 -0
  32. package/dist/bot/onboarding-capture.js +52 -0
  33. package/dist/bot/onboarding-capture.js.map +1 -0
  34. package/dist/bot/profile.d.ts +2 -0
  35. package/dist/bot/profile.d.ts.map +1 -0
  36. package/dist/bot/profile.js +11 -0
  37. package/dist/bot/profile.js.map +1 -0
  38. package/dist/install/cli.d.ts +4 -1
  39. package/dist/install/cli.d.ts.map +1 -1
  40. package/dist/install/cli.js +41 -1
  41. package/dist/install/cli.js.map +1 -1
  42. package/dist/tools/provision-any.d.ts +15 -0
  43. package/dist/tools/provision-any.d.ts.map +1 -1
  44. package/dist/tools/provision-any.js +66 -2
  45. package/dist/tools/provision-any.js.map +1 -1
  46. package/package.json +3 -1
@@ -24,6 +24,7 @@
24
24
  import { chromium as baseChromium } from "playwright";
25
25
  import { createRequire } from "node:module";
26
26
  import { detectAsn } from "./asn.js";
27
+ import { CHROME_PROFILE_DIR } from "./profile.js";
27
28
  // Lazy registration: installing the plugin mutates the chromium singleton
28
29
  // from playwright-extra so we only do it once per process. We require()
29
30
  // the CJS modules lazily (the stealth toolchain only ships CJS) and treat
@@ -142,7 +143,10 @@ async function detectChromiumChannel() {
142
143
  return null;
143
144
  }
144
145
  export class BrowserController {
145
- browser = null;
146
+ // The persistent browser context. Persistent (launchPersistentContext)
147
+ // rather than an ephemeral context so the profile carries the user's
148
+ // Google session across runs — see profile.ts / google-login.ts.
149
+ context = null;
146
150
  page = null;
147
151
  humanize;
148
152
  // Tracks the simulated mouse position so successive clicks can move
@@ -160,15 +164,22 @@ export class BrowserController {
160
164
  // a captcha failure behind a residential proxy is materially
161
165
  // different signal from the same failure on a raw datacenter IP.
162
166
  proxyServer = null;
167
+ profileDir;
168
+ // T6/T7 — OAuth handshake bookkeeping. When startOAuth() adopts a
169
+ // popup window as the active page, the original product page is
170
+ // parked here so settleAfterOAuth() can switch back to it once the
171
+ // Google handshake completes.
172
+ oauthProductPage = null;
163
173
  constructor(opts = {}) {
164
174
  this.humanize = opts.humanize ?? true;
175
+ this.profileDir = opts.profileDir ?? CHROME_PROFILE_DIR;
165
176
  }
166
177
  // Which browser channel the most recent .start() actually used.
167
178
  // `null` means bundled Chromium; a string like "chrome" means a
168
179
  // real installed browser of that channel. Throws if .start() hasn't
169
180
  // been called yet — there's no sensible default to return.
170
181
  get channel() {
171
- if (this.browser === null) {
182
+ if (this.context === null) {
172
183
  throw new Error("BrowserController.channel read before .start()");
173
184
  }
174
185
  return this.launchedChannel;
@@ -177,7 +188,7 @@ export class BrowserController {
177
188
  // or null for a direct connection. Useful telemetry alongside
178
189
  // `channel`. Throws if .start() hasn't run — same reason as channel.
179
190
  get proxied() {
180
- if (this.browser === null) {
191
+ if (this.context === null) {
181
192
  throw new Error("BrowserController.proxied read before .start()");
182
193
  }
183
194
  return this.proxyServer;
@@ -191,87 +202,87 @@ export class BrowserController {
191
202
  // module's existing logging convention).
192
203
  console.error(`[universal-bot] launching browser channel=${channel ?? "bundled-chromium"} ` +
193
204
  `proxy=${proxy?.server ?? "direct"}`);
194
- this.browser = await getChromium().launch({
205
+ // T3.1: probe where this run's traffic actually exits so the
206
+ // browser's declared timezone matches its egress IP (a US-timezone
207
+ // browser on a foreign proxy IP is itself an anti-bot signal).
208
+ // Done before the real launch: launchPersistentContext bakes the
209
+ // timezone in at creation, with no way to set it afterward.
210
+ const geo = await this.probeEgressGeo(channel, proxy);
211
+ if (geo !== null) {
212
+ console.error(`[universal-bot] egress geo: timezone=${geo.timezoneId}` +
213
+ (geo.geolocation !== undefined
214
+ ? ` loc=${geo.geolocation.latitude},${geo.geolocation.longitude}`
215
+ : ""));
216
+ }
217
+ // T3: a PERSISTENT context. The profile dir carries the user's
218
+ // Google session (established by `mcp login` — see google-login.ts),
219
+ // so the OAuth-first signup path reuses it instead of starting
220
+ // logged-out. launchPersistentContext takes launch + context
221
+ // options in one call.
222
+ const context = await getChromium().launchPersistentContext(this.profileDir, {
195
223
  headless: process.env.UNIVERSAL_BOT_HEADLESS !== "false",
196
- // `channel:` is a Playwright launch option that tells it to use a
197
- // real installed browser instead of the bundled binary. When null
198
- // we omit the key entirely so Playwright falls back to default.
224
+ // `channel:` selects a real installed browser over the bundled
225
+ // binary; omitted entirely when null.
199
226
  ...(channel !== null ? { channel } : {}),
200
- // `proxy:` routes all egress through a residential proxy. Omitted
201
- // (direct connection) for the ~80% of users on residential
202
- // networks — see resolveProxy().
227
+ // `proxy:` routes egress through a residential proxy — only for
228
+ // datacenter-class egress (see resolveProxy()).
203
229
  ...(proxy !== null ? { proxy } : {}),
204
230
  args: [
205
231
  "--disable-blink-features=AutomationControlled",
206
232
  "--no-sandbox",
207
233
  "--disable-dev-shm-usage",
208
234
  ],
209
- });
210
- // T3.1: learn where this run's traffic actually exits (the proxy
211
- // exit when proxied, the machine otherwise) so the browser's
212
- // declared timezone matches its egress IP. A US-timezone browser
213
- // on a foreign proxy IP is itself an anti-bot signal.
214
- const geo = await this.probeEgressGeo();
215
- if (geo !== null) {
216
- console.error(`[universal-bot] egress geo: timezone=${geo.timezoneId}` +
217
- (geo.geolocation !== undefined
218
- ? ` loc=${geo.geolocation.latitude},${geo.geolocation.longitude}`
219
- : ""));
220
- }
221
- const context = await this.browser.newContext({
222
235
  viewport: { width: 1280, height: 720 },
223
236
  userAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
224
237
  // locale stays en-US deliberately: matching it to the proxy
225
238
  // country would render signup pages in that language, and the
226
- // Claude vision form-planner expects English. timezone is the
227
- // dominant IP-geo-mismatch signal; an English browser abroad is
228
- // unremarkable, so locale is left as the weak, safe choice.
239
+ // Claude vision form-planner expects English.
229
240
  locale: "en-US",
230
- // timezone + geolocation track the real egress (T3.1). Falls
231
- // back to a fixed default when the probe fails — no worse than
232
- // the pre-T3.1 hardcoded value.
241
+ // timezone + geolocation track the real egress (T3.1); a fixed
242
+ // default when the probe failed.
233
243
  timezoneId: geo?.timezoneId ?? "America/New_York",
234
244
  ...(geo?.geolocation !== undefined
235
245
  ? { geolocation: geo.geolocation, permissions: ["geolocation"] }
236
246
  : {}),
237
247
  });
248
+ this.context = context;
238
249
  // Patch the navigator.webdriver flag — most anti-bot heuristics look here.
239
250
  await context.addInitScript(() => {
240
251
  Object.defineProperty(navigator, "webdriver", { get: () => undefined });
241
252
  });
242
- this.page = await context.newPage();
253
+ this.page = context.pages()[0] ?? (await context.newPage());
243
254
  }
244
- // Probe the run's actual egress geo by loading ipinfo.io through a
245
- // throwaway context. The proxy (when active) is set at launch level,
246
- // so every context inherits it this reports the *proxy exit* geo,
247
- // not the machine's. Best-effort: any failure returns null and
248
- // start() keeps a default timezone. Adds one short navigation to
249
- // run start-up.
250
- async probeEgressGeo() {
251
- if (!this.browser)
252
- return null;
253
- const browser = this.browser;
254
- let ctx;
255
- let geo = null;
255
+ // Probe the run's actual egress geo by loading ipinfo.io. Launches a
256
+ // throwaway browser: the persistent context isn't up yet, and its
257
+ // timezone has to be known before it is. The throwaway inherits the
258
+ // same channel + proxy so it reports the real egress. Best-effort
259
+ // any failure returns null and start() keeps a default timezone.
260
+ async probeEgressGeo(channel, proxy) {
261
+ let probe;
256
262
  try {
257
- ctx = await browser.newContext();
258
- const page = await ctx.newPage();
263
+ probe = await getChromium().launch({
264
+ headless: process.env.UNIVERSAL_BOT_HEADLESS !== "false",
265
+ ...(channel !== null ? { channel } : {}),
266
+ ...(proxy !== null ? { proxy } : {}),
267
+ args: ["--no-sandbox", "--disable-dev-shm-usage"],
268
+ });
269
+ const page = await probe.newPage();
259
270
  await page.goto("https://ipinfo.io/json", {
260
271
  timeout: 10000,
261
272
  waitUntil: "domcontentloaded",
262
273
  });
263
274
  const body = await page.evaluate(() => document.body.innerText);
264
- geo = parseEgressGeo(body);
275
+ return parseEgressGeo(body);
265
276
  }
266
277
  catch (err) {
267
278
  console.error(`[universal-bot] egress geo probe failed — using default ` +
268
279
  `timezone: ${err instanceof Error ? err.message : String(err)}`);
280
+ return null;
269
281
  }
270
282
  finally {
271
- if (ctx !== undefined)
272
- await ctx.close();
283
+ if (probe !== undefined)
284
+ await probe.close();
273
285
  }
274
- return geo;
275
286
  }
276
287
  // Decide whether this run egresses through a residential proxy, and
277
288
  // return Playwright's proxy settings or null for a direct connection.
@@ -604,6 +615,14 @@ export class BrowserController {
604
615
  if (!this.page)
605
616
  throw new Error("Browser not started");
606
617
  await locator.waitFor({ state: "visible", timeout: 10000 });
618
+ // Scroll the element into the viewport BEFORE measuring it. A
619
+ // humanized click is a raw page.mouse.click(x, y) at viewport
620
+ // coordinates — boundingBox() of a below-the-fold element returns
621
+ // an off-screen y, and the click then lands on nothing (it was
622
+ // why a Sentry OAuth button below the fold never navigated). The
623
+ // regular .click() path auto-scrolls; the humanized path must too
624
+ // — same fix check() already carries.
625
+ await locator.scrollIntoViewIfNeeded({ timeout: 5000 }).catch(() => { });
607
626
  const box = await locator.boundingBox();
608
627
  if (box === null) {
609
628
  // Element exists but isn't in the layout (e.g., display:none).
@@ -869,6 +888,58 @@ export class BrowserController {
869
888
  throw new Error("Browser not started");
870
889
  return await this.page.textContent("body") || "";
871
890
  }
891
+ // Discrete strings an API key might occupy — for credential
892
+ // extraction. Gathered so a key is read WHOLE and un-glued from its
893
+ // neighbours: extractText() concatenates the whole <body>, which
894
+ // fuses a key to an adjacent "Copy"/"Done" button with no separator.
895
+ //
896
+ // Two surfaces:
897
+ // 1. input/textarea VALUES — a copy-to-clipboard key field. An
898
+ // input's value is not in textContent at all. Hidden and
899
+ // password fields are excluded (captcha tokens / the signup
900
+ // password), keeping this a clean credential surface.
901
+ // 2. Each element's OWN direct text — the text nodes that are its
902
+ // immediate children, excluding descendants. A key in a
903
+ // <code>/<span>/<div> yields its clean value here even when a
904
+ // sibling button shares the same parent.
905
+ async extractCredentialCandidates() {
906
+ if (!this.page)
907
+ throw new Error("Browser not started");
908
+ return await this.page.evaluate(() => {
909
+ const out = [];
910
+ const isVisible = (el) => {
911
+ const r = el.getBoundingClientRect();
912
+ return r.width > 2 && r.height > 2;
913
+ };
914
+ document.querySelectorAll("input, textarea").forEach((el) => {
915
+ if (el instanceof HTMLInputElement &&
916
+ (el.type === "hidden" || el.type === "password")) {
917
+ return;
918
+ }
919
+ const value = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement
920
+ ? el.value
921
+ : "";
922
+ if (value.trim().length > 0 && isVisible(el))
923
+ out.push(value.trim());
924
+ });
925
+ document.querySelectorAll("body *").forEach((el) => {
926
+ if (el.tagName === "SCRIPT" || el.tagName === "STYLE")
927
+ return;
928
+ if (!isVisible(el))
929
+ return;
930
+ let direct = "";
931
+ el.childNodes.forEach((n) => {
932
+ if (n.nodeType === Node.TEXT_NODE)
933
+ direct += n.textContent ?? "";
934
+ });
935
+ direct = direct.trim();
936
+ // A real key is short; a long blob is a paragraph, not a key.
937
+ if (direct.length > 0 && direct.length <= 256)
938
+ out.push(direct);
939
+ });
940
+ return out;
941
+ });
942
+ }
872
943
  // Wait for the signup form to actually render before the planner
873
944
  // screenshots the page (F1). SPA and two-stage signup pages render
874
945
  // the form after JS executes; planning against a pre-render
@@ -949,6 +1020,30 @@ export class BrowserController {
949
1020
  return anc !== null ? clean(anc.textContent) : null;
950
1021
  };
951
1022
  const inConsent = (el) => el.closest('[class*="osano"],[id*="onetrust"],[id*="cookie"],[class*="cookie-consent"],[class*="cookie-banner"],[class*="cookieConsent"]') !== null;
1023
+ // Accessible label of a descendant icon — an icon-only "Sign in
1024
+ // with Google" button carries no text, but its <img alt>, its
1025
+ // <svg><title>, or a descendant [aria-label] names the provider.
1026
+ const iconLabelFor = (el) => {
1027
+ const img = el.querySelector("img[alt]");
1028
+ if (img !== null) {
1029
+ const alt = clean(img.getAttribute("alt"));
1030
+ if (alt !== null)
1031
+ return alt;
1032
+ }
1033
+ const svgTitle = el.querySelector("svg title");
1034
+ if (svgTitle !== null) {
1035
+ const t = clean(svgTitle.textContent);
1036
+ if (t !== null)
1037
+ return t;
1038
+ }
1039
+ const labelled = el.querySelector("[aria-label]");
1040
+ if (labelled !== null) {
1041
+ const l = clean(labelled.getAttribute("aria-label"));
1042
+ if (l !== null)
1043
+ return l;
1044
+ }
1045
+ return null;
1046
+ };
952
1047
  const selectorFor = (el) => {
953
1048
  const tag = el.tagName.toLowerCase();
954
1049
  let base;
@@ -1023,6 +1118,8 @@ export class BrowserController {
1023
1118
  r.bottom <= window.innerHeight &&
1024
1119
  r.right <= window.innerWidth,
1025
1120
  inConsentWidget: inConsent(el),
1121
+ href: (el.getAttribute("href") ?? "").slice(0, 300) || null,
1122
+ iconLabel: iconLabelFor(el),
1026
1123
  });
1027
1124
  }
1028
1125
  return out;
@@ -1054,11 +1151,126 @@ export class BrowserController {
1054
1151
  return { count: 0, tag: null, id: null, name: null };
1055
1152
  }
1056
1153
  }
1154
+ // ───────────── OAuth handshake (T6/T7) ─────────────
1155
+ // Click an OAuth provider button and adopt whichever page now
1156
+ // carries the handshake. Google OAuth either redirects the current
1157
+ // tab or opens a popup window; this normalizes both so the agent's
1158
+ // consent loop can treat `this.page` as "the page showing Google's
1159
+ // screens" without caring which transport the service chose.
1160
+ // settleAfterOAuth() restores the product page afterwards.
1161
+ async startOAuth(selector) {
1162
+ if (!this.page || !this.context)
1163
+ throw new Error("Browser not started");
1164
+ this.oauthProductPage = this.page;
1165
+ // Race a popup `page` event against the click. context-level
1166
+ // "page" fires for both window.open popups and target=_blank.
1167
+ const popupPromise = this.context
1168
+ .waitForEvent("page", { timeout: 8000 })
1169
+ .catch(() => null);
1170
+ await this.click(selector);
1171
+ const popup = await popupPromise;
1172
+ if (popup !== null && popup !== this.page && !popup.isClosed()) {
1173
+ this.page = popup;
1174
+ }
1175
+ try {
1176
+ await this.page.waitForLoadState("domcontentloaded", { timeout: 30000 });
1177
+ }
1178
+ catch {
1179
+ // best-effort — the agent's consent loop re-reads state regardless
1180
+ }
1181
+ }
1182
+ // URL of the active page (the OAuth page mid-handshake, the product
1183
+ // page otherwise). Cheap — no screenshot, unlike getState().
1184
+ currentUrl() {
1185
+ return this.page !== null ? this.page.url() : "";
1186
+ }
1187
+ // True when the active OAuth page is gone — for the popup flow, the
1188
+ // popup closing IS the signal the handshake finished.
1189
+ oauthPageClosed() {
1190
+ return this.page === null || this.page.isClosed();
1191
+ }
1192
+ // Advance a provider's consent / account-chooser screen by one click
1193
+ // — the scope-gated auto-approve (T7/T13). Returns false when no
1194
+ // approve control is present — the agent then aborts rather than
1195
+ // hang. Clicks only; never types (the critical guarantee holds here).
1196
+ async advanceOAuthConsent(provider) {
1197
+ if (!this.page)
1198
+ throw new Error("Browser not started");
1199
+ if (provider === "github") {
1200
+ // GitHub's authorize screen: a single green "Authorize <app>"
1201
+ // button. The accessible name starts with "Authorize".
1202
+ const authorize = this.page
1203
+ .getByRole("button", { name: /^authorize\b/i })
1204
+ .first();
1205
+ if ((await authorize.count().catch(() => 0)) > 0) {
1206
+ try {
1207
+ await authorize.click({ timeout: 8000 });
1208
+ return true;
1209
+ }
1210
+ catch {
1211
+ return false;
1212
+ }
1213
+ }
1214
+ return false;
1215
+ }
1216
+ // Google. Account chooser: Google renders each account with a
1217
+ // stable data-identifier attribute (the account email).
1218
+ const tile = this.page.locator("[data-identifier]").first();
1219
+ if ((await tile.count().catch(() => 0)) > 0) {
1220
+ try {
1221
+ await tile.click({ timeout: 8000 });
1222
+ return true;
1223
+ }
1224
+ catch {
1225
+ // fall through to the approve-button path
1226
+ }
1227
+ }
1228
+ // Consent screen: the approve control's accessible name is
1229
+ // "Continue" or "Allow". A full-match regex excludes "Cancel".
1230
+ const approve = this.page
1231
+ .getByRole("button", { name: /^(continue|allow)$/i })
1232
+ .first();
1233
+ if ((await approve.count().catch(() => 0)) > 0) {
1234
+ try {
1235
+ await approve.click({ timeout: 8000 });
1236
+ return true;
1237
+ }
1238
+ catch {
1239
+ return false;
1240
+ }
1241
+ }
1242
+ return false;
1243
+ }
1244
+ // Restore the product page once the OAuth handshake completes. A
1245
+ // no-op for the same-tab redirect flow (the active page already IS
1246
+ // the product page); for the popup flow, waits briefly for the popup
1247
+ // to close, then switches `this.page` back to the product tab.
1248
+ async settleAfterOAuth() {
1249
+ const product = this.oauthProductPage;
1250
+ this.oauthProductPage = null;
1251
+ if (product === null || product === this.page)
1252
+ return; // same-tab
1253
+ for (let i = 0; i < 12 && this.page !== null && !this.page.isClosed(); i++) {
1254
+ await this.sleep(1000);
1255
+ }
1256
+ if (this.page !== null && !this.page.isClosed()) {
1257
+ await this.page.close().catch(() => undefined);
1258
+ }
1259
+ this.page = product;
1260
+ await this.page.bringToFront().catch(() => undefined);
1261
+ try {
1262
+ await this.page.waitForLoadState("domcontentloaded", { timeout: 30000 });
1263
+ }
1264
+ catch {
1265
+ // best-effort
1266
+ }
1267
+ }
1057
1268
  async close() {
1058
1269
  if (this.page)
1059
1270
  await this.page.close();
1060
- if (this.browser)
1061
- await this.browser.close();
1271
+ // Closing the persistent context shuts the browser down too.
1272
+ if (this.context)
1273
+ await this.context.close();
1062
1274
  }
1063
1275
  }
1064
1276
  // Random integer in [min, max]. We use Math.random() (not crypto)
@@ -1172,7 +1384,15 @@ export function parseEgressGeo(text) {
1172
1384
  // chooser pick, and inventory button-ranking — one keyword set, no
1173
1385
  // drift (F3 Issue 8). OAuth provider names go firmly negative so the
1174
1386
  // bot never wanders into a Google/GitHub login dead end.
1175
- export function scoreSignupButton(text) {
1387
+ //
1388
+ // `oauthProvider` (T6/T13) inverts that for the requested provider:
1389
+ // when an OAuth-first signup is requested, the "Sign in with
1390
+ // <provider>" affordance is the PRIMARY target, not a dead end — so it
1391
+ // must score positive enough to survive inventory ranking/capping.
1392
+ // Stated as a rule, not arithmetic (spec refinement): under OAuth-first
1393
+ // the provider's button outranks any form field. Only the REQUESTED
1394
+ // provider flips positive; the others stay negative.
1395
+ export function scoreSignupButton(text, oauthProvider) {
1176
1396
  const t = text.toLowerCase();
1177
1397
  let score = 0;
1178
1398
  if (t.includes("create account") || t.includes("create your account"))
@@ -1192,9 +1412,14 @@ export function scoreSignupButton(text) {
1192
1412
  // forms; it should beat nothing but lose to OAuth markers.
1193
1413
  if (t.includes("continue"))
1194
1414
  score += 2;
1195
- // OAuth / SSO buttons are submit-typed too — the provider name is
1196
- // the reliable discriminator, so drive those firmly negative.
1197
- if (/\b(google|github|gitlab|microsoft|apple|facebook|okta|sso|saml)\b/.test(t)) {
1415
+ if (oauthProvider !== undefined && new RegExp(`\\b${oauthProvider}\\b`).test(t)) {
1416
+ // OAuth-first: the requested provider's button is the goal. Score
1417
+ // it above every form-field-class button so ranking never caps it out.
1418
+ score += 50;
1419
+ }
1420
+ else if (/\b(google|github|gitlab|microsoft|apple|facebook|okta|sso|saml)\b/.test(t)) {
1421
+ // OAuth / SSO buttons are submit-typed too — the provider name is
1422
+ // the reliable discriminator, so drive those firmly negative.
1198
1423
  score -= 20;
1199
1424
  }
1200
1425
  if (t.includes("sign in") || t.includes("log in") || t.includes("login"))
@@ -1208,7 +1433,7 @@ export function scoreSignupButton(text) {
1208
1433
  // carries dozens of nav/footer buttons (F3 Issue 3 + Tension 2: a
1209
1434
  // flat cap could truncate the real email field). Re-indexes the kept
1210
1435
  // set and reports how many buttons were dropped.
1211
- export function rankAndCapInventory(elements, buttonCap = 25) {
1436
+ export function rankAndCapInventory(elements, buttonCap = 25, oauthProvider) {
1212
1437
  const isButtonish = (e) => e.tag === "button" ||
1213
1438
  e.tag === "a" ||
1214
1439
  e.type === "submit" ||
@@ -1219,7 +1444,7 @@ export function rankAndCapInventory(elements, buttonCap = 25) {
1219
1444
  .filter(isButtonish)
1220
1445
  .map((e) => ({
1221
1446
  e,
1222
- score: scoreSignupButton(`${e.visibleText ?? ""} ${e.ariaLabel ?? ""} ${e.labelText ?? ""}`),
1447
+ score: scoreSignupButton(`${e.visibleText ?? ""} ${e.ariaLabel ?? ""} ${e.labelText ?? ""}`, oauthProvider),
1223
1448
  }))
1224
1449
  .sort((a, b) => b.score - a.score);
1225
1450
  const keptButtons = ranked.slice(0, buttonCap).map((x) => x.e);