| name | chromiumfish |
| description | Anti-detect Chromium (fingerprint spoofed in the C++ engine) driven through the standard Playwright MCP over a CDP endpoint. This is the default browser for all work: one long-lived session on port 9222. Use to extract a site's design system (colors, fonts, @font-face, CSS variables, logo), to recon sites behind bot-detection or that block plain automation, to browse without leaking the real machine identity, and to keep persistent browser profiles that survive restarts (log in once, reuse). Triggers: "extract the design system", "scrape this site", "the site blocks me", "recon a site", "get past bot detection", "log in and keep the session", "open a browser", anti-detect, stealth browser, fingerprint spoofing. |
ChromiumFish — anti-detect browser under the standard Playwright MCP
Fingerprint-hardened Chromium fork (arman-bd/chromiumfish, MIT). Spoofing lives in the C++ engine (UA, Client Hints, fonts, audio, WebRTC, WebGL), not in injected JS, so tamper probes find nothing.
The whole design: zero custom code. chromiumfish serve exposes a CDP endpoint; the ordinary @playwright/mcp attaches with --cdp-endpoint. You then use the same browser_navigate / browser_snapshot / browser_click / browser_evaluate / browser_take_screenshot tools as always — they just drive a browser that presents as a Windows desktop instead of the host machine.
The spoofing survives the CDP bridge — the make-or-break test, numbers in §9.
Every command, flag and number below was executed, not assumed. Anything unverified is called out in §15.
1. Policy: one browser, one session
ChromiumFish is the browser. There is no "plain sites go to bare Playwright" branch.
The reason is asymmetry, not paranoia. Deciding per site means deciding right every time; the cost of forgetting stealth once (a burned IP, a profile flagged on a site you needed) is far larger than the cost of running a stealth browser on a site that never checked. A single always-on path removes the decision.
So the operating model is:
- A default session runs
chromiumfish serve --headless on port 9222 and stays up (§3).
- Playwright MCP attaches to it over
--cdp-endpoint and every browser tool call goes there.
- Before any browser work, confirm the session is alive. If it isn't, start it. That is a precondition, not an optional step (§3).
- Switch the session to headful for debugging and for logins needing a human (§6). Same port, same profile — the mode changes, the path does not.
The one exception: visual review of your own local HTML
Render-check local HTML artifacts on a bare Playwright browser, not ChromiumFish.
ChromiumFish does not merely spoof the enumerated font list — it substitutes at render time. Measured (§10): 198 of 404 locally installed font families that render under bare Playwright fall back to a generic face under ChromiumFish, including Helvetica Neue, Helvetica, Menlo, Monaco, Geneva, Avenir, Futura, Gill Sans, Optima, Palatino, Baskerville, Didot and System Font. A page whose CSS names one of those gets silently reviewed in the wrong typeface — different metrics, different line breaks, different visual weight than a real visitor sees.
@font-face webfonts and locally installed fonts outside the block list are unaffected — identical widths, visually identical screenshots (§10). So the exception is narrow, but it is not detectable by eye: the substitution is silent. Use bare Playwright for the render check and it never applies.
This exception covers local artifacts you authored. It does not cover looking at somebody else's site, which always goes through the default session.
Boundaries — do not use this to: defeat authentication or access controls, scrape behind credentials you do not hold, evade a site's Terms of Service, or solve CAPTCHAs automatically. Public pages and accounts you legitimately own. Restated with the escalation ladder in §12.
2. Install
Global install with pipx, no virtualenv to activate and no ./venv/bin/ prefixes anywhere:
pipx install chromiumfish
chromiumfish fetch
chromiumfish then resolves on PATH (~/.local/bin/chromiumfish). Confirm:
command -v chromiumfish
chromiumfish -V # chromiumfish 0.2.3 (browser 149.0.7827.115)
chromiumfish path
Why pipx and not pip install --user. The system Python is PEP 668 externally-managed; python3 -m pip install chromiumfish fails outright:
error: externally-managed-environment
× This environment is externally managed
pipx sidesteps this by giving the package its own venv under ~/.local/pipx/venvs/chromiumfish and exposing only the entry point.
Skip the chromiumfish[mcp] extra. It only pulls in the fork's own bundled MCP server, which this skill deliberately does not use — the whole point is serve + the standard @playwright/mcp (§16 explains why the bundled one is worse). Plain chromiumfish is all you need.
The browser cache is global, not per-install. It lives at:
~/Library/Caches/chromiumfish/<version>/mac-arm64/ChromiumFish.app/Contents/MacOS/chromiumfish
Demonstrated: after pipx install, chromiumfish fetch returned instantly with no download, because a previous install had already populated that directory. One download serves every install, upgrade and reinstall on the machine. Measured 327 MB unpacked on disk (136 MiB compressed download on a cold first fetch).
Upgrade and inventory:
pipx upgrade chromiumfish
pipx list --short
Verified on chromiumfish 0.2.3, browser 149.0.7827.115, @playwright/mcp 1.62.0-alpha via npx -y @playwright/mcp@latest, macOS arm64, Python 3.14.5.
CLI surface, all of it: fetch · path · clear · serve · mcp · flow.
3. The default session
Start it
chromiumfish serve --headless --persona-seed alpha-7 --port 9222 \
--window-size 2560x1440 \
"--extra-args=--user-data-dir=$PWD/profiles/alpha-7"
serve blocks — it is the session, so background it:
mkdir -p profiles
nohup chromiumfish serve --headless --persona-seed alpha-7 --port 9222 \
--window-size 2560x1440 \
"--extra-args=--user-data-dir=$PWD/profiles/alpha-7" >> serve.log 2>&1 &
Two flag-syntax traps, both hit in testing:
--extra-args values start with -, so argparse eats them as flags. Must use the --extra-args=... equals form; the space form dies with usage: chromiumfish serve ....
--extra-args is comma-separated, not space-separated.
--window-size must match the persona's screen — see §4.
--lang/--accept-lang control server-side locale. A site that localises by Accept-Language will otherwise serve its default language: verified, a test site redirected to its /en path without them and to the local-language path with them. They set the HTTP Accept-Language header. navigator.languages stays en-US,en (persona-level, spoofed in C++), so don't rely on JS locale.
Keeping it up — the operational loop
The session is a plain foreground process that you backgrounded. It does not survive a reboot, a logout, or pkill. So every piece of browser work starts by confirming it, and the confirmation is one command:
curl -sf -m 2 http://127.0.0.1:9222/json/version >/dev/null && echo alive || echo down
alive means a CDP endpoint answered on 9222. Start it only if it did not — this is safe to re-run, and re-running blindly is not (§13: a second serve on an occupied port prints success and silently hands you the old browser):
curl -sf -m 2 http://127.0.0.1:9222/json/version >/dev/null || \
nohup chromiumfish serve --headless --persona-seed alpha-7 --port 9222 \
--window-size 2560x1440 \
"--extra-args=--user-data-dir=$PWD/profiles/alpha-7" >> serve.log 2>&1 &
Then wait for ready — poll, don't sleep a fixed amount:
for i in $(seq 1 60); do curl -sf -m 2 http://127.0.0.1:9222/json/version >/dev/null && break; sleep 0.5; done
Measured cold-start to first successful /json/version: ~1.04 s (two consecutive runs, 1041 ms and 1035 ms, port confirmed free beforehand). The loop caps at 30 s for a slow machine.
After a reboot the port is free and the profile directory is still on disk. Re-run the start command with the same seed and same --user-data-dir and the session comes back with its cookies and logins intact (§5) — that is the whole reason the profile is pinned to a path rather than a temp dir. Do not "start fresh" after a reboot; changing the seed on an existing profile is the mismatch described in §4.
Who really owns the port, and how to stop:
lsof -nP -iTCP:9222 -sTCP:LISTEN # e.g. chromiumf 91601 ... TCP 127.0.0.1:9222 (LISTEN)
pkill -f "chromiumfish serve" # stop all sessions
After starting, run the §13 canary once to confirm you are on the persona you asked for, not a leftover browser.
Several profiles at once
One serve per profile, one port per profile. Verified isolated: markers written on :9222 were absent on :9223, and each port kept its own persona.
chromiumfish serve --headless --persona-seed alpha-7 --port 9222 \
"--extra-args=--user-data-dir=$PWD/profiles/alpha-7" &
chromiumfish serve --headless --persona-seed zulu-99 --port 9223 \
"--extra-args=--user-data-dir=$PWD/profiles/zulu-99" &
Ports are not self-describing — keep a written seed → port → profile map next to the work. 9222 stays the default session; extra ports are for work that genuinely needs a second identity.
4. Choosing an identity (persona seed)
A seed is any stable string. It deterministically generates a fingerprint: the same seed always regenerates the same identity, a new seed gives a different one.
Inspect a seed before committing to it
Start serve on that seed, attach Playwright MCP, and run the canary from §13:
chromiumfish serve --headless --persona-seed candidate-1 --port 9222 &
for i in $(seq 1 60); do curl -sf -m 2 http://127.0.0.1:9222/json/version >/dev/null && break; sleep 0.5; done
npx -y @playwright/mcp@latest --cdp-endpoint http://127.0.0.1:9222
# then: browser_navigate to any page, browser_evaluate the §13 canary
Read cores, mem, screen, ua from the result, then set --window-size to the reported screen.
Actual measured seeds
Every row was read from a live browser, not predicted:
| seed | cores | deviceMemory | screen |
|---|
alpha-7 | 8 | 16 | 2560x1440 |
bravo-9 | 8 | 16 | 1920x1080 |
charlie-3 | 8 | 16 | 1920x1080 |
delta-11 | 4 | 4 | 1920x1080 |
echo-42 | 8 | 8 | 1920x1080 |
zulu-99 | 4 | 4 | 2560x1440 |
test | 4 | 4 | 1440x900 |
A further seed produced 12 cores, so observed ranges are cores 4–12, deviceMemory 4–16, screen one of 1440x900 / 1920x1080 / 2560x1440.
What varies and what never does
| Varies with seed | Never varies |
|---|
hardwareConcurrency (4–12) | OS — always Windows (Win32, Windows NT 10.0; Win64; x64) |
deviceMemory (4–16) | GPU — always ANGLE (Intel, Intel(R) UHD Graphics 770 (0xA780) Direct3D11 vs_5_0 ps_5_0, D3D11) |
screen (3 observed values) | Browser brand/version — always Chrome 149 |
| navigator.languages — always en-US,en |
| The font block list (§10) |
This is the ceiling of the tool. You cannot present as macOS, Linux, mobile, AMD or NVIDIA. Every profile is a Windows desktop with the same integrated Intel GPU, so anyone correlating across your profiles sees one hardware model repeatedly. Seeds give distinct identities, not a diverse fleet.
Picking a seed
- Pin the seed to the profile and never change it. Reuse the same seed every time you start that profile.
- A different seed per profile, so two profiles don't look like the same machine. Prefer seeds that differ in
cores/mem/screen, not just in name — bravo-9 and charlie-3 are identical in every measured field, so they read as the same machine despite different names.
- Match
--window-size to the seed's screen. Mismatch is self-detecting: with seed test (screen 1440x900) and the default --window-size 1920x1080, the page reported innerWidth 1920 on a 1440-wide screen — a viewport wider than the monitor, impossible on real hardware.
Changing the seed on an existing profile — don't
Tested directly. Wrote a marker under seed alpha-7 on a profile, stopped serve, restarted the same profile under seed zulu-99:
| seed alpha-7 | seed zulu-99, same profile |
|---|
localStorage marker | SWAPTEST | SWAPTEST — unchanged |
| cookie | who=SWAPTEST | who=SWAPTEST — unchanged |
| cores / deviceMemory | 8 / 16 | 4 / 4 |
Verdict: yes, this gives you away. The site gets back the exact session cookie it issued to an 8-core/16 GB machine, now presented by a 4-core/4 GB machine. A returning-session fingerprint mismatch is precisely what fraud and bot systems look for — more suspicious than a clean first visit. Treat seed and profile directory as one unit; if you need a different identity, start a new profile.
5. persona-seed vs profile
| --persona-seed | --user-data-dir |
|---|
| Controls | fingerprint: cores, deviceMemory, screen, UA details | storage: cookies, localStorage, logins, cache |
| Persistence | deterministic from the string | on disk, ~9 MB per profile |
| Default | none (still a Windows persona) | temp dir, deleted on exit |
Out of the box serve calls tempfile.mkdtemp(prefix="cf-serve-") and rmtrees it on shutdown — every login is lost when serve stops. Passing --user-data-dir through --extra-args overrides this (the flag lands after the built-in one and wins).
Verified: wrote a localStorage value plus a cookie, killed serve, restarted with the same --user-data-dir → both read back intact. That is the log-in-once path, the reason the default session survives reboots (§3), and the foundation of §12 level 4.
6. Headless (default is headed) vs headful
# headless — the default session
chromiumfish serve --headless --persona-seed alpha-7 --port 9222 \
"--extra-args=--user-data-dir=$PWD/profiles/alpha-7"
# headful — visible window, for debugging and hard logins
chromiumfish serve --persona-seed alpha-7 --port 9222 --window-size 2560x1440 \
"--extra-args=--user-data-dir=$PWD/profiles/alpha-7"
Note serve defaults to headed; omitting --headless opens a window.
Does headless leak? No. bot.sannysoft.com through the CDP bridge scored 31 passed / 0 failed in both modes, with identical UA, platform and WebGL renderer. Headless is safe here — which is the entire point, since under bare Playwright headless is exactly what gets caught (§9).
To switch modes, stop the session and restart it on the same port with the same seed and the same --user-data-dir. Nothing is lost; the profile is on disk.
One real difference: headful takes its viewport from the actual OS window (measured 1728x930 on the test display) rather than --window-size. Set the viewport explicitly with browser_resize before taking comparable screenshots.
7. Attach Playwright MCP
npx -y @playwright/mcp@latest --cdp-endpoint http://127.0.0.1:9222
It attaches to the running browser instead of launching its own. Tools exposed (24, unchanged): browser_navigate, browser_snapshot, browser_click, browser_type, browser_fill_form, browser_evaluate, browser_take_screenshot, browser_resize, browser_network_requests, browser_tabs, browser_wait_for, browser_find, browser_press_key, browser_hover, browser_select_option, browser_navigate_back, browser_console_messages, browser_handle_dialog, browser_file_upload, browser_drag, browser_drop, browser_run_code_unsafe, browser_network_request, browser_close.
There is no browser_new_context tool — contexts aren't client-controllable, so there is no way to accidentally spawn an unspoofed context.
file:// is blocked by default. browser_navigate to a file:// URL fails with Error: Access to "file:" protocol is blocked. This is @playwright/mcp's own restriction, not the browser's — the identical error appears on a bare Playwright browser. Two ways through, both verified:
# either allow it explicitly
npx -y @playwright/mcp@latest --cdp-endpoint http://127.0.0.1:9222 --allow-unrestricted-file-access
# or serve the directory and use http://
python3 -m http.server 8765 --bind 127.0.0.1
Measured font widths came back identical over file:// and http://127.0.0.1 (§10), so the choice is purely about which restriction you'd rather relax.
State lives in the browser, not the MCP client. Verified across two separate client processes: process A navigated and wrote a marker; process B started fresh, called browser_evaluate without navigating, and read back the same url, title, marker and cookie. Restarting the agent does not lose the page — which is what makes one long-lived session on 9222 practical.
Registration JSON in §14.
8. Parameter passthrough — what you may and may not set
Every row confirmed by running it.
| Parameter | Set it here | What happens if set on the Playwright side |
|---|
| User-Agent / Client Hints | persona seed (automatic) | --user-agent MyCustomUA/1.0 silently ignored over CDP — UA stayed Windows NT 10.0; Win64; x64. Cannot break the spoof, cannot use it either |
| Viewport | browser_resize tool | --viewport-size 390x844 silently ignored over CDP. browser_resize works |
| Window size (initial) | serve --window-size | must match persona screen (§4) |
| Device emulation | — | --device "iPhone 15" hard-crashes the server: Error: Device emulation is not supported with cdpEndpoint. |
| Locale (server-side) | serve --extra-args=--lang=..,--accept-lang=.. | no Playwright MCP flag exists |
| Timezone | serve --timezone Europe/Berlin | verified: page reported tz: Europe/Berlin, offsetMin: -120. Omit it and the page reports the host timezone |
| Profile / cookies / storage | serve --extra-args=--user-data-dir=... | --isolated wipes it — same marker read back as null. Never pass --isolated when you want the profile |
file:// access | — | --allow-unrestricted-file-access works over CDP (§7) |
| Headless / headful | serve --headless (default headed) | meaningless over CDP; the browser is already running |
| Permissions | --grant-permissions geolocation accepted, no crash, state preserved | effect itself not verified (§15) |
| Proxy | serve --proxy | not verified end-to-end (§15) |
Rule of thumb: identity is set at serve, interaction is done through Playwright MCP. Playwright-side flags that try to shape identity are either ignored or fatal — none silently degrade the spoof, which is the good outcome.
9. Measured: does the spoofing survive CDP?
bot.sannysoft.com, one machine, one day.
| Config | passed / failed | UA + WebGL reported |
|---|
| (a) bare Playwright MCP, headless | 28 / 3 | HeadlessChrome/150.0.0.0, MacIntel, ANGLE (Apple, ANGLE Metal Renderer: …) — fails User Agent, HEADCHR_UA, CHR_MEMORY |
(b) serve --headless + Playwright MCP over CDP | 31 / 0 | Windows NT 10.0; Win64; x64, Win32, ANGLE (Intel, Intel(R) UHD Graphics 770 (0xA780) Direct3D11 vs_5_0 ps_5_0, D3D11) |
(c) same as (b) + --user-agent + --viewport-size overrides | 31 / 0 | byte-identical to (b) — overrides ignored |
| (d) serve headful + Playwright MCP over CDP | 31 / 0 | identical to (b) |
The spoofing fully survives the CDP attach. Nothing is lost in the bridge, because the spoof is compiled into the engine rather than applied by the driver. (c) proves Playwright-side overrides cannot degrade it; (d) proves headless costs nothing.
Full identity under (b): webdriver: false, userAgentData.platform: "Windows", zero cdc_ properties.
10. Measured: fonts — enumeration and rendering
The question that decides §1: does ChromiumFish only fake the list of installed fonts, or does it actually restrict what can be painted?
Answer: it restricts rendering. A blocked family is not merely hidden from enumeration — asking for it in CSS produces a substitute face, silently.
Method
One local page, both browsers, same machine, same day, served over http://127.0.0.1:8765. Text Handgloves 123 WWiill at font-size: 100px in an off-screen span with font-family: "<X>", monospace; width read with getBoundingClientRect(). If the width equals the plain-monospace baseline, the family was not applied. document.fonts.ready awaited first.
Three kinds of font were probed: macOS system faces, a custom .ttf installed into ~/Library/Fonts, and the same files loaded as @font-face webfonts from disk. Plus a nonexistent family as control.
Result
| family | source | bare Playwright | ChromiumFish | verdict |
|---|
WebFaceTest | @font-face, local .ttf | 1277.25 | 1277.25 | identical |
WebFaceBrand | @font-face, local .ttf | 1598.75 | 1598.75 | identical |
BrandTestFace | installed in ~/Library/Fonts | 1626 | 1626 | identical |
Arial | system | 1028.19 | 1028.19 | identical |
Helvetica Neue | system | 1027.91 | 1264.31 | substituted |
Zapfino | system | 1626 | 1264.31 | substituted |
Chalkduster | system | 1277.25 | 1264.31 | substituted |
NoSuchFontXYZ123 | control | 1260.22 | 1264.31 | falls back in both |
generic monospace | baseline | 1260.22 | 1264.31 | differs |
1264.31 is the ChromiumFish monospace baseline — the three substituted rows collapsed exactly onto the fallback. Screenshots agree: the sample string renders in the real face in both browsers for the @font-face and installed-font rows, and in plain fallback under ChromiumFish for the system-font rows.
How wide the block list is
Swept all 424 ASCII family names found in /System/Library/Fonts, /Library/Fonts and ~/Library/Fonts (read out of each file's name table with fontTools), measuring every one against three fallbacks in both browsers:
| count |
|---|
| families probed | 424 |
| rendered under bare Playwright | 404 |
| substituted under ChromiumFish | 198 |
| identical width in both | 49 |
| non-Latin faces, fallback for this string in both (differ only by the ~4 px baseline delta) | 157 |
Blocked (excerpt): Helvetica, Helvetica Neue, Menlo, Monaco, Geneva, Lucida Grande, Avenir and Avenir Next in every weight, Futura, Gill Sans, Optima, Palatino, Baskerville, Didot, Big Caslon, Hoefler Text, Papyrus, Zapfino, Chalkduster, Chalkboard, Marker Felt, Noteworthy, Snell Roundhand, Apple Chancery, American Typewriter, Copperplate, Skia, Andale Mono, Charter, Brush Script MT, PT Sans / PT Serif / PT Mono, STIXGeneral, System Font, and the whole Hiragino / Songti / Heiti / Kohinoor / Sangam MN groups.
Not blocked, identical widths (the full 49): Arial, Arial Black, Arial Narrow, Arial Rounded MT Bold, Arial Unicode MS, Times, Times New Roman, Georgia, Verdana, Tahoma, Trebuchet MS, Impact, Comic Sans MS, Microsoft Sans Serif, Webdings, Wingdings (+2, +3) — plus a long tail of less famous local faces: Academy Engraved LET, Athelas, Charter Black, Galvji, Grantha Sangam MN, Iowan Old Style (+ Black), Kefa III, Mukta Mahee and its weights, Party LET, PT Sans Caption, PT Sans Narrow, PT Serif Caption, Rockwell, Savoye LET, Seravek and its weights, SignPainter, STIX Two Math, STIX Two Text, Superclarendon — and the custom face installed for this test. system-ui and BlinkMacSystemFont also resolve to the same face in both.
Note the block list is family-name granular, not family-group granular: PT Sans and PT Serif are blocked while PT Sans Caption, PT Sans Narrow and PT Serif Caption are not.
So the filter is a name-level block list of macOS-signature families, not a whitelist: a family it has never heard of — including one you installed yourself an hour ago — passes straight through to real system matching.
The mirror image
Windows-only names that do not exist on the host — Segoe UI, Calibri, Cambria, Consolas, Candara, Corbel, Franklin Gothic Medium, MS Gothic, Lucida Console, Lucida Sans Unicode, Palatino Linotype, Garamond — appear to render under ChromiumFish while falling back under bare Playwright. They are aliases, not fonts: all twelve collapse onto three widths (1028.19 sans / 971.78 serif / 1260.22 mono). Consistent with the Windows persona, and harmless.
Two practical consequences
document.fonts.check() is useless as a detector. It returned true for the nonexistent control family in both browsers. Only width measurement distinguishes applied from fallback.
- Reviewing your own local HTML on ChromiumFish is unsafe when the CSS names a macOS system face — hence the single exception in §1. Self-hosted
@font-face stacks are safe; so are locally installed custom faces. Both were identical over file:// and http://.
11. Recipes
All below assume the default session is running on :9222 with Playwright MCP attached (§3).
Extract a site's design system
browser_navigate to the page, then one browser_evaluate:
() => {
const t={},f={},b=(o,k)=>{if(k)o[k]=(o[k]||0)+1};
document.querySelectorAll('*').forEach(e=>{const c=getComputedStyle(e);
if(e.textContent&&e.textContent.trim()){b(t,c.color);b(f,c.fontFamily);}
if(c.backgroundColor&&c.backgroundColor!=='rgba(0, 0, 0, 0)')b(t,c.backgroundColor);});
const top=o=>Object.entries(o).sort((a,b)=>b[1]-a[1]).slice(0,8);
const rules=s=>{try{return s.cssRules||[]}catch(e){return[]}};
const faces=[],vars={};
for(const s of document.styleSheets)for(const x of rules(s)){
if(x.constructor.name==='CSSFontFaceRule')faces.push(x.style.fontFamily+' '+(x.style.src||'').slice(0,90));
if(x.style)for(const p of x.style)if(p.startsWith('--'))vars[p]=x.style.getPropertyValue(p).trim();}
return {title:document.title,lang:document.documentElement.lang,url:location.href,
bodyFont:getComputedStyle(document.body).fontFamily,topColors:top(t),topFonts:top(f),
fontFaces:faces.slice(0,6),cssVars:vars,
logos:[...document.querySelectorAll('img,link[rel*=icon]')]
.map(e=>e.getAttribute('src')||e.getAttribute('href'))
.filter(s=>s&&/logo|favicon/i.test(s)).slice(0,8)};
}
Reading the output — the traps, all seen on a real production site:
cssVars is frequently framework boilerplate, not brand. One tested site returned 28 custom properties that were entirely Bootstrap defaults (--blue: #007bff, --danger: #dc3545). Never treat those as the site's palette. Trust topColors (computed, frequency-ranked) and the logo.
topFonts ranks by element count, so the body font dominates and the brand display face hides far below it. On that same site the body font scored 1241 elements while the actual display face scored 18 — and the giveaway was the single @font-face rule pointing at a self-hosted .otf. Always cross-check topFonts against fontFaces.
topFonts reports the declared family, not the painted one. On this browser the two diverge for any macOS system face (§10), so treat the declared stack as the finding and don't infer the rendered face from a screenshot.
- Resolve
logos[] against the origin. Sites commonly ship a light/dark pair, so take both.
Screenshots desktop 1440 + mobile 390
browser_resize {width: 1440, height: 900}
browser_take_screenshot {filename: "desktop-1440.png", fullPage: true}
browser_resize {width: 390, height: 844}
browser_take_screenshot {filename: "mobile-390.png", fullPage: true}
Verified end-to-end — produced 1437x4262 and 390x7063 PNGs (desktop width is 1437, not 1440: the scrollbar). Viewport became 390x844 while screen stayed at the persona value and the UA stayed Windows desktop.
This is a narrow viewport, not device emulation — maxTouchPoints: 0, devicePixelRatio: 1, desktop UA. CSS breakpoints fire correctly, so responsive layout is real; but a site that switches on UA sniffing or touch support still serves desktop. True device emulation is impossible over CDP (§8).
Where the files land: filename resolves against the MCP server process's cwd, not --output-dir. Observed: with --output-dir ./out/shots, console and page dumps went there but the PNGs landed in cwd. Pass a path you control and confirm where they appeared.
Capture a site's network contract
browser_network_requests returns method, URL and response status per line — the fastest way to learn how a page actually loads its data (which endpoints, which query parameters, which of them are public):
16. [GET] https://shop.example.com/api/v1/catalog/featured => [200] OK
43. [GET] https://shop.example.com/api/v1/page/config => [200] OK
Navigate, wait for the page to settle, then read the list and filter for the paths that matter (/api/, /graphql, XHR/fetch). Verified working over the CDP bridge; the line format above is exactly what the tool emits.
12. Getting past defences — escalation ladder
Climb only as far as you must. Each level costs more setup than the one before, and most sites stop at level 1 or 2.
Level 1 — User-Agent gate
Cheapest diagnosis, run it first:
curl -s -o /dev/null -w "%{http_code}\n" https://example-corp.com/
curl -s -H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36' \
-o /dev/null -w "%{http_code}\n" https://example-corp.com/
Verified against a site behind a commercial DDoS/bot shield: bare curl → 403, the same request with a browser UA → 404 (wrong path, but through the shield). If a UA header alone fixes it, the gate is UA-level — you are already past it on the default session, and there is nothing more to do. Don't escalate.
Level 2 — headless detection
The site accepts a real browser but blocks headless automation. This is what ChromiumFish solves, and the numbers are in §9: bare Playwright headless fails HEADCHR_UA / CHR_MEMORY / User Agent; ChromiumFish fails nothing, headless or headful.
Nothing to configure beyond the default session. Confirm with the §13 canary that you really are on the spoofed browser.
Level 3 — behavioural checks and JS challenges
The site scores how you browse, or runs an interstitial JS challenge before letting you through. What helps, in order of effort:
- Arrive like a person. Load the homepage first and navigate in via
browser_click, instead of jumping straight to a deep URL. A first-ever request to a deep link with no referrer is a strong bot signal.
- Let the page settle. Use
browser_wait_for on real content rather than firing the next action immediately. Back-to-back sub-second actions do not look human.
- Warm the profile. Visit the homepage, read something, then go deeper — in the same persistent profile, so the site sees an aging session rather than a brand-new one every run. The default session already gives you this for free if you keep it up (§3).
- Keep the challenge cookie. Once an interstitial clears it usually issues a cookie with a long lifetime. In a persistent profile (§5) that cookie survives restarts, so you pass the challenge once rather than every run.
Honesty flag: these individual techniques are reasoning, not measured — no site in testing required them. The persistence they depend on is measured (§5).
Level 4 — CAPTCHA and Turnstile
These are not solved automatically, and this skill does not attempt to. The practical route is human-in-the-loop, once:
- Restart the session headful on the same port, seed and
--user-data-dir (§6).
- Navigate to the page and solve the challenge by hand in the visible window.
- Stop serve. The clearance cookie is now in the profile on disk.
- Restart headless on the same profile and seed, and continue automated work.
This is the single biggest practical payoff of persistent profiles. The mechanism — headful mode, and cookies surviving a kill/restart — is verified (§5, §6); the CAPTCHA round-trip itself was not tested against a live vendor (§15).
Do not change the seed between steps 2 and 4: same cookie from different hardware is exactly the mismatch described in §4.
Signs you were detected anyway
- A run of
200s that turns into sudden 403/429.
- Navigation silently redirected to a challenge or "unusual traffic" page — check
location.href after browser_navigate, not just the status.
- HTTP
200 with an empty or skeletal DOM (document.body.innerText.length near zero) — content withheld while the page looks fine.
browser_network_requests showing API calls returning 403 while the shell page returns 200.
What to do: stop hammering it. Re-run the §13 canary to confirm the spoof is actually active — a common cause is having attached to a browser that is not the one you think. Then slow down, warm the profile, and re-enter through the homepage. If it still blocks, the answer may legitimately be that the site does not want automated access.
The boundary
Escalation stops here. Do not attempt to defeat authentication or access controls, scrape content behind credentials you do not hold, work around a site's Terms of Service, or solve CAPTCHAs programmatically. A site that still refuses after level 3 has made its position clear; respect it and find the data another way.
13. Diagnostics
Is the spoof intact? Run this canary before trusting any run — it also reports the persona's screen for --window-size:
() => ({ua:navigator.userAgent, plat:navigator.platform, wd:navigator.webdriver,
cores:navigator.hardwareConcurrency, mem:navigator.deviceMemory,
screen:screen.width+'x'+screen.height, vp:innerWidth+'x'+innerHeight,
tz:Intl.DateTimeFormat().resolvedOptions().timeZone,
webgl:(()=>{const c=document.createElement('canvas').getContext('webgl');
const d=c.getExtension('WEBGL_debug_renderer_info');
return c.getParameter(d.UNMASKED_RENDERER_WEBGL);})()})
Healthy on seed alpha-7, actual output:
{"ua":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
"plat":"Win32","wd":false,"cores":8,"mem":16,"screen":"2560x1440","vp":"2560x1353",
"webgl":"ANGLE (Intel, Intel(R) UHD Graphics 770 (0xA780) Direct3D11 vs_5_0 ps_5_0, D3D11)"}
Healthy = plat: "Win32", wd: false, WebGL says Intel(R) UHD Graphics 770 … D3D11, vp ≤ screen, and cores/mem/screen match the seed you asked for. If you see the host machine's real OS or GPU, the CDP attach didn't happen and you are on a bare Playwright browser.
Is the session alive? curl -sf -m 2 http://127.0.0.1:9222/json/version >/dev/null && echo alive || echo down
Port already in use — the nastiest failure. Starting a second serve on an occupied port prints ready and looks successful: it probes /json/version, gets the existing browser's answer, and reports it. The new persona never launched and you are silently driving the old profile. Verified — the "new" session still returned the previous persona and its localStorage. This is exactly why §3 gates the start behind a health check instead of just running serve again.
lsof -nP -iTCP:9222 -sTCP:LISTEN # who really owns the port
pkill -f "chromiumfish serve" # stop all sessions
Am I looking at the right typeface? If a page's layout looks wrong, check whether its CSS names a blocked family (§10) before blaming the CSS. Measure rather than trust document.fonts.check(), which lies:
() => { const w=f=>{const s=document.createElement('span');
s.style.cssText='position:absolute;left:-9999px;font-size:100px;white-space:nowrap';
s.style.fontFamily=f?'"'+f+'", monospace':'monospace'; s.textContent='Handgloves 123 WWiill';
document.body.appendChild(s); const x=s.getBoundingClientRect().width; s.remove(); return x; };
const base=w(null);
return Object.fromEntries(['Helvetica Neue','Arial','Menlo'].map(f=>[f, w(f)===base?'FALLBACK':w(f)])); }
14. MCP registration snippet
Requires the default session already running on that port (§3).
{
"mcpServers": {
"chromiumfish": {
"command": "npx",
"args": ["-y", "@playwright/mcp@latest", "--cdp-endpoint", "http://127.0.0.1:9222"]
}
}
}
Add "--allow-unrestricted-file-access" to args if you need file:// navigation (§7).
For several profiles register one entry per port (chromiumfish-a → 9222, chromiumfish-b → 9223). One MCP instance cannot switch ports at runtime — the endpoint is fixed at startup.
15. Limitations and what was not verified
Limitations (observed):
- Every persona is a Windows / Intel UHD 770 desktop (§4). Seeds vary cores, memory and screen; never OS or GPU.
- 198 of 404 locally installed font families are substituted at render time (§10), including most named macOS system faces. Not configurable, not seed-dependent.
- Mobile = narrow viewport only, never true device emulation (§11).
file:// is blocked by @playwright/mcp unless --allow-unrestricted-file-access is passed (§7) — true of bare Playwright too.
--isolated on the Playwright side silently discards the persistent profile.
serve reports success on an occupied port (§13).
serve is a plain process: it dies on reboot, logout or pkill, and nothing restarts it. The health-check-then-start loop in §3 is manual by design.
navigator.languages stays en-US,en regardless of --accept-lang.
serve defaults to headed; forget --headless and a window opens.
- Changing the seed on an existing profile produces a cookie/fingerprint mismatch (§4).
- Canvas/WebGL raster spoofing needs a separate optional Windows render bridge — not bundled, not set up here. Out of the box canvas hashes come from local SwiftShader.
- macOS + Linux builds only; no Windows build.
--no-sandbox is baked into the launcher's BASE_ARGS.
- First run pulls a 136 MiB binary from GitHub Releases; 327 MB unpacked.
Not verified (reasoning or untested, not measured):
- Whether the font block list is literally a name list in the engine, or a side effect of a wider substitution table. The behaviour in §10 is measured; the implementation was not read.
- Whether an installed font whose family name collides with a blocked one (e.g. a custom face named
Futura) renders or is substituted. Only non-colliding names were tested.
- Running
serve under launchd / a supervisor so it survives reboot. No plist was written or tested; §3 documents the manual loop only.
- Level 3 behavioural techniques in §12 — realistic navigation, pacing, profile warming. The persistence they rely on is verified; their effect on a real scoring system is not.
- Level 4 CAPTCHA/Turnstile round-trip against a live vendor. Headful mode and cookie persistence are verified; no challenge was actually solved in testing.
--proxy end-to-end · --grant-permissions actual effect · geolocation · --storage-state.
chromiumfish mcp mode (§16) · the built-in agent loop · chromiumfish flow · the JS/npm SDK · canvas-bridge · Linux/Windows builds.
16. Alternatives (fallbacks only)
chromiumfish mcp — the fork ships its own MCP server (navigate, snapshot, get_text, screenshot, click, type_text, eval_js, run_task; needs the chromiumfish[mcp] extra). Rejected as primary: its snapshot is a hand-rolled CSS-selector list rather than Playwright's ref-based accessibility tree, screenshot is viewport-only with no fullPage, there is no resize, and it launches via launch_agent() which also uses a temp profile — so no persistent logins, and no default session to keep alive. Use only if the CDP bridge breaks.
- Built-in in-browser agent loop (
launch_agent() / run_task) — a perceive-think-act loop requiring an OpenAI-compatible LLM. Not useful for deterministic extraction and recon work, where auditable fixed steps matter and the MCP client is already the brain. It adds an LLM dependency, cost and nondeterminism to replace steps you want to control exactly.
- Own daemon / raw CDP client / one-shot scripts — rejected; nothing to maintain in the chosen path.
- Bare Playwright MCP — no longer a routine option (§1). It stays installed for exactly one job: render-checking local HTML you authored, where ChromiumFish's font substitution would show you the wrong typeface.