Skip to content
osb

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.

what the second instance seesbash
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

  1. 01

    Launch once

    One Chrome process opens the persistent profile with a CDP debugging port. It holds the sign-in.

  2. 02

    Attach many

    Every opencode instance connects over CDP. Attach works across processes, so each gets its own tab.

  3. 03

    Guard the tabs

    A plugin hooks tool execution and denies the calls that would break a sibling instance.

the wrapper starts the browser if it is downbash
$ ~/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:9222

Sharing 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.

two clients, one browserbash
A sees pages: 3
B sees pages: 3
same target list? true
B reads A localStorage: set-by-A

Why 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.

two tabs, two different orderings

MCP reports

[0] pricing
[1] about

CDP /json/list

[0] about
[1] pricing

Since 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.

verdictrule
deny

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.

deny

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.

deny

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.

deny

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.

allow

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.

allow

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.

deny

Fails closedbrowser_tabs close

If CDP cannot be reached, live tabs cannot be enumerated, so no close can be verified. Unverifiable means denied.

allow

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.

test/run.sh — three instances race to closetext
== 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: 0

Each 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

clone and linkbash
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
start the browserbash
$ ~/bin/chrome-cdp-profile
chrome-cdp-profile: up on 9222

$ ~/bin/chrome-cdp-profile --stop   # graceful shutdown

Then point the Playwright MCP at the wrapper — seethe install guide for the config snippet and the environment variables.

Apache-2.0, and yours to fork.

edrayel/opencode-shared-browser