
One signed-in Chrome,
shared across opencode instances.
Playwright's MCP launches a browser per session, so pointing two instances at a profile holding real Google accounts makes them fight over an exclusive profile lock — the second one aborts. Launch Chrome once, attach every instance over CDP, and let a guard plugin keep them from interfering.
Apache-2.0 · Linux/macOS · no runtime dependency beyond Playwright's own Chromium
Why not just share a profile?
Chromium takes an exclusive lock on a user-data directory. A second process does not degrade gracefully — it refuses to start.
ERROR:process_singleton_posix.cc:347] Failed to create
.../SingletonLock: File exists (17)
ERROR:chrome_main_delegate.cc:520] Failed to create a
ProcessSingleton for your profile directory. ... Aborting now
to avoid profile corruption.A profile per instance would dodge the lock, but each profile is a separate sign-in — which defeats the point. Attaching over CDP is the only arrangement that gives N processes one browser with one session.
How it works
- 01
Launch once
One Chrome process opens the persistent profile with a CDP debugging port. It holds the sign-in.
- 02
Attach many
Every opencode instance connects over CDP. Attach works across processes, so each gets its own tab.
- 03
Guard the tabs
A plugin hooks tool execution and denies the calls that would break a sibling instance.
$ ~/bin/chrome-cdp-profile
chrome-cdp-profile: up on 9222 (profile ~/.config/google-chrome-for-testing)
# opencode attaches; no restart ordering to remember
playwright-mcp: no Chrome on 9222 — starting it via ~/bin/chrome-cdp-profile
playwright-mcp: started Chrome on 9222
playwright-mcp: mode=cdp endpoint=http://127.0.0.1:9222Sharing means really sharing
Every attached instance sees every tab. Cookies,localStorage and IndexedDB are one bucket. Left alone, two agents on the same site will clobber each other — and one instance ending its session can kill the browser for everyone.
A sees pages: 3
B sees pages: 3
same target list? true
B reads A localStorage: set-by-AWhy tab ownership cannot be verified
The obvious implementation checks the tab index against a list of targets you own. That does not work, and it fails silently.
MCP reports
[0] pricing
[1] aboutCDP /json/list
[0] about
[1] pricingSince browser_tabs close accepts no target ID, there is no way to address one specific tab and prove it is yours. So the guard allows a close only when every live tab is either claimed by this session or inert — then whatever closes is ours by definition.
The rules, enforced
Prompts asking an agent to be careful are not enforcement. The plugin hooks tool execution and throws, so a denied call cannot be talked past. Every decision is appended to a log.
Always deniedbrowser_close
Tears down the shared browser. Every other instance attached to it loses their tabs mid-task, and there is no way to tell how much work that was.
Never the last tabbrowser_tabs close
The same failure by another route. Closing the final page makes the browser exit, so the guard refuses any index that resolves to it.
Explicit index requiredbrowser_tabs close
A bare close carries no intent. Without an index the guard cannot reason about which tab would go, so it declines rather than guess.
Denied while any foreign tab existsbrowser_tabs close
The MCP tab index does not map onto a CDP target, and close accepts no target ID — so ownership of one specific tab is unverifiable. Closing is allowed only when every live tab is claimed by this session or inert.
Claims expire after 15 mintool.execute.before
Ownership lives only in the agent process. A stale claim from a finished instance would otherwise block closes forever, so it ages out and the tab counts as unowned again.
Blank tabs are ignoredbrowser_tabs close
The browser always keeps one seed about:blank that no session claims. Treating it as foreign would deny every close permanently.
Fails closedbrowser_tabs close
If CDP cannot be reached, live tabs cannot be enumerated, so no close can be verified. Unverifiable means denied.
Own tabs are closablebrowser_tabs close
When every live tab belongs to this session, whichever one the index resolves to is ours by definition.
How it is tested
The guard's behaviour is a pure function of the tool, its arguments, the session and the live tabs — so the suite drives the real plugin from separate long-lived processes instead of spawning agents.
== concurrent instances: 3-way race ==
round 1: denied=3/3 tabs 1 -> 4
PASS round 1: every instance denied
PASS round 1: no tab destroyed
round 2: denied=3/3 tabs 1 -> 4
PASS round 2: every instance denied
PASS round 2: no tab destroyed
passed: 8 failed: 0Each round asserts both the decision and the resulting browser state, so a run only passes if no tab was destroyed along the way.
Known limitations
Published rather than buried. Two of these are inherent to sharing one browser, and no amount of guarding removes them.
A blank tab mid-navigation is closable
about:blank is treated as inert, so if another instance is navigating, its tab can briefly read as closable. The window is narrow but real.
Same-URL tabs are indistinguishable
Ownership is tracked by URL. Two tabs on the same site cannot be told apart, so a same-site instance could have its tab closed.
Storage is shared by design
Cookies, localStorage and IndexedDB are one bucket across instances. Unrelated domains are unaffected; the same origin is not.
The profile has real credentials
Every attached instance can act as you. Use a dedicated profile, not your daily browser profile.
Install
git clone <repo> ~/dev/opencode-shared-browser
cp bin/chrome-cdp-profile bin/playwright-mcp-chromium-safe ~/bin/
cp plugins/browser-guard.ts ~/.config/opencode/plugins/
chmod +x ~/bin/chrome-cdp-profile ~/bin/playwright-mcp-chromium-safe$ ~/bin/chrome-cdp-profile
chrome-cdp-profile: up on 9222
$ ~/bin/chrome-cdp-profile --stop # graceful shutdownThen point the Playwright MCP at the wrapper — seethe install guide for the config snippet and the environment variables.