@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.
- package/dist/bin.js +2 -2
- package/dist/bin.js.map +1 -1
- package/dist/bot/agent.d.ts +15 -1
- package/dist/bot/agent.d.ts.map +1 -1
- package/dist/bot/agent.js +531 -52
- package/dist/bot/agent.js.map +1 -1
- package/dist/bot/browser.d.ts +15 -3
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +281 -56
- package/dist/bot/browser.js.map +1 -1
- package/dist/bot/google-login.d.ts +18 -0
- package/dist/bot/google-login.d.ts.map +1 -0
- package/dist/bot/google-login.js +379 -0
- package/dist/bot/google-login.js.map +1 -0
- package/dist/bot/index.d.ts +5 -0
- package/dist/bot/index.d.ts.map +1 -1
- package/dist/bot/index.js +14 -0
- package/dist/bot/index.js.map +1 -1
- package/dist/bot/llm-client.d.ts.map +1 -1
- package/dist/bot/llm-client.js +19 -12
- package/dist/bot/llm-client.js.map +1 -1
- package/dist/bot/oauth-lock.d.ts +2 -0
- package/dist/bot/oauth-lock.d.ts.map +1 -0
- package/dist/bot/oauth-lock.js +28 -0
- package/dist/bot/oauth-lock.js.map +1 -0
- package/dist/bot/oauth-providers.d.ts +16 -0
- package/dist/bot/oauth-providers.d.ts.map +1 -0
- package/dist/bot/oauth-providers.js +100 -0
- package/dist/bot/oauth-providers.js.map +1 -0
- package/dist/bot/onboarding-capture.d.ts +17 -0
- package/dist/bot/onboarding-capture.d.ts.map +1 -0
- package/dist/bot/onboarding-capture.js +52 -0
- package/dist/bot/onboarding-capture.js.map +1 -0
- package/dist/bot/profile.d.ts +2 -0
- package/dist/bot/profile.d.ts.map +1 -0
- package/dist/bot/profile.js +11 -0
- package/dist/bot/profile.js.map +1 -0
- package/dist/install/cli.d.ts +4 -1
- package/dist/install/cli.d.ts.map +1 -1
- package/dist/install/cli.js +41 -1
- package/dist/install/cli.js.map +1 -1
- package/dist/tools/provision-any.d.ts +15 -0
- package/dist/tools/provision-any.d.ts.map +1 -1
- package/dist/tools/provision-any.js +66 -2
- package/dist/tools/provision-any.js.map +1 -1
- package/package.json +3 -1
package/dist/bot/browser.js
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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:`
|
|
197
|
-
//
|
|
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
|
|
201
|
-
// (
|
|
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.
|
|
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)
|
|
231
|
-
//
|
|
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
|
|
245
|
-
// throwaway
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
// start() keeps a default timezone.
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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 (
|
|
272
|
-
await
|
|
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
|
-
|
|
1061
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
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);
|