@mehmoodqureshi/chrome-mcp 0.9.5 → 0.9.7
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.
- package/README.md +60 -17
- package/dist/shared/protocol.d.ts +7 -0
- package/dist/src/bridge/evict.d.ts +5 -5
- package/dist/src/bridge/evict.js +5 -5
- package/dist/src/bridge/peer.d.ts +94 -0
- package/dist/src/bridge/peer.js +187 -0
- package/dist/src/bridge/profiles.d.ts +44 -0
- package/dist/src/bridge/profiles.js +121 -0
- package/dist/src/bridge/server.d.ts +84 -4
- package/dist/src/bridge/server.js +267 -14
- package/dist/src/cli.js +45 -19
- package/dist/src/config.d.ts +2 -0
- package/dist/src/config.js +13 -2
- package/dist/src/executor/extension-executor.d.ts +3 -0
- package/dist/src/executor/extension-executor.js +9 -0
- package/dist/src/executor/select.js +5 -1
- package/dist/src/mcp/tools.d.ts +2 -0
- package/dist/src/mcp/tools.js +75 -4
- package/dist/src/telemetry.d.ts +53 -0
- package/dist/src/telemetry.js +207 -0
- package/extension-dist/background.js +20 -5
- package/extension-dist/manifest.json +1 -1
- package/extension-dist/options.html +7 -5
- package/extension-dist/options.js +12 -4
- package/package.json +1 -1
|
@@ -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
|
|
11
|
-
* (superseding
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
12
|
-
* (superseding
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
298
|
-
|
|
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
|
@@ -22,6 +22,7 @@ const workspace_1 = require("./bridge/workspace");
|
|
|
22
22
|
const auth_1 = require("./bridge/auth");
|
|
23
23
|
const server_2 = require("./mcp/server");
|
|
24
24
|
const tools_1 = require("./mcp/tools");
|
|
25
|
+
const telemetry_1 = require("./telemetry");
|
|
25
26
|
const extension_install_1 = require("./extension-install");
|
|
26
27
|
/** Hard deadline for clean shutdown before we force-exit (a stuck socket must not hang us). */
|
|
27
28
|
const SHUTDOWN_DEADLINE_MS = 3000;
|
|
@@ -211,8 +212,44 @@ async function main() {
|
|
|
211
212
|
dataDir,
|
|
212
213
|
onLog: (m) => (0, server_2.logErr)(m),
|
|
213
214
|
onDisplacement: (d) => (0, server_2.logErr)(`SECURITY: extension connection displaced (different id: ${d.differentId})`),
|
|
215
|
+
// Only the server that owns the port publishes pairing files. A peer (another
|
|
216
|
+
// Claude session sharing this Chrome) leaves them alone, and publishes them
|
|
217
|
+
// itself if the hub exits and it takes the port over.
|
|
218
|
+
onRole: (role, info) => {
|
|
219
|
+
if (role === 'hub')
|
|
220
|
+
publishPairing(info.port, info.token);
|
|
221
|
+
else
|
|
222
|
+
(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`);
|
|
223
|
+
},
|
|
214
224
|
});
|
|
225
|
+
const publishPairing = (port, pairToken) => {
|
|
226
|
+
const path = (0, auth_1.writeHandshake)(dataDir, { port, token: pairToken });
|
|
227
|
+
(0, server_2.logErr)(`pairing handshake written to ${path} (mode 0600; token not logged)`);
|
|
228
|
+
// Drop the same port + token into the bundled extension folder so a Load
|
|
229
|
+
// unpacked from there pairs itself. Best-effort: a read-only install just
|
|
230
|
+
// falls back to the Options-page paste.
|
|
231
|
+
const extDir = installExtension();
|
|
232
|
+
const bundled = (0, auth_1.writeBundledPairing)(extDir, { port, token: pairToken });
|
|
233
|
+
if (bundled) {
|
|
234
|
+
(0, server_2.logErr)(`auto-pairing file written to ${bundled} — Load unpacked from ${extDir} needs no token paste`);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
(0, server_2.logDebug)(`auto-pairing file not written (extension folder missing or read-only at ${extDir})`);
|
|
238
|
+
}
|
|
239
|
+
// Anyone who loaded the extension straight from the package folder (0.8.0
|
|
240
|
+
// docs) keeps pairing too.
|
|
241
|
+
if (extDir !== (0, extension_install_1.bundledExtensionDir)())
|
|
242
|
+
(0, auth_1.writeBundledPairing)((0, extension_install_1.bundledExtensionDir)(), { port, token: pairToken });
|
|
243
|
+
};
|
|
215
244
|
const port = await bridge.start();
|
|
245
|
+
(0, tools_1.setProfileBridge)(bridge);
|
|
246
|
+
(0, telemetry_1.initTelemetry)({
|
|
247
|
+
dataDir,
|
|
248
|
+
version: version(),
|
|
249
|
+
disabledByFlag: cfg.noTelemetry,
|
|
250
|
+
log: (m) => (0, server_2.logErr)(m),
|
|
251
|
+
context: () => ({ role: bridge.role, browsers: bridge.connectedProfiles().length }),
|
|
252
|
+
});
|
|
216
253
|
// Never includes the pairing token — only the resolved, non-secret config.
|
|
217
254
|
(0, server_2.logDebug)(`resolved config: ${JSON.stringify({
|
|
218
255
|
wsPort: port,
|
|
@@ -222,23 +259,7 @@ async function main() {
|
|
|
222
259
|
prefer: cfg.prefer,
|
|
223
260
|
policy: cfg.policy,
|
|
224
261
|
})}`);
|
|
225
|
-
const handshakePath = (0,
|
|
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 });
|
|
262
|
+
const handshakePath = (0, datadir_1.handshakePath)(dataDir);
|
|
242
263
|
if (process.env.CHROME_MCP_TOKEN) {
|
|
243
264
|
(0, server_2.logErr)('token: pinned from CHROME_MCP_TOKEN (stable; pair once, never again).');
|
|
244
265
|
}
|
|
@@ -246,7 +267,10 @@ async function main() {
|
|
|
246
267
|
(0, server_2.logErr)('token: persisted across restarts (--persist-token; pair once, never again).');
|
|
247
268
|
}
|
|
248
269
|
const cleanup = () => {
|
|
249
|
-
|
|
270
|
+
// The handshake belongs to whichever server owns the port; a peer exiting
|
|
271
|
+
// must not delete the hub's.
|
|
272
|
+
if (bridge.role === 'hub')
|
|
273
|
+
(0, auth_1.removeHandshake)(dataDir);
|
|
250
274
|
};
|
|
251
275
|
// Manual pairing helper: run the bridge, print the path, keep alive. NOT MCP.
|
|
252
276
|
if (cfg.printPairing) {
|
|
@@ -293,7 +317,9 @@ async function main() {
|
|
|
293
317
|
return;
|
|
294
318
|
shuttingDown = true;
|
|
295
319
|
cleanup();
|
|
296
|
-
|
|
320
|
+
// Telemetry first: its final summary reads the bridge's role and paired
|
|
321
|
+
// browsers, which bridge.stop() clears synchronously.
|
|
322
|
+
exitWithDeadline(Promise.allSettled([(0, telemetry_1.stopTelemetry)(), (0, server_2.stopMcpServer)(), bridge.stop()]));
|
|
297
323
|
};
|
|
298
324
|
process.on('SIGINT', shutdown);
|
|
299
325
|
process.on('SIGTERM', shutdown);
|
package/dist/src/config.d.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface CliConfig {
|
|
|
34
34
|
showExtensionPath: boolean;
|
|
35
35
|
/** `--persist-token`: reuse a stable on-disk token so the extension never re-pairs. */
|
|
36
36
|
persistToken: boolean;
|
|
37
|
+
/** `--no-telemetry`: send no anonymous usage statistics (see src/telemetry.ts). */
|
|
38
|
+
noTelemetry: boolean;
|
|
37
39
|
/**
|
|
38
40
|
* `--tools`: advertise only these tools. `undefined` = the whole catalog.
|
|
39
41
|
* Names are validated against the catalog by `setToolAllowlist` at startup —
|