Skip to content

Latest commit

 

History

History
757 lines (580 loc) · 43.1 KB

File metadata and controls

757 lines (580 loc) · 43.1 KB

umbra

umbra

Stealth Chrome MCP server for AI agents. Real Chrome • 31/31 sannysoft • 0% creepjs • 77 tools • multi-browser • proxy pools • encrypted sessions • live human handoff over Cloudflare tunnel.

umbra — the darkest part of a shadow, where light is fully blocked.

Built by merging the best parts of obscura 536072b (per-session fingerprint payload) + fantoma 86f20eb (zero-mouse ARIA driver) + stealth-browser-mcp def424d (nodriver + MCP surface) — and filling in their gaps: the Page.enable() injection bug, real-GPU headless via --headless=new + ANGLE Vulkan, dynamic UA-CH version pinning, mDNS-aware WebRTC SDP filter, MCP token-efficient minification, and the _untrusted: true cognitive-separation flag on every page-sourced response.


⚡ at a glance

umbra
bot.sannysoft.com 31 / 31 ✓ (perfect)
creepjs headless 0 % (matches vanilla Chrome)
creepjs stealth 0 %
headless real GPU via --headless=new + ANGLE Vulkan (no SwiftShader tell)
CDP automation tells stripped webdriver cdc_* $cdc_ _phantom _selenium __webdriver_* __nightmare ...
TLS / JA3 / JA4 real Chrome stack + optional curl_cffi for raw HTTP
WebRTC mDNS-aware SDP filter (real-Chrome behavior, no LAN IP leak)
MCP tools 77 — broad primitives + batch flagship + proxy_pool_* (not 95 narrow ones)
token efficiency 75% tokens / 83% bytes saved vs raw output, measured over 79 calls
proxy support pool w/ 5 rotation strategies, CDP auth (any provider), sticky sessions, geo filters
handoff live remote-view via cloudflared Quick Tunnel (works VPS → home laptop)
web search built-in meta-search: auto-dorks × ddg/bing/brave/google → merged, ranked, deduped. no API keys, no searxng server
CDP schema drift resilient — survives Chrome field churn (e.g. dropped sameParty) without hangs

🚀 quick start

git clone https://github.com/GabriWar/umbra.git
cd umbra
pip install -e ".[all]"
python -m umbra.server   # ctrl+c after a few seconds — verify tools register

requirements: Python 3.10+, a Chromium-based browser (Chrome / Chromium / Edge — auto-detected).

extra enables install
(default) core 50 tools, encrypted sessions, proxy pool, tls_fetch (curl_cffi w/ Chrome JA3+JA4) pip install -e .
[markdown] extract_markdown (readability + markdownify) pip install -e ".[markdown]"
[playwright] optional Playwright backend pip install -e ".[playwright]"
[test] pytest + asyncio for regression suite pip install -e ".[test]"
[all] everything above pip install -e ".[all]"

recommended companion: cloudflared — handoff_* exposes a live remote-view of the browser via a Cloudflare Quick Tunnel (no signup, no auth). Without it, handoff falls back to localhost-only.

sudo pacman -S cloudflared       # arch / cachyos
sudo apt install cloudflared     # debian / ubuntu
brew install cloudflared         # macos
# else: github.com/cloudflare/cloudflared/releases/latest

🤖 MCP setup

Wire into Claude Code (or any MCP client w/ the same shape):

claude mcp add-json umbra '{
  "type":"stdio",
  "command":"/full/path/to/your/python",
  "args":["-m","umbra.server"],
  "env":{
    "UMBRA_CONTAINER":"1",
    "PYTHONPATH":"/full/path/to/umbra/src"
  }
}'

Restart Claude Code → /mcp shows umbra w/ 77 tools. For Cursor / Claude Desktop / Cline / others, edit their mcp_servers config with the same shape.


🌐 HTTP API mode

stdio is for one MCP client per process. For remote agents, n8n, OpenAI function-calling, plain curl, or anything that isn't an MCP client — run umbra as an HTTPS server with API-key auth.

# local HTTPS (self-signed cert auto-generated + cached in ~/.cache/umbra/tls/)
UMBRA_API_KEYS="$(openssl rand -hex 32)" \
  uv run umbra-server --transport http --host 127.0.0.1 --port 8765 --tls-self-signed

# prod TLS (use a real cert from caddy/nginx/letsencrypt or pass directly)
UMBRA_API_KEYS=key1,key2 \
  uv run umbra-server --transport http --host 0.0.0.0 --port 443 \
    --tls-cert /etc/ssl/umbra.crt --tls-key /etc/ssl/umbra.key

Endpoints (all gated by X-API-Key: <key> or Authorization: Bearer <key>, except /healthz):

route method purpose
/healthz GET liveness, no auth
/api/tools GET list all 84 tools + JSON schemas
/api/tools/{name} POST call tool, body = {args...}
/api/call POST generic dispatch, body = {"tool":"...","args":{...}}
/mcp POST native streamable-http MCP for proper MCP clients
# discover tools
curl -k -H "x-api-key: $KEY" https://localhost:8765/api/tools | jq '[.tools[].name]'

# call a tool
curl -k -H "x-api-key: $KEY" -X POST https://localhost:8765/api/tools/spawn \
  -H 'content-type: application/json' \
  -d '{"url":"https://example.com"}'

server flags

--transport stdio|sse|http      stdio = MCP only (default), http = REST + MCP + auth
--host 127.0.0.1                bind address (use 0.0.0.0 for LAN)
--port 8765
--path /mcp                     native MCP mount path
--api-key KEY                   repeatable; or set UMBRA_API_KEYS=k1,k2
--no-auth                       disable auth (dev only — bearer leak hazard)
--tls-cert PATH --tls-key PATH  enable HTTPS with your cert
--tls-self-signed               auto-generate + cache a self-signed cert
--idle-timeout 1800             reap tabs idle ≥ this many seconds (0 disables GC)
--gc-interval 60                how often the idle GC runs
--no-orphan-sweep               skip startup chrome cleanup
-v                              verbose logs

lifecycle hygiene

  • idle GC — tabs not touched in --idle-timeout get auto-closed. Browsers with no remaining tabs follow. Manual trigger: call the cleanup_stale tool with idle_seconds.
  • startup orphan sweep — chrome procs from prior umbra-server crashes (matched by --user-data-dir=/tmp/uc_* w/ a parent pid that isn't us) are SIGTERM'd + their profile dirs rmtreed. Skip with --no-orphan-sweep.
  • graceful shutdown — SIGTERM/SIGINT closes every browser + sweeps profile dirs before exit.

TODOs

  • multi-tenancy. Right now all API keys share one global _state — every key sees every browser/tab/proxy/session by id. For multiple users with isolation, we need to:

    • tag every browser/tab/route/hook/handoff/session entry with the calling key (owner_key_id).
    • scope list_browsers / list_tabs / proxy_pool_list / session_list to the caller's namespace.
    • reject cross-tenant tab_id / browser_id references with 403.
    • per-key proxy pools and quota (max tabs, max bandwidth, idle-timeout override).
    • audit log keyed by owner_key_id.

    For now: one server = one trust domain. Run separate umbra-server processes on different ports if you need real isolation.

  • mTLS option (client-cert auth) instead of bearer keys.

  • per-key rate limiting + quotas.

  • CloakBrowser integration ✓ shipped — patched chromium is now the default. See § CloakBrowser. Deferred follow-ons:

    • humanize layer port (bezier mouse curves w/ aim points, per-char typing w/ typos+self-correct, scroll accel/decel). Opt-in on aria_click/aria_type/click_at/scroll/drag. Works on stock chromium too.
    • geoip-from-proxy → lookup proxy exit IP, derive timezone+locale, apply via CDP Emulation.setTimezoneOverride + setLocaleOverride. Opt-in geoip: true on spawn.
    • deterministic fingerprint seed — spawn(fingerprint_seed='abc') → seedable PRNG for rotate_fingerprint reproducibility.
    • storage quota normalization via CDP Storage.overrideQuotaForOrigin.
    • WebRTC IP override via CDP — covered by cloak's native patch when cloak is active; CDP fallback only useful on stock.

🎯 recipes

batch ⭐ flagship — N tools in one MCP round-trip

batch([
  {"tool": "navigate",        "args": {"tab_id": "t0", "url": "https://news.ycombinator.com"}},
  {"tool": "wait_for_text",   "args": {"tab_id": "t0", "text": "Hacker News"}},
  {"tool": "aria_snapshot",   "args": {"tab_id": "t0"}},
  {"tool": "extract_links",   "args": {"tab_id": "t0", "limit": 30}},
  {"tool": "extract_markdown","args": {"tab_id": "t0"}},
])
# → {"results":[...5 entries with ok/data/ms each...],
#    "elapsed_ms":1840, "ok_count":5, "fail_count":0}

Serial in declared order, single MCP round-trip. Saves protocol framing per call AND composes with cross-call dedup (identical re-calls inside the batch return _unchanged_since instead of full payloads). Use it whenever you have ≥2 calls in mind — it's almost always the right choice.

stop_on_error=True short-circuits on first failure (default: keep going + report fail_count).

proxy_pool_* — multi-provider rotation, any provider, any format

5 rotation strategies, rolling health, geo + tag filters, sticky sessions. Plugs into spawn(use_proxy_pool=True) — picks one entry per browser process (Chrome locks proxy per-process; for parallel distinct egress IPs use multiple browser_ids).

input formats — auto-detected, mix-and-match in same load:

# standard URL (auth optional, scheme optional, defaults to http://)
http://user:pass@gateway.provider.com:8080
socks5://1.2.3.4:1080

# provider IP-list export (host:port:user:pass — webshare, IPRoyal, Decodo, ...)
31.59.20.176:6754:user:pass

# sticky-session gateway (one URL, N session-suffixed users)
gw.bright.com:22225:user-session-abc123-country-US:pass

# inline metadata for filtering
http://gw.proxy.com:8080#country=US,tags=residential|sticky

rotation strategies — round_robin (default), random, least_used, best_health, sticky_browser (same browser_id always gets same entry).

creds-stripped flag + CDP auth — Chrome's --proxy-server= silently strips inline creds; umbra feeds Chrome a creds-free URL and answers proxy auth challenges via CDP Fetch.authRequired. Works for HTTP / HTTPS proxies w/ Basic auth — Bright Data, Oxylabs, Smartproxy/Decodo, IPRoyal, SOAX, NetNut, Webshare, ProxyMesh, etc.

SOCKS5 + auth caveat — Chromium has no support for SOCKS5 username/password auth (RFC 1929) — open since 2014, effectively wontfix. CDP Fetch.authRequired is HTTP-layer only; SOCKS5 auth is a TCP-subnegotiation that completes BEFORE any HTTP fires, so the Fetch domain never sees it. Workaround matrix:

transport auth works in umbra?
http://host:port none ✓
http://user:pass@host:port basic ✓ (via CDP)
socks5://host:port none ✓
socks5://user:pass@host:port RFC 1929 ✗ — unfixable in chrome

If u need SOCKS5 + auth, run a local HTTP→SOCKS5 forwarder (gost -L=http://:8080 -F=socks5://user:pass@upstream) and point umbra at the local HTTP port instead.

# MCP usage
proxy_pool_load(data="/path/to/proxies.txt", rotation="round_robin")
proxy_pool_health_check(timeout_s=8.0, parallel=8)   # parallel probe, updates rolling health

spawn(url="https://target.com", browser_id="us-1",
      use_proxy_pool=True, proxy_country="US", proxy_tag="residential")
# → {"tab_id":"t0", "proxy":{"id":"a3b1...", "host":"http://1.2.3.4:8080",
#                            "country":"US", "tags":["residential"], "health":1.0}}

proxy_pool_remove("a3b1...")   # bad rep? drop it

7 MCP tools: proxy_pool_load, proxy_pool_add, proxy_pool_remove, proxy_pool_clear, proxy_pool_list, proxy_pool_health_check, proxy_pool_export.

route through Tor (free, multi-exit, no provider)

Tor's SOCKS5 supports stream isolation — different SOCKS user/pass = different circuit = different exit IP. One tor daemon, N distinct exits, zero provider cost:

sudo systemctl enable --now tor   # binds 127.0.0.1:9050
# ~/.umbra/proxies.txt — each line = one isolated circuit (user/pass arbitrary)
socks5://circ1:x@127.0.0.1:9050#tags=tor
socks5://circ2:x@127.0.0.1:9050#tags=tor
socks5://circ3:x@127.0.0.1:9050#tags=tor
socks5://circ4:x@127.0.0.1:9050#tags=tor
socks5://circ5:x@127.0.0.1:9050#tags=tor
proxy_pool_load(data="~/.umbra/proxies.txt")
spawn(use_proxy_pool=True, proxy_tag="tor")

caveats — Tor exits are publicly listed (check.torproject.org/exit-addresses); most anti-bot stacks (Cloudflare Bot Mgmt, DataDome, Akamai, PerimeterX) blocklist them. Useful for archive sites / IP-leak testing / gov forms. Useless against hardened scraping targets. Slow: ~3–10s per first req per circuit, 1–3s after warm. Pin exit country via ExitNodes {us} in /etc/tor/torrc + systemctl reload tor.

handoff_start — captcha / 2FA wall? hand the wheel back

agent → handoff_start("t0", "solve recaptcha")
         → returns https://random.trycloudflare.com/h-XYZ/
agent → tells user: "open this URL"
user  → opens URL on phone/laptop, sees live page, clicks/types
user  → hits "I'M DONE"
agent → handoff_wait("t0")  blocks until done, returns post-handoff URL+title
agent → continues automation

Built on Cloudflare Quick Tunnels (no signup, instant). URL contains a 192-bit auth token in the path → URL knowledge = auth. Forces HTTP/2 for sustained WebSocket reliability.

extract_markdown — page → clean markdown (firecrawl-style)

extract_markdown('t0')
# → {"_untrusted": True,
#    "title": "Web Scraping - Wikipedia",
#    "markdown": "# Web Scraping\n\nMethod of extracting data...",
#    "source_html_len": 87432}

Mozilla Readability + markdownify. Falls back to <body> for list pages (HN, reddit) where readability gives up.

session_save / session_load — log in once, skip auth forever

session_save('t0', 'github-me', passphrase='hunter2')
# → encrypted blob in ~/.local/share/umbra/sessions/github.com/github-me.fern

# Next time:
session_load('t0', 'github-me', passphrase='hunter2')
# → cookies + localStorage injected, you're logged in

Fernet (AES-128-CBC + HMAC-SHA256) + PBKDF2-HMAC-SHA256 200k iterations. Per-(domain, name) namespace, path-traversal-safe.

tls_fetch — skip the DOM entirely for JSON APIs

tls_fetch('https://api.example.com/users')
# → {"status": 200, "body": "{...}"}

curl_cffi pinned to running Chrome version — JA3+JA4+HTTP/2 SETTINGS frames match Chrome exactly. ~50ms vs ~500ms via spawn+navigate.

web_search — meta-search with dorks, no API keys

web_search('thinkpad x230 coreboot firmware')
# → {"intent": "firmware",
#    "dorks": ["thinkpad x230 coreboot firmware (site:github.com OR site:gitlab.com ...) -amazon.com -ebay.com",
#              "thinkpad x230 coreboot firmware filetype:bin ...", ..., "thinkpad x230 coreboot firmware"],
#    "engines": {"duckduckgo": 10, "bing": 20, "brave": 28},
#    "results": [{"url": "https://github.com/0xbb/coreboot-x230", "title": "...", "snippet": "...",
#                 "score": 3.47, "engines": ["bing","brave","duckduckgo"], "dork": "..."}, ...]}

web_search('fastmcp tool decorator', engines=['google', 'bing'], tab_id='t0')   # google via a live stealth tab
web_search('rust async runtime', dorks=['rust async runtime site:docs.rs', 'tokio vs async-std'])  # your own dorks
web_search('ubuntu 24.04 release', time_range='month', blocked_domains=['reddit.com'], max_results=5)

A port of the useful half of searxng — the engines and the result aggregation — plus a dork layer:

  1. dorks — intent classified from keywords (code/docs/pdf/dataset/forum/news/firmware) → site: / filetype: / inurl: / intitle: variants + the raw query as baseline. Pass dorks=[...] to supply your own (you're the LLM), auto_dork=False for a plain query.
  2. engines — duckduckgo + bing + brave scraped over curl_cffi (Chrome JA3); google driven through a live umbra tab (pass tab_id; paced 1.5–3s between queries — ~5 back-to-back trips /sorry/, then handoff_start). Each engine only receives the dorks it honors (ddg lite 202s on filetype:/inurl:), and hits that violate a dork's operators are dropped — engines silently ignore operators and return generic junk otherwise.
  3. merge — searxng-style: normalize URL (www., trailing /, fragment), score = Σ over every (dork, engine) list the URL appears in of specificity × dork_order × engine_weight / position, junk domains dropped, per-host cap, allowed_domains / blocked_domains.

Proxies: HTTP engines egress through proxy= or, after proxy_pool_load, a pool entry (default; proxy_country/proxy_tag filter). Google uses its tab's proxy.

Env: BRAVE_API_KEY → brave via the official API (no 429s, 2k/mo free). UMBRA_SEARXNG_URL → adds a searxng engine if you run one anyway.

multi-browser orchestration

spawn(url='...', browser_id='alice')
spawn(url='...', browser_id='bob')
# alice and bob have fully isolated cookies, profiles, identities
list_browsers()
# → [{"browser_id":"alice","tab_count":3}, {"browser_id":"bob","tab_count":1}]

request interception graph (route_* + HAR record/replay)

Match DSL: url_pattern, url_regex, method, resource_type, header_match, status_min/max. Actions: block (14 custom error_reasons; response-stage block synthesizes 5xx via fulfill), fulfill (status+headers+body|body_b64), continue (request rewrite: new_url/new_method/new_post_data/headers — headers MERGED w/ originals, not replaced), modify (response-stage getResponseBody → body_replace=[[regex,repl],...] or outright body/status/headers override), tee (pure spy: pass-through + capture body), redirect (synth 302 + Location). Per-rule delay_ms (latency injection), times (auto-disable after N hits), priority (higher fires first), capture (cross-stage body buffer for any action), enabled (pause without remove). HAR-1.2 record/replay (loose URL-only mode for query-string drift). Tracker/resource blocking from StealthOptions(block_trackers=, block_resources=) integrated into the same engine — single Fetch handler, no double-fire race. Engine: src/umbra/driver/intercept.py.


🧰 the 77 tools

                  ┌─ browser            spawn / close / list_browsers / close_browser /
                  │                     navigate / list_tabs / switch_tab / back / forward /
                  │                     reload / kill_all
                  │
                  ├─ ARIA               aria_snapshot / aria_click / aria_type / find_by_text
                  │  (zero mouse)       fill_form / current_state
                  │
                  ├─ input (CDP)        click_at / press_key / scroll / drag / hover /
                  │  humanized          paste_text / select_option / wait_for / wait_for_text
                  │
                  ├─ extraction         extract_text / extract_links / grep_text / dom_query /
                  │  _untrusted=true    inspect_element / extract_markdown / clone_element
                  │
                  ├─ visual             screenshot / screenshot_region
                  │
                  ├─ JS                 evaluate / inject_css
                  │
                  ├─ devtools           get_console_logs / get_network_requests / clear_logs /
                  │                     get_response_body / memory_metrics / get_cookies /
                  │                     set_cookies / clear_cookies
                  │
                  ├─ stealth ops        check_detection / warm_session / rotate_fingerprint /
                  │                     set_verbosity
                  │
                  ├─ network ctrl       block_urls / set_extra_headers / set_viewport /
                  │                     dynamic_hook
                  │
                  ├─ interception       route_add / route_add_many / route_remove /
                  │  (full graph)       route_set_enabled / route_block_set / route_list /
                  │                     route_captures / har_record_start / har_record_stop /
                  │                     har_dump / har_clear / har_replay_load
                  │
                  ├─ proxy pool ⭐      proxy_pool_load / proxy_pool_add / proxy_pool_remove /
                  │  multi-provider     proxy_pool_clear / proxy_pool_list /
                  │                     proxy_pool_health_check / proxy_pool_export
                  │
                  ├─ handoff            handoff_start / handoff_wait / request_user_input
                  │  (cloudflared)
                  │
                  ├─ sessions           session_save / session_load / session_list /
                  │  (encrypted)        session_delete
                  │
                  ├─ files              upload_file / setup_downloads / wait_for_download
                  │
                  ├─ TLS                tls_fetch  (raw HTTP w/ Chrome JA3+JA4)
                  │
                  ├─ search             web_search  (dorks × ddg/bing/brave/google → ranked)
                  │
                  └─ batch ⭐ flagship  batch  (N tools in one round-trip; composes w/ dedup)

🐍 use as a python library

import asyncio
from umbra import stealth_browser

async def main():
    async with stealth_browser(timezone="America/New_York", block_trackers=True) as b:
        tab = await b.new_tab("https://news.ycombinator.com")
        await asyncio.sleep(2)
        await tab.save_screenshot("hn.png")

asyncio.run(main())
# ARIA driver — zero mouse coords
from umbra import stealth_browser, AriaDriver

async with stealth_browser() as b:
    tab = await b.new_tab("https://github.com/login")
    drv = AriaDriver(tab)
    await drv.snapshot()
    print(drv.render_tree())
    # [0] textbox "Login or email"
    # [1] textbox "Password"
    # [2] button "Sign in"
    await drv.type(0, "me@example.com")
    await drv.type(1, "...")
    await drv.click(2)
# Proxy pool — parallel browsers w/ distinct egress IPs
from umbra import StealthBrowser, StealthOptions
from umbra.proxypool import ProxyPool

pool = ProxyPool(rotation="round_robin")
pool.load_lines(open("proxies.txt").read())   # or load_json / load_csv / load_file

async def main():
    for i in range(3):
        b = StealthBrowser(StealthOptions(proxy_pool=pool))
        b._pool_browser_id = f"scraper-{i}"
        async with b:
            tab = await b.new_tab("https://api.ipify.org")
            print(await tab.evaluate("document.body.innerText"))

🔬 token efficiency

Counter-intuitively, umbra costs LESS context than minimal browser-MCPs (incl. playwright-mcp) on any real agent session — its 77-tool catalog adds ~13KB upfront, but per-call savings recover that within 3 calls and dominate after.

measured (79-call e2e session against real Chrome + httpbin, all 77 tools exercised, see tests/test_token_audit.py):

uncompressed (set_verbosity='full') compressed (default) saved
total tokens (cl100k_base) 136,285 33,357 75 %
total bytes (JSON) 420,648 71,282 83 %
median per call — 14 tokens / 4 ms —

Top per-tool wins: clone_element 97 %, dom_query 52 %, tls_fetch 40 %, proxy_pool_export 45 %. The handful of zero-save tools (screenshot*, aria_snapshot, inspect_element) either ship base64 binaries (incompressible) or are already pre-RLE'd in the driver before _compact sees them.

how the savings happen

Every MCP tool response goes through _compact():

  • drops None only — empty [] / "" / 0 / False KEPT (they're informative)
  • columnar layout for 4+ homogeneous-dict arrays: {"_columnar":true,"keys":[...],"rows":[[...]]} — 44% smaller on real dom_query/cookies/network
  • constant-column hoist — shared values factored to _constant: {col: val}
  • word-boundary string truncation w/ explicit ...[+Nc, raise max_str to see full] marker
  • list truncation w/ {_truncated, shown, total, more_via} marker — caller sees what was cut and how to lift the cap
  • _untrusted: true flag (8 bytes) instead of wrapping content in <external>...</external> tags
  • cross-call dedup ledger — identical repeat calls return {"_unchanged_since": "cN", "_hash": "..."} instead of full payload (force_refresh=True to bypass)
  • ARIA pattern grouping (RLE) — long lists w/ repeating (role,name) cycles collapse to a single [range] cycle×N (period P): ... line. Real HN comments page: ~70% smaller snapshot.
  • URL footnoting in extract_links — repeated hosts factored to _hosts: {h1: "https://..."} then referenced. ~50% smaller on link-heavy pages.

Toggle off via set_verbosity('full') when you need raw byte-exact output. Lossless: zero failures, zero inflations across all 79 audit calls.


⚔️ vs claude-in-chrome

Same task, same values, same prompt, same model (sonnet subagent with zero prior context and a call budget), two different toolsets. Each agent kept a verbatim call log; the numbers below are from the transcripts, not the agents' self-reports.

React form — demoqa.com/automation-practice-form: react-select ×3, react-datepicker, file upload, confirmation modal.

umbra claude-in-chrome
browser tool calls 12 44
agent tokens 50k 86k
wall time 63 s 233 s
screenshots 0 13 (892 KB base64)
fields that landed 10 / 10 9 / 10
"success" reported, nothing happened 0 3

jQuery form — demo.automationtesting.in/Register.html: select2, jQuery-UI multiselect with no ARIA role, 3-part date, password confirm.

umbra claude-in-chrome
browser tool calls 27 47
agent tokens 78k 87k
screenshots 6 11
fields that landed 13 / 13 13 / 13
clicks that missed their target 1 4

(Both were then blocked by the same page bug — a required <select> whose only option is the placeholder. Both detected it; neither forced through.)

why the gap

It is not speed. It is whether the tool's return value can be trusted.

claude-in-chrome's computer(left_click, ref) on a React radio returned "Clicked on element ref_139" — the radio stayed empty. form_input(ref, true) on a checkbox returned "Checkbox checked (previous: false)", every screenshot afterwards showed it ticked, and the submitted form had no hobbies at all: the DOM attribute flipped, React's state never did. Nothing short of reading the final confirmation table could catch that one.

So the agent did the only rational thing — "I was forced into a screenshot-after-every-action discipline" — and switched to pixel coordinates for anything React-flavoured. On the jQuery page the layout shifted a few pixels while the footer loaded, and the same click missed three times in a row on stale coordinates, then missed Submit the same way. "Clicked at (722, 705)" — reported as success.

umbra's set_fields writes through the native prototype setter and fires click/input/change, which is what React's synthetic event system listens for — the same checkboxes landed. Its reply is now: {…} — what the field holds, not what the tool attempted. aria_click acts on element identity, so layout drift is irrelevant, and it reports navigated: true, url: … when the click took you somewhere. combo_select opens, filters and picks in one call with no window for the menu to unmount between steps. The agent verified once, at the end, against the modal.

what claude-in-chrome does that umbra does not

Be fair about it:

  • drives your real Chrome, with your real logged-in session — the structural difference. umbra spawns an isolated Chrome. (spawn(user_data_dir=…) now launches on an existing profile, which closes most of the gap, but your own Chrome has to be closed first.)
  • remote browsers — list_connected_browsers / switch_browser drive a Chrome on another machine on the same account.
  • gif_creator — records the session with click indicators and action labels.
  • find by purpose — natural-language element lookup ("the add-to-cart button"). umbra's find_by_text matches literal text; unlabelled widgets are instead named in the aria_snapshot(fields=True) footer.
  • shortcuts_* — user-defined workflows in the extension.

What umbra has that it does not: route interception & mocking, HAR record/replay, proxy pool, stealth/fingerprint/cloak, tls_fetch, session save/load, set_fields, combo_select, human handoff.

on method

umbra's numbers are after the form-filling work in bd4f561. Before it, the same React form cost an umbra agent 59 calls — worse than claude-in-chrome's 44. The comparison measures tool design, not the model, and the fix was found by exactly this loop: give a context-free agent the task, read its call log, fix what it tripped on, repeat. 59 → 27 → 18 → 12.


🥷 CloakBrowser (default chromium)

spawn defaults to chromium="cloak" — when the CloakBrowser patched chromium build is installed under ~/.umbra/cloak/<tag>/, every browser uses it. Cloak ships 49-57 C++ source patches against canvas, WebGL, audio, font, GPU, WebRTC, screen, and timing fingerprint surfaces. Native patches beat JS shims because detectors check the underlying API surface, not just property values — so umbra auto-downgrades its own stealth_mode to minimal (automation-tell cleanup only) when cloak is active, to avoid double-fingerprinting.

setup (one-time)

Cloak is not bundled (license: free use, no redistribute) and is not silently auto-downloaded. Install once:

python -m umbra --setup           # download + verify + cache
python -m umbra --setup --force   # re-download
python -m umbra --setup --tag <t> # pin a specific release
python -m umbra --status          # show install state (no network)
python -m umbra --uninstall       # wipe ~/.umbra/cloak/

First spawn() with cloak missing on an interactive TTY prompts to install. Non-TTY (MCP/HTTP server, CI, scripts) silently falls back to stock chromium with a one-line warning — spawn never hangs on input. A .declined marker is written if the user says no, suppressing future prompts; delete ~/.umbra/cloak/.declined to re-enable.

MCP tools: cloak_status(), cloak_install(force=False, tag=None).

opt-out

# per-spawn:
spawn(chromium="stock")          # MCP / python
# globally:
export UMBRA_NO_CLOAK=1          # kill-switch — every spawn uses stock
# or point at your own build:
export UMBRA_CLOAK_BINARY=/path/to/chrome

platforms

platform cloak build umbra behavior
linux x64 ✓ auto
linux arm64 ✓ auto
windows x64 ✓ auto
darwin arm64 ✓ (separate tag) auto
darwin x64 ✗ fall back to stock + warn
windows arm64 ✗ fall back to stock + warn

GitHub anon API rate limit is 60/h — set GITHUB_TOKEN to lift it. Manifest cached 24h.

measured impact (2026-05, linux-x64, headless)

Public aggregate detectors do not visibly shift with cloak — they mostly probe the surfaces JS shims already cover (navigator.webdriver, basic canvas hash, automation flags). The C++ patches harden deeper surfaces those aggregates don't score:

signal stock + JS shim + cloak note
creepjs headless % ≤5 ≤5 unchanged — aggregate baseline
creepjs stealth % ≤5 ≤5 unchanged
sannysoft pass 30+/31 29/31 WebGL Vendor/Renderer now report "no webgl context" — cloak strips the uniquely-identifying GPU strings on purpose (intentional surface cut, not a regression)
automation tells 0 0 unchanged
UA-CH brands.Chromium version matches UA matches UA ✓ fixed — cloak's internal UA-CH stub clobbered our Network.setUserAgentOverride, so the minimal payload now re-asserts navigator.userAgentData via defineProperty from the Python-side detected Chrome version. Only injected under cloak.

Where cloak actually helps (not aggregate-scored by the public detectors): canvas/audio per-pixel noise patterns, font enumeration consistency, exact GPU info strings, screen geometry edge cases, WebRTC IP leak at the C++ level, timing-API quantization. If your adversary fingerprints those specifically (FingerprintJS Pro, sift, akamai bot manager), cloak shifts the needle in ways sannysoft/creepjs summaries won't show.

license note (read this)

CloakBrowser's binary license permits free personal and commercial use but forbids redistribution. umbra never bundles the binary — it always pulls from upstream releases on your machine. Don't repackage ~/.umbra/cloak into your own product or container image you ship to third parties; the auto-download flow exists exactly so each user fetches their own copy. See BINARY-LICENSE.md upstream for the exact terms.


🛡️ stealth coverage matrix

detection vector obscura fantoma sb-mcp umbra
canvas / audio / WebGL fp ✓ partial ✗ ✓ (per-session noise, deterministic w/in session)
navigator.webdriver ✓ ✓ ✓ ✓
cdc_* / _phantom / _selenium ✗ n/a ✓ (nodriver) ✓ delete-only (no in operator tell)
event.isTrusted ✗ ✓ (no synth events) ✗ ✓ (CDP Input.dispatch* only)
mouse / scroll behavioral fp n/a ✓ ✗ ✓ (ARIA driver default)
keystroke timing fp n/a ✓ key-pair ✗ flat 50ms ✓ key-pair + log-normal jitter
Cloudflare turnstile (passive) ✗ partial ✓ ✓ (real Chrome)
TLS / JA3 / JA4 ✗ ✗ ✓ (real Chrome) ✓ + tls_fetch for raw HTTP
WebGL real GPU in headless ✗ no GL ✗ ✗ SwiftShader ✓ ANGLE Vulkan
WebRTC outgoing SDP host partial ✗ ✗ ✓ (mDNS-aware filter, real-Chrome behavior)
UA-CH version mismatch ✗ ✗ ✗ ✓ (dynamic Chrome version + setUserAgentOverride)
iframe + shadow DOM piercing ✗ ✓ ✗ ✓
tracker/fp-script blocking ✓ (3520) ✗ ✗ ✓ (3520 + dynamic hooks)
session warming (cookie age) ✗ ✗ ✗ ✓ (4 profiles)
live human handoff ✗ ✗ ✗ ✓ (cloudflared tunnel)
proxy auth (CDP, any provider) ✗ ✗ ✗ ✓ + 5-strategy rotation pool
MCP tool surface ✗ ✗ ✓ (95 narrow) ✓ (77 broad)
prompt-injection signaling ✗ ✗ ✗ ✓ (_untrusted: true on all extraction)

🩹 CDP schema resilience

nodriver's CDP parser hardcodes Chrome protocol field names — when Chrome changes the schema between releases, the parser KeyErrors. Worse, the listener task dies on the unhandled raise → every subsequent CDP call on that tab hangs forever (no awaiter ever wakes up).

umbra ships three monkey-patches in umbra/nodriver_patch.py to make this class of bug impossible:

  1. Transaction.__call__ — every parser exception becomes future.set_exception(...) so the awaiter gets a real error, never a hang.
  2. Connection._listener — wraps the per-message dispatch so a single bad parse can't kill the listener task; future calls keep working.
  3. Cookie.from_json — tolerant of Chrome 146+ dropping sameParty (matches the pattern already used in CookieParam.from_json; upstream inconsistency).

Patches are idempotent (per-class flag + module-level short-circuit, safe to call N times) and partial-failure tolerant (each patch runs in its own try/except — one failing doesn't block the others). Applied automatically at umbra.browser import — zero config.


🏗️ architecture

                  ┌────────────────────────────────────────────┐
                  │  FastMCP server  (umbra.server, 77 tools)  │
                  │  + _compact() minification                 │
                  │  + _untrusted prompt-injection signaling   │
                  │  + cross-call dedup ledger                 │
                  └────────────────────────────────────────────┘
                                       │
       ┌────────────┬──────────────────┼──────────────┬──────────────┐
       ▼            ▼                  ▼              ▼              ▼
  ┌─────────┐  ┌──────────┐      ┌────────────┐  ┌──────────┐  ┌──────────┐
  │ Browser │  │  Drivers │      │  Stealth   │  │  Proxy   │  │  Misc    │
  │  multi  │  │  ARIA    │      │  payload   │  │  pool    │  │  session │
  │  inst.  │  │  CDP     │      │  3520 list │  │  CDP auth│  │  handoff │
  └────┬────┘  │ humanizer│      │  detection │  │  rotation│  │    tls   │
       │       │ intercept│      └─────┬──────┘  └────┬─────┘  └──────────┘
       │       └────┬─────┘            │              │
       ▼            ▼                  ▼              ▼
  ┌──────────────────────────────────────────────────────────────────────┐
  │nodriver (real Chrome via CDP) + Page.addScriptToEvaluateOnNewDocument│
  │  --headless=new + --use-angle=vulkan + dynamic UA-CH version pinning │
  └──────────────────────────────────────────────────────────────────────┘
                                       │
                                       ▼
                              ┌─────────────────┐
                              │   real Chrome   │
                              │  146.0.7680.x   │
                              └─────────────────┘

🧪 regression tests

pip install -e ".[test]"
pytest -m e2e -v -s                                      # full e2e
.venv/bin/python tests/test_token_audit.py               # token efficiency audit (79 calls)
UMBRA_PROXY_LIST=/path/to/proxies.txt \                  # opt-in: also exercise proxy pool
  .venv/bin/python tests/test_token_audit.py

Covers bot.sannysoft.com + creepjs + UA-CH consistency + automation-tell checks + (when enabled) end-to-end proxy pool spawn/auth/rotation. Catches drift if Chrome / nodriver update breaks something.


🛠️ roadmap

distribution

  • Submit to Smithery.ai registry — add smithery.yaml + tag a release. Auto-indexes for Claude Desktop / Cursor / Cline users.
  • Add .claude-plugin/plugin.json for Claude Code's plugin marketplace.
  • Submit to Anthropic's official marketplace via claude.ai/settings/plugins/submit.

features

  • Proxy pool rotation — shipped. ProxyPool w/ 5 rotation strategies, rolling health, geo + tag filters, sticky sessions, multi-format loaders (URL, host:port:user:pass, sticky-session gateway, JSON, CSV). CDP Fetch.authRequired handler so creds work on any provider despite Chrome's flag stripping. 7 MCP tools.
  • Full request interception graph — shipped as route_* + har_* (see recipes section).
  • Battle-test the ARIA tree on edge cases — fantoma-derived snapshot covers the 95% case (forms, lists, dialogs, nav) but real-world weirdness still exposes gaps: shadow-DOM-inside-iframe-inside-shadow-DOM, custom elements w/ delegated focus, <canvas>-rendered "trees" (Figma/Notion), virtual-scroll lists where ARIA indexes shift mid-snapshot, aria-owns cross-references, RTL/i18n role inflections. Need a regression corpus (gmail, github, notion, figma, linear, jira, gov forms) + property-based tests.
  • Network API ergonomics — current route_add(...) is declarative; Playwright's route(pattern, async (route, request) => {...}) is callback-based. Add route_handler(tab_id, pattern, js_handler_src) that lets the caller register a JS expression evaluated per paused request — returns {action: 'fulfill'|'continue'|...} per-call. Tradeoffs: sandbox the JS, network round-trip per request (slow), but unbeatable for "fulfill only if request body contains X" / "rewrite based on prior response" / dynamic decisions.
  • HAR tooling polish — current HAR record/replay is HAR-1.2 byte-exact + loose URL-only fallback. Add per-entry matchers (matchUrl(regex), matchPostData(json_path), matchHeaders(...) for query-drift / session-token tolerance), body morphing (updateContent(transform) to mutate a recorded body before serving), strict vs fallback modes, HAR sanitization (strip Authorization/Cookie/Set-Cookie/PII before commit). Unlocks committing HAR fixtures to test repos without leaking secrets.
  • Per-browser exit-node selection via Tailscale — userspace tailscaled per-browser w/ distinct exit nodes for self-hosted residential proxy farms (alternative to paid providers).

📜 license

MIT + Attribution Requirement. Free for any use (commercial, research, hobby) — but if you ship it in a product or publish research using it, please credit:

Powered by [umbra](https://github.com/GabriWar/umbra) by Gabriel Duarte Guerra.

(in your README, docs, about page, or paper acknowledgements — anywhere a human reading your project can see it).

Third-party attributions in LICENSE:

  • stealth/payload.js patterns from h4ckf0r0day/obscura @ 536072b (Apache-2.0)
  • stealth/tracker_domains.txt from obscura (Peter Lowe ad/tracker host file)
  • driver/aria.py + humanizer.py patterns from Huzy85/fantoma @ 86f20eb (MIT)
  • MCP tool surface convention from vibheksoni/stealth-browser-mcp @ def424d (MIT)