{"id":49916875,"url":"https://github.com/askalf/browser-bridge","last_synced_at":"2026-07-11T14:00:24.449Z","repository":{"id":356808812,"uuid":"1234149384","full_name":"askalf/browser-bridge","owner":"askalf","description":"Stealth headless Chromium in a container. Exposes Chrome DevTools Protocol on 9222. Connect from Playwright, Puppeteer, MCP browser tools, or any agent that wants a remote browser without bundling one.","archived":false,"fork":false,"pushed_at":"2026-07-11T12:54:17.000Z","size":170,"stargazers_count":3,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"master","last_synced_at":"2026-07-11T13:14:24.750Z","etag":null,"topics":["cdp","docker","headless-chrome","playwright","puppeteer"],"latest_commit_sha":null,"homepage":"https://github.com/askalf/browser-bridge/pkgs/container/browser-bridge","language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/askalf.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-09T20:13:25.000Z","updated_at":"2026-07-11T12:53:17.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/askalf/browser-bridge","commit_stats":null,"previous_names":["askalf/browser-bridge"],"tags_count":6,"template":false,"template_full_name":null,"purl":"pkg:github/askalf/browser-bridge","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/askalf%2Fbrowser-bridge","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/askalf%2Fbrowser-bridge/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/askalf%2Fbrowser-bridge/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/askalf%2Fbrowser-bridge/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/askalf","download_url":"https://codeload.github.com/askalf/browser-bridge/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/askalf%2Fbrowser-bridge/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35364269,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-11T02:00:05.354Z","response_time":104,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["cdp","docker","headless-chrome","playwright","puppeteer"],"created_at":"2026-05-16T17:25:17.321Z","updated_at":"2026-07-11T14:00:24.439Z","avatar_url":"https://github.com/askalf.png","language":"JavaScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# browser-bridge\n\n\u003e _browser-bridge — own your browser — stealth headless Chromium, your CDP endpoint. Part of **[Own Your Stack](https://github.com/askalf)** — own your AI infrastructure instead of renting it by the token._\n\n\u003e Stealth headless Chromium in a container. Exposes Chrome DevTools Protocol (CDP) on port 9222. Connect from Playwright, Puppeteer, MCP browser tools, or any agent that wants a remote browser without bundling one.\n\n```bash\ndocker run --rm -p 9222:9222 --shm-size=512m ghcr.io/askalf/browser-bridge:latest\n```\n\n```ts\n// Then connect from anywhere on the host\nimport { chromium } from 'playwright';\nconst browser = await chromium.connectOverCDP('http://localhost:9222');\n```\n\n[![Build](https://img.shields.io/github/actions/workflow/status/askalf/browser-bridge/build.yml?style=flat-square\u0026label=build\u0026labelColor=020612)](https://github.com/askalf/browser-bridge/actions)\n[![stealth](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/askalf/browser-bridge/badges/stealth.json\u0026style=flat-square\u0026labelColor=020612)](https://github.com/askalf/browser-bridge/actions/workflows/stealth.yml)\n[![GHCR](https://img.shields.io/badge/ghcr.io-askalf%2Fbrowser--bridge-00ff88?style=flat-square\u0026labelColor=020612)](https://github.com/askalf/browser-bridge/pkgs/container/browser-bridge)\n[![License](https://img.shields.io/badge/MIT-00ff88?style=flat-square\u0026label=license\u0026labelColor=020612)](LICENSE)\n\n## Why\n\nBundling a browser into every agent / scraper / MCP server / test runner is overhead — image size, OS dependencies, font rendering, fingerprint maintenance. browser-bridge centralizes one browser container that any number of clients can share via CDP. Production-grade defaults (full puppeteer-extra stealth evasions, non-root user, healthcheck, optional VPN proxy) so you don't have to assemble them yourself.\n\n## What you get\n\n- **Stealth** — puppeteer-extra with the full evasion set: `navigator.webdriver`, `navigator.plugins`, `navigator.languages`, WebGL vendor, Chrome runtime, iframe quirks, the works. `--enable-automation` is dropped from the default args. The **stealth badge** above isn't a claim — CI builds the image and drives it, as an ordinary CDP client, through a bot-detection battery (the vectors sannysoft / CreepJS probe) on every relevant change; the badge is that live score, and the build fails if it regresses. See [`stealth-score.mjs`](stealth-score.mjs).\n- **CDP on 0.0.0.0:9222** — Chromium binds to localhost on recent versions; a built-in HTTP-aware proxy fronts it on the wildcard so other containers (or your dev machine) can reach it.\n- **Optional token auth** — set `BRIDGE_TOKEN` and every CDP request/WebSocket must present it (`Authorization: Bearer`, `X-Bridge-Token`, or `?token=`). The one thing raw CDP has always been missing. Off by default.\n- **Connect by service name** — Chromium rejects DNS names in the Host header, which is why remote-CDP setups usually make you dig up the container IP. The proxy bridges that: `connectOverCDP('http://browser:9222')` works with a compose service name (with token auth on, or via `BRIDGE_ALLOW_HOSTNAMES=1`).\n- **Realistic browser args** — 1920×1080 viewport, `en-US,en` lang, accelerated 2D canvas, WebGL on, font-render hinting set. Many \"headless\" containers fail bot checks because they ship without these; we ship with them.\n- **Optional VPN proxy** — set `HTTPS_PROXY` or `HTTP_PROXY` to route Chromium's traffic through a VPN sidecar (Gluetun, etc.). Supported out of the box.\n- **Non-root** — runs as the `browser` user, not root. CDP escapes don't get privilege.\n- **CDP origin lock** — `--remote-allow-origins` defaults to loopback origins instead of `*`, closing the DNS-rebinding / cross-origin CDP hijack hole. CDP libraries (Playwright, Puppeteer) send no Origin header and are unaffected; override with `CDP_ALLOWED_ORIGIN` if your client needs one.\n- **Idle page reaper** — clients that die without closing their tabs no longer leak them. Idle blank tabs, pages idle past a TTL, and pages beyond a hard count cap get closed; idle is measured from last *navigation*, so an actively reused page is never reaped. All tunable.\n- **Health + metrics** — `/healthz` (CDP-connection health with a cached deep page-load check) and `/metrics` (pages open/created/reaped, nav count, uptime) on container-internal `:9224`. The Docker healthcheck hits `/healthz`, so \"unhealthy\" means the browser is actually gone — not just that a TCP port answers.\n- **Heartbeat logs** — one log line per minute with page/nav/reap counts. Pair with restart policy for self-recovery.\n\n## Usage\n\n### Standalone\n\n```bash\ndocker run --rm -p 9222:9222 --shm-size=512m ghcr.io/askalf/browser-bridge:latest\n```\n\n`--shm-size` matters: Chromium's default `/dev/shm` (64MB) is too small for non-trivial pages and you'll see crashes without it.\n\n### docker-compose\n\n```yaml\nservices:\n  browser:\n    image: ghcr.io/askalf/browser-bridge:latest\n    expose:\n      - \"9222\"\n    shm_size: '512m'\n    restart: unless-stopped\n```\n\n### With a VPN sidecar (Gluetun)\n\n```yaml\nservices:\n  vpn:\n    image: qmcgaw/gluetun\n    cap_add: [NET_ADMIN]\n    environment:\n      VPN_SERVICE_PROVIDER: protonvpn\n      OPENVPN_USER: ${VPN_USER}\n      OPENVPN_PASSWORD: ${VPN_PASS}\n\n  browser:\n    image: ghcr.io/askalf/browser-bridge:latest\n    network_mode: \"service:vpn\"\n    shm_size: '512m'\n    environment:\n      HTTPS_PROXY: http://localhost:8888\n      HTTP_PROXY: http://localhost:8888\n```\n\n### Connect from Playwright\n\n```ts\nimport { chromium } from 'playwright';\n\nconst browser = await chromium.connectOverCDP('http://localhost:9222');\nconst ctx = browser.contexts()[0] ?? await browser.newContext();\nconst page = await ctx.newPage();\nawait page.goto('https://example.com');\nconsole.log(await page.title());\n```\n\n### Connect from Puppeteer\n\n```ts\nimport puppeteer from 'puppeteer-core';\n\nconst browser = await puppeteer.connect({\n  browserWSEndpoint: 'ws://localhost:9222',\n});\n```\n\n### Connect from hands\n\n[hands](https://github.com/askalf/hands) is a computer-use agent that mostly drives the local desktop directly, but its `read_page` tool fetches URLs over plain HTTP. Pointing it at a browser-bridge gives it a real Chromium for the cases where a server bounces non-browser User-Agents or where the page is a JS-heavy SPA:\n\n```ts\n// hands/src/tools/read-page.ts — variant using browser-bridge instead of fetch()\nimport { chromium } from 'playwright';\n\nconst browser = await chromium.connectOverCDP(\n  process.env.BROWSER_BRIDGE_URL ?? 'http://localhost:9222',\n);\nconst ctx = browser.contexts()[0] ?? await browser.newContext();\nconst page = await ctx.newPage();\ntry {\n  await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15_000 });\n  const html = await page.content();\n  return { text: cleanHtml(html), meta: { url, finalUrl: page.url(), status: 200 } };\n} finally {\n  await page.close();\n}\n```\n\nSet `BROWSER_BRIDGE_URL` in the hands environment to the bridge container's address (e.g. `http://browser-bridge:9222` on a shared docker network).\n\n### Connect from deepdive\n\n[deepdive](https://github.com/askalf/deepdive) is a research agent whose `BrowserSession` (`src/browser.ts`) launches a local Playwright Chromium by default. Swap `chromium.launch()` for `chromium.connectOverCDP()` to share one browser-bridge across many deepdive runs:\n\n```ts\n// deepdive/src/browser.ts — BrowserSession.start() using browser-bridge\nimport { chromium } from 'playwright';\n\nasync start(): Promise\u003cvoid\u003e {\n  const bridgeUrl = process.env.BROWSER_BRIDGE_URL;\n  if (bridgeUrl) {\n    this.browser = await chromium.connectOverCDP(bridgeUrl);\n    this.context = this.browser.contexts()[0] ?? await this.browser.newContext({\n      userAgent: this.opts.userAgent ?? DEFAULT_USER_AGENT,\n      viewport: { width: 1920, height: 1080 },\n      locale: 'en-US',\n    });\n    return;\n  }\n  // ...fallback to local chromium.launch() as today\n}\n```\n\nThe browser-bridge container already ships the stealth args deepdive's `STEALTH_ARGS` lists (`--disable-blink-features=AutomationControlled`, the realistic viewport, locale, font-render hinting), so when connected over CDP you can drop deepdive's local args.\n\n### Raw CDP\n\nIf you don't want a Playwright/Puppeteer dependency at all, browser-bridge speaks the wire protocol directly. Fetch the WebSocket URL from `/json/version`, open a socket, and send framed JSON-RPC:\n\n```bash\n# 1. Discover the WebSocket debugger URL\ncurl -s http://localhost:9222/json/version | jq -r .webSocketDebuggerUrl\n# ws://localhost:9222/devtools/browser/4b3f...\n```\n\n```jsonc\n// 2. Send a CDP command over that socket — minimal Page.navigate\n{ \"id\": 1, \"method\": \"Page.navigate\", \"params\": { \"url\": \"https://example.com\" } }\n\n// Response\n{ \"id\": 1, \"result\": { \"frameId\": \"ABCD...\", \"loaderId\": \"1234...\" } }\n```\n\nSee the [Chrome DevTools Protocol docs](https://chromedevtools.github.io/devtools-protocol/) for the full method surface (`Page.*`, `Network.*`, `Runtime.evaluate`, `DOM.*`, etc.).\n\n### Connect from an MCP browser tool\n\nThe CDP endpoint `http://localhost:9222/json/version` and `ws://localhost:9222/devtools/...` are standard. Most MCP browser servers accept a `browserURL` config option — point it at this container.\n\n## Authentication\n\nCDP has no auth story of its own — anyone who can open a socket to `:9222` owns the browser. By default the bridge keeps the classic open behavior (bind it to a private network). Set `BRIDGE_TOKEN` to require a shared secret on every request and WebSocket upgrade instead:\n\n```yaml\nservices:\n  browser:\n    image: ghcr.io/askalf/browser-bridge:latest\n    expose: [\"9222\"]\n    shm_size: '512m'\n    environment:\n      BRIDGE_TOKEN: ${BRIDGE_TOKEN}\n```\n\nThe token can travel three ways — `Authorization: Bearer \u003ctoken\u003e`, an `X-Bridge-Token` header, or a `?token=` query parameter. Comparison is timing-safe, the token is stripped before anything reaches Chromium, and failures are counted in `/metrics` (`authFailures`).\n\n```ts\n// Playwright — ws endpoint with the token in the query string.\n// The bridge resolves the root path to the browser target server-side,\n// so you don't need the /devtools/browser/\u003cuuid\u003e discovery round-trip.\nconst browser = await chromium.connectOverCDP('ws://browser:9222/?token=' + process.env.BRIDGE_TOKEN);\n\n// Puppeteer — same one-liner\nconst browser = await puppeteer.connect({\n  browserWSEndpoint: `ws://browser:9222/?token=${process.env.BRIDGE_TOKEN}`,\n});\n```\n\n```bash\n# curl / raw CDP\ncurl -s \"http://localhost:9222/json/version?token=$BRIDGE_TOKEN\"\ncurl -s -H \"X-Bridge-Token: $BRIDGE_TOKEN\" http://localhost:9222/json/list\n```\n\nWith a token set you can also connect by DNS/service name (`ws://browser:9222/...` above): Chromium's own Host-header check — which rejects DNS names and doubles as DNS-rebinding protection — is handled by the proxy, and auth covers the rebinding risk. Without a token the bridge keeps Chromium's IP/localhost-only Host behavior; set `BRIDGE_ALLOW_HOSTNAMES=1` if you want service-name connections on an open bridge and accept that trade-off.\n\n## Session isolation\n\nBy default the bridge fronts **one** Chromium and every client connects to the same browser: convenient and cheap, but a client that calls `browser.close()` takes the browser down for everyone, and any connection can enumerate or close pages it didn't open. When several independent clients share a bridge, set `BRIDGE_SESSION_MODE=isolated` and each connection gets its **own** stealth Chromium:\n\n```yaml\nservices:\n  browser:\n    image: ghcr.io/askalf/browser-bridge:latest\n    environment:\n      BRIDGE_SESSION_MODE: isolated\n      BRIDGE_MAX_SESSIONS: \"20\"   # concurrent-session cap\n    expose: [\"9222\"]\n    shm_size: '512m'\n```\n\n- **Hard isolation** — no client sees or closes another's targets; `browser.close()` only kills that client's session.\n- **Stealth preserved** — every session launches through the same puppeteer-extra stealth config, so evasions apply exactly as in shared mode.\n- **Still a byte-pipe** — the proxy routes each connection to its session's browser; there is no per-message CDP parsing on the hot path.\n\nChoosing a session:\n\n```ts\n// Ephemeral — a fresh browser for this connection, disposed on disconnect:\nawait chromium.connectOverCDP('http://browser:9222');\n\n// Named — reused across reconnects (survives a disconnect until it goes idle),\n// for a long-lived logged-in session:\nawait puppeteer.connect({ browserWSEndpoint: 'ws://browser:9222/?session=my-login' });\n```\n\nNamed sessions are reaped after `BRIDGE_SESSION_IDLE_MS` (default 5m) with no connections. Acquisitions past `BRIDGE_MAX_SESSIONS` get a `503` — a launch-per-connection endpoint is otherwise an easy resource-exhaustion vector. `isolated` is opt-in; the default stays `shared`.\n\n## MCP endpoint\n\nShips an optional MCP server (`mcp-server.mjs`) so any MCP client — Claude Code, an agent framework, anything that speaks MCP — can drive the bridge with **no puppeteer or CDP code of its own**. It's a thin MCP server that is itself a CDP client of the bridge, exposing six tools over Streamable HTTP:\n\n| Tool | Does |\n|---|---|\n| `browser_navigate` | Go to a URL, wait for load, report status + title. |\n| `browser_screenshot` | PNG of the viewport (or `fullPage`). |\n| `browser_evaluate` | Run a JS expression in the page, return the result. |\n| `browser_get_content` | Page as `html` or visible `text`. |\n| `browser_get_console` | Console + page-error messages captured this session. |\n| `browser_pdf` | Render the page to a PDF resource. |\n\nRun it as a second process alongside the bridge (same image), pointed at the bridge's CDP endpoint:\n\n```yaml\nservices:\n  browser:\n    image: ghcr.io/askalf/browser-bridge:latest\n    expose: [\"9222\"]\n    shm_size: '512m'\n  browser-mcp:\n    image: ghcr.io/askalf/browser-bridge:latest\n    command: [\"node\", \"/app/mcp-server.mjs\"]\n    environment:\n      BRIDGE_CDP_URL: http://browser:9222\n      # BRIDGE_TOKEN: ${BRIDGE_TOKEN}   # required on MCP requests + presented to the bridge\n    ports: [\"9225:9225\"]\n```\n\nThen point a client at `http://\u003chost\u003e:9225/mcp`. **Each MCP session opens one bridge connection** (`?session=mcp-\u003cid\u003e`), so with the bridge in isolated session mode every MCP session gets its own stealth browser; in shared mode they share one. The browser is opened lazily on the first tool call and disposed when the MCP session ends. If `BRIDGE_TOKEN` is set it's required on MCP requests (`Authorization: Bearer`, `X-Bridge-Token`, or `?token=`) and presented onward to the bridge.\n\n## Configuration\n\n| Env var | Default | Effect |\n|---|---|---|\n| `PUPPETEER_EXECUTABLE_PATH` | `/usr/bin/chromium` | Which Chromium binary to launch (rarely needs overriding). |\n| `HTTPS_PROXY` | unset | Outbound proxy passed to Chromium as `--proxy-server`. |\n| `HTTP_PROXY` | unset | Same as `HTTPS_PROXY`; either works. |\n| `CDP_ALLOWED_ORIGIN` | loopback origins | Comma-separated Origin header values allowed on CDP websocket connections (`--remote-allow-origins`). Most CDP clients send no Origin header and don't need this. |\n| `BRIDGE_TOKEN` | unset | Shared secret required on every CDP request/WebSocket when set (`Authorization: Bearer`, `X-Bridge-Token`, or `?token=`). Unset = open, pre-0.2.0 behavior. |\n| `BRIDGE_ALLOW_HOSTNAMES` | unset | Accept DNS-name Host headers (compose service names) *without* a token. Not needed when `BRIDGE_TOKEN` is set. Opt-in because Chromium's Host check doubles as DNS-rebinding protection. |\n| `BRIDGE_SESSION_MODE` | `shared` | `shared` = one browser for all clients (default). `isolated` = a browser per connection (see [Session isolation](#session-isolation)). |\n| `BRIDGE_MAX_SESSIONS` | `20` | *(isolated)* Concurrent-session cap; acquisitions past it return `503`. |\n| `BRIDGE_SESSION_IDLE_MS` | `300000` | *(isolated)* Reap a session this long (5m) after its last connection closes. |\n| `BRIDGE_HEALTH_PORT` | `9224` | Health/metrics port (binds `127.0.0.1` inside the container). |\n| `BRIDGE_REAP_INTERVAL_MS` | `30000` | How often the page/session reaper runs. |\n| `BRIDGE_BLANK_TTL_MS` | `120000` | *(shared)* Reap `about:blank` tabs idle this long. |\n| `BRIDGE_MAX_IDLE_MS` | `900000` | *(shared)* Reap any page with no navigation for this long (15m). Raise it if your clients hold pages open while working. |\n| `BRIDGE_MAX_PAGES` | `25` | *(shared)* Hard page-count cap; the most-idle pages beyond it are reaped. |\n| `BRIDGE_MCP_PORT` | `9225` | *(mcp-server.mjs)* Port the MCP endpoint listens on. |\n| `BRIDGE_MCP_PATH` | `/mcp` | *(mcp-server.mjs)* Request path for the MCP endpoint. |\n| `BRIDGE_CDP_URL` | `http://127.0.0.1:9222` | *(mcp-server.mjs)* The bridge CDP endpoint the MCP server connects to. |\n\nPorts:\n\n- **9222** (TCP) — CDP entry point. The image's `EXPOSE` targets this.\n- **9224** (TCP, container-internal) — `/healthz` + `/metrics`, bound to `127.0.0.1` inside the container. The Docker `HEALTHCHECK` hits it; it is intentionally not reachable from outside the container.\n- **9225** (TCP) — the optional [MCP endpoint](#mcp-endpoint), only when you run `mcp-server.mjs`.\n\n## Health \u0026 metrics\n\n```bash\ndocker exec \u003ccontainer\u003e curl -s http://127.0.0.1:9224/healthz\n# {\"ok\":true,\"connected\":true,\"pageCheck\":\"ok\",\"pagesOpen\":2}\n\ndocker exec \u003ccontainer\u003e curl -s http://127.0.0.1:9224/metrics\n# {\"uptimeSec\":4211,\"pagesOpen\":2,\"pagesCreated\":17,\"pagesReaped\":3,\n#  \"navCount\":42,\"healthChecks\":280,\"lastReapAt\":1765500000000,\n#  \"authFailures\":0,\"hostBlocked\":0,\"cdpConnectionsTotal\":5,\n#  \"cdpConnectionsActive\":1,\"connected\":true}\n```\n\n`/healthz` returns `503` only when the CDP connection is gone (restart the container). A wedged-but-connected Chrome shows up as `\"pageCheck\":\"degraded\"` — the deep check opens a throwaway context and evaluates `1+1`, refreshed at most once a minute.\n\n## Image tags\n\n- `ghcr.io/askalf/browser-bridge:latest` — bleeding edge from `master`.\n- `ghcr.io/askalf/browser-bridge:v\u003cX.Y.Z\u003e` — pinned releases.\n- `ghcr.io/askalf/browser-bridge:vX.Y` and `ghcr.io/askalf/browser-bridge:vX` — minor/major aliases pointing at the latest matching release.\n\n## Security model\n\n- Runs as non-root (`browser:browser`).\n- `--no-sandbox` is set inside the container because Chromium's setuid sandbox doesn't work in unprivileged containers; the broader sandbox is the Linux user namespace the container provides.\n- CDP is unauthenticated **by default** — anyone who can reach `:9222` can drive the browser. **Bind to a private network** (docker-compose service network, internal VPN, etc.), and set `BRIDGE_TOKEN` for defense in depth. Even with a token, don't expose `:9222` to the public internet — CDP was never designed to be an internet-facing protocol.\n- Without a token, DNS-name Host headers are refused (Chromium's own anti-DNS-rebinding posture, preserved by the proxy); IP and localhost Hosts work as always.\n- Every Chromium command is exposed via CDP. Treat the CDP endpoint with the same care you'd treat raw shell on the container.\n\n## What it isn't\n\n- **Not a queue or scheduler.** It's just one browser. Run multiple containers + a queue (BullMQ or similar) for parallelism.\n- **Not session-pinned.** All clients share the same Chromium instance. For session isolation, use Playwright/Puppeteer browser contexts.\n- **Not a Chrome extension host.** Headless Chromium doesn't load extensions reliably.\n\n## License\n\nMIT — see [LICENSE](LICENSE).\n\n## Own Your Stack\n\nPart of **[Own Your Stack](https://github.com/askalf)** — open tools for owning your AI infrastructure instead of renting it by the token. One subscription. Your box. Your terms.\n\n- **[dario](https://github.com/askalf/dario)** — own your routing\n- **[hybrid](https://github.com/askalf/hybrid)** — own your inference\n- **[deepdive](https://github.com/askalf/deepdive)** — own your research\n- **[hands](https://github.com/askalf/hands)** — own your computer-use\n- **[browser-bridge](https://github.com/askalf/browser-bridge)** — own your browser _(you are here)_\n- **[redstamp](https://github.com/askalf/redstamp)** — own your agent security\n- **[truecopy](https://github.com/askalf/truecopy)** — own your agent skills\n- **[strongroom](https://github.com/askalf/strongroom)** — own your agent secrets\n- **[cordon](https://github.com/askalf/cordon)** — own your prompts\n- **[fieldpass](https://github.com/askalf/fieldpass)** — own your agent browser\n- **[amnesia](https://github.com/askalf/amnesia)** — own your search\n- **[askalf](https://askalf.org)** — own your operation: the AI operation that runs Sprayberry Labs\n\n---\nPart of **[Own Your Stack](https://github.com/askalf)** — own your AI infrastructure instead of renting it. Built by Thomas Sprayberry.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Faskalf%2Fbrowser-bridge","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Faskalf%2Fbrowser-bridge","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Faskalf%2Fbrowser-bridge/lists"}