Headless Chromium for programs and for AI agents. Each caller gets its own browser, admitted by memory, leased per session, and closed as soon as the caller that owns it exits.
Two uses share one pool:
- Programs render a page to HTML (
pbrowse render) or run a short Python script against a browser (pbrowse script). - Agents open interactive sessions and drive them step by step (open, click, fill, read text, take
screenshots), through the
pbrowsecommand line or thepbrowse-mcpMCP server.
An optional agent Chrome runs a headful Chrome on a host you choose. It is reached over a private network (Tailscale) through a gate, and it has a viewer for a person to watch and operate. Read the Security section before enabling it.
- Linux or macOS.
- Python 3.10 or newer (the repository pins 3.12 in
.python-version) and uv. - Chromium, installed through Playwright (see Install).
- Optional: Tailscale for tailnet addresses. Without it the gate admits only loopback.
- Optional, agent Chrome viewer on Linux: an X server (Xvnc) and a Chrome build.
git clone <this repository> d-browser
cd d-browser
uv sync --frozen
uv run playwright install chromium
uv run pbrowse statusuv sync --frozen installs exactly the versions in uv.lock.
Render a public page as a program would:
uv run pbrowse render https://example.com --net publicThe reply is one JSON object with ok, url, status, title, html and host.
Open an interactive session and read it:
sid=$(uv run pbrowse open https://example.com --target pool --net public)
uv run pbrowse text "$sid"
uv run pbrowse close "$sid"pbrowse-mcp speaks MCP over stdio. Register it with your MCP client, for example:
{
"mcpServers": {
"d-browser": {
"command": "uv",
"args": ["--directory", "/path/to/d-browser", "run", "pbrowse-mcp"]
}
}
}The server exposes these tools: browser_open, browser_goto, browser_click, browser_fill,
browser_paste, browser_press, browser_select, browser_upload, browser_text, browser_shot,
browser_wait, browser_wait_human, browser_sessions, browser_status, browser_close,
browser_cdp_url, browser_view_url, browser_login, and browser_login_doctor. The login_* tools appear
only on a host configured as the login host (see Configuration).
| Group | Commands |
|---|---|
| Sessions | open, goto, text, fill, paste, press, select, upload, shot, wait-human, sessions, close |
| Programs | render URL --net public|local, script (reads {"script", "argv"} on stdin) |
| Inspection | status, limits (every setting with its source), targets, policy, owner, audit |
| Agent Chrome | serve, login, login-doctor, agent-chrome, exec-display, exec-chrome, exec-chrome-mac, ensure-password, set-password, set-login-token |
| Extensions | ext add, ext status (Linux agent Chrome) |
pbrowse --help and pbrowse <command> --help give every option. Commands print JSON on stdout and use the
exit code to report failure.
- Owner. Every request names its owner process, and the pool verifies it: the caller itself or one of its
ancestors. The owner is found from
PBROWSE_OWNER_PIDwhen set, then the nearest agent process, the nearestsshdsession for forwarded calls, the MCP server process, or, forpbrowse script, the program that ran it. Each owner gets its own Chromium, and only that owner can use its sessions. When the owner exits, its Chromium closes at once. Exit is observed through pidfd (Linux) or kqueue (macOS); nothing polls. - Capacity. A new Chromium starts only when the memory of all pool Chromiums, plus one estimated Chromium, stays under the memory limit. Chromiums of the same user are counted, including orphans of a dead pool. Memory is PSS on Linux and RSS elsewhere.
- Leases. A script's tab closes when the job ends. A short session closes after
short_idle_swithout a step; each step renews it. A session opened with--hold debugclosesdebug_max_safter opening. An owner's Chromium closeschrome_idle_safter its last tab. A lease never closes a tab in the middle of a step. - Network. Every connection a page makes goes through the pool's policy proxy: http, https, ws, wss,
redirect hops, and requests from
data:pages. The proxy resolves each host once, refuses the connection unless every resolved address is allowed, and connects to the address it checked, so DNS rebinding cannot change the answer.local(the default) allows loopback and the tailnet ranges (100.64.0.0/10, fd7a:115c:a1e0::/48).publicallows only public addresses, and only ports 80, 443, 8080 and 8443. WebRTC is limited to proxied connections and QUIC is off. - Gate access. Each gate listens on 127.0.0.1 and requires a random token, which Chromium receives as the proxy credentials. A request without it gets 407.
- Egress. A render or script job may name an upstream HTTP proxy. The address goes on stdin, never in argv.
The egress must be loopback or public; a LAN or tailnet egress needs
egress_private=1. Link-local, multicast, reserved and unspecified addresses are always refused. - Access. The pool listens on a 0600 Unix socket and accepts only peers with the same user ID. The pool recomputes each script's SHA-256 and refuses a mismatch.
- Screenshots and uploads. Screenshots are written only to
~/.local/state/d-browser/shots/, as new 0600 files. Uploads are staged in a per-session 0700 folder, and symlinks inside a--dirtree are refused. - Frozen pool. A pool that accepts a connection but sends no acknowledgement within 5 s is killed, with
its marked Chromium groups. Each kill is recorded in
~/.cache/d-browser/kills.jsonl.
Every setting is read on each request, in this order: environment variable, then
~/.config/d-browser/limits.json, then the default. pbrowse limits shows the value and its source.
examples/limits.json lists the keys.
| key | env | default | meaning |
|---|---|---|---|
memory_mb |
PBROWSE_MEMORY_MB |
8192 | memory limit for all pool Chromiums |
chrome_mb |
PBROWSE_CHROME_MB |
400 | estimate for one more Chromium |
chrome_idle_s |
PBROWSE_CHROME_IDLE_S |
30 | how long an owner's Chromium lives after its last tab |
short_idle_s |
PBROWSE_SHORT_IDLE_S |
120 | idle time of a short session lease |
debug_max_s |
PBROWSE_DEBUG_MAX_S |
7200 | cap of a --hold debug lease |
egress_private |
PBROWSE_EGRESS_PRIVATE |
0 | 1 allows a LAN or tailnet egress address |
agent_queue_max |
PBROWSE_AGENT_QUEUE_MAX |
32 | steps and opens waiting on the agent Chrome (1-256) |
human_idle_s |
PBROWSE_HUMAN_IDLE_S |
30 | seconds without a person's input before the agent Chrome is observing again (1-3600) |
upload_mb |
PBROWSE_UPLOAD_MB |
50 | largest single upload, in MB (1-512) |
Invalid values are skipped and named in pbrowse status.
These optional files describe one host. Templates are in examples/.
~/.config/d-browser/pool.json: how this host runs its agent Chrome.agent_chromeis"agent"(the default: headless or agent-controlled) or"login"(a person's own Chrome for signing in; the gate never attaches).displaynames the X display for visible sessions,geometrythe window size, andslicea systemd slice that contains the browser processes.~/.config/d-browser/inventory.json: which hosts are known and where the agent Chrome runs. It gives this host'sselfname, thehostswith their tailnet names and optional LAN addresses,deny(hosts that may not connect),agent_chrome(the host, the viewer address and the CDP address), andtargets(more agent Chromes by name). Two targets on one host are refused at load. The file is plain JSON that you write for your own network; the code does not infer it.
Read this before enabling the agent Chrome. The design favours containment on a host you control, and it has these exposures:
- Admission is by network identity only. On the agent Chrome gate, a tailnet peer is admitted when
Tailscale names it as a registered host that is not on
deny. There is no per-user credential and no token on the CDP or viewer endpoints. A LAN address is never admitted. - An admitted peer gets full control of a logged-in profile. The
/cdpendpoint gives the full DevTools protocol for the agent Chrome: cookies, storage and every tab. A compromised registered host therefore reaches every account signed in to that Chrome. Keep the set of trusted hosts small, and usedenyfor hosts that should not connect. - Local file upload.
pbrowse uploadandbrowser_uploadread any file the calling process can read and set it on a page's file input. A prompt-injected agent can be induced to upload local secrets to a hostile page. Restrict the agent's file access, or do not give it upload. - Script execution.
pbrowse scriptruns caller-supplied Python inside the pool. The boundary is the 0600 socket and the same-user check, so this is not a privilege escalation, but any same-user code runs with the pool's rights. - Login targets are off by default. A login Chrome is used only when
pool.jsonsetsagent_chrometo"login", and thelogin_*tools are offered only on that host. Enable it only on a machine where a person signs in. - Local DevTools port. Chromium's DevTools port listens on 127.0.0.1, so any local user on a multi-user host can reach it. Use a single-user host for the agent Chrome.
- Prompt injection. Page text, titles and HTML are returned to the agent as model input.
- Transport. The viewer and CDP endpoints are plain HTTP and WebSocket. Confidentiality relies on the WireGuard encryption of the tailnet. Do not expose these ports to other networks.
- Credentials at rest. The login-extension token is stored 0600 and refused if readable by others. The profile directory is 0700. Egress credentials are read from stdin and held in memory only.
- Viewer password. The VNC viewer password (DES, 8 characters) protects the viewer between the gate and the X server, on a Unix socket. It is legacy-weak and is never offered on TCP.
- Force-installed extension. The agent Chrome managed policy (
agentchrome.py, theTAMPERMONKEYconstant) force-installs the Tampermonkey userscript manager, Chrome Web Store IDdhdgffkkebhmkfjojejmpbldmpobfkfo. The extension is optional in the sense that it can be removed from the policy, but the ID is not read from a configuration file: changing or removing it means editing that constant, and the policy file changes with it. The code closes the welcome tab that the extension opens at start.
To report a security issue, use the repository's Report a vulnerability button (GitHub security advisories) in the Security tab. Do not open a public issue for it.
- The agent Chrome is for one trusted user on one or more trusted hosts. It is not a multi-tenant service.
- Playwright controls Chromium; behaviour follows the Chromium version it installs.
- The gate admits by Tailscale identity. Without Tailscale only loopback is reachable.
- Login flows against real sites are a person's task: the tools report a slider, captcha or verification step and never bypass it.
The tests use a fake browser, real Unix sockets and real exit notification. Run them with:
uv run --python 3.12 --frozen python -W error::SyntaxWarning -m unittest discover -s testsThese four projects were built together and can be used together. Each one also runs on its own.
| Project | What it does | How it connects |
|---|---|---|
| -agents-msger | Messages between agent sessions on different machines. Sender identity is detected from the process tree. | Its hub lists which sessions are alive on each host (/who, /status). |
| -d-browser | A browser pool for agents. One Chromium per caller, verified by the kernel, closed when the caller exits. Provides the pbrowse command. |
For a call forwarded from another machine, it asks the agents-msger hub whether that caller is still alive and closes its Chromium when it is gone. |
| -wsearch | Search service. The calling model writes the query in several languages; fixed code runs the searches and merges the results. | Its browser features (page rendering, scripts) use d-browser's pbrowse. |
| -verdict | Decision recall. Before an agent decides, a hook gives it similar earlier decisions and the general design rules. Recall is done by code (BM25). | No dependency on the others. |
Using them together, in this order:
- agents-msger: run one hub, and one shim on every machine (see the agents-msger README, Install). Its installer
writes
~/.config/mcp-msgers/config.json, which holds the hub address and the token file. - d-browser: install it on the machines that run browsers. It reads that same agents-msger config to find the hub;
nothing else needs to be set. Its
pbrowsecommand is installed with it. - wsearch: install it, and point
PBROWSE_BINat d-browser'spbrowse(or putpbrowseonPATH). - verdict: install the hook in Claude Code (see the verdict README, Hook setup). It needs none of the others.
The agents-msger link is optional. d-browser reads the hub address and token from the agents-msger config
(~/.config/mcp-msgers/config.json, or the file named by MCP_MSGERS_CONFIG). Without it, a forwarded caller's
Chromium closes when that caller's connection closes.
MIT. See LICENSE. Bundled third-party parts (noVNC, pako, the Playwright Extension CRX) keep their
own licenses; see NOTICE.