Drive stealth Chromium to bypass bot detection in web automation
Fortress is a stealth Chromium engine that corrects the browser fingerprint in C++ so Playwright and Puppeteer scrapers and agents get blocked less.
153.0.8010.36Add to Favorites
Why it matters
Fortress enables developers to run web scrapers and browser automation agents without getting blocked by bot detectors. It patches Chromium at the C++ level to present a genuine browser fingerprint that passes CreepJS, Sannysoft, BrowserScan, and live Cloudflare challenges, so your Playwright or Puppeteer scripts reach their target pages and complete their runs.
Outcomes
What it gets done
Spoof browser fingerprints (canvas, WebGL, audio, navigator) inside Chromium's C++ engine so detection tools read the browser as human
Expose a CDP endpoint that works as a drop-in replacement for standard Chromium with Playwright, Puppeteer, or any CDP client
Pass bot-detection gauntlets including 0% headless score on CreepJS and live Cloudflare Turnstile challenges
Configure per-launch personas with coherent GPU, timezone, language, keyboard layout, and TLS fingerprint using command-line switches
Source
Get it from source
Spark does not host a copy of it.
Open sourceReports
Agent outcome reports
No reports yet
Overview
Fortress is a Chromium fork that corrects the surfaces bot detectors read (canvas, WebGL, audio, fonts, navigator and about thirty more) inside Chromium's C++, with no JavaScript patch layer on top. It ships as an ordinary browser binary that exposes a CDP endpoint on port 9222. Use it when scrapers or browser agents driven through Playwright, Puppeteer or another CDP client get blocked on the browser fingerprint. It is meant for legitimate automation, testing and scraping of publicly available data, and each site's terms and local law still apply.
What it does
Fortress is a stealth Chromium engine. Bot detectors flag automation by reading the browser fingerprint, and Fortress corrects that fingerprint inside Chromium's C++ so the browser presents as an ordinary Chrome install. The corrected surfaces are canvas, WebGL, audio, fonts, navigator and about thirty more. The fork is 34 small single-surface patches, and real V8, Blink and BoringSSL keep the engine, the user-agent and the JA3/JA4 TLS shape in agreement, giving a Windows persona on a matching stack.
The design choice is to patch the engine rather than the page. A JavaScript stealth patch leaves a layer the page can find: .toString() shows the override's source, and re-grabbing a primitive from an iframe or Web Worker reaches past it. In Fortress the getter for navigator.vendor is the C++ getter, so it reports [native code] from the main frame, iframes and workers alike.
The engine is one of three layers in bot detection. Driver and binary artifacts (such as cdc_ ChromeDriver variables) are avoided by driving raw CDP and skipping chromedriver. CDP side effects such as the Runtime.enable leak belong to the control layer, and Fortress exposes raw CDP on :9222 with no Runtime.enable leak. The fingerprint surface is the engine's job, and that is what Fortress is. The binary alone leaves the CDP channel open, so the client that drives it still has to avoid leaking through CDP.
Live detector results: CreepJS reads 0% headless and 0% stealth, bot.sannysoft.com shows 0 failed rows, BrowserScan reads "No bots detected, could be a human", and rebrowser's bot-detector confirms the same result. A Cloudflare Turnstile challenge was cleared by a human click on a headed run from a datacenter IP. Detection keeps moving, so it will not pass everything forever; releases follow a monthly Chromium rebase and are gated by a dated, reproducible gauntlet.
Comparison with other approaches: Stock Playwright has no spoof layer. puppeteer-extra-stealth injects JavaScript, so toString does not yield [native code] and it does not survive realm re-acquisition from an iframe or worker. undetected-chromedriver patches the CDP or config layer and only partly avoids the Runtime.enable leak. Camoufox and CloakBrowser also patch in the engine; Camoufox forks Firefox, which is a small share of real traffic and gives a non-Chromium TLS shape, while Fortress forks Chromium and V8 so a Chrome user-agent is coherent by construction. Fortress is a fully open engine with a published, auditable patch series and a reproducible from-source build, whereas the paywalled forks ship binaries and commercial vendors keep patches in-house.
Evidence for the fingerprint effect: on aa.com behind Akamai Bot Manager, from the same residential IP, a stock or headless browser gets Access Denied while Fortress loads the real page and receives Akamai's _abck sensor cookie, and lowes.com, macys.com and kohls.com behave the same, so the variable is the fingerprint, not the IP. Against stockx.com (PerimeterX) a stock browser gets HTTP 403 while an agent with the Fortress MCP returns clean JSON.
When to use - and when NOT to
Use it when a Playwright, Puppeteer or other CDP-based scraper or agent finishes fewer runs because sites flag the browser. Point the existing code at Fortress over CDP and nothing else changes.
Do not expect it to fix an IP problem. A datacenter range gets flagged before any page script runs, and blocking that persists usually traces to proxies and behavior. Route egress through residential or mobile proxies and retry; if it clears, the fingerprint was fine. On a Linux host the default persona is Windows, but the TLS shape and some OS-facing signals follow the machine underneath, so match the persona to the egress OS.
Do not add puppeteer-stealth, undetected-chromedriver or JavaScript fingerprint patches on top, because they self-reveal and undo the engine's work. Never pass --user-agent; use the --uxr-ua-* switches, since --user-agent desyncs the user-agent from the UA Client Hints.
Fortress is built for legitimate automation, testing and scraping of publicly available data. Respect each site's terms of service and the law in your jurisdiction.
Inputs and outputs
Fortress is launched through pip install tilion-fortress, npm install tilion-fortress, a Docker image, a portable tar.gz bundle or a Debian package. The Python and Node installs auto-fetch a prebuilt native binary and verify its SHA-256. Docker works on any OS:
docker run --rm -p 9222:9222 tilion/fortress:latest
The output is a CDP endpoint on :9222 that any CDP client connects to. Two Chromium channels exist. stable is the default, Chromium 149, and matches the Chrome version most real users run. latest is Chromium 151. In Python use Fortress(channel="latest"), in Node Fortress.launch({ channel: "latest" }), in Docker the :149 or :151 tag, or set FORTRESS_CHANNEL=latest. Native binaries cover Linux x64 (both versions) and Windows x64 (151), and Windows 149 and macOS run through the Docker image.
The persona is a coherence-checked default Windows identity. Override any surface with --uxr-* switches: platform, UA platform, OS, architecture, bitness and platform version, UA brand, hardware concurrency, device memory, WebGL vendor, renderer and full parameters, canvas and audio seeds, timezone, languages, screen width and height, and the WebRTC policy (for example disable_non_proxied_udp). The binary carries zero brand strings. TILION_NO_DEFAULTS=1 gives a bare launch, and TILION_TZ and TILION_LANG are quick overrides. The switches ride on the command line, so they are readable through /proc/<pid>/cmdline, which means one persona per launch and no sharing the host with untrusted code.
The 151.0.7908.0 refresh added per-locale keyboards (QWERTY, QWERTZ and AZERTY), per-persona media devices and a delivery-parity coherence pass, so platform, GPU, timezone, language, voices and keyboard move together as one coherent real device on every launch from a single binary.
Launch APIs: Fortress().start() or with Fortress() as f (giving f.cdp_url) in Python and await Fortress.launch() (giving f.cdpUrl, closed with f.close()) in Node; Puppeteer connects with browserURL: "http://localhost:9222".
Native bundles come as a portable tar.gz (extract and run ./tilion-fortress/tilion --headless=new --remote-debugging-port=9222) or a Debian package installed with sudo apt install ./tilion-fortress_151.0.7908.0_amd64.deb. The Docker image is about 302 MB to pull and 851 MB on disk.
Integrations
Fortress works under browser-use (cdp_url="http://localhost:9222"), Crawl4AI (CDP endpoint), Stagehand (connectOverCDP), the LangChain Playwright toolkit, and plain Playwright or Puppeteer in Python and JavaScript.
The Fortress MCP (Beta) gives Claude Desktop, Claude Code, Cursor, Cline, Windsurf or any MCP client 29 local, free tools. Install it with pip install "tilion[mcp]" (command tilion-mcp) or run npx -y tilion-mcp, which needs Python on the PATH. Reads auto-approve and writes gate, the server is pre-warmed for a first call around 100 ms, concurrency-safe, and timeout- and SSRF-guarded.
The 29 tools include these groups: getting blocked pages (fetch_protected_page, read_page, get_page_html, search_web), structured data (extract_page and extract_document for PDF, DOCX and XLSX), whole sites (crawl_site with automatic SPA handling and recon_site_apis to find a private JSON API), driving a page (page_elements, click_button, fill_field, press_key, wait_for, evaluate_js), multi-step flows (run_browser_task for login, pagination, infinite scroll and checkout), capture and auth (screenshot_page, save_page, download_file, save_profile, load_profile) and get_stealth_cdp_endpoint to hand a CDP URL to Playwright, Puppeteer or browser-use. Claude Code adds the MCP server with claude mcp add fortress -- tilion-mcp (or -- npx -y tilion-mcp), and Claude Desktop, Cursor, Cline and Windsurf use a mcpServers entry named fortress with the command tilion-mcp.
The engine rebuilds from source with build/build.sh, downloads are checked against the release SHA256SUMS, and Docker images can be verified by digest. tools/gauntlet.py --bundle <dir> launches a bundle headless on port 9333 by default (--port, --keep, --json available), runs CreepJS, Sannysoft and BrowserScan over raw CDP and asserts invariants such as navigator.webdriver, a Windows user agent, video codec support, the emoji font, the WebGL renderer, hardware concurrency, device memory, languages, timezone, canvas noise and the WebGPU adapter, exiting non-zero when a surface regresses. The license is BSD-3-Clause for the Fortress patches and tooling, matching Chromium, while Chromium and the bundled fonts keep their own licenses. The official distribution channels are the GitHub source, the Docker Hub image tilion/fortress, and tilion-fortress on PyPI and npm; anything else is untrusted.
Who it's for
People running scrapers and browser agents on Playwright, Puppeteer or another CDP client that keep getting blocked on the browser fingerprint, and agent builders who want stealth browsing as MCP tools.
Source README
One browser engine to rule them all
Stealth Chromium engine · v3 (Chromium 153)
Fortress is a Chromium fork that stops scrapers and browser agents from getting blocked. Bot detectors flag automation by reading the browser fingerprint. Fortress corrects that fingerprint inside Chromium's C++, so the browser reads as an ordinary Chrome install. Point your existing Playwright or Puppeteer at it over CDP; nothing else in your code changes.
Headless, on datacenter IPs, with no proxies, Fortress loaded 86.4% of 92 protected sites. The next best stack (Camoufox) loaded 74.5%.
A site reads the fingerprint. Stock Chrome driven by a script fails CreepJS, BrowserScan, rebrowser and the WebGL check and is blocked; Fortress passes all four.
Real runs, fully headless
Unedited captures over CDP, with no stealth plugins. Reproduce with examples/scrape_demos.py.


Akamai, before and after, on aa.com from the same residential IP: a stock browser gets Access Denied, Fortress loads the page and Akamai issues its _abck sensor cookie. Same result on lowes.com, macys.com and kohls.com.
Contents
Quick start · Every session a distinct machine · The Fortress MCP · Works with your stack · Why patch the engine · How Fortress compares · Results · Configure the persona · Build & verify · Reference
Quick start
pip install tilion-fortress # or: npm install tilion-fortress
docker run --rm -p 9222:9222 tilion/fortress:latest # any OS, raw CDP on :9222
from tilion_fortress import Fortress
from playwright.sync_api import sync_playwright
with Fortress() as f: # launches the engine on a CDP endpoint
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp(f.cdp_url)
page = browser.new_page()
page.goto("https://bot.sannysoft.com")
import { Fortress } from "tilion-fortress";
import { chromium } from "playwright";
const f = await Fortress.launch();
const browser = await chromium.connectOverCDP(f.cdpUrl);
const page = await browser.newPage();
await page.goto("https://browserscan.net");
The pip and npm SDKs download a prebuilt, SHA-256-verified binary (Linux x64, Windows x64). Portable tarballs, a .deb and the Windows .zip are on Releases.
Free for developers
v3 is free for developers, with no machine, session or concurrency caps. Activate once:
tilion activate # sign in with GitHub, Google or email; key saved to ~/.tilion/license.jwt
The key is checked offline and Fortress never reports usage. Without activation it still runs, on the public first-generation engine. For CI, Docker and agents, set TILION_LICENSE_KEY=<key> or run tilion activate --token <key>; one key covers a fleet. Production keys for larger organizations are at fortress.tilion.com/pricing.
| Command | What it does |
|---|---|
tilion activate |
Unlock v3 with a one-click device flow (browser sign-in) |
tilion activate --headless |
Same flow, prints the URL and code (SSH or remote hosts) |
tilion activate --token <jwt> |
Save a key with no browser (CI, agents) |
tilion license status [--json] |
Show the saved key's tier and expiry (--json for scripts) |
tilion license refresh |
Re-issue the key before it expires |
tilion license logout |
Remove the saved key and drop back to v1 |
tilion get [platform] |
Download the build for this host. tilion get list shows all: linux-x64, arm64, armhf, x86, win-x64, mac-arm64, mac-x64 |
tilion mcp |
Run the Fortress MCP server, stealth browsing as agent tools |
tilion [--port N] |
Launch the engine and print the CDP endpoint |
Platforms
| Platform | Package | Widevine | Status |
|---|---|---|---|
| Linux x64 | portable tarball · Docker · pip / npm | ||
| Windows x64 | portable .zip + tillion.cmd launcher |
||
| Linux arm64 | portable tarball (Graviton, dense cloud fleets) | no | |
| Linux x86 · armhf | portable tarball (32-bit x86 / ARM) | no | |
| macOS (arm64 / x64) | .app, built from source |
build from source |
The arm64, x86 and armhf builds ship without the bundled Widevine CDM.
Set it up with an AI assistant
Every session a distinct machine
Each launch mints a fresh, internally coherent machine: GPU, screen, cores, timezone, language and fonts all agree. The persona reaches the renderer over IPC, so nothing shows on the command line and every BrowserContext can hold its own identity. Across 40 back-to-back launches of the same binary:
| Across 40 launches | Distinct |
|---|---|
| Canvas fingerprint | 40 / 40 |
| Audio fingerprint | 40 / 40 |
| GPU (WebGL renderer) | 25 |
| Screen resolution | 14 |
| Timezone (geo-coherent with language) | 23 |
| Platform mix | 31 Windows · 9 macOS |
Reproduce with tools/gauntlet.py --runs 40. A 64-clone fleet resumed from one snapshot comes up 48/48 distinct, because on_restore re-keys the whole persona with no relaunch.
The Fortress MCP
A Model Context Protocol server that gives Claude, Codex, Cursor, Windsurf, Cline or any MCP client a stealth browser as 29 tools, local and free. When a plain fetch is blocked, the agent calls a tool and gets the page.
pip install "tilion[mcp]" # or zero-install: npx -y tilion-mcp
claude mcp add fortress -- tilion-mcp # Claude Code
For Claude Desktop, Cursor (~/.cursor/mcp.json), Cline and Windsurf, add the same block to the client's MCP config:
{ "mcpServers": { "fortress": { "command": "tilion-mcp" } } }
| tools | |
|---|---|
| Get blocked pages | fetch_protected_page · read_page · get_page_html · search_web |
| Structured data | extract_page (schema-aware) · extract_document (PDF/DOCX/XLSX) |
| Whole sites | crawl_site (auto-SPA) · recon_site_apis (find the private JSON API) |
| Drive a page | page_elements · click_button · fill_field · press_key · wait_for · evaluate_js |
| Multi-step flows | run_browser_task (login, paginate, infinite-scroll, checkout, …) |
| Capture / auth | screenshot_page · save_page · download_file · save_profile / load_profile |
| Bring your own | get_stealth_cdp_endpoint: a CDP url for Playwright / Puppeteer / browser-use |
The full tool reference, benchmarks and agent skill are in mcp/.
Works with your stack
With Fortress running (Docker or the tilion command), one line changes: launch() becomes connect_over_cdp("http://localhost:9222").
| Framework | Connect via |
|---|---|
| browser-use (~70k stars) | cdp_url="http://localhost:9222" |
| Crawl4AI (~58k stars) | CDP endpoint |
| Stagehand (~21k stars) | connectOverCDP |
| LangChain Playwright toolkit | Playwright CDP |
| Playwright / Puppeteer (Python & JS) | connect_over_cdp / connect |
Fortress MCP + pip install tilion facade |
tools for agents, raw CDP underneath |
Why patch the engine, not the page
A JavaScript stealth patch is a function standing where a native one belongs, and detectors check for exactly that:
| The tell | Why it catches a JS spoof |
|---|---|
toString self-reveal |
A native method stringifies to function get vendor() { [native code] }; an override stringifies to its own source, so one .toString() catches it. |
Descriptor and hasOwnProperty |
getOwnPropertyDescriptor exposes redefined props, and hasOwnProperty('toString') returns true on a tampered function where a native one returns false. |
failsTypeError |
Native getters throw a specific TypeError on the wrong this; a naive shim stays quiet, and the silence is the signal. |
Realm re-acquisition defeats every main-world patch. A detector takes a pristine primitive from another realm and turns it on your function:
const iframe = document.createElement('iframe'); document.body.appendChild(iframe);
const realToString = iframe.contentWindow.Function.prototype.toString;
realToString.call(navigator.__lookupGetter__('vendor')); // returns your source code. Caught.
In Fortress the getter for navigator.vendor is the C++ getter. It reports [native code] because it is native code, the same in every frame and worker. Fortress applies the Camoufox idea to V8 and Blink, so the fingerprint matches a Chrome user agent by construction.
The four layers of bot detection
| Layer | The tells | Where the fix lives | Fortress |
|---|---|---|---|
| A: driver / binary artifacts | cdc_ ChromeDriver vars, WebDriver protocol surface |
Drive raw CDP, skip chromedriver | |
| B: CDP side-effects | Runtime.enable leaks via sourceURL + init-script footprints, however clean the binary is |
The control / CDP-client layer: hold back Runtime.enable, use Runtime.addBinding + isolated worlds |
|
| C: fingerprint surface | canvas, WebGL, WebGPU, audio, fonts, navigator, across main frame, iframes, workers | The engine (C++), because JS overrides self-reveal | |
| D: network / IP egress | datacenter ASN, IP reputation, geo-vs-persona mismatch | Your proxies (residential / mobile), or the Tilion hosted version |
Fortress is the Layer C engine, built to be driven so A and B hold too, and to stay geo-coherent (timezone, locale, WebRTC) once you bring a Layer D IP.
How Fortress compares
| Stock Playwright | puppeteer-extra-stealth | undetected-chromedriver | Camoufox | Antidetect apps¹ | Fortress v3 | |
|---|---|---|---|---|---|---|
| Spoof layer | none | JS injection | CDP/config patch | C++ engine (Firefox) | C++ engine | C++ engine (Chromium) |
toString yields [native code] |
n/a | n/a | ||||
| Survives realm re-acquisition (iframe/worker) | ||||||
No Runtime.enable leak |
||||||
| Engine = Chrome / V8 (majority traffic) | ||||||
| Coherent Chromium TLS shape | ||||||
| Parameter-level WebGL + WebGPU coherence | n/a | varies | ||||
| Per-launch fleet dynamism (distinct machine) | ||||||
| Full-persona re-key on snapshot resume | on_restore |
|||||
| Native single-surface C++ patch engine | n/a | n/a | n/a | |||
| States its own limits | n/a | n/a | n/a |
¹ Multilogin / GoLogin / Dolphin / Nstbrowser: closed-source engine patches, persistent identity by design, bundled residential egress (their Layer-D advantage over Fortress).
Fortress builds on prior art: fingerprint-chromium, ChromiumFish and CloakBrowser came first, and Camoufox, Multilogin, GoLogin and Dolphin also patch engine C++. Fortress's edge is the combination: the broadest native surface set on a Chromium base, fleet dynamism with on_restore, and parameter-level coherence. Where it is behind: IP egress (bring your own) and behavioral humanization beyond mouse motion.
| Axis | v2 | v3 |
|---|---|---|
| Chromium base | 151 / 152 | 153.0.8010.36 |
| Single-surface C++ patches | ~34 | 81 |
| Persona transport | command line, per launch, host-readable | IPC-delivered, in-process, per-context isolated |
| Multi-identity in one binary | one persona per process | per-BrowserContext isolation |
| Snapshot resume | relaunch to change identity | on_restore full-persona re-key, no relaunch |
| Fleet distinctness | not measured | 48/48 across 64 clones (500-clone stress-gated) |
| Canvas noise | seeded | idempotent, byte-exact cross-path, 64-bit seeds, fail-closed |
| WebGL | renderer string only (incoherent numbers) | parameter-level limits + extensions + string, per backend |
| WebGPU | absent / software-backend tell | coherent with the persona GPU + per-clone variation |
| Fonts | metric substitutes (~33) | 154-family substrate, enumeration gated to the persona |
| Client-Hints | UA basics | full Sec-CH-UA + Device-Memory on the navigation path |
prefers-color-scheme |
fleet-uniform | per persona (about a third dark) |
| Mouse motion | raw driver | recorded-human trajectories (~10k paths, ~16× more human) |
| DRM | no Widevine | real Widevine CDM bundled, EME verified |
Results
Benchmark: 92 protected sites, eight stacks
Run on 2026-09-28 from US-East cloud machines, headless, on datacenter IPs with no proxies. Every tool loaded every target twice, each time on a fresh machine destroyed afterwards (1,584 machines). A run counts only if the real page loaded with no challenge or deny page. Method and target list: docs/BENCHMARK.md.
| Stack | Served | n | 95% range |
|---|---|---|---|
| Fortress | 86.4% | 159/184 | 81 to 91 |
| Camoufox | 74.5% | 137/184 | 68 to 80 |
| puppeteer-extra + stealth | 72.3% | 133/184 | 65 to 78 |
| Brave | 36.4% | 67/184 | 30 to 44 |
| nodriver | 35.9% | 66/184 | 29 to 43 |
| stock Chrome | 34.2% | 63/184 | 28 to 41 |
| patchright | 34.2% | 63/184 | 28 to 41 |
| undetected-chromedriver | 33.7% | 62/184 | 27 to 41 |
95% Wilson intervals; Fortress's does not overlap the next stack's. Fortress got 2/2 on ten sites where neither Camoufox nor puppeteer-stealth got more than 1/2, and there is no site where it got 0/2 while either of them got 2/2. A repeat run the same day scored 85.3%.
Live detectors
Headless, from a datacenter IP, on the v3 binary. Reproduce with tools/gauntlet.py --bundle ./fortress-v153; dated re-runs are in docs/GAUNTLET_RESULTS.md.
| Suite | Stock Chromium | Fortress v3 |
|---|---|---|
| CreepJS | flagged headless | 0% headless · 0% stealth, worker signals coherent |
| bot.sannysoft.com | red rows | 0 failed · every intoli + PHANTOM_/HEADCHR_ check green · navigator.webdriver undefined |
| browserscan.net | bot detected | “Normal: No bots detected, could be a human” · WebDriver / Selenium / PhantomJS / Headless / CDP / DevTool all Normal |
| BrowserLeaks · WebGL | SwiftShader leak | Unmasked ANGLE (Intel HD Graphics, Direct3D11) with fully coherent D3D11 parameters |
| WebGPU (secure context) | software-backend tell | navigator.gpu coherent with the persona GPU: adapter identity, limits, subgroup sizes (verified) |
| AmIUnique · pixelscan · iphey | automation flags | consistent, no automation flags |
| rebrowser bot-detector | Runtime.enable LEAK |
no leak · webdriver=false · clean init-scripts (raw CDP) |
| 64-clone fleet | one shared identity | 48/48 distinct canvas / audio / Math.random (fleet gate) |
| Cloudflare Turnstile | blocked | cleared: a human click cleared a live challenge (headed, datacenter IP) |




Unedited capture in a real window: Fortress clears a live Cloudflare challenge, turns bot.sannysoft.com all green and reads BrowserScan "Normal".
Configure the persona
The default is a fresh coherent persona per launch, delivered over IPC. Pin or override any surface with --uxr-* switches:
--uxr-platform / --uxr-ua-platform / --uxr-ua-os / --uxr-ua-arch / --uxr-ua-bitness
--uxr-ua-platform-version / --uxr-ua-brand / --uxr-hw-concurrency / --uxr-device-memory
--uxr-webgl-vendor / --uxr-webgl-renderer / --uxr-webgl-fullparams / --uxr-webgpu-vendor
--uxr-canvas-seed / --uxr-audio-seed / --uxr-timezone / --uxr-languages / --uxr-color-scheme
--uxr-screen-width / --uxr-screen-height / --uxr-webrtc-policy=disable_non_proxied_udp
| Env var | Purpose |
|---|---|
TILION_NO_DEFAULTS=1 |
Skip the default persona (bare launch) |
TILION_TZ / TILION_LANG |
Quick timezone / language override |
Every flag, env var and coherence rule: docs/UXR_CONFIG.md.
Build & verify
export CHROMIUM_VERSION=$(cat CHROMIUM_VERSION) # 153.0.8010.36
build/build.sh # depot_tools, sync the tag, apply patches, gn gen, ninja
build/rebase-monthly.sh 154.0.XXXX.0 # bump + 3-way apply + rebuild + gauntlet-gate
Output: out/Fortress/chrome. The fork is 81 single-surface patches, and the gauntlet gates every release. The first-generation patch series is public and rebuilds with the same script.
Fortress ships only from these channels:
| Official source | |
|---|---|
| Source | github.com/tiliondev/fortress |
| Docker | tilion/fortress |
| Python | tilion-fortress |
| Node | tilion-fortress |
Every release ships SHA256SUMS (the SDKs check it on install). Verify the Docker image by digest against the release notes:
BASE=https://github.com/tiliondev/fortress/releases/download/v153.0.8010.36
curl -LO $BASE/fortress-v153-linux-x64.tar.gz
curl -Ls $BASE/SHA256SUMS | sha256sum -c --ignore-missing # -> OK
docker inspect --format '{{index .RepoDigests 0}}' tilion/fortress:153.0.8010.36
Reference
Still blocked on Cloudflare, DataDome or Kasada. Usually the IP: datacenter ranges are flagged before any page script runs. Retry through a residential or mobile proxy; if it clears, the fingerprint was fine. Tilion Cloud runs Fortress on residential egress (waitlist at tilion.com).
The fingerprint looks off on a Linux host. The default persona is Windows, but TLS and some OS signals follow the host. Match the persona to your egress OS with --uxr-*, or run the native Windows build.
macOS. Run the Docker image or build the .app from source.
A detector flags something the gauntlet passes. Confirm you are on the current rebase, then email team@tilion.dev with the test page.
Does Fortress work with Selenium? Drive it over CDP. chromedriver adds the driver artifacts Fortress is built to avoid.
How is it different from puppeteer-stealth, undetected-chromedriver, nodriver and patchright? Those patch the JavaScript or CDP layer, which toString and realm re-acquisition reveal, and headless they send HeadlessChrome in the user agent. In the benchmark they loaded 34 to 36% (puppeteer-stealth 72.3%) against Fortress's 86.4%.
How is it different from Camoufox? Same engine-level idea. Camoufox forks Firefox; Fortress forks Chromium/V8, the majority engine, and adds WebGPU coherence and on_restore fleet re-identity. Benchmark: 86.4% vs 74.5%.
Is it an alternative to Multilogin, GoLogin, Kameleo, AdsPower or Dolphin? Those are closed-source builds sold as persistent profiles with bundled proxies. Fortress is an engine you run yourself, with a fresh identity per launch, published first-generation patches and no telemetry.
Does it run headless? Yes. The benchmark is headless, and headless and headed launches present the same fingerprint.
Do I still need proxies? For sites that block datacenter ranges, yes. Fortress keeps timezone, locale and WebRTC coherent with whatever exit you use.
Is it open source? What does it cost? Source-available under the Fortress Source Available License 1.1; see License.
Is this legal? Fortress is for legitimate automation, testing and scraping of public data. Respect each site's terms and your local law.
Will it pass everything forever? No. Detection moves, so Fortress rebases on Chromium monthly and publishes a dated, reproducible gauntlet.
- First-party residential and mobile egress, wired to the existing geo-coherence
- Keystroke and scroll variance (mouse motion has shipped)
- TLS ClientHello (JA3/JA4) in the persona surface set
- Linux arm64 / x86 / armhf tarballs (rolling out) and a macOS
.app - Code-signed Windows
.exeand macOS.app
Shipped in v3: the IPC persona with per-context isolation, on_restore, parameter-level WebGL and WebGPU, recorded-human mouse motion, the Widevine CDM, the native Windows build, the MCP server and the 92-site benchmark.
patches/ the first-generation C++ patch series (Fortress Source Available License 1.1)
build/ args.gn, build.sh, apply-patches.sh, rebase-monthly.sh, windows/, macos/
packaging/ tilion launcher, fonts.conf, Dockerfile, .deb + bundle builders
fonts/ 222 metric-compatible OS-named font files (154 distinct families, incl. color emoji)
sdk/ python + node (tilion-fortress) prebuilt-binary SDKs
mcp/ the Fortress MCP server (29 tools) and the agent skill
tools/ gauntlet.py, the CreepJS / Sannysoft / BrowserScan CI gate
docs/ UXR_CONFIG (the --uxr-* reference), GAUNTLET_RESULTS, BENCHMARK
examples/scrape_demos.py
#!/usr/bin/env python3
"""
Fortress live-scraping demos — reproduces the GIFs in the README.
Each demo drives the real Fortress engine over CDP and overlays a "verification"
HUD (green highlights + value tags) so you can *see* the scrape happen, then
writes an animated GIF.
pip install tilion-fortress playwright pillow
python examples/scrape_demos.py structured # -> fortress-scrape-structured.gif
python examples/scrape_demos.py paginated | detail | js | all
Patterns shown:
structured books.toscrape.com -> typed records build into a live JSON panel
paginated quotes.toscrape.com -> auto-pagination across pages 1..3
detail books.toscrape.com -> deep detail-page crawl (UPC/price/tax/stock/reviews)
js quotes.toscrape.com/js -> client-side-rendered DOM captured over CDP
Fortress spoofs the fingerprint in the engine's C++, so nothing here adds JS stealth.
If a site still blocks you it's the IP (datacenter) — route egress through a residential proxy.
"""
from __future__ import annotations
import sys, time
from pathlib import Path
OUT = Path(__file__).resolve().parent
VIEWPORT = {"width": 1180, "height": 820}
FPS = 9
# One declarative overlay controller, re-injected after every navigation.
FX_SETUP = r"""
window.FX = (function(){
const A = '#39d353';
function ensureBar(title){
let bar = document.getElementById('fx-bar');
if(!bar){ bar=document.createElement('div'); bar.id='fx-bar';
bar.style.cssText=`position:fixed;top:0;left:0;right:0;height:44px;z-index:2147483000;
background:rgba(13,17,23,.94);color:#e6edf3;display:flex;align-items:center;gap:12px;
padding:0 16px;font:600 15px system-ui,Segoe UI,sans-serif;box-shadow:0 2px 18px rgba(0,0,0,.4)`;
document.body.appendChild(bar); }
bar.innerHTML=`<span style="font-size:18px">🏰</span><span style="letter-spacing:.5px">FORTRESS</span>`+
`<span style="color:${A};font-weight:700">● ${title}</span>`+
`<span id="fx-status" style="margin-left:auto;color:#9da7b3;font-weight:500"></span>`;
}
return { render(s){
ensureBar(s.title||'stealth engine');
const st=document.getElementById('fx-status'); if(st) st.textContent=s.status||'';
let root=document.getElementById('fx-root');
if(!root){ root=document.createElement('div'); root.id='fx-root'; document.body.appendChild(root); }
root.innerHTML='';
(s.boxes||[]).forEach(b=>{
let el=null; try{ el=document.querySelectorAll(b.sel)[b.idx||0]; }catch(e){}
if(!el) return; const r=el.getBoundingClientRect();
const isA=!!b.active, pop=b.pop==null?1:b.pop;
const box=document.createElement('div');
box.style.cssText=`position:fixed;left:${r.left-5}px;top:${r.top-5}px;width:${r.width+10}px;
height:${r.height+10}px;z-index:2147482000;border-radius:9px;border:3px solid ${A};
box-shadow:0 0 ${isA?22*pop:7}px ${A}${isA?'':'55'};pointer-events:none`;
root.appendChild(box);
if(b.label){ const t=document.createElement('div'); const op=isA?pop:1;
t.style.cssText=`position:fixed;left:${r.left-5}px;top:${r.top+r.height+7}px;z-index:2147482100;
background:${A};color:#08160c;font:700 12px system-ui,Segoe UI,sans-serif;padding:4px 9px;
border-radius:7px;opacity:${op};transform:translateY(${(1-op)*6}px);
box-shadow:0 3px 10px rgba(0,0,0,.35);white-space:nowrap;max-width:340px;overflow:hidden;text-overflow:ellipsis`;
t.textContent=b.label; root.appendChild(t); }
});
if(s.panel){ const p=document.createElement('div');
p.style.cssText=`position:fixed;right:14px;top:58px;width:340px;max-height:82vh;overflow:hidden;
z-index:2147482200;background:rgba(13,17,23,.93);border:1px solid ${A}55;border-radius:12px;
padding:12px 14px;font:600 12px ui-monospace,Consolas,monospace;color:#e6edf3;box-shadow:0 10px 34px rgba(0,0,0,.45)`;
let html=`<div style="color:${A};font-weight:700;margin-bottom:8px;font-family:system-ui">${s.panel.title}</div>`;
(s.panel.rows||[]).forEach(r=>{ html+=`<div style="padding:3px 0;border-top:1px solid #ffffff10;color:#c9d3de">${r}</div>`; });
p.innerHTML=html; root.appendChild(p); }
if(s.done){ const d=document.createElement('div');
d.style.cssText=`position:fixed;left:50%;top:52%;transform:translate(-50%,-50%);z-index:2147483100;
background:rgba(13,17,23,.96);color:#e6edf3;padding:18px 26px;border-radius:14px;border:1px solid ${A};
box-shadow:0 10px 40px rgba(0,0,0,.5);text-align:center;font:600 15px system-ui,Segoe UI,sans-serif`;
d.innerHTML=`<div style="font-size:21px;color:${A};margin-bottom:6px">${s.done.title}</div>`+
`<div style="color:#9da7b3;font-weight:500">${s.done.sub}</div>`; root.appendChild(d); }
}};
})();
"""
class Cap:
def __init__(self, pg, frames_dir):
self.pg, self.dir, self.n = pg, frames_dir, 0
frames_dir.mkdir(parents=True, exist_ok=True)
for f in frames_dir.glob("*.png"):
f.unlink()
def fx(self):
self.pg.evaluate(FX_SETUP)
def frame(self, state, reps=1):
for _ in range(reps):
self.pg.evaluate("(s)=>window.FX.render(s)", state)
self.pg.screenshot(path=str(self.dir / f"f{self.n:04d}.png"))
self.n += 1
def pops(self, base, box, reps=(0.34, 0.7, 1.0)):
for pv in reps:
b = dict(box); b["active"] = True; b["pop"] = pv
self.frame({**base, "boxes": base.get("boxes", []) + [b]})
# ---------------- demos ----------------
def d_paginated(pg, cap):
total = 0
for page in (1, 2, 3):
pg.goto(f"https://quotes.toscrape.com/page/{page}/", wait_until="domcontentloaded", timeout=30000)
pg.evaluate("window.scrollTo(0,0)"); cap.fx()
cnt = pg.evaluate("()=>document.querySelectorAll('div.quote').length")
data = pg.evaluate("""()=>[...document.querySelectorAll('div.quote')].slice(0,3).map(q=>
({a:q.querySelector('.author')?.textContent, t:[...q.querySelectorAll('a.tag')].slice(0,2).map(x=>x.textContent).join(', ')}))""")
for i, q in enumerate(data):
base = {"title": "paginated scrape", "status": f"page {page}/3 · {total+i+1} quotes",
"boxes": [{"sel": "div.quote", "idx": j, "active": False} for j in range(i)]}
cap.pops(base, {"sel": "div.quote", "idx": i, "label": f"✓ {q['a']} · #{q['t']}"})
total += cnt
cap.frame({"title": "paginated scrape", "status": f"page {page}/3 · {total} quotes",
"boxes": [{"sel": "div.quote", "idx": j, "active": False} for j in range(3)]}, reps=2)
for _ in range(10):
cap.frame({"title": "paginated scrape", "status": "done · 30 quotes",
"done": {"title": "30 quotes · 3 pages", "sub": "authors + tags · auto-pagination · 0 blocks"}})
def d_structured(pg, cap):
pg.goto("https://books.toscrape.com/", wait_until="networkidle", timeout=30000)
pg.evaluate("window.scrollTo(0,90)"); cap.fx()
books = pg.evaluate("""()=>[...document.querySelectorAll('article.product_pod')].slice(0,8).map(a=>
({t:a.querySelector('h3 a')?.getAttribute('title'),p:a.querySelector('.price_color')?.textContent?.trim(),
r:(a.querySelector('p.star-rating')?.className||'').replace('star-rating','').trim(),
s:a.querySelector('.instock')?.textContent?.trim()?'In stock':'-'}))""")
rows = []
for i, bk in enumerate(books):
t = (bk['t'] or '')[:22]
base = {"title": "structured extraction", "status": f"{i+1}/8 records",
"panel": {"title": "records[] → JSON", "rows": list(rows)},
"boxes": [{"sel": "article.product_pod", "idx": j, "active": False} for j in range(i)]}
cap.pops(base, {"sel": "article.product_pod", "idx": i, "label": f"✓ {bk['p']} · ★{bk['r']}"})
rows.append(f'{{ "{t}", "{bk["p"]}", "★{bk["r"]}", "{bk["s"]}" }}')
cap.frame({"title": "structured extraction", "status": f"{i+1}/8 records",
"panel": {"title": "records[] → JSON", "rows": list(rows)},
"boxes": [{"sel": "article.product_pod", "idx": j, "active": False} for j in range(i+1)]})
for _ in range(10):
cap.frame({"title": "structured extraction", "status": "done",
"panel": {"title": "records[] → JSON", "rows": list(rows)},
"done": {"title": "20 records → structured", "sub": "title · price · rating · stock · typed"}})
def d_detail(pg, cap):
pg.goto("https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html",
wait_until="domcontentloaded", timeout=30000)
pg.evaluate("window.scrollTo(0,220)"); cap.fx()
fields = pg.evaluate(r"""()=>{
const rows=[...document.querySelectorAll('table.table-striped tr')];
const get=(l)=>{const tr=rows.find(r=>r.querySelector('th')?.textContent.trim()===l);return tr?tr.querySelector('td').textContent.trim():'';};
return [
{sel:'div.product_main h1', idx:0, label:'title: '+(document.querySelector('div.product_main h1')?.textContent||'').slice(0,20)},
{sel:'table.table-striped tr', idx:0, label:'UPC: '+get('UPC')},
{sel:'table.table-striped tr', idx:3, label:'Price (incl tax): '+get('Price (incl. tax)')},
{sel:'table.table-striped tr', idx:5, label:'Availability: '+get('Availability')},
{sel:'table.table-striped tr', idx:6, label:'Reviews: '+get('Number of reviews')},
];
}""")
for i, f in enumerate(fields):
base = {"title": "deep detail crawl", "status": f"extracting field {i+1}/{len(fields)}",
"boxes": [{"sel": fields[j]['sel'], "idx": fields[j]['idx'], "label": fields[j]['label'], "active": False} for j in range(i)]}
cap.pops(base, {"sel": f['sel'], "idx": f['idx'], "label": f['label']})
hold = {"title": "deep detail crawl", "status": "done",
"boxes": [{"sel": f['sel'], "idx": f['idx'], "label": f['label'], "active": False} for f in fields]}
for _ in range(10):
cap.frame({**hold, "done": {"title": f"{len(fields)} fields · deep crawl", "sub": "UPC · price · tax · stock · reviews"}})
def d_js(pg, cap):
pg.goto("https://quotes.toscrape.com/js/", wait_until="domcontentloaded", timeout=30000); cap.fx()
for _ in range(3):
cap.frame({"title": "JS-rendered (CDP)", "status": "client-side render — waiting for JS…"})
pg.wait_for_selector("div.quote", timeout=15000); time.sleep(0.5); pg.evaluate("window.scrollTo(0,0)")
data = pg.evaluate("()=>[...document.querySelectorAll('div.quote')].slice(0,4).map(q=>({a:q.querySelector('.author')?.textContent}))")
for i, q in enumerate(data):
base = {"title": "JS-rendered (CDP)", "status": f"rendered by V8 · {i+1} captured",
"boxes": [{"sel": "div.quote", "idx": j, "active": False} for j in range(i)]}
cap.pops(base, {"sel": "div.quote", "idx": i, "label": f"✓ {q['a']}"})
for _ in range(10):
cap.frame({"title": "JS-rendered (CDP)", "status": "done · full browser render",
"done": {"title": "JS-rendered · captured", "sub": "client-side DOM · real V8 · over raw CDP"}})
DEMOS = {"paginated": d_paginated, "structured": d_structured, "detail": d_detail, "js": d_js}
def build_gif(frames_dir: Path, out_gif: Path):
from PIL import Image
files = sorted(frames_dir.glob("f*.png"))
imgs = [Image.open(f).convert("RGB").resize((760, int(760 * Image.open(f).height / Image.open(f).width))) for f in files]
pal = imgs[len(imgs) // 2].quantize(colors=256)
frames = [im.quantize(palette=pal, dither=Image.Dither.FLOYDSTEINBERG) for im in imgs]
frames[0].save(out_gif, save_all=True, append_images=frames[1:], duration=int(1000 / FPS), loop=0, optimize=True)
print(f"[*] wrote {out_gif} ({out_gif.stat().st_size // 1024} KB, {len(frames)} frames)")
def run_one(name: str):
from tilion_fortress import Fortress
from playwright.sync_api import sync_playwright
frames_dir = OUT / f".frames_{name}"
print(f"[*] demo '{name}': launching Fortress...")
with Fortress() as f:
with sync_playwright() as p:
b = p.chromium.connect_over_cdp(f.cdp_url)
ctx = b.contexts[0] if b.contexts else b.new_context()
pg = ctx.pages[0] if ctx.pages else ctx.new_page()
pg.set_viewport_size(VIEWPORT)
cap = Cap(pg, frames_dir)
DEMOS[name](pg, cap)
print(f"[*] captured {cap.n} frames")
b.close()
build_gif(frames_dir, OUT / f"fortress-scrape-{name}.gif")
def main():
which = sys.argv[1] if len(sys.argv) > 1 else "structured"
names = list(DEMOS) if which == "all" else [which]
for n in names:
if n not in DEMOS:
print(f"unknown demo '{n}'. choose: {', '.join(DEMOS)} | all"); return 2
run_one(n)
return 0
if __name__ == "__main__":
raise SystemExit(main())
tools/gauntlet.py
#!/usr/bin/env python3
"""
Fortress detection gauntlet — the stealth CI gate.
Launches a tilion-fortress bundle headless and runs the live detection suites
(CreepJS, bot.sannysoft.com, browserscan.net) over CDP, then asserts the key
stealth invariants. Exit 0 = clean, non-zero = a surface regressed.
Usage:
tools/gauntlet.py --bundle /path/to/tilion-fortress [--port 9333] [--keep] [--json]
With --json it prints a machine-readable {surfaces, invariants, ok} report on stdout
(and still sets the exit code) so CI and dashboards can consume it; see
docs/gauntlet-sample.json. Without it, the human-readable report is unchanged.
No third-party deps — raw CDP over a hand-rolled WebSocket so it runs anywhere
Python 3 does.
"""
import argparse, base64, json, os, shutil, socket, struct, subprocess, sys, tempfile, time, http.client
def ws_eval(port, expr, timeout=20):
"""Evaluate JS in the active page via CDP, return the JSON value."""
c = http.client.HTTPConnection("127.0.0.1", port, timeout=timeout)
c.request("GET", "/json")
pages = [t for t in json.loads(c.getresponse().read()) if t.get("type") == "page"]
if not pages:
raise RuntimeError("no page target")
path = pages[0]["webSocketDebuggerUrl"].split(str(port), 1)[1]
s = socket.create_connection(("127.0.0.1", port), timeout=timeout)
key = base64.b64encode(os.urandom(16)).decode()
s.send(("GET %s HTTP/1.1\r\nHost:127.0.0.1:%d\r\nUpgrade:websocket\r\n"
"Connection:Upgrade\r\nSec-WebSocket-Key:%s\r\nSec-WebSocket-Version:13\r\n\r\n"
% (path, port, key)).encode())
buf = b""
while b"\r\n\r\n" not in buf:
buf += s.recv(4096)
msg = json.dumps({"id": 1, "method": "Runtime.evaluate",
"params": {"expression": expr, "returnByValue": True,
"awaitPromise": True}}).encode()
mask = os.urandom(4)
frame = bytearray([0x81, 0x80 | 126]) + struct.pack(">H", len(msg) & 0xFFFF) + mask
frame += bytes(b ^ mask[i % 4] for i, b in enumerate(msg))
s.send(frame)
def recv_frame():
h = s.recv(2)
ln = h[1] & 0x7F
if ln == 126:
ln = struct.unpack(">H", s.recv(2))[0]
elif ln == 127:
ln = struct.unpack(">Q", s.recv(8))[0]
data = b""
while len(data) < ln:
data += s.recv(ln - len(data))
return data
for _ in range(120):
try:
j = json.loads(recv_frame())
except Exception:
continue
if j.get("id") == 1:
return j["result"]["result"].get("value")
raise RuntimeError("no CDP eval response")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--bundle", required=True, help="path to extracted tilion-fortress/")
ap.add_argument("--port", type=int, default=9333)
ap.add_argument("--keep", action="store_true", help="leave the browser running")
ap.add_argument("--json", action="store_true",
help="emit a machine-readable {surfaces, invariants, ok} JSON report on "
"stdout (and set the exit code) instead of the human text report")
args = ap.parse_args()
launcher = os.path.join(args.bundle, "tilion.cmd" if os.name == "nt" else "tilion")
if not os.path.exists(launcher):
sys.exit(f"no launcher at {launcher}")
# Isolated, OS-appropriate temp dirs (not a hardcoded /tmp, which doesn't exist on Windows).
profile = tempfile.mkdtemp(prefix="fortress-gauntlet-")
home = tempfile.mkdtemp(prefix="fortress-gauntlet-home-")
cmd = [launcher, "--headless=new", "--no-sandbox", "--disable-gpu",
f"--remote-debugging-port={args.port}",
f"--user-data-dir={profile}", "about:blank"]
if...
FAQ
Common questions
Discussion
Questions & comments · 0
Sign In Sign in to leave a comment.