Skip to content

fix(bex-bridge): a live extension keeps the slot instead of being evicted#372

Merged
BitHighlander merged 3 commits into
developfrom
fix/bex-bridge-slot-eviction
Jul 20, 2026
Merged

fix(bex-bridge): a live extension keeps the slot instead of being evicted#372
BitHighlander merged 3 commits into
developfrom
fix/bex-bridge-slot-eviction

Conversation

@BitHighlander

Copy link
Copy Markdown
Collaborator

Problem

bex-bridge.ts holds a single socket and every new connection replaced it unconditionally:

let sock: BridgeSocket | null = null
...
// Single BEX per vault: a new connection replaces the old (SW restart case).
if (sock && sock !== ws) { sock.close(); failAllPending(...) }
sock = ws

With the extension installed in two Chrome profiles — the case that surfaced this — both service workers connect to ws://localhost:1646/bex-bridge, and each connection evicts the other. The loser's reconnect backoff (mcpBridge.ts:245-252, reset to 5s on every successful open) brings it straight back, so the slot flaps forever.

Every MCP tool call then lands on whichever wallet holds the slot at that instant. No error is surfaced.

How it was observed

  • Extension console logged bridge connected every ~10s, indefinitely.
  • bex_status and bex_ext_console returned two different SW_STARTED_AT values, and the mapping flipped between calls. Both read the same module constant (providerLog.ts:48), so two values can only mean two service workers.
  • bex_tabs listed tabs from one profile while bex_snapshot answered from the other, so tab ids from the first were tab_not_found in the second (browserTools.ts:266-273).
  • Two profiles confirmed with KeepKey Client 0.0.37 installed (Default, Profile 1), one Chrome process.

The broken browser automation is the visible symptom. The hazard is that a signing request can be routed to a nondeterministically chosen wallet.

Change

A newcomer only takes the slot if the incumbent has gone silent for STALE_MS (45s). Any inbound frame stamps liveness, heartbeat pings included — the BEX pings every 20s (HEARTBEAT_MS), so a live extension is never judged stale.

This preserves the case the unconditional replacement existed for: a service worker restart whose close frame never arrived. A refused instance is closed with 4409 so the BEX can distinguish "someone else holds this slot" from an ordinary disconnect.

Tests

bun test src/bun/mcp.test.ts — 26 pass, 0 fail.

Three new cases, all verified to fail against the old behaviour (differential run with the guard neutralized: 3 fail / 23 pass):

  • a LIVE incumbent keeps the slot, newcomer refused with 4409
  • a heartbeat keeps the incumbent fresh past the stale window
  • a SILENT incumbent is replaced, and its in-flight calls fail immediately (previously the 30001ms timeout, i.e. the existing fast-fail guarantee is retained)

Known ceiling

A genuinely dead incumbent still wedges the bridge for up to 45s. Closing that needs a stable per-instance id from the BEX so the vault can tell "same instance reconnecting" from "second instance". That is a BEX-side change and is written up in the handoff doc; the vault side is deliberately not built ahead of it.

Follow-up owned by the BEX

A refused instance currently retries forever against a slot it can never win. It should treat close code 4409 as terminal and surface it. Handoff: docs/handoff-bex-bridge-slot-eviction.md.

…cted

The bridge holds one socket and every new connection replaced it
unconditionally. With the extension installed in two Chrome profiles, both
service workers connect to ws://localhost:1646/bex-bridge and evict each
other on every reconnect — the loser's backoff brings it straight back, so
the slot flaps indefinitely. Each MCP tool call then lands on whichever
wallet happens to hold the slot at that instant, with no error surfaced.
Observed as bex_tabs and bex_snapshot answering from different profiles
(tab ids from one are tab_not_found in the other) and two distinct
SW_STARTED_AT values from bex_status vs bex_ext_console.

Silent nondeterministic routing of signing requests is the real hazard, not
the broken automation.

A newcomer now only takes the slot if the incumbent has gone silent for
STALE_MS (45s, comfortably above the BEX's 20s heartbeat), which preserves
the case this replacement existed for: a service worker restart whose close
frame never arrived. A refused instance is closed with 4409 so the BEX can
tell "someone else holds this" from an ordinary disconnect.

Ceiling: a genuinely dead incumbent still wedges the bridge for up to 45s.
Closing that needs a stable per-instance id from the BEX so the vault can
tell "same instance reconnecting" from "second instance"; tracked in the
BEX handoff.
Any MCP operation that touches a page must be visible in that page, enforced
at the executeBrowserTool dispatcher rather than per call site, plus a
registry test so a newly added tool cannot silently opt out.
@BitHighlander
BitHighlander merged commit 9c30145 into develop Jul 20, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant