agent-browser · diff
git:20260924.a44cf8f to git:20261005.01b2b5b
139 added, 20 removed. Audit A to A.
---
name: agent-browser
description: "This skill should be used when automating browser interactions via Vercel's agent-browser CLI. It handles web page navigation, form filling, screenshots, and data scraping using ref-based element selection."
---
<!-- grok-harness-invoke:start -->
**Grok Build (`plugins/soleur/lib/harness.ts` `invokeSkill()`):** Read this SKILL.md in this process and run it to completion. A one-segment `soleur:<name>` in this document names a SKILL — on Grok Build, Read `plugins/soleur/skills/<name>/SKILL.md` in this process; it is not a nested tool_use. A multi-segment id such as `soleur:<domain>:<name>` names an AGENT: spawn it, never Read it, and on Grok Build spawn_subagent takes the id with its colons replaced by hyphens (`agentIdToGrokSubagentType`). **Claude Code:** Skill tool for a skill (`soleur:<name>`), Task tool with `subagent_type` for an agent. Forbidden is executing a subset, not the Read.
<!-- grok-harness-invoke:end -->
# agent-browser: CLI Browser Automation
Vercel's headless browser automation CLI designed for AI agents. Uses ref-based selection (@e1, @e2) from accessibility snapshots.
## Setup Check
```bash
# Check installation
command -v agent-browser >/dev/null 2>&1 && echo "Installed" || echo "NOT INSTALLED - run: npm install --prefix ~/.local -g agent-browser@0.22.3 && agent-browser install"
```
### Install if needed
```bash
npm install --prefix ~/.local -g agent-browser@0.22.3
agent-browser install # Downloads Chrome for Testing (~300MB)
# On Linux if system deps missing:
# agent-browser install --with-deps
```
### Required launch flag on Linux: `--no-sandbox`
On Ubuntu 23.10+, containers, and VMs, the host's AppArmor policy restricts
unprivileged user namespaces, so Chrome for Testing cannot initialize its zygote
sandbox and the browser fails to launch. Export the no-sandbox flag once per
session **before the first `agent-browser` command** — the daemon reads it at
launch and every later command in the session inherits it:
```bash
export AGENT_BROWSER_ARGS="--no-sandbox"
```
Inline alternative (first `open` only): `agent-browser open <url> --args "--no-sandbox"`.
This confines `--no-sandbox` to agent-browser's own ephemeral automation Chrome —
the same posture Playwright already runs here; it does not touch your real browser.
### Troubleshooting: Chrome fails to launch / `open` hangs (no usable sandbox)
Symptom on pinned 0.22.3: `agent-browser open <url>` **hangs indefinitely** with
zero stdout/stderr (even `--debug` prints nothing). On newer versions it fails
fast with `No usable sandbox! ... unprivileged user namespaces ... AppArmor` and a
`--args "--no-sandbox"` hint. Both are the same cause.
1. Set the launch flag: `export AGENT_BROWSER_ARGS="--no-sandbox"` (see above).
2. If it still fails, a stale/wedged daemon may be holding the socket. Clear it:
`pkill -f agent-browser-linux-x64; rm -rf /tmp/agent-browser/* "/run/user/$(id -u)/agent-browser/"*`
then retry. (Never kill `playwright-mcp` processes — those are a separate stack.)
The commonest cause is a daemon whose worktree was REAPED: resolve each match's
`/proc/<pid>/cwd` and expect one ending `(deleted)`, often weeks old and inherited
by every later session on the machine. Such a daemon does not answer and **survives
SIGTERM** — it needs `kill -9`. Nothing in the CLI's error names any of this; the
only symptom is `Resource temporarily unavailable (os error 11)` (#7947).
3. Verify: `AGENT_BROWSER_ARGS="--no-sandbox" timeout 45 agent-browser open https://example.com --headless` → exit 0 + a `✓` line.
### Troubleshooting: Playwright MCP backend closed between calls
This is the **other** browser-automation symptom #6605 reported (the "MCP tools
de-register" half) — distinct from the agent-browser CLI hang above, and covering the
Playwright **MCP** stack. If a `mcp__plugin_soleur_playwright__browser_*` call
(or the same call on a host's own `mcp__playwright__*` registration) returns
`browserBackend.callTool: Target page, context or browser has been closed`, the browser
backend dropped while the MCP server itself stayed registered (a lifecycle event, not a
dead tool).
- Known root cause on this host: a Wayland/Vulkan GPU crash — already diagnosed and
+ Two known causes, check in this order. **(1) A hook killing the browser at the end of
+ a turn.** Older plugin versions registered `browser-cleanup-hook.sh` on `Stop`, which
+ fires every turn and SIGTERMed every Playwright Chrome of the user (ADR-271; method in
+ `knowledge-base/project/learnings/bug-fixes/2026-10-04-stop-hook-killed-live-playwright-chrome-heartbeat-theory-refuted.md`).
+ Tell-tale: the next call after a turn boundary fails, and the session transcript
+ (`~/.claude/projects/<slug>/<session>.jsonl`) carries a `hook_success` attachment whose
+ `hookEvent` is `Stop` and whose `command` matches `browser-cleanup`; the kill text
+ `Browser cleanup: killed N orphaned Playwright Chrome process(es)` is in
+ `attachment.stderr`, not `stdout`. The hook is removed from the repository, but a
+ session may run the installed plugin copy, which keeps it until the plugin is updated:
+
+ 1. Check (read-only): take `installPath` for `soleur@soleur` from
+ `~/.claude/plugins/installed_plugins.json`, then run
+ `jq -c '.hooks.Stop[].hooks[].command' <installPath>/hooks/hooks.json`. A line naming
+ `browser-cleanup-hook.sh` means the installed copy still kills the browser.
+ 2. Update: `claude plugin update soleur@soleur` (or the id `claude plugin list` prints).
+ The installed version is a git sha, so the update carries the fix only once the release
+ that removes the hook has been published; before that it changes nothing.
+ 3. Restart: the USER restarts Claude Code (an agent cannot restart its own host). A `/mcp`
+ reconnect reuses the cached `.mcp.json` command and does not reload hooks.
+ 4. Until then, complete a whole browser flow inside ONE turn. Interim and untested: the
+ user can `chmod -x` the installed copy's `hooks/browser-cleanup-hook.sh`, which should
+ make that hook fail without blocking until the next update overwrites it.
+
+ **A second killer survives the update: a session in ANOTHER checkout still on the old
+ `.mcp.json` launch string.** That string contains `pkill -9 -f` and, on every start or
+ `/mcp` reconnect of that session, kills the proxy, server and Chrome of EVERY slot of
+ a session on the new string. Read-only check: `for w in $(git worktree list --porcelain |
+ sed -n 's/^worktree //p'); do grep -aq pkill "$w/.mcp.json" && echo "$w"; done` prints each
+ checkout still carrying it. Bring those checkouts to this `.mcp.json` and restart their
+ sessions (the string a running session loaded cannot be changed from here).
+
+ **A login that should persist is missing in the repo registration:** that session was
+ given a numbered slot or a `-p<pid>` directory. Run `grep -a 'was skipped:' "$(ls -t
+ ~/.cache/claude-cli-nodejs/"$(pwd | sed 's/[^A-Za-z0-9]/-/g')"/mcp-logs-playwright/*.jsonl |
+ head -1)"` from the project directory: the repo registration logs under `mcp-logs-playwright`
+ in the directory named after the working directory with every character that is not a
+ letter or digit replaced by a dash (`/.worktrees/` becomes `--worktrees-`). The plugin
+ registration's `mcp-logs-plugin-soleur-playwright` never runs the slot script and never
+ prints that line. The line names why slot 0 (the persistent profile) was not used, and the
+ remedy depends on the reason:
+
+ - "its lock is held by another live launch": the lease is held by the other session's
+ proxy process, and `browser_close` does not release it (it closes Chrome, not the
+ proxy). End that session, or disconnect its Playwright MCP server, then reconnect.
+ - "its SingletonLock names a live Chrome": that Chrome must exit, for example
+ `browser_close` in the session that owns it, then reconnect.
+ - A reason naming another HOST (a renamed host or a copied home directory) is
+ cleared by hand: after confirming no Chrome of yours uses that profile, `rm
+ <slot-0 dir>/Singleton*`, which removes only the stale lock files and no login.
+
+ A search for another killer must cover every enabled plugin's `hooks.json` (the installed
+ copy too), not only `settings.json`. The server's ping heartbeat is not a cause on stdio
+ (ADR-271). **(2) A Wayland/Vulkan GPU crash** — already diagnosed and
remediated in `.claude/playwright-mcp.config.json` (forces the X11/XWayland backend
and disables the GPU); see `knowledge-base/project/learnings/workflow-patterns/2026-06-17-playwright-mcp-wayland-vulkan-launch-crash.md`.
- If it still recurs, recycle the context and re-navigate (the pattern in
+
+ **Whatever the cause**, recycle the context and re-navigate (the pattern in
`plugins/soleur/skills/qa/SKILL.md`: `browser_close` — safe even if already closed —
then `browser_navigate`); the backend restarts. Note that snapshot `ref=` handles do
**not** survive the restart; target elements by name/selector
(`button:has-text("Save")`, `input[aria-label="..."]`) across it. A separate
`"these deferred tools are no longer available"` notice means the MCP server
disconnected (reload via `ToolSearch`) — a different failure from the backend-close.
### Troubleshooting: version mismatch
If you see "Version mismatch between agent-browser (expects 1200) and installed Playwright (1208)":
1. Check which binary is running: `which agent-browser && agent-browser --version`
2. If it resolves to `/usr/bin/agent-browser` (version 0.5.0), a stale system install is shadowing the correct version
3. Fix: `sudo npm uninstall -g agent-browser` to remove the system binary
4. Verify: `which agent-browser` should now resolve to `~/.local/bin/agent-browser` (0.22.3)
## Core Workflow
**The snapshot + ref pattern is optimal for LLMs:**
1. **Navigate** to URL
2. **Snapshot** to get interactive elements with refs
3. **Interact** using refs (@e1, @e2, etc.)
4. **Re-snapshot** after navigation or DOM changes
+ **Playwright MCP flow: when the flow is finished, call `browser_close`.** Nothing reaps the
+ browser at turn end (ADR-271): it lives as long as the session that launched it, and a
+ logged-in window left open after a credential hand-off stays logged in. For the
+ `agent-browser` CLI the same duty is `agent-browser close`.
+
### Preflight: verify the plugin install before any snapshot
The redactor is reached through `${CLAUDE_PLUGIN_ROOT}`. An ambient value pointing at a
directory that is not a Soleur install would resolve to a path that does not exist — or, worse,
to one an attacker chose. Verify plugin IDENTITY and halt if it does not hold (ADR-179 decision 2);
a `test -f` on the script alone is a shape check and was measured bypassable.
```bash
[ -f "${CLAUDE_PLUGIN_ROOT}/.claude-plugin/plugin.json" ] \
&& grep -q '"name"[[:space:]]*:[[:space:]]*"soleur"' "${CLAUDE_PLUGIN_ROOT}/.claude-plugin/plugin.json" \
|| { echo "SOLEUR_SNAPSHOT_HALT reason=plugin-root-unverified root=[${CLAUDE_PLUGIN_ROOT}]" >&2
echo " Cannot locate the snapshot redactor, so no accessibility snapshot may be taken here." >&2
echo " Root EMPTY: no Soleur plugin is loaded in this session. Install it and start a NEW session." >&2
echo " Root set but wrong: a repo checkout is not an install. Run 'claude plugin update soleur@soleur-marketplace' (or the id 'claude plugin list' prints, if you added the repository directly), then RESTART Claude Code." >&2
echo " Nothing has been captured yet, so nothing has leaked." >&2
exit 2; }
```
```bash
# Step 1: Open URL
agent-browser open https://example.com
# Step 2: Get interactive elements with refs
agent-browser snapshot -i --json 2>&1 | python3 "${CLAUDE_PLUGIN_ROOT}/skills/agent-browser/scripts/redact-a11y-snapshot.py"
# Step 3: Interact using refs
agent-browser click @e1
agent-browser fill @e2 "search query"
# Step 4: Re-snapshot after changes
agent-browser snapshot -i 2>&1 | python3 "${CLAUDE_PLUGIN_ROOT}/skills/agent-browser/scripts/redact-a11y-snapshot.py"
```
## Key Commands
### Navigation
```bash
agent-browser open <url> # Navigate to URL
agent-browser back # Go back
agent-browser forward # Go forward
agent-browser reload # Reload page
agent-browser close # Close browser
```
### Snapshots (Essential for AI)
```bash
agent-browser snapshot 2>&1 | python3 "${CLAUDE_PLUGIN_ROOT}/skills/agent-browser/scripts/redact-a11y-snapshot.py" # Full accessibility tree
agent-browser snapshot -i 2>&1 | python3 "${CLAUDE_PLUGIN_ROOT}/skills/agent-browser/scripts/redact-a11y-snapshot.py" # Interactive elements only (recommended)
agent-browser snapshot -i --json 2>&1 | python3 "${CLAUDE_PLUGIN_ROOT}/skills/agent-browser/scripts/redact-a11y-snapshot.py" # JSON output for parsing
agent-browser snapshot -c 2>&1 | python3 "${CLAUDE_PLUGIN_ROOT}/skills/agent-browser/scripts/redact-a11y-snapshot.py" # Compact (remove empty elements)
agent-browser snapshot -d 3 2>&1 | python3 "${CLAUDE_PLUGIN_ROOT}/skills/agent-browser/scripts/redact-a11y-snapshot.py" # Limit depth
```
### Interactions
```bash
agent-browser click @e1 # Click element
agent-browser dblclick @e1 # Double-click
agent-browser fill @e1 "text" # Clear and fill input
agent-browser type @e1 "text" # Type without clearing
agent-browser press Enter # Press key
agent-browser hover @e1 # Hover element
agent-browser check @e1 # Check checkbox
agent-browser uncheck @e1 # Uncheck checkbox
agent-browser select @e1 "option" # Select dropdown option
agent-browser scroll down 500 # Scroll (up/down/left/right)
agent-browser scrollintoview @e1 # Scroll element into view
```
### Get Information
```bash
agent-browser get text @e1 # Get element text
agent-browser get html @e1 # Get element HTML
agent-browser get value @e1 # Get input value
agent-browser get attr href @e1 # Get attribute
agent-browser get title # Get page title
agent-browser get url # Get current URL
agent-browser get count "button" # Count matching elements
```
### Screenshots & PDFs
```bash
agent-browser screenshot # Viewport screenshot
agent-browser screenshot --full # Full page
agent-browser screenshot output.png # Save to file
agent-browser screenshot --full output.png # Full page to file
agent-browser pdf output.pdf # Save as PDF
```
### Wait
```bash
agent-browser wait @e1 # Wait for element
agent-browser wait 2000 # Wait milliseconds
agent-browser wait "text" # Wait for text to appear
```
## Semantic Locators (Alternative to Refs)
```bash
agent-browser find role button click --name "Submit"
agent-browser find text "Sign up" click
agent-browser find label "Email" fill "user@example.com"
agent-browser find placeholder "Search..." fill "query"
```
## Sessions (Parallel Browsers)
```bash
# Run multiple independent browser sessions
agent-browser --session-name browser1 open https://site1.com
agent-browser --session-name browser2 open https://site2.com
# List saved states
agent-browser state list
```
## Examples
### Credential safety on a login or credential page
An accessibility snapshot serializes the **value** of input fields. A value the
agent never typed — a password manager's autofill, a static `value=`, a JS
assignment, or a freshly-minted credential shown in a panel — is rendered into
the transcript and into any snapshot file written to disk. Nothing about the
call looks credential-adjacent, which is why this is a gate and not advice: the
PreToolUse hook `browser-snapshot-credential-guard.sh` denies an unrouted
snapshot before it runs (#7947).
**Route every snapshot on a credential-bearing page through the redactor**,
[redact-a11y-snapshot.py](./scripts/redact-a11y-snapshot.py):
```bash
agent-browser snapshot -i 2>&1 | python3 "${CLAUDE_PLUGIN_ROOT}/skills/agent-browser/scripts/redact-a11y-snapshot.py"
```
Stating the ceiling first, because the rule is otherwise read as "piping makes a
snapshot safe": the redactor is **defense-in-depth on one sink**, not a control
that makes snapshotting a credential page safe. It keys on the node's accessible
name, because neither surface serializes the input's `type` — measured, see the
[Phase 0.1 record](../../../../knowledge-base/project/specs/feat-one-shot-7946-7947-sentry-org-token-and-snapshot-redaction/phase-0-measurement.md).
It therefore cannot see a localised field name, a credential outside a
text-input role, or a value split across segmented inputs.
**A screenshot is safe for a `type=password` field** — the browser renders it as
dots — **and is NOT safe for a generated-credential panel.** Measured: a readonly
`type=text` box named "Token" renders its value in clear in the screenshot
exactly as it does in the snapshot. On a page displaying a credential, capture
neither: read the value with `agent-browser get value <sel>` into a file, use it,
and shred the file.
**When the credential is TEXT, not an input, `get value` finds nothing — use
`eval`, and never let its output reach the transcript.** Measured on the Anthropic
Console key panel (#8505): the fresh key is leaf text, zero `<input>` holds it.
Redirect `eval`'s stdout straight into a file created mode 0600, then print only
its length and prefix. Run both lines in ONE Bash call — `umask` does not carry
across calls — and note the file holds a JSON *string* (quoted); unwrap it with
`json.load`, never by printing it:
```bash
umask 077
agent-browser eval "(()=>{const m=document.body.innerText.match(/sk-ant-[A-Za-z0-9_-]{20,}/);return m?m[0]:''})()" > "${SCRATCH:?}/key.json"
python3 -c 'import json,sys; v=json.load(open(sys.argv[1])); print(len(v), v.startswith("sk-ant-"))' "${SCRATCH:?}/key.json"
```
Neither redactor is a backstop here: `redact-a11y-snapshot.py` and the MCP proxy
key on an accessibility node's NAME, so a raw key string returned by `eval` or by
the MCP's `browser_run_code_unsafe` passes both unredacted. Return a length or a
fixed code from any in-page script, never the value, and catch every error inside
the script so a thrown message cannot carry the value out either.
### Login Flow
`fill @e2` needs a ref, and a ref comes from a snapshot — so the login step
snapshots **through the redactor** rather than not at all.
```bash
agent-browser open https://app.example.com/login
agent-browser snapshot -i 2>&1 | python3 "${CLAUDE_PLUGIN_ROOT}/skills/agent-browser/scripts/redact-a11y-snapshot.py"
# Output shows: textbox "Email" [ref=e1], textbox "Password" [ref=e2], button "Sign in" [ref=e3]
agent-browser fill @e1 "user@example.com"
# Pass the secret by env indirection -- never a literal, which lands in the
# transcript as the tool-call argument before any snapshot happens.
agent-browser fill @e2 "$APP_PASSWORD"
agent-browser click @e3
agent-browser wait 2000
# Verify logged in -- still through the redactor: the password manager may have
# refilled the field on the post-login page.
agent-browser snapshot -i 2>&1 | python3 "${CLAUDE_PLUGIN_ROOT}/skills/agent-browser/scripts/redact-a11y-snapshot.py"
```
### Search and Extract
```bash
agent-browser open https://news.ycombinator.com
agent-browser snapshot -i --json 2>&1 | python3 "${CLAUDE_PLUGIN_ROOT}/skills/agent-browser/scripts/redact-a11y-snapshot.py"
# Parse JSON to find story links
agent-browser get text @e12 # Get headline text
agent-browser click @e12 # Click to open story
```
### Form Filling
```bash
agent-browser open https://forms.example.com
agent-browser snapshot -i 2>&1 | python3 "${CLAUDE_PLUGIN_ROOT}/skills/agent-browser/scripts/redact-a11y-snapshot.py"
agent-browser fill @e1 "John Doe"
agent-browser fill @e2 "john@example.com"
agent-browser select @e3 "United States"
agent-browser check @e4 # Agree to terms
agent-browser click @e5 # Submit button
agent-browser screenshot confirmation.png
```
### Debug Mode
```bash
# Run with visible browser window
agent-browser --headed open https://example.com
agent-browser --headed snapshot -i 2>&1 | python3 "${CLAUDE_PLUGIN_ROOT}/skills/agent-browser/scripts/redact-a11y-snapshot.py"
agent-browser --headed click @e1
```
## JSON Output
Add `--json` for structured output:
```bash
agent-browser snapshot -i --json 2>&1 | python3 "${CLAUDE_PLUGIN_ROOT}/skills/agent-browser/scripts/redact-a11y-snapshot.py"
```
Returns:
```json
{
"success": true,
"data": {
"refs": {
"e1": {"name": "Submit", "role": "button"},
"e2": {"name": "Email", "role": "textbox"}
},
"snapshot": "- button \"Submit\" [ref=e1]\n- textbox \"Email\" [ref=e2]"
}
}
```
## vs Playwright MCP
| Feature | agent-browser (CLI) | Playwright MCP |
|---------|---------------------|----------------|
| Interface | Bash commands | MCP tools |
| Selection | Refs (@e1) | Refs (e1) |
| Output | Text/JSON | Tool responses |
| Parallel | Session names | Tabs |
| Browser | Chrome for Testing | Chromium |
| Runtime | Rust native | Node.js |
| Best for | Quick automation | Tool integration |
Use agent-browser when:
- You prefer Bash-based workflows
- You want simpler CLI commands
- You need quick one-off automation
Use Playwright MCP when:
- You need deep MCP tool integration
- You want tool-based responses
- You're building complex automation
### Wrapping the server
**The plugin registers the wrapped server.** `plugins/soleur/.mcp.json`
registers `playwright` with `python3` running
`"${CLAUDE_PLUGIN_ROOT}/skills/agent-browser/scripts/playwright-mcp-redact-proxy.py"`
in front of `npx @playwright/mcp@0.0.78`, so on Claude Code its tools arrive as
`mcp__plugin_soleur_playwright__*` — already wrapped, no customer
configuration. The registration passes
- `--user-data-dir-name soleur-playwright-mcp-profile`, so the wrapped browser
+ `--user-data-dir-name soleur-playwright-mcp-profile` and `--chromium-fallback`, so the wrapped browser
runs on its own profile under `$XDG_CACHE_HOME` (default `~/.cache`), separate
from any `playwright` registration the customer configured themselves. On a
registration routed through the proxy, every `tools/call` result is rewritten
through `redact-a11y-snapshot.py` at the stdio boundary — between the server's
stdout and the client's stdin, before the model reads it — which is what
"redacted in flight" means: a content rewrite, not encryption and not
transport security. A registration not routed through it is not covered by
- anything at runtime (#7980). **The registration ships no config and no env
- block, so the server runs upstream defaults: headed — a visible Chrome window
- opens when a tool drives the browser — on channel `chrome` (real Google
- Chrome).** Headed is deliberate: the credential-handoff flows need an
- operator-visible window. On a display-less host or where Chrome is absent the
- server still connects but browser tools fail to launch — see the playbook at
+ anything at runtime (#7980). **The registration ships no config; its `env` block carries only
+ `PLAYWRIGHT_MCP_PING_TIMEOUT_MS=0`, an inert guard on stdio that is not a fix for
+ anything (ADR-271). The server runs upstream defaults: headed — a visible Chrome
+ window opens when a tool drives the browser — on channel `chrome` (real Google
+ Chrome), except that when no Google Chrome executable exists at
+ `/opt/google/chrome/chrome` (Linux) or `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome` (macOS)
+ and the launch names no browser, `--chromium-fallback` makes the proxy append
+ `--browser chromium --sandbox`, which Playwright maps to its bundled Chromium with the
+ Chromium sandbox kept on, like real Chrome. If that sandbox cannot start on the host (no
+ unprivileged user namespaces, an Ubuntu AppArmor restriction, running as root, a
+ default-seccomp container), the first browser call fails with a sandbox error and no
+ browser opens; the supported remedy is to install Google Chrome, never to disable the
+ sandbox, which the proxy refuses in every spelling.** Headed is deliberate: the
+ credential-handoff flows need an operator-visible window. On a display-less host
+ the server still connects but browser tools fail to launch — see the playbook at
the end of this section.
**Preconditions.** The plugin server exists only where all of these hold: the
session is Claude Code ≥2.1.139 with the soleur plugin installed; `python3`
and `npx` are on `PATH` and both scripts sit under `${CLAUDE_PLUGIN_ROOT}` (the
proxy refuses to start beside a missing redactor); and the server is not
toggled off in `/mcp`. Devin's local CLI also discovers a plugin-root
`.mcp.json` (its own documentation), so the tools may appear there too —
still wrapped; on Codex discovery is unverified. On any harness that does
not discover it, a disabled toggle, or a failed precondition,
`mcp__plugin_soleur_playwright__*` simply does not
exist — treat it as a missing registration and take the file-form path the
calling skill prescribes (the `filename:` + redactor + shred form on the
registration that does answer, or `agent-browser`), never a bare
`browser_snapshot` on an unwrapped server. **The `/mcp` toggle** can disable
the plugin's `playwright` server without uninstalling the plugin; while it is
off the tools are absent for the whole session and the same fallback applies.
**Wrap your own registration (advanced).** A customer's own `playwright`
registration is not covered by the plugin server — it stays unwrapped unless
the customer routes it through the proxy, with the proxy referenced through the
bare plugin-root anchor per ADR-179:
```json
{
"mcpServers": {
"playwright": {
"command": "python3",
"args": [
"${CLAUDE_PLUGIN_ROOT}/skills/agent-browser/scripts/playwright-mcp-redact-proxy.py",
"--",
"npx", "@playwright/mcp@0.0.78",
"--user-data-dir=<profile-dir>"
]
}
}
}
```
Claude Code does not expand `${CLAUDE_PLUGIN_ROOT}` inside a project `.mcp.json`
(the variable exists for plugin-provided servers). Replace it with the
`installPath` that `claude plugin list --json` prints for `soleur`; that path
carries the plugin version, so re-check it after every plugin update.
`--user-data-dir=<profile-dir>` may instead be
`--user-data-dir-name <basename>` placed before the `--`: the proxy resolves it
under `$XDG_CACHE_HOME` (default `~/.cache`) and refuses a basename carrying a
separator or `..`. Add `--config=<file>` only when the project has that file:
the proxy refuses to start on a named config that does not exist. An
`.mcp.json` edit loads only on a full Claude Code restart, never on a `/mcp`
reconnect, and only the user can restart; afterwards verify with `ToolSearch
select:mcp__playwright__browser_snapshot` — a description ending with the marker
below means wrapped, no match means the server did not connect (see the end of
- this section). This repository wraps the command in a `bash -c` prelude (`pkill`
- of a stale proxy, server and browser on the same profile; `env -u
- WAYLAND_DISPLAY`; an X11 display) that is Linux-only; the proxy itself is POSIX
- (stdlib `selectors` + `subprocess`) and does not run on Windows.
+ this section). This repository wraps the command in a `bash -c` prelude (it sources
+ `plugins/soleur/skills/agent-browser/scripts/playwright-mcp-profile-slot.sh`, which leases a profile directory by a
+ kernel `flock` and never kills a process by pattern — slot 0 is the persistent
+ `~/.cache/playwright-mcp-profile` and a launch whose slot is held by another live session
+ takes the next numbered slot `~/.cache/playwright-mcp-profile-<n>` at once, with a stderr
+ line in the MCP log saying the persistent profile's logins are not available in that
+ session; a `/mcp` reconnect waits up to 7 s for its own previous launch to release slot 0
+ (the wait needs `flock`: where it is missing a reconnect that finds the old Chrome still
+ alive lands on an empty `-p<pid>` profile).
+ When `flock` is missing (stock macOS) the first session still takes slot 0 without a lease
+ if no live Chrome owns it, and a second session that starts before the first one's browser
+ exists (Playwright starts it lazily) also gets slot 0 and Playwright's "already in use"
+ error; with no usable `flock` and slot 0 busy, or when all 32 slots
+ are busy, it uses a unique `playwright-mcp-profile-p<pid>` directory that nothing reaps;
+ `env -u WAYLAND_DISPLAY`; an X11 display) that is Linux-only; the proxy itself is POSIX
+ (stdlib `selectors` + `subprocess`) and does not run on Windows. **Cleaning up profile
+ directories:** these directories are at-rest credential stores (mode 700; they hold
+ whatever logins were done in them) and removal is manual. Only the `-p<pid>` fallbacks are
+ removable: remove one only when the pid in its name is dead (`kill -0 <pid>` fails) and its
+ `SingletonLock` (`readlink <dir>/SingletonLock`, target `host-<pid>`) names no live pid. The
+ numbered slots (`playwright-mcp-profile`, `-1` to `-31`) hold the persistent logins: do not
+ delete them as stale. A slot is in use when `flock -n <dir>/.pwslot.lock true` fails (rc 1)
+ (rc 66 means the directory is missing), but that test sees only sessions on the new launch
+ string: an old-string session holds no `flock`, so also require that `readlink
+ <dir>/SingletonLock` names no live pid (as above) before calling a slot free.
**Fail-closed in three arms, no bypass variable.**
1. **At startup** — the proxy writes `playwright-mcp-redact-proxy: refusing to
start: <reason>` to stderr and exits 2, spawning no server, when it cannot
load and self-test the redactor, or when the launch opens a raw sink around
it: a disk/session sink or foreign browser — `--save-session`,
`--save-trace`, `--save-video`, `--storage-state`, `--secrets`,
`--output-dir`, `--init-script`, `--init-page`, `--cdp-endpoint`,
`--endpoint`, `--extension`, `--executable-path`, `--daemon`,
`--allow-unrestricted-file-access`, `--grant-permissions`,
`--ignore-https-errors`, `--no-sandbox`, and each setting's
`PLAYWRIGHT_MCP_*` env twin (`PLAYWRIGHT_MCP_SANDBOX` for `--no-sandbox`),
plus env-only `PLAYWRIGHT_MCP_USER_DATA_DIR` and
`PLAYWRIGHT_MCP_SNAPSHOT_MODE`; a trailing valued flag, which would swallow
the appended `--snapshot-mode none` as its value; a config file (from
`--config` or `PLAYWRIGHT_MCP_CONFIG`) that is missing, is not JSON, or sets
`saveSession`, `saveTrace`, `saveVideo`, `secrets`, `outputDir`,
`allowUnrestrictedFileAccess`, `extension`, `server.port` / `server.host`, a
capability other than `vision`, `browser.cdpEndpoint` /
`browser.remoteEndpoint`, `browser.initPage` / `browser.initScript`,
`browser.contextOptions.storageState` / `.permissions` /
`.ignoreHTTPSErrors`, `browser.launchOptions.executablePath` /
`.chromiumSandbox: false` / a remote-debugging `args` flag, or a
`browser.userDataDir` conflicting with `--user-data-dir-name`; `--port`,
`--host`, `PLAYWRIGHT_MCP_PORT` or `PLAYWRIGHT_MCP_HOST`
(an HTTP transport around the relay); `--caps` or `PLAYWRIGHT_MCP_CAPS` other
than `vision` (devtools, pdf and storage write raw page state);
`--output-mode file`; a `DEBUG` value that can enable any `pw:` logger (the
`debug` package splits on whitespace and commas and treats `*` as a wildcard,
so `pw:*` counts); `DEBUG_FILE`; or a `--user-data-dir-name` that is not a
bare basename (a separator or `..`), is combined with an explicit
`--user-data-dir` in the server argv, or resolves under a relative
`XDG_CACHE_HOME` / an unset `HOME`. The reason names the setting, never its
value.
2. **Per call** — it appends `--snapshot-mode none` to the child so no action
tool writes a tree to disk, and refuses, before the server sees it, a
`browser_snapshot` call carrying a `filename` key and any `tools/call` whose
`arguments` carry `_meta` (measured: `_meta.json` returns the tree as one
escaped string the line-anchored predicate cannot see). A refusal is an
`isError` result whose text begins `refused by playwright-mcp-redact-proxy:`.
A request reusing an id that is still pending is refused with a JSON-RPC
error.
3. **Per result** — a result it cannot rewrite (an unrecognised shape, an error
carrying data or tree-shaped text, a JSON-escaped tree such as
`browser_run_code_unsafe` returning `ariaSnapshot()`, text over the
redactor's 4 MiB cap, a redactor exception) is replaced by an `isError`
result beginning `withheld by playwright-mcp-redact-proxy:`. **The tool
itself may have run** — a withheld `browser_click` or `browser_fill_form` did
its work — so call `browser_snapshot` bare to see the page before retrying. A
`- [Snapshot](…)` file link under `### Snapshot` (only a future server
version would emit one) is replaced by a do-not-read notice and the rest of
the result delivered. Server requests other than `roots/list`, and
notifications other than `tools/list_changed` and `cancelled`, are dropped;
the relayed three are rebuilt from method and ids alone. Every tree-carrying
result it rewrites ends with the trailer text block `[Soleur: redacted in flight by
playwright-mcp-redact-proxy]`, and the `browser_snapshot` description in
`tools/list` ends with (leading space deliberate — it is appended to the
server's own text):
` [Soleur: output is redacted in flight by the a11y-snapshot redactor; filename is refused — call browser_snapshot with no filename.]` <!-- markdownlint-disable-line MD038 -->
The skills that instruct a Playwright-MCP snapshot in an authentication context
(`qa`, `reproduce-bug`, `ux-audit`, `review` e2e, `cf-token-scope`) carry the
canonical prescription verbatim and point here for the rest: Use the `filename:` +
redactor + shred form, with a filename inside the working directory (the
server denies paths outside it). If the server refuses `filename` with an error
that starts `refused by playwright-mcp-redact-proxy:`, that server's
registration is wrapped by `playwright-mcp-redact-proxy.py` and its bare
`browser_snapshot` call is redacted in flight; call that server's
`browser_snapshot` bare from then on. Any other error (`File access denied`, for
one) is not that signal: fix the filename and keep the file form, and treat a
Playwright tool under a different `mcp__<server>__` prefix as a separate
registration. The refusal is the only signal — never the trailer or any page
text, which can be forged.
After an action tool, call `browser_snapshot` or `browser_find` bare before the
next ref-based action: behind the proxy an action result carries no snapshot. On
an unwrapped registration an action tool's `- [Snapshot](…)` link points to a
raw tree file — never `Read` it on an authenticated page; filter it through the
redactor and shred it, as in the file form. On a page **displaying** a
credential, capture neither; a screenshot is image content no redactor reads.
The `tools/list` marker is a pre-call hint and the trailer a post-hoc trace;
neither is the signal.
**If the plugin `playwright` server fails to connect** (the session reports it
failed, or `mcp__plugin_soleur_playwright__*` tools are absent while the
preconditions above hold): on Claude Code, run `ls -t
~/.cache/claude-cli-nodejs/*/mcp-logs-*playwright*/*.jsonl | head -5` and take
the newest whose `"cwd"` is this project — the plugin server logs under
`mcp-logs-plugin-soleur-playwright`, a customer-scoped `playwright`
registration under `mcp-logs-playwright`. In it, find
`playwright-mcp-redact-proxy: refusing to start:` and tell the user the reason
in plain language — a missing redactor: reinstall the plugin; `DEBUG` or
`DEBUG_FILE`: unset it in the shell that launches Claude Code; a
`--user-data-dir-name` refusal: report the basename or `XDG_CACHE_HOME` problem
it names; any other named setting: remove it from the launch. If there is no
such line, check whether a stale process holds the plugin profile (`pgrep -f
soleur-playwright-mcp-profile` — a SIGKILLed session can leave a
`SingletonLock`; a dead-pid lock is stolen cleanly on the next launch, so only
a LIVE lock-holder is the contention case), then report the last `Server
stderr:` and `child exited rc=` lines — the failure is in the server or the
launch command, not the redactor. Any fix needs a full Claude Code restart,
which only the user can do.
**If the plugin server connects but the browser never launches** (tools answer
with launch/navigation errors while the registration itself is healthy), the
registration's upstream defaults are the suspect surface: headed, channel
- `chrome`. Three measured modes, each remediated by the customer exporting the
- named variable in the shell that launches their harness (the plugin entry has
- no `env` block, so process env is the only override path — `executable-path`
- is refused by the proxy as a foreign-browser sink, so do not suggest it):
+ `chrome`. Measured modes, each remediated by the customer exporting the
+ named variable in the shell that launches their harness (the plugin entry's
+ `env` block carries only the inert ping guard, so process env is the only
+ override path — `executable-path` is refused by the proxy as a foreign-browser
+ sink, so do not suggest it):
- **No display** (headless host, SSH, container): a headed browser cannot
open. `PLAYWRIGHT_MCP_HEADLESS=1` is the supported opt-out.
- - **No real Chrome** (channel `chrome` resolves to Google Chrome, not bundled
- Chromium): install Chrome, or `npx playwright install chromium` plus
- `PLAYWRIGHT_MCP_CHANNEL=chromium`.
+ - **No real Chrome**: the plugin registration's `--chromium-fallback` already
+ switches to bundled Chromium, with its sandbox on. If Playwright then reports the
+ bundled browser is missing, install it with the PINNED command
+ `npx @playwright/mcp@0.0.78 install-browser chromium` (the pinned package's
+ `cli.js` rewrites `install-browser` to Playwright's `install`, its `--help`
+ prints Playwright's install usage, `install-browser chromium --dry-run` resolves
+ "playwright chromium v1232"; Playwright's own missing-browser error names the
+ UNPINNED form `npx @playwright/mcp install-browser <target>`, which can fetch a
+ newer package whose Chromium revision differs, so always use the pinned form; the
+ top-level `--help` does not list it, and the `npx playwright install` warning
+ banner it prints is cosmetic). Note the pinned server's
+ `--help` lists only `chrome, firefox, webkit, msedge` as `--browser` values, but
+ `chromium` is accepted and maps to the bundled browser. A registration
+ without the flag (a customer's own) still needs real Chrome or an explicit
+ `--browser chromium`.
+ - **`Chromium sandboxing failed!` / `No usable sandbox!` on the first browser call**
+ (bundled Chromium, sandbox kept on): the host does not allow unprivileged user
+ namespaces (common on Ubuntu 23.10+/24.04, in containers, or as root). Do NOT try
+ `chromiumSandbox: false`, `--no-sandbox` or `PLAYWRIGHT_MCP_SANDBOX`; Playwright's
+ message suggests it and the proxy refuses it. Install Google Chrome
+ (`google-chrome-stable`, which ships the AppArmor profile that lets its sandbox
+ run), asking the operator for the privilege prompt if one is needed, then restart
+ the session. Running as root: use a non-root user.
+ - **A second concurrent session gets `Browser is already in use for <dir>, use
+ --isolated ...` on the plugin registration**: the plugin registration
+ (`soleur-playwright-mcp-profile`) has NO lease, so the first session's browser holds
+ the profile for that session's whole lifetime (before ADR-271 the Stop hook freed it
+ at each turn end; now nothing does). Nothing is killed. Call `browser_close` in the
+ other session or end it. `--isolated` does not apply: the proxy always injects a
+ user-data-dir and the pinned server rejects the combination, and ADR-271 rejects
+ `--isolated` (it wipes OAuth sessions). This repository's own `.mcp.json` registration
+ does lease slots, so two sessions there each get a browser.
- **Wayland/GPU variance**: a headed launch on a Wayland host was measured
working with system Chromium (no Vulkan/ozone/crash lines), but the
2026-06 dogfood crash class existed — on a crash-looping host,
`PLAYWRIGHT_MCP_HEADLESS=1` sidesteps the compositor path entirely, or the
customer keeps their own registration with an env-forcing prelude as this
repository's `.mcp.json` does.