Stealth browsing, Tor and I2P for Hermes Agent, with a small multi-engine router that decides which network exit each browser tab uses.
- What it is
- Use cases
- Architecture
- Requirements
- Installation (step by step)
- Tutorial
- Plugins reference
- Router reference
- Configuration
- Tests and CI
- Troubleshooting
- Security model and limits
- Updating
- Google search via 4get
- Changelog
- Repository layout
- License and credits
The stack gives Hermes two complementary ways to browse.
1. Fast readers. Seven Hermes plugins that fetch a page and return text. Simple, quick, no interaction. See Plugins reference.
2. Interactive browsers. Three camofox-browser instances, each with a different network exit, behind a FastAPI router. They support clicks, form filling, JavaScript execution and persistent sessions, through Hermes' browser_* tools.
| URL prefix | Engine | Exit |
|---|---|---|
vps+ or none (new tab) |
camofox |
Host IP |
tor+ |
camofox-tor (optional: browser-tor-interactive) |
Tor exit node |
i2p+ |
camofox-i2p |
I2P network |
*.onion |
camofox-tor (automatic; optional: browser-tor-interactive) |
Tor |
*.i2p |
camofox-i2p (automatic) |
I2P |
| Task | Tool |
|---|---|
| Read a simple page | browser_http |
| Read a JS page | browser_clear |
| Read a bot-protected site | browser_camoufox |
| Read anonymously | browser_tor |
Read a .i2p site |
browser_i2p |
| Web search | searxng_search |
| Cloudflare-protected page | solverr_fetch |
| Fill a form or log in | browser_navigate + browser_type |
| Navigate and interact over Tor | browser_navigate with tor+https://... |
Visit a .onion |
browser_navigate with http://xxx.onion or tor+http://xxx.onion |
Decision rule: if you only need to read, use a plugin. If you need to interact (click, form, login), use browser_navigate and pick the exit with the URL prefix.
Five scenarios this stack is designed for. Each one lists the tools, why the stack fits, and what to watch out for.
Scenario: you need the text of a page that returns 403 to plain HTTP clients or flags headless Chromium.
Tools: browser_camoufox (stealth Firefox, humanize) as first try; browser_clear (stealth Chromium) as fallback; browser_http for the easy cases.
Why this stack: the three readers use different fingerprinting surfaces, so you can try them in order without changing client. The Tutorial shows the fallback chain: browser_http β browser_clear β browser_camoufox, cost in latency increasing with each step.
Watch out: none of these are Tor Browser and none give anonymity guarantees. browser_camoufox rotates the fingerprint, which is different from Tor Browser's uniform fingerprint. For real anonymity, use the Tor path (case 2).
Scenario: you need to open a page and read its contents through the Tor network, possibly refreshing the circuit between requests (default engine).
Tools: browser_tor (reader) for one-shot anonymous reads; browser_navigate with tor+https://... for interactive sessions; camofox-tor under the hood.
Why this stack: by default browser_tor sends a NEWNYM to the shared tor-proxy before every request and returns circuit_renewed in the response. camofox-tor is a full stealth browser with block_webrtc and remote DNS, so it can be used to log in (not just read). For the official Tor Browser (uniform fingerprint, JavaScript off) use the optional engines from ai-stack v3.7: browser-tor-real for browser_tor (BROWSER_TOR_URL=http://browser-tor-real:8081 in Hermes' environment) and browser-tor-interactive for tor+ (CAMOFOX_TOR_URL=http://browser-tor-interactive:9377 in this repo's .env). browser-tor-interactive is behind the hermes profile in ai-stack (COMPOSE_PROFILES=hermes).
Watch out: with the default engines NEWNYM is global for tor-proxy: if you have an ongoing camofox-tor session, a browser_tor call can change its exit node mid-session and invalidate site sessions. Use one Tor path per task. The official Tor Browser engines send no NEWNYM, and with JavaScript off, pages that render with JS (many sign-up forms) look empty in the snapshot. Snowflake needs 10-30 s after a reboot to reconnect.
Scenario: you have a .onion or .i2p URL and want to read it without choosing the engine.
Tools: browser_navigate with the plain URL. The router routes .onion to Tor and .i2p to I2P automatically.
Why this stack: no prefix needed, no extra configuration. browser_i2p (reader) is available for one-shot I2P reads. .i2p sites can take 10-60 s on the first request because the tunnel has to be built.
Watch out: I2P has no outproxy in the default configuration, so .i2p sites work but clearnet sites do not go through I2P. Tor .onion sites work regardless of the exit IP; the exit node only matters for clearnet.
Scenario: you need to log into a site, and you can choose whether the session should exit from the host IP or from Tor.
Tools: browser_navigate + browser_snapshot + browser_type + browser_click. Prefix tor+ to route the whole session over Tor, vps+ or no prefix for the host IP.
Why this stack: each engine has an isolated browser context, so cookies from a tor+ session never leak into a vps+ session. You can run several tasks in parallel on different engines under the same userId. The router keeps one public tab id per task and routes actions to the active engine.
Watch out: browser_type does not fire React's onChange on some modern forms. Use browser_console with the native value setter (Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set) as a workaround. Keep the number of tabs per engine modest and raise Hermes' browser.inactivity_timeout for long multi-step flows.
Scenario: the target site shows a Cloudflare interstitial or a Turnstile challenge, and plain reads return the challenge page.
Tools: solverr_fetch for one-shot reads; browser_clear (Chromium with challenge handling) if you need to stay in the browser.
Why this stack: solverr_fetch calls a FlareSolverr fork that solves most challenges transparently. browser_clear is stealth Chromium that falls back to Solverr when it detects a challenge. Both share the URL validation rules of the rest of the stack, so they cannot be pointed at internal services.
Watch out: no solver works on every challenge. If both fail, use the interactive browser (browser_navigate + browser_camoufox or browser_clear) and solve the challenge manually via browser_console.
flowchart LR
H[Hermes Agent] -->|CAMOFOX_URL| R[camofox-router :9378]
R -->|vps| V[camofox]
R -->|tor| T[camofox-tor]
R -->|i2p| I[camofox-i2p]
T --> TP[tor-proxy :9150 SOCKS5]
I --> IP[i2p-proxy :4444 HTTP]
V --> NET((Internet, host IP))
Hermes only ever sees one public tab id per task. Behind it the router keeps a tabset:
public tab id -> { uid, active engine, tabs: { vps: realId, tor: realId, i2p: realId } }
snapshot,click,typeand every other action go to the active tab of the tabset.- Navigating with an explicit prefix (
vps+,tor+,i2p+) to a different engine never navigates the wrong tab. The router creates a tab on the requested engine (or reuses the parked one), makes it active, and parks the previous one. - Parked tabs keep their page and cookies until the engine's idle timeout.
- Each engine has its own isolated browser context: logins are not shared between engines.
- Several Hermes tasks (subagents) use different
userIds, so they get separate contexts, each on its own engine. - State is kept in memory. A router restart clears it (see Troubleshooting).
camofox-browser does not read Firefox user.js files. camofox/Dockerfile therefore builds a small derived image that patches the launch options in /app/server.js: block_webrtc: true plus firefox_user_prefs (network.proxy.socks_remote_dns=true, media.peerconnection.enabled=false). The build fails loudly if the upstream string changes. Pin CAMOFOX_IMAGE to a digest for reproducible builds.
- Docker with Compose v2
- Hermes Agent running in Docker (container named
hermes) - A Docker network shared by Hermes, the Camofox containers and the router (default
ai-net, created by the installer if missing) - A
tor-proxycontainer (SOCKS5 on:9150) forcamofox-tor - An
i2p-proxycontainer (HTTP proxy on:4444) forcamofox-i2p
The proxy and reader containers come from openwebui-tor-i2p-scraper. That project builds, on the same ai-net network:
| Container | Role | Port |
|---|---|---|
tor-proxy |
Tor + Snowflake (SOCKS5; ControlPort for NEWNYM) | 9150 / 9151 |
i2p-proxy |
i2pd router (HTTP proxy) | 4444 |
browser-clear |
Chromium reader with stealth | 8080 |
browser-tor |
Firefox reader over Tor | 8081 |
browser-i2p |
Firefox reader over I2P | 8083 |
browser-http |
Direct HTTP reader (curl_cffi) | 8084 |
browser-camoufox |
Camoufox reader for anti-bot pages | 8085 |
solverr |
FlareSolverr fork for Cloudflare challenges | 8191 |
searxng |
Self-hosted metasearch | 8080 (8888 on the host) |
Start that stack first, then install this one. It also needs outgoing UDP open for Snowflake and a few minutes of I2P warm-up on first boot.
The plugins are thin HTTP clients. Each one calls a separate container that is not started by this compose file:
| Plugin | Calls | Provided by |
|---|---|---|
browser_http |
http://browser-http:8084/browse |
openwebui-tor-i2p-scraper |
browser_clear |
http://browser-clear:8080/browse |
openwebui-tor-i2p-scraper |
browser_camoufox |
http://browser-camoufox:8085/browse |
openwebui-tor-i2p-scraper |
browser_i2p |
http://browser-i2p:8083/browse |
openwebui-tor-i2p-scraper |
browser_tor |
http://browser-tor:8081/browse (override with BROWSER_TOR_URL) |
openwebui-tor-i2p-scraper, or browser-tor/ in this repo |
searxng_search |
http://searxng:8080/search |
searxng service (profile full) |
solverr_fetch |
http://solverr:8191 (override with SOLVERR_URL) |
solverr service (profile full) |
The interactive browsers (browser_navigate, browser_snapshot, ...) only need the router and the Camofox containers. A plugin whose container is missing returns an error string; the others keep working.
install-all.sh installs both halves on a fresh server: first openwebui-tor-i2p-scraper (Tor + Snowflake, I2P, reader containers), then this stack through ./install.sh.
git clone https://github.com/thotganesh/hermes-stealth-stack.git
cd hermes-stealth-stack
./install-all.shWhat it does:
- Checks Docker and Compose v2 and creates the Docker network if missing.
- Clones the scraper project (or fast-forwards an existing clone) and prepares
i2p-data. - Generates the Tor ControlPort password and its hash when they are empty, writes them to the
.envfiles (mode 600) and never prints them. - Starts
tor-proxy,i2p-proxy,browser-clear,browser-i2pandbrowser-tor, plusbrowser-http,browser-camoufox,solverrandsearxngwhen their folders exist in the scraper project. It waits for Tor to bootstrap. - Connects the
hermescontainer to the network if needed, then runs./install.sh.
Options (environment variables):
| Variable | Default | Purpose |
|---|---|---|
SCRAPER_DIR |
~/openwebui-tor-i2p-scraper |
Where the scraper project lives; an existing directory is used as is |
SCRAPER_REPO |
GitHub URL of the scraper project | Repository to clone |
SCRAPER_REF |
unset | Branch or tag to check out after cloning |
BROWSER_TOR |
scraper |
scraper runs the scraper's browser-tor; hermes runs the one in browser-tor/ of this repo |
SKIP_SCRAPER=1 |
off | Skip part 1 |
SKIP_HERMES=1 |
off | Skip part 2 |
The script can be re-run. A service whose folder is missing from the scraper checkout is skipped with a warning, and the matching plugin returns errors until that container exists.
Status: the script passes a syntax check but has not yet been tested end to end on a clean server. On a machine that already runs both stacks, use
SKIP_HERMES=1, because part 2 recreates the Camofox containers and restarts Hermes, which closes open tabs.
To install each half yourself, follow the manual steps below.
git clone https://github.com/thotganesh/hermes-stealth-stack.git
cd hermes-stealth-stack
cp .env.example .envEdit .env if needed (network name, image pins, proxy host names). Never commit .env.
./install.shWhat it does:
- Checks Docker and Compose v2; creates the network if it does not exist.
- Warns if
tor-proxyori2p-proxyare not running. - Copies all 7 plugins into
~/.hermes/plugins/. SetHERMES_HOMEif your Hermes data is elsewhere. - Builds and starts
camofox,camofox-tor,camofox-i2p,camofox-router, plussearxngandsolverrin full mode. Existingsearxng/solverrcontainers are reused, not touched. ASEARXNG_SECRETis generated if empty. - Waits for the router healthcheck.
- Sets
CAMOFOX_URL=http://camofox-router:9378(andSEARXNG_URL,SOLVERR_URL) in~/.hermes/.env. - Enables the plugins and restarts the
hermescontainer. IfHERMES_RESTART_UNITis set, it restarts that systemd unit too.
docker compose ps
docker run --rm --network ai-net curlimages/curl:latest -s http://camofox-router:9378/health/new
Use browser_navigate to visit tor+https://check.torproject.org/api/ip
You should see "IsTor":true.
./install-browser-tor.shRequires TOR_CONTROL_PASSWORD in .env, the same password as the ControlPort (9151) of your tor-proxy. If a browser-tor container already exists it is left untouched. The fresh-install path of this script is untested.
The router is not published on a host port. The examples run curl from a container on the same network:
alias rcurl='docker run --rm --network ai-net curlimages/curl:latest -s'rcurl http://camofox-router:9378/health
rcurl http://camofox-router:9378/router/statsrcurl -X POST http://camofox-router:9378/tabs \
-H 'Content-Type: application/json' \
-d '{"userId":"demo","url":"tor+https://check.torproject.org/api/ip"}'The response contains a tabId. Read the page:
rcurl "http://camofox-router:9378/tabs/<TAB_ID>/snapshot?userId=demo"Expected: {"IsTor":true,"IP":"..."}.
rcurl -X POST http://camofox-router:9378/tabs/<TAB_ID>/navigate \
-H 'Content-Type: application/json' \
-d '{"userId":"demo","url":"vps+https://example.com"}'The response includes "switched": true and "switched_from": "tor". The Tor tab stays alive in the background.
A prefix with no URL reactivates the parked tab exactly as it was:
rcurl -X POST http://camofox-router:9378/tabs/<TAB_ID>/navigate \
-H 'Content-Type: application/json' \
-d '{"userId":"demo","url":"tor+"}'Since v1.0.12 the router first checks that the tab still exists on the engine. If it was destroyed you get 409 no_tab_on_backend instead of a false success.
Routed automatically: a .onion URL goes to Tor, a .i2p URL goes to I2P, no prefix needed.
rcurl -X DELETE "http://camofox-router:9378/sessions/demo"Open tor+https://check.torproject.org/api/ip and tell me IsTor and the IP.
Then open vps+https://example.com.
Then go back to the Tor tab with tor+ alone, without reloading.
- New tab, no prefix: the session's engine, or
vps(host IP) if there is none. - Existing tab, URL without prefix: stays on the engine the tab is currently on. It never switches silently.
- Existing tab, explicit prefix: switches engine as described above.
Forgetting tor+ on a new tab means the request leaves from the host IP. Verify with check.torproject.org/api/ip whenever anonymity matters.
All plugins live in plugins/<name>/ (plugin.yaml + __init__.py), are installed by install.sh, and are enabled with hermes plugins enable <name>. Output is returned as text, truncated to roughly 5000 characters (4500 for the browser_tor text field). Error messages returned by the plugins are in Italian.
| Plugin | Parameters | Timeout | Typical speed | JS |
|---|---|---|---|---|
browser_http |
url |
30 s | ~200 ms | no |
browser_clear |
url, wait_selector (optional CSS selector) |
60 s | 1-3 s | yes |
browser_camoufox |
url, wait_ms (default 5000) |
90 s | 2-5 s | yes |
browser_tor |
url, wait_selector (optional) |
100 s | 3-10 s | yes |
browser_i2p |
url |
60 s | 10-60 s | yes |
searxng_search |
query, limit (default 5) |
30 s | < 1 s | n/a |
solverr_fetch |
url (validated: http/https, public hosts only) |
see plugin | 10-30 s | n/a |
- browser_http: direct fetch with Chrome impersonation (curl_cffi). Best for plain HTML and APIs.
- browser_clear: stealth Chromium, for modern SPAs and pages with light anti-bot protection.
- browser_camoufox: stealth Firefox with
humanizeenabled and a Windows profile, for sites that block Chromium. - browser_tor: anonymous reads. With the default
browser-torcontainer, NEWNYM before every request; responses includecircuit_renewed, andTOR_REQUIRE_NEWNYM=1on that container fails instead of reusing a circuit. Withbrowser-tor-real(setBROWSER_TOR_URL), the official Tor Browser with JavaScript off and no NEWNYM per request. Rejected URLs return a message with the reason. - browser_i2p: reads through the I2P HTTP proxy, for
.i2psites. - searxng_search: self-hosted metasearch (JSON API); returns title, URL and a 200-character snippet per result.
- solverr_fetch: solves Cloudflare/Turnstile challenges through FlareSolverr (community fork
ghcr.io/unseensnick/solverr, override withSOLVERR_IMAGE).
| Tool | Purpose |
|---|---|
browser_navigate |
Open a URL. Prefix tor+ / i2p+ / vps+ selects the exit. Returns {tabId, url, httpStatus, navigationOk} |
browser_snapshot |
Structured DOM snapshot of the active tab |
browser_click |
Click an element by ref |
browser_type |
Type into a form field |
browser_scroll, browser_back, browser_press |
Navigation helpers |
browser_console |
Run JavaScript in the page |
Only these are forwarded; everything else returns 404 route_not_supported.
| Route | Purpose |
|---|---|
POST /tabs |
Create a tab |
GET /tabs?userId= |
List tabs across all engines (merged, each with a backend field) |
/tabs/{id} and /tabs/{id}/{action} |
Actions on the tab (navigate, snapshot, click, ...) |
DELETE /tabs/{id} |
Close all tabs of the tabset |
/sessions/{userId}/... |
Session endpoints (cookies, traces); DELETE /sessions/{userId} fans out to every known engine |
GET /health, GET /router/stats |
Diagnostics |
Responses carry an x-router-backend header with the engine that served them. Session-level calls that cannot be attributed to one engine need an X-Camofox-Backend header.
| HTTP | Code | Meaning / fix |
|---|---|---|
| 400 | invalid_json, body_required, userId_required |
Malformed request |
| 400 | proxy_override_not_allowed |
Tab creation with a proxy field is rejected |
| 400 | url_rejected |
URL not allowed: non-http(s), or loopback/private/internal/ambiguous host on vps |
| 401 | unauthorized |
ROUTER_API_KEY is set and the Bearer token is missing or wrong |
| 403 | upload_disabled |
Set ROUTER_ALLOW_UPLOAD=1 to enable /upload |
| 404 | route_not_supported |
Legacy/global endpoint, blocked by design |
| 404 | backend_tab_gone |
The engine destroyed the tab (it answered 410). The tab was removed from the tabset: create a new one |
| 409 | backend_mismatch |
Prefix for another engine on a non-navigate call, or auto-switch disabled |
| 409 | tab_backend_unknown |
Tab id unknown (expired or router restarted): create a new tab |
| 409 | no_tab_on_backend |
Bare prefix (e.g. tor+) but no live tab on that engine. detail: tab_gone if it died. Navigate to tor+https://... to open one |
| 409 | session_backend_unknown, ambiguous_backend, backend_not_known_for_user |
Session-level calls where the engine cannot be inferred |
| 502 | backend_unreachable, backend_bad_response |
Engine down or invalid answer |
| 207 | (multi-status) | Partial result when closing tabs on several engines |
| Situation | Router behaviour |
|---|---|
Engine answers 410 on /tabs/{id}/... |
Returns 404 backend_tab_gone, drops the dead tab from the tabset |
Bare tor+ and the tab is gone on the engine |
409 no_tab_on_backend, tab dropped |
Bare tor+ and the engine is unreachable |
Tab is kept (it cannot be verified), switch proceeds |
tor+https://... on a parked tab that returns 404/410 |
Tab recreated transparently, response has "recreated": true |
A tab usually dies because an action timed out (for example a click that never completes, logged by the engine as operation_timeout) and the engine discards it.
Copy .env.example to .env.
| Variable | Default | Purpose |
|---|---|---|
DOCKER_NETWORK |
ai-net |
Shared external network |
CAMOFOX_IMAGE |
ghcr.io/jo-inc/camofox-browser:latest |
Base image (pin a digest for reproducible builds) |
CAMOFOX_VPS_URL, CAMOFOX_TOR_URL, CAMOFOX_I2P_URL |
http://camofox*:9377 |
Engine URLs used by the router |
TOR_SOCKS_HOST, TOR_SOCKS_PORT |
tor-proxy, 9150 |
Tor SOCKS5 proxy |
I2P_HTTP_HOST, I2P_HTTP_PORT |
i2p-proxy, 4444 |
I2P HTTP proxy |
SEARXNG_URL, SOLVERR_URL |
http://searxng:8080, http://solverr:8191 |
Written into Hermes' .env |
SEARXNG_IMAGE, SOLVERR_IMAGE, CURL_IMAGE |
see .env.example |
Image overrides |
INSTALL_FULL |
false |
If true, also create the searxng/solverr containers when they are missing. Leave false when ai-stack provides them (all 7 plugins are installed regardless) |
SEARXNG_SECRET |
generated | SearXNG secret |
HERMES_RESTART_UNIT |
unset | systemd unit to restart after install |
TOR_CONTROL_PASSWORD, TOR_REQUIRE_NEWNYM |
empty, 0 |
browser-tor container |
BROWSER_TOR_URL |
http://browser-tor:8081 |
Backend of the browser_tor plugin, read from Hermes' environment. http://browser-tor-real:8081 selects the official Tor Browser |
CAMOFOX_TOR_URL |
http://camofox-tor:9377 |
Router backend for tor+. http://browser-tor-interactive:9377 selects the official Tor Browser |
ROUTER_API_KEY |
empty (off) | Optional Bearer authentication |
| Variable | Default | Purpose |
|---|---|---|
ROUTER_API_KEY |
empty | If set, every request needs Authorization: Bearer <key>. The built-in Hermes browser tools do not send it, so leave it empty unless you use a custom client |
ROUTER_AUTO_SWITCH |
1 |
Allow engine switching on navigate. With 0, a different prefix returns 409 backend_mismatch (the pre-v1.0.11 behaviour) |
ROUTER_ALLOW_UPLOAD |
0 |
Allow /upload |
ROUTER_STATE_TTL |
3600 |
Seconds of inactivity before router state expires |
ROUTER_MAX_ENTRIES |
10000 |
Cap per state map |
The provided compose files only pass ROUTER_API_KEY through. To change the others, add them to the camofox-router environment: section of docker-compose.override.yml and recreate the container.
The override gives each Camofox container shm_size: 1gb and mem_limit: 2g. The router has a healthcheck on /health.
Camofox shuts the browser down when idle and reaps inactive tabs. Hermes has its own inactivity setting:
hermes config set browser.inactivity_timeout 600The Hermes default is 120 seconds. Raise it for long multi-step flows so parked tabs survive. If hermes is not in the host PATH, run it inside its container (docker exec hermes hermes config set ...) and restart Hermes if the value does not apply.
| Script | What it checks |
|---|---|
./test-privacy.sh |
WebRTC disabled on every backend (RTCPeerConnection must be undefined), IsTor: true on camofox-tor, no UDP/DNS outside the proxy (35 s capture) |
./test-i2p.sh |
I2P fail-safe: stops i2p-proxy briefly, expects an error and no silent leak through the host IP, restarts it |
./test-privacy-i2p.sh |
60 s DNS/UDP capture on camofox-i2p (outbound connections only) |
./test-routing.sh |
Legacy routes blocked (404), private/file:///internal URLs rejected (400), unknown tab and session (409), proxy field rejected, Tor tab exits from Tor |
./test-multitab.sh |
A vps tab and a tor tab in parallel for the same user, merged GET /tabs, Tor exit, engine switch without leak, vps+ reactivation with state preserved, private URL rejected via switch, session cleanup |
camofox/router/test_router_dead_tabs.py |
Dead tab handling (v1.0.12) against a fake backend. Touches no real engine |
test-routing.sh and test-multitab.sh use a container as curl client. The default is open-webui; override with CURL_CONTAINER=<container> (any container on the network that has curl). The router URL is ROUTER_URL (default http://camofox-router:9378).
Note:
test-routing.shadapts to the router mode. WithROUTER_AUTO_SWITCH=0it expects409 backend_mismatchwhen a vps tab is navigated totor+or a.onion; with the default (1) it checks that the router switches engine instead. Export the same value in the shell you run the script from.
Run the dead tab tests inside the router container:
cd camofox/router
docker cp test_router_dead_tabs.py camofox-router:/app/
docker exec camofox-router python /app/test_router_dead_tabs.pyExpected last line: ALL TESTS PASSED. The file is not part of the image, so copy it each time.
.github/workflows/ci.yml runs on every push and pull request:
shellcheckoninstall.sh,install-all.shandtest-privacy.shdocker compose --profile full config -qwith.env.examplepy_compileof the router and all plugins- Router unit tests (
test_router_dead_tabs.py, fake backend) - Build of the router image
- Build of the patched Camofox image (fails if the upstream
server.jspatch string changed)
The integration scripts (test-routing.sh, test-multitab.sh, the privacy tests) need the real stack and are run locally.
The router code is baked into the image (no bind mount), so docker restart is not enough. Rebuild:
docker compose up -d --build camofox-routerRebuilding with the override can also recreate the Camofox engines, which closes their open tabs.
Router state is in memory. After a restart or rebuild, create a new tab. GET /tabs?userId=... re-registers live tabs that exist on exactly one engine.
Before v1.0.12 the router passed a backend 410 Gone through unchanged and kept the dead tab in the tabset. Upgrade to v1.0.12: the client then receives 404 backend_tab_gone and creates a new tab.
Every tab creation and engine switch writes a log line with the chosen engine:
docker logs --since 30m camofox-router 2>&1 | grep -vE "GET /health|snapshot|evaluate" | tail -40
docker logs --since 30m camofox-tor 2>&1 | tail -20Router log messages are in Italian: nuovo tab (new tab), riattivato (reactivated), navigate su tab parcheggiato (navigate on parked tab), plus warnings for dead tabs.
Harmless uvicorn noise from websocket probes.
Tor/I2P proxies may be slow to start. Wait 30-60 seconds and retry. Check docker logs camofox-tor --tail 20 and docker logs camofox-i2p --tail 20.
Hermes is in Browser Use mode. The stack bypasses it through CAMOFOX_URL: verify it points to the router and that the Camofox containers are healthy.
All containers must be on the same Docker network: docker network inspect ai-net.
camofox-tor and tor-proxy are separate containers; restarting camofox-tor does not renew the circuit. NEWNYM goes to the shared tor-proxy and affects every user of it. With the official Tor Browser engines no NEWNYM is sent: the circuit is managed by the Tor daemon, so restarting the browser container does not change the exit either.
- Trusted network. The router is designed for a private, single-user deployment. By default it has no authentication; any container on the network can create tabs, read snapshots and impersonate another
userId. EnableROUTER_API_KEYfor custom clients. Do not expose port 9378 publicly; use an SSH tunnel or an authenticating reverse proxy for remote access. - Fail-closed routing. Only the routes listed above are forwarded. Legacy endpoints are blocked.
- Backend is never guessed. An unknown tab id returns 409 rather than being sent to the default engine.
- URL validation. Only
http/https. Onvps, loopback, private, internal and ambiguous numeric hosts are rejected, including via switch.solverr_fetchandbrowser-torvalidate URLs too. - Isolated engine contexts. Cookies and logins are not shared across engines.
- Bounded state. Router state expires after
ROUTER_STATE_TTLand each map is capped atROUTER_MAX_ENTRIES. - Privacy hardening. WebRTC off and remote DNS in the derived image; verify with
./test-privacy.sh.
camofox-tor and camofox-i2p run with CAMOFOX_ALLOW_PRIVATE_NETWORK=true so the browser can reach the proxy containers. A page or an agent-driven navigation can therefore reach other services on the same Docker network (Hermes, Open WebUI, ...). Mitigations: keep the Camofox stack on a network shared only with the proxies, do not let untrusted input drive browser_navigate, and use firewall rules to limit these containers to the proxy ports.
The i2p backend of camofox-router uses a camofox-i2p container with a
30 second internal timeout on tab creation. The I2P network can take
40-90 seconds for the first hit to an eepsite (slow tunnels, expired leases,
reseed). When I2P is congested, camofox-i2p returns
{"error":"Internal server error","retryable":false} after 30s.
For reliable I2P use the browser_i2p plugin (120s timeout) instead of
browser_navigate with .i2p. The router still routes .i2p to
camofox-i2p transparently, but the plugin is the reliable path.
If camofox-i2p stays stuck (even after docker restart), the fix is
docker restart i2p-proxy followed by 60s of warm-up.
The default Tor path (browser_tor on browser-tor, tor+ on camofox-tor) is not equivalent to Tor Browser. Camoufox randomizes the fingerprint while Tor Browser makes all users look the same, so the threat model is different and it does not defend against advanced correlation. The optional browser-tor-real and browser-tor-interactive engines (ai-stack v3.7) run the official Tor Browser with JavaScript off, which gives the uniform fingerprint, but they do not defend against advanced correlation either. A new tab with no prefix exits from the host IP. Traffic analysis, timing attacks and malicious exit nodes are out of scope. With the default engines NEWNYM is global for the shared tor-proxy and can change the exit of an ongoing camofox-tor session. Page content returned to the agent is untrusted input (prompt injection). Use this stack as a convenience layer for scripted browsing, not as a replacement for Tor Browser.
Respect the terms of service of the sites you automate and the laws that apply to you. This project does not help bypass the anti-abuse systems of third-party services.
Important
ai-stack must be updated and running first. This stack uses the containers of openwebui-tor-i2p-scraper (tor-proxy, i2p-proxy, browser-tor, browser-i2p, browser-clear, browser-camoufox, browser-http, solverr, searxng). update.sh refuses to run if any of them is missing.
If you cloned the repository with git:
cd ~/hermes-stealth-stack
./update.sh
The script:
- Pre-check: every ai-stack container must be
Up, otherwise it stops - Refuses if the working tree is dirty
- Backs up
.envand the compose files to~/stack-backup-*-hermes/ - Runs
git fetch+git pull --ff-only - Rebuilds only the local service (
camofox-router) - Runs
docker compose up -d --remove-orphans - Syncs the 7 plugins from
plugins/into~/.hermes/plugins/ - Prints the container status
If Hermes plugins changed, restart the hermes container:
docker restart hermes
Update everything in one command (both repos, correct order):
sudo bash ~/update-all.sh
The update-all.sh script is not part of this repository: it is a small wrapper that calls ~/ai-stack/update.sh first and ~/hermes-stealth-stack/update.sh second.
The fourget_search plugin searches the web through the fourget container of ai-stack, which drives a real Firefox (via 4play) to obtain Google / Brave / DuckDuckGo results without an API key and without CAPTCHAs -- once the Firefox profile is "warm".
Requirements: ai-stack must be running with fourget on the shared network. The plugin falls back to searxng_search if fourget is unreachable, so even without ai-stack the plugin still returns SearXNG results.
The plugin ships in plugins/fourget_search/. install.sh copies it into ~/.hermes/plugins/ and enables it. If you installed manually:
docker cp ~/hermes-stealth-stack/plugins/fourget_search hermes:/opt/data/plugins/
docker exec hermes hermes plugins enable fourget_search
docker restart hermes
Verify:
docker exec hermes hermes plugins list 2>&1 | grep fourget
docker exec hermes hermes plugins doctor fourget_search 2>&1
Expected: Status: enabled, and doctor reports 1 tool(s) without warnings.
Google needs a warm Firefox profile. See the ai-stack warm-up guide -- 5 minutes, once.
Without the warm-up, every query returns a CAPTCHA. The plugin still works (falls back to SearXNG), but Google-specific results are blocked.
Once enabled, ask the model to search the web in plain language. The plugin is registered as fourget_search and the model is expected to prefer it over searxng_search (see the ai-stack README for the SOUL.md update).
From Hermes CLI:
docker exec hermes hermes chat
> cerca su internet "docker networking best practices"
From Telegram (or any channel connected to Hermes):
cerca su internet le ultime novita su Docker
fourget_search(query: str, engine: str = "auto", limit: int = 8) -> str
query: required, the search stringengine: one ofauto(default),google,brave,duckduckgo,startpage.automerges multiple engines via 4getlimit: max results returned (default 8)
fourget_search -> fourget API -> on failure -> searxng_search (SearXNG API) -> on failure -> error string. The caller always gets a text response (results or error), never a silent failure.
| Symptom | Cause | Fix |
|---|---|---|
| Plugin returns "Nessun risultato" | fourget unreachable, searxng unreachable |
Check containers: docker ps --filter name=fourget --filter name=searxng |
Plugin not visible in hermes tools |
plugin.yaml missing provides_tools |
Verify with hermes plugins doctor fourget_search -- the manifest must declare provides_tools: [fourget_search] |
Model ignores the plugin and uses search_files |
SOUL.md not updated | Add the fourget_search line to SOUL.md (see ai-stack README) |
| Every Google query returns a CAPTCHA | Firefox profile cold | Re-run the warm-up |
docker exec hermes hermes plugins disable fourget_search
docker exec hermes rm -rf /opt/data/plugins/fourget_search
docker restart hermes
No other plugin is affected.
Router
- SSRF: risoluzione hostname per
vps, rifiuto di IP privati/loopback/link-local (anche per tor/i2p, sui literal), userinfo non consentito. - Fail-closed:
tor+/i2p+senza URL β400 url_rejected;.onion/.i2psolo sul backend corrispondente.
Installer
install.sheinstall-all.sh: flag--dry-rune backup automatico<file>.bak.installeral primo tocco di ogni.env.
Documentazione
- Sottosezione "Router Hermes" in Security hardening.
- README: expanded the "Google search via 4get" section with plugin activation, warm-up dependency, usage examples (CLI + Telegram), function parameters, fallback chain, troubleshooting table, rollback
- No code changes
- New
fourget_searchplugin that searches the web through thefourgetcontainer of ai-stack - Uses 4get+4play to drive a real Firefox; returns Google/Brave/DuckDuckGo results without an API key or proxies
- Falls back to
searxng_searchiffourgetis unreachable plugin.yamldeclaresprovides_tools: [fourget_search]- README: new "Google search via 4get" section, credits updated
- New
update.sh: refuses if any ai-stack container is missing; refuses if the working tree is dirty; backs up.envand compose files;git fetch+git pull --ff-only; rebuildscamofox-router;docker compose up -d --remove-orphans; syncs the 7 plugins into~/.hermes/plugins/; prints status - README: new "Updating" section
install-all.shrefuses to clone a second copy of the scraper when ator-proxycontainer from another directory is already running (used to end in aname already in useconflict)i2p-datapermissions: replacedchmod -R 777withchown -R 100:65533+chmod -R 750, matching the container'si2pduser. The fix is no longer undone by a re-run
_wanted: unknown prefixes (e.g.foo+https://...) are now rejected by_check_urlinstead of causing aKeyErrorβ 500 in_send_check_url: non-string URLs (arrays, objects, numbers) returnurl non validoinstead of aTypeErrorβ 500SESS_PATH:userIdconstrained to[A-Za-z0-9_-]{1,128}β blocks..,%, spaces and other characters that could be mis-decoded by the backend
INSTALL_FULLnow controls only container creation, not plugin installation- All 7 plugins are installed regardless of
INSTALL_FULL - Default is
false: Hermes no longer tries to create its ownsearxng/solverrwhen ai-stack provides them - Set
INSTALL_FULL=trueonly if you want Hermes to manage those containers itself
router.py:ROUTER_DEFAULT_BACKENDenv var (defaultvps, no behaviour change; set totorto make Tor the fallback engine)docker-compose.override.yml: passROUTER_DEFAULT_BACKENDtocamofox-routerplugins/browser_tor,plugins/browser_i2p:js_enabledfalse (matches the container default)- URL validation: strip trailing dot before host comparison (
open-webui.is now treated asopen-webui) - Image pinning:
CAMOFOX_IMAGE,SOLVERR_IMAGE,SEARXNG_IMAGE,CURL_IMAGEacceptsha256:digests in.env
- Backend
410on/tabs/{id}/...is mapped to404 backend_tab_goneand the dead tab is removed from the tabset. - Bare engine switch (
tor+) probes the backend first and returns409 no_tab_on_backendif the tab is gone. - Navigating a parked tab that died on the backend recreates it transparently (
recreated: true). - Automated tests in
camofox/router/test_router_dead_tabs.py.
- Per-request engine selection with
tor+,i2p+,vps+. - Bare prefix switches to an existing tab without reloading.
.onionand.i2prouted automatically.- Isolated browser contexts per engine.
- New errors
no_tab_on_backendandtab_backend_unknown. - Added
test-multitab.sh.
Privacy hardening (v1.0.3), fail-closed router and URL validation (v1.0.6), optional browser-tor container. See the releases page and git tag -l.
hermes-stealth-stack/
βββ plugins/ # 7 Hermes plugins (plugin.yaml + __init__.py)
βββ camofox/
β βββ Dockerfile # derived image with privacy patches
β βββ prefs/user.js
β βββ router/ # FastAPI router, Dockerfile, tests
βββ browser-tor/ # optional browser-tor container
βββ searxng/settings.yml
βββ docs/
β βββ INSTALL.md
β βββ TOOLS.md
β βββ MULTITAB.md # multi-engine tabs
β βββ ARCHITECTURE.md
β βββ SKILL-stealth-web-stack-multitab.md # Hermes skill notes
βββ .github/workflows/ci.yml
βββ docker-compose.yml
βββ docker-compose.override.yml
βββ docker-compose.browser-tor.yml
βββ install-all.sh # one-shot installer (scraper stack + this stack)
βββ install.sh
βββ install-browser-tor.sh
βββ test-*.sh
βββ .env.example
MIT - see LICENSE.
- Camoufox by daijiro
- camofox-browser by jo-inc
- Hermes Agent by Nous Research
ghcr.io/unseensnick/solverr, a community FlareSolverr fork- openwebui-tor-i2p-scraper: Tor/Snowflake and I2P proxies and the reader containers this stack builds on
- 4get by lolcat β multi-engine search frontend used by the
fourget_searchplugin (AGPL-3.0) - 4play by lolcat β browser bridge for 4get (AGPL-3.0)
browser_http,browser_clearandbrowser_camoufoxtalk to theai-stackcontainers, which reject non-public URLs (HTTP 400, shown as "URL rifiutato (...)").solverr_fetchand the Camofox router apply the same rules (trailing dots are stripped before checking).camofox-torandcamofox-i2pmust be attached toproxy-net, the isolated network wheretor-proxy/i2p-proxylive.install.shcreates it if missing. WebRTC-off and remote-DNS preferences are applied at image build time bycamofox/Dockerfile;camofox/prefs/user.jsis kept for reference and is not read by camofox-browser.INSTALL_FULL=falseis the default:searxngandsolverrare expected fromai-stackand are never recreated if they exist. Do not rundocker compose --profile full upby hand while ai-stack owns them (samecontainer_name).browser_torallows up to ~130 s per call (the plugin's request timeout: NEWNYM wait + 90 s navigation over Snowflake with the default engine).- Network-level egress rules for the clear-exit containers live in
ai-stack/hardening/.