@achamm/veilbrowser 0.3.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/LICENSE +21 -0
- package/README.md +272 -0
- package/dist/browser.d.ts +16 -0
- package/dist/browser.js +43 -0
- package/dist/cdp.d.ts +39 -0
- package/dist/cdp.js +128 -0
- package/dist/human.d.ts +33 -0
- package/dist/human.js +73 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/launcher.d.ts +67 -0
- package/dist/launcher.js +272 -0
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +156 -0
- package/dist/page.d.ts +194 -0
- package/dist/page.js +527 -0
- package/dist/stealth.d.ts +28 -0
- package/dist/stealth.js +74 -0
- package/package.json +54 -0
- package/src/browser.ts +43 -0
- package/src/cdp.ts +133 -0
- package/src/human.ts +82 -0
- package/src/index.ts +6 -0
- package/src/launcher.ts +325 -0
- package/src/mcp.ts +155 -0
- package/src/page.ts +571 -0
- package/src/stealth.ts +103 -0
package/src/page.ts
ADDED
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent-facing page API.
|
|
3
|
+
*
|
|
4
|
+
* Design goal: an LLM agent should never write a CSS/XPath selector. Selectors
|
|
5
|
+
* are the #1 source of automation breakage. Instead, `snapshot()` returns the
|
|
6
|
+
* page as a flat, numbered list of meaningful elements pulled from Chrome's
|
|
7
|
+
* accessibility tree — the same semantic layer a screen reader sees. The agent
|
|
8
|
+
* acts on a stable integer `ref`; we resolve the geometry and drive real input.
|
|
9
|
+
*/
|
|
10
|
+
import type { CDP } from "./cdp.js";
|
|
11
|
+
import { buildStealth } from "./stealth.js";
|
|
12
|
+
import { Rng, mousePath, moveDelay, keyDelay, sleep, type Point } from "./human.js";
|
|
13
|
+
|
|
14
|
+
export interface Element {
|
|
15
|
+
ref: number;
|
|
16
|
+
role: string;
|
|
17
|
+
name: string;
|
|
18
|
+
value?: string;
|
|
19
|
+
center: Point;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Snapshot {
|
|
23
|
+
url: string;
|
|
24
|
+
title: string;
|
|
25
|
+
/** Human/agent-readable index, e.g. `[3] button "Sign in"`. */
|
|
26
|
+
text: string;
|
|
27
|
+
elements: Element[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One account offered in a FedCM account chooser. */
|
|
31
|
+
export interface FedCmAccount {
|
|
32
|
+
accountId: string;
|
|
33
|
+
email?: string;
|
|
34
|
+
name?: string;
|
|
35
|
+
givenName?: string;
|
|
36
|
+
idpConfigUrl?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A FedCM dialog Chrome would normally render as native browser UI. */
|
|
40
|
+
export interface FedCmDialog {
|
|
41
|
+
dialogId: string;
|
|
42
|
+
/** "AccountChooser" | "AutoReauthn" | "ConfirmIdpLogin" | "SelectAccount" ... */
|
|
43
|
+
type: string;
|
|
44
|
+
title?: string;
|
|
45
|
+
subtitle?: string;
|
|
46
|
+
accounts: FedCmAccount[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const INTERESTING = new Set([
|
|
50
|
+
"button", "link", "textbox", "searchbox", "combobox", "checkbox", "radio",
|
|
51
|
+
"menuitem", "menuitemcheckbox", "tab", "switch", "slider", "option",
|
|
52
|
+
"listbox", "spinbutton", "textarea",
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* True if `url` targets a loopback / private-network host. Fingerprinters
|
|
57
|
+
* (iphey, pixelscan, …) port-scan these from page JS to profile the machine's
|
|
58
|
+
* OTHER software — VNC on :5900, a local automation API on :3001, etc. — which
|
|
59
|
+
* also leaks your LAN to every site you visit. Exotic IP encodings (decimal,
|
|
60
|
+
* hex) are a known gap; real-world scanners use the canonical forms below.
|
|
61
|
+
*/
|
|
62
|
+
export function isPrivateHost(url: string): boolean {
|
|
63
|
+
let host: string;
|
|
64
|
+
try { host = new URL(url).hostname.toLowerCase(); } catch { return false; }
|
|
65
|
+
host = host.replace(/^\[|\]$/g, ""); // strip IPv6 brackets
|
|
66
|
+
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
|
67
|
+
if (host === "::1" || host === "0.0.0.0") return true;
|
|
68
|
+
const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.\d{1,3}$/);
|
|
69
|
+
if (!m) return false;
|
|
70
|
+
const a = +m[1]!, b = +m[2]!;
|
|
71
|
+
return (
|
|
72
|
+
a === 127 || // 127.0.0.0/8 loopback
|
|
73
|
+
a === 10 || // 10.0.0.0/8
|
|
74
|
+
a === 0 || // 0.0.0.0/8
|
|
75
|
+
(a === 192 && b === 168) || // 192.168.0.0/16
|
|
76
|
+
(a === 172 && b >= 16 && b <= 31) ||// 172.16.0.0/12
|
|
77
|
+
(a === 169 && b === 254) // 169.254.0.0/16 link-local
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Only these (private) requests are intercepted, so normal browsing keeps its
|
|
82
|
+
// exact timing — no global request pause. Globs over-capture slightly (e.g.
|
|
83
|
+
// 172.1*); isPrivateHost() is the real gate in the handler. http/https only:
|
|
84
|
+
// CDP's Fetch domain does not intercept WebSocket handshakes, so raw ws:// to a
|
|
85
|
+
// private host falls back to Chrome's own Private Network Access (a timeout, not
|
|
86
|
+
// a uniform block). Real port-scanners (and the :3001/:5900 probes) use HTTP.
|
|
87
|
+
const PRIVATE_URL_PATTERNS = ["localhost", "127.", "0.0.0.0", "10.", "192.168.", "172.1", "172.2", "172.3", "169.254.", "[::1]"]
|
|
88
|
+
.flatMap((h) => ["http", "https"].map((s) => ({ urlPattern: `${s}://${h}*` })));
|
|
89
|
+
|
|
90
|
+
export class Page {
|
|
91
|
+
private rng = new Rng();
|
|
92
|
+
private mouse: Point = { x: 100, y: 100 };
|
|
93
|
+
private refs = new Map<number, { backendNodeId: number; center: Point }>();
|
|
94
|
+
private closed = false;
|
|
95
|
+
// FedCM interception state (see enableFedCm).
|
|
96
|
+
private fedcmOff?: () => void;
|
|
97
|
+
private fedcmQueue: FedCmDialog[] = [];
|
|
98
|
+
private fedcmWaiters: Array<(d: FedCmDialog) => void> = [];
|
|
99
|
+
private lastFedcmDialogId?: string;
|
|
100
|
+
// Private-network block state (see blockPrivateNetwork).
|
|
101
|
+
private blockPrivateOff?: () => void;
|
|
102
|
+
private mainFrameId?: string;
|
|
103
|
+
private topPrivate = false; // is the page's own top-level origin private?
|
|
104
|
+
|
|
105
|
+
constructor(
|
|
106
|
+
private cdp: CDP,
|
|
107
|
+
public readonly sessionId: string,
|
|
108
|
+
private targetId?: string,
|
|
109
|
+
) {}
|
|
110
|
+
|
|
111
|
+
/** Enable the domains we use and arm stealth injection on every document. */
|
|
112
|
+
async init(opts: { maskWebgl?: boolean; blockPrivateNetwork?: boolean } = {}) {
|
|
113
|
+
await this.send("Page.enable");
|
|
114
|
+
await this.send("DOM.enable");
|
|
115
|
+
await this.send("Accessibility.enable");
|
|
116
|
+
await this.normalizeUserAgent();
|
|
117
|
+
// Inject stealth before any page script runs, on every navigation/frame.
|
|
118
|
+
// Only mask WebGL on SwiftShader hosts; with a real GPU the authentic vendor
|
|
119
|
+
// is consistent and masking it would be a detectable lie.
|
|
120
|
+
const source = buildStealth({ maskWebgl: opts.maskWebgl ?? false });
|
|
121
|
+
await this.send("Page.addScriptToEvaluateOnNewDocument", { source });
|
|
122
|
+
if (opts.blockPrivateNetwork) await this.blockPrivateNetwork();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Inject cookies before navigating — e.g. a logged-in session transferred
|
|
127
|
+
* from another browser. Each cookie is a CDP CookieParam ({name, value,
|
|
128
|
+
* domain, path, secure, httpOnly, expires?, sameSite?}). Lets the browser
|
|
129
|
+
* ride an existing session instead of re-doing a bot-walled login.
|
|
130
|
+
*/
|
|
131
|
+
async setCookies(cookies: Array<Record<string, any>>) {
|
|
132
|
+
await this.send("Network.enable");
|
|
133
|
+
await this.send("Network.setCookies", { cookies });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Scrub the "HeadlessChrome" token from the UA and the matching client-hint
|
|
138
|
+
* brands. headless=new leaks it in both navigator.userAgent AND the Sec-CH-UA
|
|
139
|
+
* request headers; setUserAgentOverride with metadata fixes both at once. A
|
|
140
|
+
* no-op for headful Chrome, whose UA is already clean.
|
|
141
|
+
*/
|
|
142
|
+
private async normalizeUserAgent() {
|
|
143
|
+
const realUA = await this.evaluate<string>("navigator.userAgent");
|
|
144
|
+
const cleanUA = realUA.replace("HeadlessChrome", "Chrome");
|
|
145
|
+
if (cleanUA === realUA) return;
|
|
146
|
+
const major = (cleanUA.match(/Chrome\/(\d+)/)?.[1]) ?? "148";
|
|
147
|
+
await this.send("Emulation.setUserAgentOverride", {
|
|
148
|
+
userAgent: cleanUA,
|
|
149
|
+
acceptLanguage: "en-US,en;q=0.9",
|
|
150
|
+
platform: "Linux x86_64",
|
|
151
|
+
userAgentMetadata: {
|
|
152
|
+
brands: [
|
|
153
|
+
{ brand: "Chromium", version: major },
|
|
154
|
+
{ brand: "Google Chrome", version: major },
|
|
155
|
+
{ brand: "Not?A_Brand", version: "99" },
|
|
156
|
+
],
|
|
157
|
+
fullVersion: `${major}.0.0.0`,
|
|
158
|
+
platform: "Linux",
|
|
159
|
+
platformVersion: "6.8.0",
|
|
160
|
+
architecture: "x86",
|
|
161
|
+
model: "",
|
|
162
|
+
mobile: false,
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private send<T = any>(method: string, params: Record<string, any> = {}) {
|
|
168
|
+
return this.cdp.send<T>(method, params, this.sessionId);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Navigate and wait for the load event. */
|
|
172
|
+
async goto(url: string, opts: { timeout?: number } = {}) {
|
|
173
|
+
const loaded = this.cdp.once("Page.loadEventFired", {
|
|
174
|
+
sessionId: this.sessionId,
|
|
175
|
+
timeout: opts.timeout ?? 30000,
|
|
176
|
+
});
|
|
177
|
+
await this.send("Page.navigate", { url });
|
|
178
|
+
await loaded;
|
|
179
|
+
await sleep(this.rng.range(150, 400)); // settle, like a human reading
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Evaluate JS in the page WITHOUT Runtime.enable (avoids the CDP tell). */
|
|
183
|
+
async evaluate<T = any>(expression: string): Promise<T> {
|
|
184
|
+
const r = await this.send("Runtime.evaluate", {
|
|
185
|
+
expression,
|
|
186
|
+
returnByValue: true,
|
|
187
|
+
awaitPromise: true,
|
|
188
|
+
});
|
|
189
|
+
if (r.exceptionDetails) throw new Error(`evaluate: ${r.exceptionDetails.text}`);
|
|
190
|
+
return r.result?.value as T;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async url(): Promise<string> {
|
|
194
|
+
return this.evaluate<string>("location.href");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Build the numbered element index from the accessibility tree. */
|
|
198
|
+
async snapshot(): Promise<Snapshot> {
|
|
199
|
+
const { nodes } = await this.send("Accessibility.getFullAXTree");
|
|
200
|
+
this.refs.clear();
|
|
201
|
+
const elements: Element[] = [];
|
|
202
|
+
let ref = 0;
|
|
203
|
+
|
|
204
|
+
for (const n of nodes) {
|
|
205
|
+
if (n.ignored) continue;
|
|
206
|
+
const role: string = n.role?.value ?? "";
|
|
207
|
+
const name: string = (n.name?.value ?? "").trim();
|
|
208
|
+
if (!INTERESTING.has(role)) continue;
|
|
209
|
+
if (!name && role !== "textbox" && role !== "searchbox" && role !== "textarea") continue;
|
|
210
|
+
const backendNodeId = n.backendDOMNodeId;
|
|
211
|
+
if (!backendNodeId) continue;
|
|
212
|
+
|
|
213
|
+
const center = await this.boxCenter(backendNodeId);
|
|
214
|
+
if (!center) continue; // not visible / no layout box
|
|
215
|
+
|
|
216
|
+
ref++;
|
|
217
|
+
const value: string | undefined = n.value?.value;
|
|
218
|
+
this.refs.set(ref, { backendNodeId, center });
|
|
219
|
+
elements.push({ ref, role, name, value, center });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const [url, title] = await Promise.all([
|
|
223
|
+
this.evaluate<string>("location.href"),
|
|
224
|
+
this.evaluate<string>("document.title"),
|
|
225
|
+
]);
|
|
226
|
+
const text = elements
|
|
227
|
+
.map((e) => `[${e.ref}] ${e.role} ${JSON.stringify(e.name)}${e.value ? ` =${JSON.stringify(e.value)}` : ""}`)
|
|
228
|
+
.join("\n");
|
|
229
|
+
return { url, title, text, elements };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private async boxCenter(backendNodeId: number): Promise<Point | null> {
|
|
233
|
+
try {
|
|
234
|
+
const { model } = await this.send("DOM.getBoxModel", { backendNodeId });
|
|
235
|
+
const q = model.content as number[]; // [x1,y1, x2,y2, x3,y3, x4,y4]
|
|
236
|
+
const x = (q[0] + q[2] + q[4] + q[6]) / 4;
|
|
237
|
+
const y = (q[1] + q[3] + q[5] + q[7]) / 4;
|
|
238
|
+
if ((model.width ?? 0) <= 0 || (model.height ?? 0) <= 0) return null;
|
|
239
|
+
return { x, y };
|
|
240
|
+
} catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Move the cursor along a human curve to a target point. */
|
|
246
|
+
private async moveTo(target: Point) {
|
|
247
|
+
const path = mousePath(this.mouse, target, this.rng);
|
|
248
|
+
for (const p of path) {
|
|
249
|
+
await this.send("Input.dispatchMouseEvent", {
|
|
250
|
+
type: "mouseMoved",
|
|
251
|
+
x: p.x,
|
|
252
|
+
y: p.y,
|
|
253
|
+
buttons: 0,
|
|
254
|
+
});
|
|
255
|
+
await sleep(moveDelay(this.rng));
|
|
256
|
+
}
|
|
257
|
+
this.mouse = target;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Click an element by its snapshot ref. */
|
|
261
|
+
async click(ref: number) {
|
|
262
|
+
const target = this.refs.get(ref);
|
|
263
|
+
if (!target) throw new Error(`No element with ref ${ref}. Call snapshot() first.`);
|
|
264
|
+
await this.moveTo(target.center);
|
|
265
|
+
await sleep(this.rng.range(30, 90));
|
|
266
|
+
const common = { x: target.center.x, y: target.center.y, button: "left", clickCount: 1 };
|
|
267
|
+
await this.send("Input.dispatchMouseEvent", { type: "mousePressed", buttons: 1, ...common });
|
|
268
|
+
await sleep(this.rng.range(40, 110)); // press dwell
|
|
269
|
+
await this.send("Input.dispatchMouseEvent", { type: "mouseReleased", buttons: 0, ...common });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Bring this page's target to the foreground — CDP Input only routes to the active target. */
|
|
273
|
+
async bringToFront() {
|
|
274
|
+
await this.send("Page.bringToFront");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Trusted click at absolute viewport coords (when you can't resolve a snapshot ref). */
|
|
278
|
+
async clickAt(x: number, y: number) {
|
|
279
|
+
await this.moveTo({ x, y });
|
|
280
|
+
await sleep(this.rng.range(30, 90));
|
|
281
|
+
const common = { x, y, button: "left", clickCount: 1 };
|
|
282
|
+
await this.send("Input.dispatchMouseEvent", { type: "mousePressed", buttons: 1, ...common });
|
|
283
|
+
await sleep(this.rng.range(40, 110));
|
|
284
|
+
await this.send("Input.dispatchMouseEvent", { type: "mouseReleased", buttons: 0, ...common });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Type text into the focused element with human cadence. */
|
|
288
|
+
async type(text: string) {
|
|
289
|
+
for (const ch of text) {
|
|
290
|
+
await this.send("Input.dispatchKeyEvent", { type: "keyDown", text: ch });
|
|
291
|
+
await this.send("Input.dispatchKeyEvent", { type: "keyUp", text: ch });
|
|
292
|
+
await sleep(keyDelay(this.rng, ch));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Click a field then type into it. */
|
|
297
|
+
async fill(ref: number, text: string) {
|
|
298
|
+
await this.click(ref);
|
|
299
|
+
await sleep(this.rng.range(60, 160));
|
|
300
|
+
await this.type(text);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Capture a PNG screenshot (Buffer) — feed to a vision model. */
|
|
304
|
+
async screenshot(opts: { fullPage?: boolean } = {}): Promise<Buffer> {
|
|
305
|
+
const params: any = { format: "png" };
|
|
306
|
+
if (opts.fullPage) params.captureBeyondViewport = true;
|
|
307
|
+
const { data } = await this.send("Page.captureScreenshot", params);
|
|
308
|
+
return Buffer.from(data, "base64");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Poll an expression until truthy (replaces flaky fixed sleeps). */
|
|
312
|
+
async waitFor(expression: string, opts: { timeout?: number; poll?: number } = {}) {
|
|
313
|
+
const timeout = opts.timeout ?? 10000;
|
|
314
|
+
const poll = opts.poll ?? 100;
|
|
315
|
+
const start = Date.now();
|
|
316
|
+
while (Date.now() - start < timeout) {
|
|
317
|
+
if (await this.evaluate<boolean>(`!!(${expression})`)) return;
|
|
318
|
+
await sleep(poll);
|
|
319
|
+
}
|
|
320
|
+
throw new Error(`waitFor timed out: ${expression}`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Attach local files to a file `<input>` — even a hidden one — without an OS
|
|
325
|
+
* file picker. Uses CDP DOM.setFileInputFiles (the same primitive Playwright
|
|
326
|
+
* uses under the hood), which sets `input.files` and fires `change` directly.
|
|
327
|
+
* `selector` defaults to the first file input; pass a more specific one if the
|
|
328
|
+
* page has several. Paths must be absolute.
|
|
329
|
+
*/
|
|
330
|
+
async uploadFile(paths: string[], selector = 'input[type="file"]') {
|
|
331
|
+
const { root } = await this.send("DOM.getDocument", { depth: 0 });
|
|
332
|
+
const { nodeId } = await this.send("DOM.querySelector", {
|
|
333
|
+
nodeId: root.nodeId,
|
|
334
|
+
selector,
|
|
335
|
+
});
|
|
336
|
+
if (!nodeId) throw new Error(`uploadFile: no element matching ${selector}`);
|
|
337
|
+
await this.send("DOM.setFileInputFiles", { files: paths, nodeId });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Attach files through a control that opens a file picker (e.g. an "Upload
|
|
342
|
+
* files" menu item) WITHOUT an OS dialog. Intercepts the chooser via CDP,
|
|
343
|
+
* clicks the trigger, then feeds the paths to the input it opened for. This is
|
|
344
|
+
* the path for SPAs (like Gemini) that create the `<input>` lazily on click.
|
|
345
|
+
* Paths must be absolute.
|
|
346
|
+
*/
|
|
347
|
+
async uploadViaPicker(triggerRef: number, paths: string[], opts: { timeout?: number } = {}) {
|
|
348
|
+
await this.send("Page.setInterceptFileChooserDialog", { enabled: true });
|
|
349
|
+
try {
|
|
350
|
+
// Listen on any session — the chooser event can arrive without our page
|
|
351
|
+
// sessionId attached, which silently filtered it out before.
|
|
352
|
+
const chooser = this.cdp.once("Page.fileChooserOpened", {
|
|
353
|
+
timeout: opts.timeout ?? 15000,
|
|
354
|
+
});
|
|
355
|
+
await this.click(triggerRef);
|
|
356
|
+
const ev = await chooser;
|
|
357
|
+
await this.send("DOM.setFileInputFiles", { files: paths, backendNodeId: ev.backendNodeId });
|
|
358
|
+
} finally {
|
|
359
|
+
await this.send("Page.setInterceptFileChooserDialog", { enabled: false });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Read the page's visible text (for scraping a model response, etc.). */
|
|
364
|
+
async innerText(): Promise<string> {
|
|
365
|
+
return this.evaluate<string>("document.body ? document.body.innerText : ''");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Press a single named key on the focused element (Enter, Tab, Escape, arrows...). */
|
|
369
|
+
async press(key: string) {
|
|
370
|
+
const KEYS: Record<string, { code: string; vk: number; text?: string }> = {
|
|
371
|
+
Enter: { code: "Enter", vk: 13, text: "\r" },
|
|
372
|
+
Tab: { code: "Tab", vk: 9 },
|
|
373
|
+
Escape: { code: "Escape", vk: 27 },
|
|
374
|
+
Backspace: { code: "Backspace", vk: 8 },
|
|
375
|
+
ArrowDown: { code: "ArrowDown", vk: 40 },
|
|
376
|
+
ArrowUp: { code: "ArrowUp", vk: 38 },
|
|
377
|
+
};
|
|
378
|
+
const k = KEYS[key];
|
|
379
|
+
if (!k) throw new Error(`press: unsupported key ${key}`);
|
|
380
|
+
const base = { key, code: k.code, windowsVirtualKeyCode: k.vk, nativeVirtualKeyCode: k.vk };
|
|
381
|
+
await this.send("Input.dispatchKeyEvent", { type: "rawKeyDown", ...base, ...(k.text ? { text: k.text } : {}) });
|
|
382
|
+
if (k.text) await this.send("Input.dispatchKeyEvent", { type: "char", ...base, text: k.text });
|
|
383
|
+
await this.send("Input.dispatchKeyEvent", { type: "keyUp", ...base });
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// --- FedCM: drive federated sign-in ("Sign in with Google" one-tap, etc.) ---
|
|
387
|
+
// Chrome renders FedCM account choosers as native browser UI that no synthetic
|
|
388
|
+
// mouse click can reach (the button is a cross-origin IdP iframe, and the
|
|
389
|
+
// chooser itself is browser chrome). FedCm.enable routes the dialog to us over
|
|
390
|
+
// CDP instead, so an agent can actually complete a federated login.
|
|
391
|
+
// End-to-end run: examples/fedcm.ts.
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Start intercepting FedCM on this page. Call it ON DEMAND, right before the
|
|
395
|
+
* sign-in you're driving — never as blanket startup setup. Any page that
|
|
396
|
+
* silently probes FedCM at load (GoHighLevel, many SaaS logins) will HANG if
|
|
397
|
+
* interception is on and nothing resolves the probe, so keep it off until you
|
|
398
|
+
* need it and disableFedCm() afterwards.
|
|
399
|
+
*
|
|
400
|
+
* With {autoSelectFirst:true} (default) veil selects account 0 on every dialog
|
|
401
|
+
* automatically — the one-liner for "just sign me in". Pass false to inspect
|
|
402
|
+
* accounts via waitForFedCmDialog() and choose with selectFedCmAccount().
|
|
403
|
+
*/
|
|
404
|
+
async enableFedCm(opts: { autoSelectFirst?: boolean } = {}) {
|
|
405
|
+
const autoSelect = opts.autoSelectFirst ?? true;
|
|
406
|
+
await this.send("FedCm.enable", { disableRejectionDelay: true });
|
|
407
|
+
// A prior dismissal drops the IdP into a cooldown where the dialog silently
|
|
408
|
+
// won't reappear; clear it so the next trigger actually shows.
|
|
409
|
+
try { await this.send("FedCm.resetCooldown"); } catch {}
|
|
410
|
+
if (this.fedcmOff) return;
|
|
411
|
+
this.fedcmOff = this.cdp.on(
|
|
412
|
+
"FedCm.dialogShown",
|
|
413
|
+
(p: any) => {
|
|
414
|
+
const dialog: FedCmDialog = {
|
|
415
|
+
dialogId: p.dialogId,
|
|
416
|
+
type: p.dialogType,
|
|
417
|
+
title: p.title,
|
|
418
|
+
subtitle: p.subtitle,
|
|
419
|
+
accounts: (p.accounts ?? []).map((a: any) => ({
|
|
420
|
+
accountId: a.accountId,
|
|
421
|
+
email: a.email,
|
|
422
|
+
name: a.name,
|
|
423
|
+
givenName: a.givenName,
|
|
424
|
+
idpConfigUrl: a.idpConfigUrl,
|
|
425
|
+
})),
|
|
426
|
+
};
|
|
427
|
+
this.lastFedcmDialogId = dialog.dialogId;
|
|
428
|
+
// Bind selection to THIS session. CDP strips the sessionId off the event
|
|
429
|
+
// params, and selecting on the wrong target leaves the dialog — and the
|
|
430
|
+
// RP page's navigator.credentials.get() — hanging unresolved.
|
|
431
|
+
if (autoSelect && dialog.accounts.length) {
|
|
432
|
+
this.selectFedCmAccount(0, dialog.dialogId).catch(() => {});
|
|
433
|
+
}
|
|
434
|
+
const waiter = this.fedcmWaiters.shift();
|
|
435
|
+
if (waiter) waiter(dialog);
|
|
436
|
+
else this.fedcmQueue.push(dialog);
|
|
437
|
+
},
|
|
438
|
+
this.sessionId,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Resolve with the next FedCM dialog (or one already queued since enable). */
|
|
443
|
+
async waitForFedCmDialog(opts: { timeout?: number } = {}): Promise<FedCmDialog> {
|
|
444
|
+
const queued = this.fedcmQueue.shift();
|
|
445
|
+
if (queued) return queued;
|
|
446
|
+
return new Promise<FedCmDialog>((resolve, reject) => {
|
|
447
|
+
const waiter = (d: FedCmDialog) => {
|
|
448
|
+
clearTimeout(timer);
|
|
449
|
+
resolve(d);
|
|
450
|
+
};
|
|
451
|
+
const timer = setTimeout(() => {
|
|
452
|
+
const i = this.fedcmWaiters.indexOf(waiter);
|
|
453
|
+
if (i >= 0) this.fedcmWaiters.splice(i, 1);
|
|
454
|
+
reject(new Error("waitForFedCmDialog: timed out (is FedCM enabled, and are you signed in to the IdP?)"));
|
|
455
|
+
}, opts.timeout ?? 30000);
|
|
456
|
+
this.fedcmWaiters.push(waiter);
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Pick an account in the current FedCM dialog (index into dialog.accounts). */
|
|
461
|
+
async selectFedCmAccount(accountIndex = 0, dialogId = this.lastFedcmDialogId) {
|
|
462
|
+
if (!dialogId) throw new Error("selectFedCmAccount: no FedCM dialog has appeared yet");
|
|
463
|
+
await this.send("FedCm.selectAccount", { dialogId, accountIndex });
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Dismiss the current FedCM dialog (decline the sign-in). */
|
|
467
|
+
async dismissFedCm(dialogId = this.lastFedcmDialogId) {
|
|
468
|
+
if (!dialogId) return;
|
|
469
|
+
await this.send("FedCm.dismissDialog", { dialogId, triggerCooldown: false });
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Stop intercepting FedCM. Call after a sign-in so a later navigation that
|
|
473
|
+
* probes FedCM isn't left hanging on us. */
|
|
474
|
+
async disableFedCm() {
|
|
475
|
+
this.fedcmOff?.();
|
|
476
|
+
this.fedcmOff = undefined;
|
|
477
|
+
this.fedcmQueue = [];
|
|
478
|
+
this.fedcmWaiters = [];
|
|
479
|
+
try { await this.send("FedCm.disable"); } catch {}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* One call to complete an active federated sign-in: enables FedCM, clicks the
|
|
484
|
+
* "Sign in with Google" button (a snapshot ref), waits for the account
|
|
485
|
+
* chooser, selects an account, and returns it. For passive/one-tap flows that
|
|
486
|
+
* fire on page load, enableFedCm() BEFORE navigating, then
|
|
487
|
+
* waitForFedCmDialog() — the default autoSelectFirst signs you straight in.
|
|
488
|
+
*/
|
|
489
|
+
async signInWithFedCm(opts: { triggerRef?: number; accountIndex?: number; timeout?: number } = {}): Promise<FedCmAccount> {
|
|
490
|
+
await this.enableFedCm({ autoSelectFirst: false });
|
|
491
|
+
if (opts.triggerRef != null) await this.click(opts.triggerRef);
|
|
492
|
+
const dialog = await this.waitForFedCmDialog({ timeout: opts.timeout });
|
|
493
|
+
const idx = opts.accountIndex ?? 0;
|
|
494
|
+
const account = dialog.accounts[idx];
|
|
495
|
+
if (!account) throw new Error(`signInWithFedCm: no account at index ${idx} (dialog had ${dialog.accounts.length})`);
|
|
496
|
+
await this.selectFedCmAccount(idx, dialog.dialogId);
|
|
497
|
+
return account;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Stop the page — and any site it loads — from reaching loopback / private
|
|
502
|
+
* hosts. Detectors port-scan 127.0.0.1 from JS to fingerprint the machine's
|
|
503
|
+
* other software (and it leaks your LAN to every site). With this on, each
|
|
504
|
+
* such request is failed UNIFORMLY (same instant error, open port or closed),
|
|
505
|
+
* so the scan can't tell them apart and comes back empty. Only private-host
|
|
506
|
+
* requests are intercepted, so normal browsing keeps its exact timing.
|
|
507
|
+
*
|
|
508
|
+
* Still allowed: the agent's own top-level navigation to a private host
|
|
509
|
+
* (page.goto("http://localhost:3000")), and a localhost page loading its own
|
|
510
|
+
* localhost resources — only a PUBLIC page reaching a private host is blocked.
|
|
511
|
+
*/
|
|
512
|
+
async blockPrivateNetwork() {
|
|
513
|
+
if (this.blockPrivateOff) return;
|
|
514
|
+
// Learn the main frame so we can tell an agent nav from a page's own probe.
|
|
515
|
+
try {
|
|
516
|
+
const { frameTree } = await this.send("Page.getFrameTree");
|
|
517
|
+
this.mainFrameId = frameTree?.frame?.id;
|
|
518
|
+
this.topPrivate = isPrivateHost(frameTree?.frame?.url ?? "");
|
|
519
|
+
} catch {}
|
|
520
|
+
const offNav = this.cdp.on(
|
|
521
|
+
"Page.frameNavigated",
|
|
522
|
+
(p: any) => {
|
|
523
|
+
const f = p.frame;
|
|
524
|
+
if (f && !f.parentId) { this.mainFrameId = f.id; this.topPrivate = isPrivateHost(f.url ?? ""); }
|
|
525
|
+
},
|
|
526
|
+
this.sessionId,
|
|
527
|
+
);
|
|
528
|
+
await this.send("Fetch.enable", { patterns: PRIVATE_URL_PATTERNS });
|
|
529
|
+
const offFetch = this.cdp.on(
|
|
530
|
+
"Fetch.requestPaused",
|
|
531
|
+
(p: any) => {
|
|
532
|
+
const url: string = p.request?.url ?? "";
|
|
533
|
+
// Allow: agent-driven top-level nav, and a private page's own resources.
|
|
534
|
+
// Block: any other private-host request from a public page (the scan).
|
|
535
|
+
const isMainNav = p.resourceType === "Document" && p.frameId === this.mainFrameId;
|
|
536
|
+
if (isPrivateHost(url) && !isMainNav && !this.topPrivate) {
|
|
537
|
+
this.send("Fetch.failRequest", { requestId: p.requestId, errorReason: "AccessDenied" }).catch(() => {});
|
|
538
|
+
} else {
|
|
539
|
+
this.send("Fetch.continueRequest", { requestId: p.requestId }).catch(() => {});
|
|
540
|
+
}
|
|
541
|
+
},
|
|
542
|
+
this.sessionId,
|
|
543
|
+
);
|
|
544
|
+
this.blockPrivateOff = () => { offNav(); offFetch(); };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Lift the private-network block (re-allows localhost/LAN requests). */
|
|
548
|
+
async unblockPrivateNetwork() {
|
|
549
|
+
this.blockPrivateOff?.();
|
|
550
|
+
this.blockPrivateOff = undefined;
|
|
551
|
+
try { await this.send("Fetch.disable"); } catch {}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Close this page and detach its target from the browser. Idempotent. */
|
|
555
|
+
async close() {
|
|
556
|
+
if (this.closed) return;
|
|
557
|
+
this.closed = true;
|
|
558
|
+
this.refs.clear();
|
|
559
|
+
if (this.targetId) {
|
|
560
|
+
try {
|
|
561
|
+
// Target.closeTarget closes the page/target and frees its resources.
|
|
562
|
+
// Send to browser context (no sessionId) since we're closing the target itself.
|
|
563
|
+
await this.cdp.send("Target.closeTarget", { targetId: this.targetId });
|
|
564
|
+
} catch {
|
|
565
|
+
// Target already closed or doesn't exist; this is OK.
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
// Clean up any lingering event handlers for this session
|
|
569
|
+
this.cdp.clearHandlers(this.sessionId);
|
|
570
|
+
}
|
|
571
|
+
}
|
package/src/stealth.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page-side stealth patch — minimal and SELF-GATING.
|
|
3
|
+
*
|
|
4
|
+
* Hard-won lesson: on a real, properly-launched Chrome, the "tells" stealth
|
|
5
|
+
* bundles fix don't exist. `--disable-blink-features=AutomationControlled`
|
|
6
|
+
* already yields `navigator.webdriver === false` (the correct *human* value —
|
|
7
|
+
* NOT `undefined`, which is itself anomalous). A real profile already has the
|
|
8
|
+
* `chrome` object, 5 plugins, and real `languages`. Blindly overriding those
|
|
9
|
+
* doesn't help — and the overrides themselves (a patched `permissions.query`,
|
|
10
|
+
* a faked `toString`) are the precise signatures deep fingerprinters score as
|
|
11
|
+
* "stealth detected".
|
|
12
|
+
*
|
|
13
|
+
* So every patch here is gated: it fires ONLY when the value is actually wrong
|
|
14
|
+
* (e.g. a stripped/headless environment leaked `webdriver === true` or 0 plugins).
|
|
15
|
+
* On a healthy real Chrome this injects a script that observably does nothing.
|
|
16
|
+
*
|
|
17
|
+
* The WebGL vendor override is the one opt-in (`maskWebgl`), used solely to hide
|
|
18
|
+
* SwiftShader on GPU-less hosts. With a real GPU it stays off — the authentic
|
|
19
|
+
* vendor is consistent with the rendered pixels, so masking would be a lie.
|
|
20
|
+
*/
|
|
21
|
+
export interface StealthOptions {
|
|
22
|
+
maskWebgl?: boolean;
|
|
23
|
+
webglVendor?: string;
|
|
24
|
+
webglRenderer?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function buildStealth(opts: StealthOptions = {}): string {
|
|
28
|
+
const maskWebgl = opts.maskWebgl ?? false;
|
|
29
|
+
const vendor = opts.webglVendor ?? "Google Inc. (Intel)";
|
|
30
|
+
const renderer = opts.webglRenderer ?? "ANGLE (Intel, Mesa Intel(R) UHD Graphics)";
|
|
31
|
+
|
|
32
|
+
// Only emitted for SwiftShader hosts. When present it also needs the toString
|
|
33
|
+
// mask so the getParameter override can't be read back as non-native.
|
|
34
|
+
const webglBlock = maskWebgl
|
|
35
|
+
? String.raw`
|
|
36
|
+
try {
|
|
37
|
+
const proto = WebGLRenderingContext && WebGLRenderingContext.prototype;
|
|
38
|
+
if (proto) {
|
|
39
|
+
const getParameter = proto.getParameter;
|
|
40
|
+
proto.getParameter = function (p) {
|
|
41
|
+
if (p === 37445) return ${JSON.stringify(vendor)};
|
|
42
|
+
if (p === 37446) return ${JSON.stringify(renderer)};
|
|
43
|
+
return getParameter.apply(this, arguments);
|
|
44
|
+
};
|
|
45
|
+
const native = Function.prototype.toString;
|
|
46
|
+
const masked = proto.getParameter;
|
|
47
|
+
Function.prototype.toString = function () {
|
|
48
|
+
if (this === masked || this === Function.prototype.toString) return 'function () { [native code] }';
|
|
49
|
+
return native.call(this);
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
} catch (e) {}`
|
|
53
|
+
: "";
|
|
54
|
+
|
|
55
|
+
return String.raw`
|
|
56
|
+
(() => {
|
|
57
|
+
if (window.__veil) return;
|
|
58
|
+
Object.defineProperty(window, '__veil', { value: true, enumerable: false });
|
|
59
|
+
|
|
60
|
+
const patchGetter = (obj, prop, value) => {
|
|
61
|
+
try { Object.defineProperty(obj, prop, { get: () => value, configurable: true, enumerable: true }); } catch (e) {}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// webdriver: fix ONLY if automation leaked it as true. Real Chrome's false is correct.
|
|
65
|
+
try { if (navigator.webdriver === true) patchGetter(Object.getPrototypeOf(navigator), 'webdriver', false); } catch (e) {}
|
|
66
|
+
|
|
67
|
+
// chrome object: add only if a stripped build is missing it.
|
|
68
|
+
if (!window.chrome) window.chrome = { runtime: {} };
|
|
69
|
+
|
|
70
|
+
// plugins: backfill only if empty (real desktop reports the PDF viewers).
|
|
71
|
+
try {
|
|
72
|
+
if (navigator.plugins && navigator.plugins.length === 0) {
|
|
73
|
+
patchGetter(Object.getPrototypeOf(navigator), 'plugins', [
|
|
74
|
+
{ name: 'PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
|
|
75
|
+
{ name: 'Chrome PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
|
|
76
|
+
]);
|
|
77
|
+
}
|
|
78
|
+
} catch (e) {}
|
|
79
|
+
|
|
80
|
+
// languages: backfill only if empty.
|
|
81
|
+
if (!navigator.languages || navigator.languages.length === 0) {
|
|
82
|
+
patchGetter(Object.getPrototypeOf(navigator), 'languages', ['en-US', 'en']);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// DELIBERATELY NOT PATCHED — deviceMemory and screen.availHeight.
|
|
86
|
+
// A tempting "fix" is to clamp deviceMemory to the spec max of 8, and to shave
|
|
87
|
+
// a taskbar inset off availHeight on a WM-less Xvfb (where availHeight === height).
|
|
88
|
+
// Both were tried and REMOVED: a JS getter override is itself the tell. A
|
|
89
|
+
// fingerprinter reading the property descriptor's getter sees "() => value"
|
|
90
|
+
// instead of "[native code]", or notices availHeight became an OWN property of
|
|
91
|
+
// the screen instance instead of being inherited from Screen.prototype. That is
|
|
92
|
+
// the exact "masking detected" signature veil exists to avoid — worse than the
|
|
93
|
+
// anomalous value it hid. On a real user's machine these values are already sane
|
|
94
|
+
// (Chrome caps deviceMemory at 8; a real desktop has a taskbar), so nothing needs
|
|
95
|
+
// patching. On a headless server box that leaks an out-of-spec deviceMemory, fix
|
|
96
|
+
// it at the source (the host), never with a getter a page can unmask.
|
|
97
|
+
${webglBlock}
|
|
98
|
+
})();
|
|
99
|
+
`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Default stealth source: self-gating, no WebGL masking (authentic GPU fingerprint). */
|
|
103
|
+
export const STEALTH_SOURCE = buildStealth();
|