@mehmoodqureshi/chrome-mcp 0.9.4 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,10 +7,25 @@
7
7
  * 1. Accept any loopback upgrade.
8
8
  * 2. Require a valid `hello` (matching version + token) within HELLO_TIMEOUT;
9
9
  * otherwise send `unauthorized` and close 4401.
10
- * 3. On success, send `welcome`, promote to the single ACTIVE connection
11
- * (superseding any prior one — a security-relevant displacement event).
10
+ * 3. On success, send `welcome` and file the connection under its profile
11
+ * (superseding only that profile's prior one — a security-relevant
12
+ * displacement event).
13
+ * Other chrome-mcp servers (one per Claude session) may join the same port as
14
+ * peers with `peer_hello` and relay their commands through us; see ./peer.
12
15
  */
13
16
  import { type WireEvent, type WireMethod, type WirePolicy } from '../../shared/protocol';
17
+ /** Where a live connection's profile name came from. */
18
+ export type ProfileNaming = 'options' | 'auto' | 'legacy';
19
+ /** One paired browser, as reported by chrome_status. */
20
+ export interface PairedProfile {
21
+ name: string;
22
+ /** options = typed in the extension's Options; auto = assigned by the server
23
+ * (renamable with profile_rename); legacy = an older extension, "default". */
24
+ naming: ProfileNaming;
25
+ /** The browser's active tab when it last reported one — a hint for telling
26
+ * auto-named browsers apart. */
27
+ activeUrl?: string;
28
+ }
14
29
  export interface DisplacementInfo {
15
30
  oldExtId: string;
16
31
  newExtId: string;
@@ -33,7 +48,19 @@ export interface BridgeOptions {
33
48
  onLog?: (message: string) => void;
34
49
  onDisplacement?: (info: DisplacementInfo) => void;
35
50
  onEvent?: (event: WireEvent, data: Record<string, unknown>) => void;
51
+ /**
52
+ * Called whenever this server takes a role on the port: 'hub' when it binds
53
+ * it (publish the handshake + pairing files then), 'peer' when it joins the
54
+ * chrome-mcp already holding it. Fires again after a failover.
55
+ */
56
+ onRole?: (role: BridgeRole, info: {
57
+ port: number;
58
+ token: string;
59
+ hubPid?: number;
60
+ }) => void;
36
61
  }
62
+ /** hub = owns the port and the browser connections; peer = relays through the hub. */
63
+ export type BridgeRole = 'hub' | 'peer';
37
64
  export declare class BridgeServer {
38
65
  private readonly opts;
39
66
  private wss;
@@ -41,11 +68,41 @@ export declare class BridgeServer {
41
68
  * once; a command is routed to the connection for its target profile. A new
42
69
  * hello for the SAME profile supersedes that profile's connection only. */
43
70
  private conns;
71
+ /** Profile routing key → how that live connection got its name, and the
72
+ * install behind it (absent for extensions too old to send one). */
73
+ private origins;
74
+ /** Remembers which name each extension install was given. */
75
+ private registry;
44
76
  private boundPort;
45
77
  private readonly heartbeatMs;
78
+ /** The pairing secret. Starts as ours; a peer adopts the hub's, so if it
79
+ * later takes the port over the extension re-pairs without a new token. */
80
+ private token;
81
+ /** Peer role: the link to the hub that owns the browsers. */
82
+ private hub;
83
+ /** Hub role: the other chrome-mcp servers relaying through us. */
84
+ private readonly peers;
85
+ private stopped;
46
86
  constructor(opts: BridgeOptions);
47
- /** Bind and start listening. Returns the actual port (useful with port 0). */
87
+ /** 'hub' or 'peer' once started; null before start and between roles. */
88
+ get role(): BridgeRole | null;
89
+ /**
90
+ * Bind and start listening, or — when a pinned port is held by another live
91
+ * chrome-mcp — join it as a peer. Returns the port (useful with port 0).
92
+ */
48
93
  start(): Promise<number>;
94
+ private claim;
95
+ /**
96
+ * Join the chrome-mcp holding `port` as a peer. Tries the token in the
97
+ * handshake it published first (sessions without --persist-token each mint
98
+ * their own), then ours.
99
+ */
100
+ private joinHub;
101
+ /** The token the port's current owner published, if its handshake describes that port. */
102
+ private publishedToken;
103
+ /** The hub exited (its session ended): race the other peers for the port. */
104
+ private onHubLost;
105
+ private failover;
49
106
  /** One bind attempt. Resolves with a listening server or rejects with the listen error. */
50
107
  private listenOnce;
51
108
  stop(): Promise<void>;
@@ -73,13 +130,36 @@ export declare class BridgeServer {
73
130
  lastActiveUrl(profile: string | undefined, maxAgeMs: number): string | null;
74
131
  /** Same, for an explicitly-targeted tab (wire id) — see ExtensionConnection.lastTabUrl. */
75
132
  lastTabUrl(profile: string | undefined, tabId: string, maxAgeMs: number): string | null;
76
- private noPairMessage;
133
+ /** How to pair a browser for `profile` — surfaced whenever that profile has no live connection. */
134
+ noPairMessage(profile: string): string;
135
+ /** Every paired browser with how it was named — for chrome_status. */
136
+ pairedProfiles(): PairedProfile[];
137
+ /**
138
+ * Rename an automatically named browser. The new name sticks across restarts
139
+ * (it's stored against the extension install); a live connection is re-keyed
140
+ * in place, so it keeps working without re-pairing.
141
+ */
142
+ renameProfile(from: string, to: string): Promise<string>;
77
143
  status(): {
78
144
  extensionConnected: boolean;
79
145
  port: number;
80
146
  connectedProfiles: string[];
81
147
  };
82
148
  private handleConnection;
149
+ /**
150
+ * Pick the routing key for an authenticated hello. A Profile typed into the
151
+ * Options wins; otherwise an install keeps the name the registry gave it, and
152
+ * a new install gets the first free one — never a name another browser holds,
153
+ * so a second blank-profile Chrome joins instead of knocking the first off.
154
+ * An extension too old to send an installId pairs as "default", as before.
155
+ */
156
+ private nameFor;
157
+ /** Hub side: serve a joined peer's relayed commands against our browsers. */
158
+ private acceptPeer;
159
+ private handlePeerFrame;
160
+ /** Tell every peer which browsers are paired — on each pair, unpair and rename. */
161
+ private broadcastState;
162
+ private sendPeer;
83
163
  private reject;
84
164
  private promote;
85
165
  private send;
@@ -8,8 +8,11 @@
8
8
  * 1. Accept any loopback upgrade.
9
9
  * 2. Require a valid `hello` (matching version + token) within HELLO_TIMEOUT;
10
10
  * otherwise send `unauthorized` and close 4401.
11
- * 3. On success, send `welcome`, promote to the single ACTIVE connection
12
- * (superseding any prior one — a security-relevant displacement event).
11
+ * 3. On success, send `welcome` and file the connection under its profile
12
+ * (superseding only that profile's prior one — a security-relevant
13
+ * displacement event).
14
+ * Other chrome-mcp servers (one per Claude session) may join the same port as
15
+ * peers with `peer_hello` and relay their commands through us; see ./peer.
13
16
  */
14
17
  Object.defineProperty(exports, "__esModule", { value: true });
15
18
  exports.BridgeServer = void 0;
@@ -22,6 +25,8 @@ const connection_1 = require("./connection");
22
25
  const auth_1 = require("./auth");
23
26
  const evict_1 = require("./evict");
24
27
  const config_1 = require("../config");
28
+ const profiles_1 = require("./profiles");
29
+ const peer_1 = require("./peer");
25
30
  /** The routing label for a hello with no/blank profile — the back-compat default. */
26
31
  const DEFAULT_PROFILE = 'default';
27
32
  /** Reduce a hello's profile label to a safe routing key; blank/invalid → "default". */
@@ -61,16 +66,42 @@ class BridgeServer {
61
66
  * once; a command is routed to the connection for its target profile. A new
62
67
  * hello for the SAME profile supersedes that profile's connection only. */
63
68
  conns = new Map();
69
+ /** Profile routing key → how that live connection got its name, and the
70
+ * install behind it (absent for extensions too old to send one). */
71
+ origins = new Map();
72
+ /** Remembers which name each extension install was given. */
73
+ registry;
64
74
  boundPort = 0;
65
75
  heartbeatMs;
76
+ /** The pairing secret. Starts as ours; a peer adopts the hub's, so if it
77
+ * later takes the port over the extension re-pairs without a new token. */
78
+ token;
79
+ /** Peer role: the link to the hub that owns the browsers. */
80
+ hub = null;
81
+ /** Hub role: the other chrome-mcp servers relaying through us. */
82
+ peers = new Set();
83
+ stopped = false;
66
84
  constructor(opts) {
67
85
  this.opts = opts;
68
86
  this.heartbeatMs = opts.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
87
+ this.registry = new profiles_1.ProfileRegistry(opts.dataDir);
88
+ this.token = opts.token;
69
89
  }
70
- /** Bind and start listening. Returns the actual port (useful with port 0). */
90
+ /** 'hub' or 'peer' once started; null before start and between roles. */
91
+ get role() {
92
+ return this.wss ? 'hub' : this.hub ? 'peer' : null;
93
+ }
94
+ /**
95
+ * Bind and start listening, or — when a pinned port is held by another live
96
+ * chrome-mcp — join it as a peer. Returns the port (useful with port 0).
97
+ */
71
98
  async start() {
72
- if (this.wss)
99
+ if (this.wss || this.hub)
73
100
  return this.boundPort;
101
+ this.stopped = false;
102
+ return this.claim();
103
+ }
104
+ async claim() {
74
105
  const host = this.opts.host ?? protocol_1.BRIDGE_HOST;
75
106
  const port = this.opts.port ?? 0;
76
107
  // A fixed port can be briefly held by a just-replaced instance of ourselves
@@ -79,23 +110,34 @@ class BridgeServer {
79
110
  // it never frees up do we surface a plain-English, actionable error.
80
111
  const deadline = Date.now() + PORT_WAIT_MS;
81
112
  let evicted = false;
113
+ let triedJoin = false;
82
114
  for (let attempt = 1;; attempt++) {
83
115
  try {
84
116
  const wss = await this.listenOnce(host, port);
85
117
  const addr = wss.address();
86
118
  this.boundPort = typeof addr === 'object' && addr ? addr.port : port;
87
119
  this.wss = wss;
120
+ // A peer promoted by failover picks up names the old hub assigned since.
121
+ this.registry = new profiles_1.ProfileRegistry(this.opts.dataDir);
88
122
  this.log(`bridge listening on ${host}:${this.boundPort}`);
123
+ this.opts.onRole?.('hub', { port: this.boundPort, token: this.token });
89
124
  return this.boundPort;
90
125
  }
91
126
  catch (err) {
92
127
  const inUse = err?.code === 'EADDRINUSE';
93
128
  if (!inUse || port === 0)
94
129
  throw inUse ? new Error(portBusyMessage(host, port)) : err;
95
- // A live chrome-mcp from another session will never release the port on
96
- // its own, so waiting it out is futile — take it over (once). Only a
97
- // verified chrome-mcp is ever killed; anything else falls through to the
98
- // wait-and-retry below, which covers our own just-replaced instance.
130
+ // Another Claude session's chrome-mcp owns the port: share it rather
131
+ // than kill it, so every session keeps working at once.
132
+ if (!triedJoin) {
133
+ triedJoin = true;
134
+ if (await this.joinHub(host, port))
135
+ return this.boundPort;
136
+ }
137
+ // Still busy after a failed join: an older chrome-mcp that can't share,
138
+ // or a hung one. It will never release the port on its own, so take it
139
+ // over (once). Only a verified chrome-mcp is ever killed; anything else
140
+ // falls through to the wait-and-retry below.
99
141
  if (!evicted && this.opts.dataDir) {
100
142
  evicted = true;
101
143
  if (await (0, evict_1.evictPortOwner)(this.opts.dataDir, port, (m) => this.log(m))) {
@@ -110,6 +152,68 @@ class BridgeServer {
110
152
  }
111
153
  }
112
154
  }
155
+ /**
156
+ * Join the chrome-mcp holding `port` as a peer. Tries the token in the
157
+ * handshake it published first (sessions without --persist-token each mint
158
+ * their own), then ours.
159
+ */
160
+ async joinHub(host, port) {
161
+ const tokens = new Set();
162
+ const published = this.publishedToken(port);
163
+ if (published)
164
+ tokens.add(published);
165
+ tokens.add(this.token);
166
+ for (const token of tokens) {
167
+ const link = await peer_1.HubLink.join(host, port, token, () => this.onHubLost());
168
+ if (!link)
169
+ continue;
170
+ if (this.stopped) {
171
+ link.close();
172
+ return false;
173
+ }
174
+ this.hub = link;
175
+ this.token = token;
176
+ this.boundPort = port;
177
+ this.log(`port ${host}:${port} is held by chrome-mcp pid ${link.hubPid || '?'} — sharing its browsers as a peer`);
178
+ this.opts.onRole?.('peer', { port, token, hubPid: link.hubPid });
179
+ return true;
180
+ }
181
+ return false;
182
+ }
183
+ /** The token the port's current owner published, if its handshake describes that port. */
184
+ publishedToken(port) {
185
+ if (!this.opts.dataDir)
186
+ return null;
187
+ try {
188
+ const h = (0, auth_1.readHandshake)(this.opts.dataDir);
189
+ return h.port === port && h.pid !== process.pid && typeof h.token === 'string' ? h.token : null;
190
+ }
191
+ catch {
192
+ return null;
193
+ }
194
+ }
195
+ /** The hub exited (its session ended): race the other peers for the port. */
196
+ onHubLost() {
197
+ this.hub = null;
198
+ if (this.stopped)
199
+ return;
200
+ this.log('the chrome-mcp sharing its browsers with us went away — taking over the port');
201
+ void this.failover();
202
+ }
203
+ async failover() {
204
+ // Stagger so surviving peers don't all bind at once; the losers join the winner.
205
+ await delay(50 + Math.floor(Math.random() * 250));
206
+ for (let attempt = 1; !this.stopped && !this.wss && !this.hub; attempt++) {
207
+ try {
208
+ await this.claim();
209
+ return;
210
+ }
211
+ catch (err) {
212
+ this.log(`failover attempt ${attempt} failed: ${err instanceof Error ? err.message : String(err)}`);
213
+ await delay(1_000);
214
+ }
215
+ }
216
+ }
113
217
  /** One bind attempt. Resolves with a listening server or rejects with the listen error. */
114
218
  listenOnce(host, port) {
115
219
  return new Promise((resolve, reject) => {
@@ -135,6 +239,12 @@ class BridgeServer {
135
239
  });
136
240
  }
137
241
  async stop() {
242
+ this.stopped = true;
243
+ this.hub?.close();
244
+ this.hub = null;
245
+ for (const peer of this.peers)
246
+ peer.close(1001, 'server stopping');
247
+ this.peers.clear();
138
248
  for (const conn of this.conns.values())
139
249
  conn.close(1001, 'server stopping');
140
250
  this.conns.clear();
@@ -148,6 +258,8 @@ class BridgeServer {
148
258
  }
149
259
  /** True when ANY browser is paired (used as the selector's cheap gate). */
150
260
  hasActiveExtension() {
261
+ if (this.hub)
262
+ return this.hub.profiles.length > 0;
151
263
  for (const conn of this.conns.values())
152
264
  if (conn.isOpen())
153
265
  return true;
@@ -155,11 +267,15 @@ class BridgeServer {
155
267
  }
156
268
  /** True when the given profile has a live connection. */
157
269
  hasConnection(profile) {
270
+ if (this.hub)
271
+ return this.hub.profiles.some((p) => p.name === routeKey(profile));
158
272
  const conn = this.conns.get(routeKey(profile));
159
273
  return !!conn && conn.isOpen();
160
274
  }
161
275
  /** Profiles with a live connection right now. */
162
276
  connectedProfiles() {
277
+ if (this.hub)
278
+ return this.hub.profiles.map((p) => p.name);
163
279
  const out = [];
164
280
  for (const [profile, conn] of this.conns)
165
281
  if (conn.isOpen())
@@ -171,6 +287,8 @@ class BridgeServer {
171
287
  * Rejects with an actionable message if that profile has no live browser.
172
288
  */
173
289
  async sendCommand(method, params, opts) {
290
+ if (this.hub)
291
+ return this.hub.relay(method, params, opts);
174
292
  const profile = routeKey(opts?.profile);
175
293
  const conn = this.conns.get(profile);
176
294
  if (!conn || !conn.isOpen()) {
@@ -184,19 +302,68 @@ class BridgeServer {
184
302
  * to report URLs, a tab shuffle since, or simply nothing recent enough.
185
303
  */
186
304
  lastActiveUrl(profile, maxAgeMs) {
305
+ if (this.hub)
306
+ return null; // a peer has no cache; the caller asks properly
187
307
  const conn = this.conns.get(routeKey(profile));
188
308
  return conn?.isOpen() ? conn.lastActiveUrl(maxAgeMs) : null;
189
309
  }
190
310
  /** Same, for an explicitly-targeted tab (wire id) — see ExtensionConnection.lastTabUrl. */
191
311
  lastTabUrl(profile, tabId, maxAgeMs) {
312
+ if (this.hub)
313
+ return null;
192
314
  const conn = this.conns.get(routeKey(profile));
193
315
  return conn?.isOpen() ? conn.lastTabUrl(tabId, maxAgeMs) : null;
194
316
  }
317
+ /** How to pair a browser for `profile` — surfaced whenever that profile has no live connection. */
195
318
  noPairMessage(profile) {
196
319
  return (`No browser is paired for profile "${profile}". In that Chrome's chrome-mcp ` +
197
320
  `extension Options, set Port ${this.boundPort}, paste the token, set Profile to ` +
198
321
  `"${profile}", and Save — then it joins without disturbing your other profiles.`);
199
322
  }
323
+ /** Every paired browser with how it was named — for chrome_status. */
324
+ pairedProfiles() {
325
+ if (this.hub)
326
+ return [...this.hub.profiles];
327
+ const out = [];
328
+ for (const [name, conn] of this.conns) {
329
+ if (!conn.isOpen())
330
+ continue;
331
+ const activeUrl = conn.lastActiveUrl(Number.POSITIVE_INFINITY) ?? undefined;
332
+ out.push({ name, naming: this.origins.get(name)?.naming ?? 'legacy', ...(activeUrl ? { activeUrl } : {}) });
333
+ }
334
+ return out;
335
+ }
336
+ /**
337
+ * Rename an automatically named browser. The new name sticks across restarts
338
+ * (it's stored against the extension install); a live connection is re-keyed
339
+ * in place, so it keeps working without re-pairing.
340
+ */
341
+ async renameProfile(from, to) {
342
+ if (this.hub)
343
+ return String(await this.hub.rename(from, to));
344
+ const src = routeKey(from);
345
+ const live = this.conns.get(src);
346
+ if (live?.isOpen() && this.origins.get(src)?.naming === 'options') {
347
+ throw new Error(`Profile "${src}" was named in that Chrome's extension Options — change it there ` +
348
+ `(clear the field to let the server name it).`);
349
+ }
350
+ const target = (0, config_1.sanitizeName)(to, 'profile');
351
+ if (target !== src && this.conns.get(target)?.isOpen()) {
352
+ throw new Error(`profile "${target}" already has a paired browser`);
353
+ }
354
+ const name = this.registry.rename(src, target);
355
+ if (name !== src && live) {
356
+ this.conns.delete(src);
357
+ this.conns.set(name, live);
358
+ const origin = this.origins.get(src);
359
+ this.origins.delete(src);
360
+ if (origin)
361
+ this.origins.set(name, origin);
362
+ this.log(`profile "${src}" renamed to "${name}"`);
363
+ this.broadcastState();
364
+ }
365
+ return name;
366
+ }
200
367
  status() {
201
368
  return {
202
369
  extensionConnected: this.hasActiveExtension(),
@@ -233,6 +400,19 @@ class BridgeServer {
233
400
  this.reject(ws, 'bad_token');
234
401
  return;
235
402
  }
403
+ if (frame.type === 'peer_hello') {
404
+ // Another chrome-mcp (a second Claude session) joining to share our browsers.
405
+ const peer = frame;
406
+ clearTimeout(helloTimer);
407
+ if (peer.v !== protocol_1.PROTOCOL_VERSION)
408
+ return this.reject(ws, 'bad_version');
409
+ if (typeof peer.token !== 'string' || !(0, auth_1.tokensMatch)(peer.token, this.token))
410
+ return this.reject(ws, 'bad_token');
411
+ authed = true;
412
+ ws.off('message', onMessage);
413
+ this.acceptPeer(ws, peer.pid);
414
+ return;
415
+ }
236
416
  if (frame.type !== 'hello')
237
417
  return; // ignore noise until a hello arrives
238
418
  if (frame.v !== protocol_1.PROTOCOL_VERSION) {
@@ -240,7 +420,7 @@ class BridgeServer {
240
420
  this.reject(ws, 'bad_version');
241
421
  return;
242
422
  }
243
- if (typeof frame.token !== 'string' || !(0, auth_1.tokensMatch)(frame.token, this.opts.token)) {
423
+ if (typeof frame.token !== 'string' || !(0, auth_1.tokensMatch)(frame.token, this.token)) {
244
424
  clearTimeout(helloTimer);
245
425
  this.reject(ws, 'bad_token');
246
426
  return;
@@ -249,13 +429,77 @@ class BridgeServer {
249
429
  authed = true;
250
430
  clearTimeout(helloTimer);
251
431
  ws.off('message', onMessage);
252
- this.promote(ws, frame.ext ?? { id: 'unknown', version: '0', chrome: '0' }, routeKey(frame.profile), Array.isArray(frame.caps) ? frame.caps : undefined);
432
+ const { profile, naming, installId } = this.nameFor(frame);
433
+ this.promote(ws, frame.ext ?? { id: 'unknown', version: '0', chrome: '0' }, profile, Array.isArray(frame.caps) ? frame.caps : undefined, { naming, installId });
253
434
  };
254
435
  ws.on('message', onMessage);
255
436
  ws.on('error', () => {
256
437
  /* pre-auth socket errors are non-fatal; the close will clean up */
257
438
  });
258
439
  }
440
+ /**
441
+ * Pick the routing key for an authenticated hello. A Profile typed into the
442
+ * Options wins; otherwise an install keeps the name the registry gave it, and
443
+ * a new install gets the first free one — never a name another browser holds,
444
+ * so a second blank-profile Chrome joins instead of knocking the first off.
445
+ * An extension too old to send an installId pairs as "default", as before.
446
+ */
447
+ nameFor(frame) {
448
+ const installId = (0, profiles_1.isValidInstallId)(frame.installId) ? frame.installId : undefined;
449
+ if (frame.profile && frame.profile.trim())
450
+ return { profile: routeKey(frame.profile), naming: 'options', installId };
451
+ if (!installId)
452
+ return { profile: DEFAULT_PROFILE, naming: 'legacy' };
453
+ const heldByOther = (name) => {
454
+ const conn = this.conns.get(name);
455
+ return !!conn && conn.isOpen() && this.origins.get(name)?.installId !== installId;
456
+ };
457
+ return { profile: this.registry.assign(installId, heldByOther), naming: 'auto', installId };
458
+ }
459
+ /** Hub side: serve a joined peer's relayed commands against our browsers. */
460
+ acceptPeer(ws, pid) {
461
+ this.peers.add(ws);
462
+ this.log(`chrome-mcp pid ${pid ?? '?'} joined as a peer (${this.peers.size} sharing these browsers)`);
463
+ this.sendPeer(ws, { type: 'peer_welcome', hubPid: process.pid });
464
+ this.sendPeer(ws, { type: 'peer_state', profiles: this.pairedProfiles() });
465
+ ws.on('message', (raw) => this.handlePeerFrame(ws, raw.toString()));
466
+ ws.on('close', () => this.peers.delete(ws));
467
+ ws.on('error', () => {
468
+ /* the close that follows cleans up */
469
+ });
470
+ }
471
+ handlePeerFrame(ws, raw) {
472
+ let frame;
473
+ try {
474
+ frame = JSON.parse(raw);
475
+ }
476
+ catch {
477
+ return;
478
+ }
479
+ const answer = (id, work) => {
480
+ work.then((data) => this.sendPeer(ws, { type: 'relay_result', id, ok: true, data }), (err) => this.sendPeer(ws, (0, peer_1.relayError)(id, err)));
481
+ };
482
+ if (frame.type === 'relay')
483
+ answer(frame.id, this.sendCommand(frame.method, frame.params ?? {}, frame.opts));
484
+ else if (frame.type === 'peer_rename')
485
+ answer(frame.id, this.renameProfile(frame.from, frame.to));
486
+ }
487
+ /** Tell every peer which browsers are paired — on each pair, unpair and rename. */
488
+ broadcastState() {
489
+ if (this.peers.size === 0)
490
+ return;
491
+ const state = { type: 'peer_state', profiles: this.pairedProfiles() };
492
+ for (const peer of this.peers)
493
+ this.sendPeer(peer, state);
494
+ }
495
+ sendPeer(ws, frame) {
496
+ try {
497
+ ws.send(JSON.stringify(frame));
498
+ }
499
+ catch {
500
+ /* peer already gone */
501
+ }
502
+ }
259
503
  reject(ws, reason) {
260
504
  this.send(ws, { type: 'unauthorized', v: protocol_1.PROTOCOL_VERSION, reason });
261
505
  this.log(`rejected a connection: ${reason}`);
@@ -266,7 +510,7 @@ class BridgeServer {
266
510
  /* ignore */
267
511
  }
268
512
  }
269
- promote(ws, ext, profile, caps) {
513
+ promote(ws, ext, profile, caps, origin) {
270
514
  const sessionId = (0, node_crypto_1.randomUUID)();
271
515
  // Supersede only the SAME profile's connection (a re-pair). Other profiles
272
516
  // keep their live connections, so several browsers stay paired at once.
@@ -293,12 +537,20 @@ class BridgeServer {
293
537
  onEvent: this.opts.onEvent,
294
538
  onLog: (m) => this.log(m),
295
539
  onClose: () => {
296
- // Only clear if a newer re-pair hasn't already replaced this slot.
297
- if (this.conns.get(profile)?.sessionId === sessionId)
298
- this.conns.delete(profile);
540
+ // Only clear if a newer re-pair hasn't already replaced this slot. Look it
541
+ // up by session: profile_rename may have re-keyed the slot since.
542
+ for (const [key, c] of this.conns) {
543
+ if (c.sessionId !== sessionId)
544
+ continue;
545
+ this.conns.delete(key);
546
+ this.origins.delete(key);
547
+ }
548
+ this.broadcastState();
299
549
  },
300
550
  });
301
551
  this.conns.set(profile, conn);
552
+ this.origins.set(profile, origin);
553
+ this.broadcastState();
302
554
  const welcome = {
303
555
  type: 'welcome',
304
556
  v: protocol_1.PROTOCOL_VERSION,
@@ -306,6 +558,7 @@ class BridgeServer {
306
558
  sessionId,
307
559
  heartbeatMs: this.heartbeatMs,
308
560
  policy: this.opts.policy ?? policy_1.DENY_ALL_WIRE_POLICY,
561
+ profile,
309
562
  };
310
563
  this.send(ws, welcome);
311
564
  this.log(`extension paired (profile "${profile}", session ${sessionId}, id "${ext.id}")`);
package/dist/src/cli.js CHANGED
@@ -211,8 +211,37 @@ async function main() {
211
211
  dataDir,
212
212
  onLog: (m) => (0, server_2.logErr)(m),
213
213
  onDisplacement: (d) => (0, server_2.logErr)(`SECURITY: extension connection displaced (different id: ${d.differentId})`),
214
+ // Only the server that owns the port publishes pairing files. A peer (another
215
+ // Claude session sharing this Chrome) leaves them alone, and publishes them
216
+ // itself if the hub exits and it takes the port over.
217
+ onRole: (role, info) => {
218
+ if (role === 'hub')
219
+ publishPairing(info.port, info.token);
220
+ else
221
+ (0, server_2.logErr)(`sharing the browsers of the chrome-mcp already on port ${info.port} (pid ${info.hubPid || '?'}) — several sessions can drive Chrome at once`);
222
+ },
214
223
  });
224
+ const publishPairing = (port, pairToken) => {
225
+ const path = (0, auth_1.writeHandshake)(dataDir, { port, token: pairToken });
226
+ (0, server_2.logErr)(`pairing handshake written to ${path} (mode 0600; token not logged)`);
227
+ // Drop the same port + token into the bundled extension folder so a Load
228
+ // unpacked from there pairs itself. Best-effort: a read-only install just
229
+ // falls back to the Options-page paste.
230
+ const extDir = installExtension();
231
+ const bundled = (0, auth_1.writeBundledPairing)(extDir, { port, token: pairToken });
232
+ if (bundled) {
233
+ (0, server_2.logErr)(`auto-pairing file written to ${bundled} — Load unpacked from ${extDir} needs no token paste`);
234
+ }
235
+ else {
236
+ (0, server_2.logDebug)(`auto-pairing file not written (extension folder missing or read-only at ${extDir})`);
237
+ }
238
+ // Anyone who loaded the extension straight from the package folder (0.8.0
239
+ // docs) keeps pairing too.
240
+ if (extDir !== (0, extension_install_1.bundledExtensionDir)())
241
+ (0, auth_1.writeBundledPairing)((0, extension_install_1.bundledExtensionDir)(), { port, token: pairToken });
242
+ };
215
243
  const port = await bridge.start();
244
+ (0, tools_1.setProfileBridge)(bridge);
216
245
  // Never includes the pairing token — only the resolved, non-secret config.
217
246
  (0, server_2.logDebug)(`resolved config: ${JSON.stringify({
218
247
  wsPort: port,
@@ -222,23 +251,7 @@ async function main() {
222
251
  prefer: cfg.prefer,
223
252
  policy: cfg.policy,
224
253
  })}`);
225
- const handshakePath = (0, auth_1.writeHandshake)(dataDir, { port, token });
226
- (0, server_2.logErr)(`pairing handshake written to ${handshakePath} (mode 0600; token not logged)`);
227
- // Drop the same port + token into the bundled extension folder so a Load
228
- // unpacked from there pairs itself. Best-effort: a read-only install just
229
- // falls back to the Options-page paste.
230
- const extDir = installExtension();
231
- const bundled = (0, auth_1.writeBundledPairing)(extDir, { port, token });
232
- if (bundled) {
233
- (0, server_2.logErr)(`auto-pairing file written to ${bundled} — Load unpacked from ${extDir} needs no token paste`);
234
- }
235
- else {
236
- (0, server_2.logDebug)(`auto-pairing file not written (extension folder missing or read-only at ${extDir})`);
237
- }
238
- // Anyone who loaded the extension straight from the package folder (0.8.0
239
- // docs) keeps pairing too.
240
- if (extDir !== (0, extension_install_1.bundledExtensionDir)())
241
- (0, auth_1.writeBundledPairing)((0, extension_install_1.bundledExtensionDir)(), { port, token });
254
+ const handshakePath = (0, datadir_1.handshakePath)(dataDir);
242
255
  if (process.env.CHROME_MCP_TOKEN) {
243
256
  (0, server_2.logErr)('token: pinned from CHROME_MCP_TOKEN (stable; pair once, never again).');
244
257
  }
@@ -246,7 +259,10 @@ async function main() {
246
259
  (0, server_2.logErr)('token: persisted across restarts (--persist-token; pair once, never again).');
247
260
  }
248
261
  const cleanup = () => {
249
- (0, auth_1.removeHandshake)(dataDir);
262
+ // The handshake belongs to whichever server owns the port; a peer exiting
263
+ // must not delete the hub's.
264
+ if (bridge.role === 'hub')
265
+ (0, auth_1.removeHandshake)(dataDir);
250
266
  };
251
267
  // Manual pairing helper: run the bridge, print the path, keep alive. NOT MCP.
252
268
  if (cfg.printPairing) {
@@ -294,8 +294,10 @@ Connection:
294
294
  --profile <name> Default browser profile / identity (default "default").
295
295
  Artifacts live under profiles/<name>/. At runtime, switch
296
296
  with the profile_use tool. Several browsers can pair to the
297
- SAME port+token at once, each declaring its own Profile in
298
- the extension Options; tools route to the active profile.
297
+ SAME port+token at once; each is named automatically
298
+ (default, profile-2, ...; rename with profile_rename) unless
299
+ its extension Options pins a Profile. Tools route to the
300
+ active profile.
299
301
  --task <name> Task label (default "default"). Downloads and a meta.json
300
302
  land in profiles/<profile>/tasks/<task>/.
301
303
  --print-pairing Write the handshake, print its path, and keep the bridge
@@ -20,6 +20,9 @@ export declare class ExtensionExecutor implements Executor {
20
20
  status(): ExecutorStatus;
21
21
  ensureReady(): Promise<void>;
22
22
  ping(deadlineMs?: number): Promise<boolean>;
23
+ /** Why this executor can't serve the active profile right now: not paired, or
24
+ * paired but not answering pings. Used for the selector's NO_BACKEND message. */
25
+ unavailableReason(): string;
23
26
  dispose(): Promise<void>;
24
27
  /** The active tab's URL as reported by the last command on this profile, if it
25
28
  * is fresh enough to gate against. See `ACTIVE_URL_TTL_MS`. */