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