# Veil **A stealth automation runtime for AI agents. Drives real Chrome over raw CDP — no Playwright, no Puppeteer, no WebDriver, zero runtime dependencies.** [![npm](https://img.shields.io/npm/v/@achamm/veilbrowser?color=cb3837&logo=npm)](https://www.npmjs.com/package/@achamm/veilbrowser) [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) ![dependencies: 0](https://img.shields.io/badge/dependencies-0-brightgreen.svg) ![TypeScript native](https://img.shields.io/badge/TypeScript-native-3178c6.svg?logo=typescript&logoColor=white) Built by **[HamTek](https://hamtek.dev/veil)** — we build production-grade AI agents; veil is the proof. ![Veil passing bot.sannysoft.com and Cloudflare's challenge](assets/demo.gif) *Veil driving real Chrome: bot.sannysoft.com all-green, then straight through Cloudflare's JS challenge — no patches, no plugins.* To Instagram, Google, Reddit, Cloudflare — Veil *is* Chrome. Same binary, same TLS, same JS engine, same canvas/WebGL/font fingerprint a human's browser has. We don't reimplement the browser (that's Chromium's 20-year, 1000-engineer job, and a hand-rolled engine is *easier* to fingerprint, not harder). We replace the part that gets you caught: **the automation layer.** --- ## Why not Playwright / Puppeteer? They're powerful and great for scripted QA. For *agents on hostile sites* they have three structural problems: | Problem | Playwright/Puppeteer | Veil | |---|---|---| | **Detectable** | `navigator.webdriver=true`, `--enable-automation`, the `Runtime.enable` CDP tell, `HeadlessChrome` UA | webdriver scrubbed, no automation switches, no `Runtime.enable`, UA + client-hints normalized | | **Robotic input** | instant teleport clicks, fixed-cadence typing → behavioural detection | curved Bézier mouse paths, eased timing, human keystroke cadence | | **Brittle for agents** | CSS/XPath selectors that break constantly | accessibility-tree snapshot → stable integer `ref`s; agents never write a selector | Veil is **dependency-free** — Node 24 / Bun ship a global `WebSocket`, so the entire CDP transport is ~120 lines we own. Nothing to patch, nothing to leak. ## How Veil compares (honest) The "real Chrome over raw CDP" idea isn't new — Python's [`nodriver`](https://github.com/ultrafunkamsterdam/nodriver) pioneered it, and [Camoufox](https://camoufox.com) (a C++-patched Firefox) scores even better on pure stealth. Veil isn't claiming to out-stealth them. Its wedge is **where it lives and how agents use it**: - **TypeScript-native.** The JS/TS agent ecosystem (Vercel AI SDK, LangChain.js, MCP) has no strong raw-CDP stealth driver — it's stuck on Playwright + stealth plugins, or shelling out to Python `nodriver`. Veil is that missing piece. - **MCP-native.** Ships an MCP server, so any agent gets stealth browsing as tools with zero glue. - **Agent-first, not scraper-first.** Accessibility-tree refs and human input are built for an LLM driving the browser, not for a scraping script. - **Does two things the others don't.** Drives the native **"Sign in with Google" (FedCM)** account chooser over CDP — agents are otherwise walled out of Google-SSO apps entirely — and **blocks visited sites from port-scanning your localhost/LAN** (on by default). Neither `nodriver`, Camoufox, nor Playwright-stealth does either. ### Two more that get raised, and where they actually land Both of these came up when Veil was posted publicly, so they belong here rather than in a comment thread. - **[Patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright)** (Python + Node) — a patched, drop-in Playwright replacement. It fixes Playwright's launch-flag and JS-surface leaks: adds `--disable-blink-features=AutomationControlled`, drops `--enable-automation`, corrects `navigator.webdriver` and the `HeadlessChrome` UA. Chromium only. **If you already have Playwright code and want it less detectable, this is the lowest-friction option and you should probably use it** — it's a dependency swap, not a rewrite. Where it differs: it is still Playwright underneath, so it **launches its own browser** and therefore hits the same `user-data-dir` lock — it cannot attach to a profile you're already signed into. Attaching isn't a stealth patch, it's a different architecture, and it's the thing that actually gets you past session-scored sites. - **[Claude for Chrome](https://www.anthropic.com/)** — an extension running inside your own browser. It shares Veil's core insight (use the real browser, inherit the real session) and, being a genuine extension rather than automation, it isn't fighting bot checks at all. **If you want a human-in-the-loop assistant in your browser, it's a better fit than a library.** Where it differs: it isn't programmable. You can't call it from a script, wire it into CI, run it headless on a server, expose it as MCP tools to your own agent, or control the fingerprint. Veil is a library your code drives; that's a different job, not a better one. If you're in Python and just want raw stealth, use `nodriver` or Camoufox — they're great. If you have Playwright code already, try Patchright first. If you want an assistant in your own browser, use Claude for Chrome. **Veil is for TypeScript agents and MCP hosts that need to drive a browser programmatically — and, uniquely among these, one you're already signed into.** ## Quick start **Prerequisites:** Chrome/Chromium on PATH (or `VEIL_CHROME=/path/to/chrome`), and Bun. ```bash bun install bun run examples/selftest.ts # launches real Chrome, runs the full chain ``` **In your own project** — `bun add @achamm/veilbrowser` (or `npm install @achamm/veilbrowser`): ```ts import { Browser } from "@achamm/veilbrowser"; const browser = await Browser.launch({ headless: false }); // headful = stealthiest const page = await browser.newPage(); await page.goto("https://example.com"); // Accessibility-tree snapshot → stable integer refs (no selectors). const snap = await page.snapshot(); console.log(snap.text); // [1] textbox "Search" // [2] button "Sign in" await page.fill(1, "hello"); // act by ref — human typing, jittered timing await page.click(2); // curved Bézier mouse path, real CDP input const png = await page.screenshot(); // PNG buffer for a vision model await browser.close(); ``` ## Drive a browser you're already signed into Chrome locks a `user-data-dir`. The one profile holding your real logged-in sessions therefore **cannot be opened by a second Chrome**, which is why launching your own browser always gets you a fresh, logged-out, brand-new-looking session — the shape that gets challenged. `Browser.connect()` attaches to a browser that is already running instead: ```bash # start Chrome yourself, with the profile that has your sessions in it google-chrome --remote-debugging-port=9222 --user-data-dir=~/.config/agent-profile ``` ```ts import { Browser } from "@achamm/veilbrowser"; const browser = await Browser.connect("127.0.0.1:9222"); // ws:// URL, http:// origin, or host:port const [page] = await browser.pages(); // the tab that's already open await page.goto("https://example.com"); await browser.close(); // DETACHES ONLY — never kills a browser it didn't start ``` This matters because Google, Reddit, Meta and TikTok score the **session**, not the IP: an established profile passes where a fresh one hits a wall. > ### ⚠️ The profile becomes part of your agent's security boundary > > This is the real cost of attaching, and it is worth stating plainly rather than leaving in > the fine print. **Whatever that Chrome profile is signed into, the agent can reach.** Point > it at your everyday browser and an agent that wanders — or a prompt-injected page telling it > to — has your email, your bank, your admin panels. There is no per-site sandbox inside a > profile; a cookie jar is all-or-nothing. > > **Use a dedicated profile signed into only what the task needs.** A separate > `--user-data-dir` per agent, logged into the two or three sites it actually works with, > keeps the blast radius equal to those sites instead of your whole digital life. The > private-network block is on by default for a related reason (a page cannot port-scan your > LAN), but it does nothing about credentials that are legitimately in the jar. > > Credit to `u/zhonglin` on r/AI_Agents for naming this precisely: reusing an authenticated > browser solves session continuity and moves the profile inside the agent's trust boundary. > > A fingerprint is deliberately **not** re-applied to an attached page — changing `navigator` > properties under a document the site has already fingerprinted is more detectable than > leaving them alone. ## Python There is a Python front end in [`python/`](python/) with the same core API: ```bash pip install git+https://github.com/acunningham-ship-it/veilbrowser.git#subdirectory=python ``` ```python import asyncio from veilbrowser import Browser, Fingerprint async def main(): async with await Browser.launch(fingerprint=Fingerprint.preset("windows-chrome")) as b: page = await b.new_page() await page.goto("https://example.com") print(await page.title()) asyncio.run(main()) ``` **The stealth layer has one implementation, not two.** The injected script, the launch flags, the profile identities and the keystroke table are generated from this TypeScript source into `python/veilbrowser/_assets/` by `tools-gen-python-assets.ts`, and `tests/python-parity.test.ts` fails the build if Python's assembled script differs from the TypeScript one by a single byte. That matters because a drifted stealth patch does not fail loudly — one front end simply becomes detectable while all of its own tests stay green. See [python/README.md](python/README.md) for the API and for what is deliberately not ported. ## How the stealth works 1. **Launch (`launcher.ts`)** — a real Chrome with the flags a *normal* profile uses, minus the automation switches Playwright adds. `--disable-blink-features=AutomationControlled` flips `navigator.webdriver` to `false` at the engine level. Persistent `userDataDir` so the profile looks *used* (history, cookies), not freshly minted. 2. **Transport (`cdp.ts`)** — raw WebSocket, flat session mode. **We never call `Runtime.enable`** — that command is a primary CDP detection vector. `Runtime.evaluate` works without it. 3. **Page patch (`stealth.ts`)** — injected via `addScriptToEvaluateOnNewDocument` *before* any site code, on every frame. Every patch is **self-gating**: it fires only when a value is genuinely anomalous (a stripped/headless env leaked `webdriver === true`, 0 plugins, an empty `languages`, or a missing `window.chrome`), and is a no-op on a healthy real Chrome. That's the whole default surface — a `webdriver`/`window.chrome`/`plugins`/`languages` backfill, nothing more. It deliberately does **not** touch `permissions.query`: a patched `permissions.query` is itself a signature deep fingerprinters score as "stealth detected". WebGL-vendor spoofing (and the native-looking `toString()` mask that hides it) is **opt-in** via `maskWebgl` — off by default, used only to disguise SwiftShader on GPU-less hosts; with a real GPU the authentic vendor is left untouched. Kept deliberately small; over-patching is its own fingerprint. 4. **UA / client hints (`page.ts`)** — strips the `HeadlessChrome` token from the UA *and* the `Sec-CH-UA` brand headers. 5. **Human input (`human.ts`)** — seedable PRNG drives curved mouse paths and jittered keystroke timing. ## Coherent fingerprints / profiles By default Veil ships your *real* Chrome fingerprint — the strongest identity there is, because nothing is spoofed. When you instead need to present a **specific** identity (a Windows profile from a Linux server, a fixed profile across a pool), apply a `Fingerprint`. The design rule is **coherence, not spoof-count**: a half-spoofed profile is *worse* than none — if the UA says Windows but the client hints, `navigator.platform`, or WebGL vendor disagree, that contradiction is itself a detection signal. So a `Fingerprint` is applied as one internally-consistent set, and every derived value (client-hint platform, brand versions, Accept-Language) is computed *from* the profile so nothing can drift out of agreement. ```ts import { Browser, type Fingerprint } from "@achamm/veilbrowser"; const fp: Fingerprint = { userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/131.0.6778.86 Safari/537.36", platform: "Win32", platformVersion: "15.0.0", architecture: "x86", model: "", mobile: false, hardwareConcurrency: 16, deviceMemory: 8, languages: ["en-US", "en"], screen: { width: 2560, height: 1440, availWidth: 2560, availHeight: 1400, colorDepth: 24 }, devicePixelRatio: 1, webglVendor: "Google Inc. (NVIDIA)", webglRenderer: "ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0, D3D11)", timezone: "America/New_York", locale: "en-US", seed: 12345, }; const browser = await Browser.launch({ headless: false, fingerprint: fp }); // applied to every page // or at runtime, before the first navigation: await page.applyFingerprint(fp); ``` Don't want to hand-build one? Use a preset or a self-consistent random profile: ```ts import { Browser, Fingerprint, PRESETS } from "@achamm/veilbrowser"; await Browser.launch({ fingerprint: PRESETS["mac-chrome"] }); // windows/mac/linux/android-chrome await Browser.launch({ fingerprint: Fingerprint.random() }); // fresh coherent identity await Browser.launch({ fingerprint: Fingerprint.random(42) }); // deterministic given a seed ``` `Fingerprint.random(seed?)` picks a platform first, then derives a matching UA, client hints, WebGL, screen and timezone — so a random profile is as internally consistent as a preset. Each preset is a real Chrome-131 identity for its OS (an Apple-Silicon Mac still reports the frozen `Intel Mac OS X 10_15_7` UA + `MacIntel` platform with an `arm` architecture and an Apple Metal renderer — exactly as a real one does). **How it's applied (two layers).** The bulk goes through Chrome itself — `UA` + the full `userAgentMetadata` client hints + the legacy `navigator.platform`, the `screen` size + `devicePixelRatio`, and the **timezone / locale / geolocation** — all via CDP `Emulation.*`. These are set by the browser, so there is **no JS getter to unmask** — the strongest kind of spoof, and they resolve natively (`Intl.DateTimeFormat().resolvedOptions().timeZone`, `navigator.language`, `navigator.geolocation`). A US profile should carry a US timezone/locale (and optional US coordinates) — a region that disagrees with the UA is a contradiction detectors score. Only the values CDP can't set are injected as page-level overrides — `hardwareConcurrency`, `deviceMemory`, `languages`, `screen.avail*`/colour depth, the **WebGL vendor/renderer** (the two `UNMASKED_*` parameters), and **deterministic canvas + audio noise**. Every one is defined on the *prototype* (inherited, so no own-property tell) with its `toString()` masked to `[native code]` by a single `Function.prototype.toString` proxy that hides itself and leaves genuine native/user functions untouched — the same discipline the base stealth uses. The canvas/audio noise is **seeded, not random per call**: repeated reads of the same canvas or audio buffer return *identical* bytes (a per-call random is itself a tell), while a different `seed` yields a different — but equally stable — hash. So a profile carries its own consistent canvas/audio fingerprint instead of leaking the host's. Honest scope: this is **coherent fingerprint control**, not a magic bullet. It lets you present a consistent identity; it does not by itself defeat any particular hard target. `navigator.oscpu` is deliberately left unset (it's Firefox-only — a Chrome profile exposing it would be an anomaly); `screen.colorDepth` follows the profile but the *rendered* pixels still come from the host GPU, so keep `webglVendor`/`webglRenderer` plausible for the platform you're claiming; and WebGL-*canvas* read-back (`toDataURL` on a WebGL canvas) is left to the vendor override, not noised. ## Agent tooling (the other half of the product) The selling point isn't only stealth — it's that agents drive it *well*: - **`snapshot()`** returns the page as a flat numbered index from the accessibility tree (the semantic layer screen readers use). The #1 cause of agent breakage — guessed CSS/XPath selectors — is gone. The agent acts on a stable `ref`. **A `ref` is an identity, not a position:** an element keeps its number for as long as the document lives, so a number is never reused for a different element. Refs are `1..N` on the first snapshot and may then develop gaps (something was removed) or run out of order (something was inserted above older elements). That is deliberate — renumbering per snapshot means a remembered `ref` can actuate the *wrong* element and still report success. Here a stale ref is either still correct, or a clear error. - **`screenshot()`** returns a PNG buffer, ready for vision grounding — the viewport, the whole `{fullPage}`, a single element `{ref}`, or an explicit `{clip}` rectangle. - **`click` / `fill` / `type`** drive real CDP input with human dynamics. - **`waitFor(expr)`** replaces flaky fixed sleeps — and, like `evaluate()`, is timeout-bounded, so a wedged page rejects cleanly instead of hanging the agent. - **`goto()`** returns `{ status, ok }` for the main response, so callers can detect a 4xx/5xx wall instead of only seeing a rendered error page. - **`blockResources(types, {urls})`** drops image/font/media/etc. and URL-matched requests — a big speed and footprint win for scraping (shares one Fetch handler with the private-network guard, so both are active at once). ## Federated sign-in (FedCM) "Sign in with Google" one-tap and the newer `navigator.credentials.get({identity})` flows render their account chooser as **native browser UI** — the button is a cross-origin IdP iframe and the chooser is browser chrome, so no synthetic click can reach either. That's a wall for agents logging into Google-SSO apps. Veil drives it over CDP's FedCM domain instead: ```ts // Passive / one-tap (fires on load once you're signed in to the IdP): await page.enableFedCm(); // autoSelectFirst — picks account 0 for you await page.goto("https://app.example.com/"); await page.waitForFedCmDialog(); // chooser intercepted + auto-selected await page.disableFedCm(); // Active "Sign in with Google" button, one call: const account = await page.signInWithFedCm({ triggerRef: btn.ref }); ``` `enableFedCm` also `resetCooldown`s (Chrome silently suppresses the dialog after repeated dismissals) and binds account selection to the page's own CDP session — pick the wrong target and the dialog, plus the page's `credentials.get()`, hangs forever. Enable it **on demand**, right before the sign-in: turning it on globally hangs any site that silently probes FedCM at load. End-to-end run against the canonical demo IdP: `bun run examples/fedcm.ts`. ## No localhost / LAN leak (private-network block) Fingerprinters (iphey, pixelscan, …) don't read your process list — they can't. They **port-scan `127.0.0.1` from page JavaScript**, timing which local ports answer, and map open ports to software: VNC on `:5900`, an antidetect API on `:3001`, a dev server on `:3000`. That both fingerprints you *and* leaks your LAN to every site you visit. Most stealth stacks (nodriver, Camoufox, Playwright-stealth) don't stop it. Veil does, **on by default**. Every HTTP-family request (`fetch`/XHR/`EventSource`/ ``/`