@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
package/README.md
CHANGED
|
@@ -160,23 +160,41 @@ bundled file.
|
|
|
160
160
|
|
|
161
161
|
### Running more than one session
|
|
162
162
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
port
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
163
|
+
Every MCP host session (each Claude terminal, tab or window) starts its own
|
|
164
|
+
chrome-mcp, and they all share your Chrome at once. The first one to start owns
|
|
165
|
+
the bridge port and the extension connections — the **hub**. Each later session
|
|
166
|
+
finds the port held by a live chrome-mcp and joins it as a **peer**: its tool
|
|
167
|
+
calls are relayed through the hub to the same browsers, so every session keeps
|
|
168
|
+
working side by side. Nobody is disconnected.
|
|
169
|
+
|
|
170
|
+
When the hub's session ends, its peers race for the port; one takes it over
|
|
171
|
+
(with the same token, so the extension re-pairs by itself within a few seconds)
|
|
172
|
+
and the rest join the new hub. A call that was in flight at that moment fails
|
|
173
|
+
once with `EXTENSION_DISCONNECTED` and is retried automatically when it is safe
|
|
174
|
+
to repeat.
|
|
175
|
+
|
|
176
|
+
Peers authenticate with the pairing token from the 0600 handshake file, so only
|
|
177
|
+
your own OS user can join. A chrome-mcp too old to share the port is replaced as
|
|
178
|
+
before: it is verified to be chrome-mcp, then stopped. Anything that isn't a
|
|
179
|
+
verified chrome-mcp is never touched — a port held by some other program is
|
|
180
|
+
reported, never killed.
|
|
181
|
+
|
|
182
|
+
Sessions share one browser, so they also share its tabs: two sessions driving
|
|
183
|
+
the same tab at the same moment will step on each other. Give each session its
|
|
184
|
+
own tabs (`tab_new`), or its own Chrome profile (below).
|
|
185
|
+
|
|
186
|
+
Each session can also drive **several browsers at once**: load the extension
|
|
187
|
+
in each Chrome profile and they all pair to the same server, each under its own
|
|
188
|
+
profile name. Tools act on the active profile — pick it with `--profile <name>`
|
|
189
|
+
at startup or the `profile_use` tool at runtime.
|
|
190
|
+
|
|
191
|
+
Naming is automatic. Chrome won't tell an extension which profile it runs in, so
|
|
192
|
+
each install keeps a random id and the server names it: the first browser is
|
|
193
|
+
`default`, the next `profile-2`, then `profile-3`, and so on. Names are stored in
|
|
194
|
+
`~/.chrome-mcp/profiles.json`, so a browser keeps its name across restarts.
|
|
195
|
+
`chrome_status` lists every paired browser (with its active tab as a hint), and
|
|
196
|
+
`profile_rename` gives one a friendly name (`profile-2` → `work`). To pin a name
|
|
197
|
+
yourself instead, type it into the extension's Options → Profile; that always wins.
|
|
180
198
|
|
|
181
199
|
Without `--port`, each server binds an ephemeral port (no conflict ever), but the
|
|
182
200
|
port changes every boot — so you'd re-pair the extension each time. Pin `--port`
|
|
@@ -502,6 +520,31 @@ group/other-accessible. Windows has no such bits — `chmod` there only toggles
|
|
|
502
520
|
read-only attribute — so the check is skipped and the token's confidentiality
|
|
503
521
|
rests on the per-user ACL of `%USERPROFILE%\.chrome-mcp`.
|
|
504
522
|
|
|
523
|
+
## Telemetry
|
|
524
|
+
|
|
525
|
+
The chrome-mcp **server** sends anonymous usage statistics to PostHog, so the
|
|
526
|
+
project can see how many installs are active, which versions and platforms are
|
|
527
|
+
in use, and which tools fail most. A notice is printed the first time it runs.
|
|
528
|
+
|
|
529
|
+
What is sent: a random install id (kept in `~/.chrome-mcp/telemetry.json`), the
|
|
530
|
+
chrome-mcp version, OS, CPU architecture and Node major version, whether the
|
|
531
|
+
session owns the bridge port or shares it, how many browsers are paired, and
|
|
532
|
+
per-tool call and error **counts** with error codes — batched every 10 minutes.
|
|
533
|
+
|
|
534
|
+
What is never sent: URLs, domains, tool arguments, page content, screenshots,
|
|
535
|
+
cookies, profile names, tokens, file paths, or anything you type. Events are
|
|
536
|
+
personless and GeoIP lookup is disabled.
|
|
537
|
+
|
|
538
|
+
The **browser extension sends nothing** — it only ever talks to `127.0.0.1`.
|
|
539
|
+
|
|
540
|
+
Turn it off with any of:
|
|
541
|
+
|
|
542
|
+
```bash
|
|
543
|
+
CHROME_MCP_TELEMETRY=0 # or false / off
|
|
544
|
+
DO_NOT_TRACK=1
|
|
545
|
+
--no-telemetry # server flag
|
|
546
|
+
```
|
|
547
|
+
|
|
505
548
|
## Develop
|
|
506
549
|
|
|
507
550
|
```
|
|
@@ -64,6 +64,10 @@ export interface HelloFrame extends BaseFrame {
|
|
|
64
64
|
* NOT a security boundary (the token is) — it selects which connection slot the
|
|
65
65
|
* server routes commands to, so several browsers can stay paired at once. */
|
|
66
66
|
profile?: string;
|
|
67
|
+
/** Random id this extension install keeps in its chrome.storage.local — one per
|
|
68
|
+
* Chrome profile. With no `profile` label, the server names the browser from it
|
|
69
|
+
* ("default", "profile-2", ...) so two blank-profile browsers never collide. */
|
|
70
|
+
installId?: string;
|
|
67
71
|
/** Optional capability advertisements (see `WIRE_CAP_TAB_URL`). An extension
|
|
68
72
|
* that sends none gets the conservative path, so old builds stay correct. */
|
|
69
73
|
caps?: string[];
|
|
@@ -97,6 +101,9 @@ export interface WelcomeFrame extends BaseFrame {
|
|
|
97
101
|
heartbeatMs: number;
|
|
98
102
|
/** The active policy, so the extension can mirror the server-side gate. */
|
|
99
103
|
policy: WirePolicy;
|
|
104
|
+
/** The profile name this browser was paired as (shown in the Options page).
|
|
105
|
+
* Optional so an older server's welcome still parses. */
|
|
106
|
+
profile?: string;
|
|
100
107
|
}
|
|
101
108
|
export interface UnauthFrame extends BaseFrame {
|
|
102
109
|
type: 'unauthorized';
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* src/bridge/evict.ts — take a pinned port back from a stale chrome-mcp.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* the
|
|
4
|
+
* A second session normally JOINS the chrome-mcp holding the port as a peer
|
|
5
|
+
* (see ./peer) and nobody is stopped. This is the fallback for when joining
|
|
6
|
+
* fails: an older chrome-mcp that can't share the port, or a hung one. It will
|
|
7
|
+
* never release the port on its own, so rather than fail with EADDRINUSE and
|
|
8
|
+
* make the user kill it by hand, we do that for them.
|
|
9
9
|
*
|
|
10
10
|
* The safety bar is high, because a pid can be recycled onto an unrelated
|
|
11
11
|
* process. We evict ONLY when every check agrees:
|
package/dist/src/bridge/evict.js
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* src/bridge/evict.ts — take a pinned port back from a stale chrome-mcp.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* the
|
|
5
|
+
* A second session normally JOINS the chrome-mcp holding the port as a peer
|
|
6
|
+
* (see ./peer) and nobody is stopped. This is the fallback for when joining
|
|
7
|
+
* fails: an older chrome-mcp that can't share the port, or a hung one. It will
|
|
8
|
+
* never release the port on its own, so rather than fail with EADDRINUSE and
|
|
9
|
+
* make the user kill it by hand, we do that for them.
|
|
10
10
|
*
|
|
11
11
|
* The safety bar is high, because a pid can be recycled onto an unrelated
|
|
12
12
|
* process. We evict ONLY when every check agrees:
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/bridge/peer.ts — let several chrome-mcp servers share one Chrome.
|
|
3
|
+
*
|
|
4
|
+
* Every MCP host session (each Claude terminal/tab) spawns its own chrome-mcp,
|
|
5
|
+
* but a browser's extension dials exactly one port. So the first server to bind
|
|
6
|
+
* the port is the HUB: it owns the extension connections. A later server that
|
|
7
|
+
* finds the port held by a live chrome-mcp joins it as a PEER over the same
|
|
8
|
+
* port and relays its commands through the hub, instead of killing it. If the
|
|
9
|
+
* hub exits, its peers race to bind the port; the winner becomes the new hub
|
|
10
|
+
* (with the same token, so the extension re-pairs on its own) and the rest join
|
|
11
|
+
* it.
|
|
12
|
+
*
|
|
13
|
+
* Peers authenticate with the same token the extension uses — the handshake
|
|
14
|
+
* file is 0600, so only the same OS user can read it — and speak a small
|
|
15
|
+
* server-to-server protocol that the extension never sees:
|
|
16
|
+
* peer → hub: peer_hello, relay (a wire command), peer_rename
|
|
17
|
+
* hub → peer: peer_welcome, peer_state (paired browsers), relay_result
|
|
18
|
+
*/
|
|
19
|
+
import { PROTOCOL_VERSION, type WireMethod } from '../../shared/protocol';
|
|
20
|
+
import type { PairedProfile } from './server';
|
|
21
|
+
/** How long a joining server waits for the hub's welcome before giving up. */
|
|
22
|
+
export declare const JOIN_TIMEOUT_MS = 2000;
|
|
23
|
+
export interface PeerHelloFrame {
|
|
24
|
+
type: 'peer_hello';
|
|
25
|
+
v: typeof PROTOCOL_VERSION;
|
|
26
|
+
token: string;
|
|
27
|
+
pid: number;
|
|
28
|
+
}
|
|
29
|
+
export interface RelayFrame {
|
|
30
|
+
type: 'relay';
|
|
31
|
+
id: string;
|
|
32
|
+
method: WireMethod;
|
|
33
|
+
params: Record<string, unknown>;
|
|
34
|
+
opts?: {
|
|
35
|
+
tabId?: string;
|
|
36
|
+
timeoutMs?: number;
|
|
37
|
+
profile?: string;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export interface PeerRenameFrame {
|
|
41
|
+
type: 'peer_rename';
|
|
42
|
+
id: string;
|
|
43
|
+
from: string;
|
|
44
|
+
to: string;
|
|
45
|
+
}
|
|
46
|
+
export type PeerToHubFrame = PeerHelloFrame | RelayFrame | PeerRenameFrame;
|
|
47
|
+
export interface PeerWelcomeFrame {
|
|
48
|
+
type: 'peer_welcome';
|
|
49
|
+
hubPid: number;
|
|
50
|
+
}
|
|
51
|
+
export interface PeerStateFrame {
|
|
52
|
+
type: 'peer_state';
|
|
53
|
+
profiles: PairedProfile[];
|
|
54
|
+
}
|
|
55
|
+
export interface RelayResultFrame {
|
|
56
|
+
type: 'relay_result';
|
|
57
|
+
id: string;
|
|
58
|
+
ok: boolean;
|
|
59
|
+
data?: unknown;
|
|
60
|
+
/** Set when the hub failed with an ExecutorError, so the peer rethrows the same code. */
|
|
61
|
+
code?: string;
|
|
62
|
+
message?: string;
|
|
63
|
+
}
|
|
64
|
+
export type HubToPeerFrame = PeerWelcomeFrame | PeerStateFrame | RelayResultFrame;
|
|
65
|
+
/** Encode a hub-side failure so the peer can rethrow it with its code intact. */
|
|
66
|
+
export declare function relayError(id: string, err: unknown): RelayResultFrame;
|
|
67
|
+
/**
|
|
68
|
+
* A peer's connection to the hub. Mirrors the hub's paired-browser list (pushed
|
|
69
|
+
* on every change) so the synchronous `hasConnection` checks stay synchronous,
|
|
70
|
+
* and relays commands as request/response pairs.
|
|
71
|
+
*/
|
|
72
|
+
export declare class HubLink {
|
|
73
|
+
private readonly ws;
|
|
74
|
+
readonly hubPid: number;
|
|
75
|
+
private readonly onClose;
|
|
76
|
+
profiles: PairedProfile[];
|
|
77
|
+
private readonly pending;
|
|
78
|
+
private seq;
|
|
79
|
+
private closed;
|
|
80
|
+
private constructor();
|
|
81
|
+
/**
|
|
82
|
+
* Join the hub on `port` with `token`. Resolves null when nothing answers as a
|
|
83
|
+
* chrome-mcp hub in time: a wrong token, an older server that doesn't speak
|
|
84
|
+
* the peer protocol (it ignores the frame and times us out), or no listener.
|
|
85
|
+
*/
|
|
86
|
+
static join(host: string, port: number, token: string, onClose: () => void): Promise<HubLink | null>;
|
|
87
|
+
isOpen(): boolean;
|
|
88
|
+
relay(method: WireMethod, params: Record<string, unknown>, opts?: RelayFrame['opts']): Promise<unknown>;
|
|
89
|
+
rename(from: string, to: string): Promise<unknown>;
|
|
90
|
+
close(): void;
|
|
91
|
+
private request;
|
|
92
|
+
private handleMessage;
|
|
93
|
+
private handleClose;
|
|
94
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* src/bridge/peer.ts — let several chrome-mcp servers share one Chrome.
|
|
4
|
+
*
|
|
5
|
+
* Every MCP host session (each Claude terminal/tab) spawns its own chrome-mcp,
|
|
6
|
+
* but a browser's extension dials exactly one port. So the first server to bind
|
|
7
|
+
* the port is the HUB: it owns the extension connections. A later server that
|
|
8
|
+
* finds the port held by a live chrome-mcp joins it as a PEER over the same
|
|
9
|
+
* port and relays its commands through the hub, instead of killing it. If the
|
|
10
|
+
* hub exits, its peers race to bind the port; the winner becomes the new hub
|
|
11
|
+
* (with the same token, so the extension re-pairs on its own) and the rest join
|
|
12
|
+
* it.
|
|
13
|
+
*
|
|
14
|
+
* Peers authenticate with the same token the extension uses — the handshake
|
|
15
|
+
* file is 0600, so only the same OS user can read it — and speak a small
|
|
16
|
+
* server-to-server protocol that the extension never sees:
|
|
17
|
+
* peer → hub: peer_hello, relay (a wire command), peer_rename
|
|
18
|
+
* hub → peer: peer_welcome, peer_state (paired browsers), relay_result
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.HubLink = exports.JOIN_TIMEOUT_MS = void 0;
|
|
22
|
+
exports.relayError = relayError;
|
|
23
|
+
const ws_1 = require("ws");
|
|
24
|
+
const protocol_1 = require("../../shared/protocol");
|
|
25
|
+
const types_1 = require("../executor/types");
|
|
26
|
+
/** How long a joining server waits for the hub's welcome before giving up. */
|
|
27
|
+
exports.JOIN_TIMEOUT_MS = 2_000;
|
|
28
|
+
/** Slack on top of the hub's own command timeout, so the hub always answers first. */
|
|
29
|
+
const RELAY_GRACE_MS = 5_000;
|
|
30
|
+
/** Upper bound for a relayed command whose caller gave no timeout. */
|
|
31
|
+
const RELAY_DEFAULT_TIMEOUT_MS = 65_000;
|
|
32
|
+
/** Encode a hub-side failure so the peer can rethrow it with its code intact. */
|
|
33
|
+
function relayError(id, err) {
|
|
34
|
+
return {
|
|
35
|
+
type: 'relay_result',
|
|
36
|
+
id,
|
|
37
|
+
ok: false,
|
|
38
|
+
...(err instanceof types_1.ExecutorError ? { code: err.code } : {}),
|
|
39
|
+
message: err instanceof Error ? err.message : String(err),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A peer's connection to the hub. Mirrors the hub's paired-browser list (pushed
|
|
44
|
+
* on every change) so the synchronous `hasConnection` checks stay synchronous,
|
|
45
|
+
* and relays commands as request/response pairs.
|
|
46
|
+
*/
|
|
47
|
+
class HubLink {
|
|
48
|
+
ws;
|
|
49
|
+
hubPid;
|
|
50
|
+
onClose;
|
|
51
|
+
profiles = [];
|
|
52
|
+
pending = new Map();
|
|
53
|
+
seq = 0;
|
|
54
|
+
closed = false;
|
|
55
|
+
constructor(ws, hubPid, onClose) {
|
|
56
|
+
this.ws = ws;
|
|
57
|
+
this.hubPid = hubPid;
|
|
58
|
+
this.onClose = onClose;
|
|
59
|
+
ws.on('message', (raw) => this.handleMessage(raw.toString()));
|
|
60
|
+
ws.on('close', () => this.handleClose());
|
|
61
|
+
ws.on('error', () => this.handleClose());
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Join the hub on `port` with `token`. Resolves null when nothing answers as a
|
|
65
|
+
* chrome-mcp hub in time: a wrong token, an older server that doesn't speak
|
|
66
|
+
* the peer protocol (it ignores the frame and times us out), or no listener.
|
|
67
|
+
*/
|
|
68
|
+
static join(host, port, token, onClose) {
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
let settled = false;
|
|
71
|
+
const ws = new ws_1.WebSocket(`ws://${host}:${port}`);
|
|
72
|
+
const done = (link) => {
|
|
73
|
+
if (settled)
|
|
74
|
+
return;
|
|
75
|
+
settled = true;
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
ws.off('message', onFirst);
|
|
78
|
+
if (!link) {
|
|
79
|
+
try {
|
|
80
|
+
ws.terminate();
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
/* ignore */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
resolve(link);
|
|
87
|
+
};
|
|
88
|
+
const timer = setTimeout(() => done(null), exports.JOIN_TIMEOUT_MS);
|
|
89
|
+
timer.unref?.();
|
|
90
|
+
const onFirst = (raw) => {
|
|
91
|
+
let frame;
|
|
92
|
+
try {
|
|
93
|
+
frame = JSON.parse(raw.toString());
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return done(null);
|
|
97
|
+
}
|
|
98
|
+
if (frame.type !== 'peer_welcome')
|
|
99
|
+
return done(null); // 'unauthorized' or anything else
|
|
100
|
+
done(new HubLink(ws, typeof frame.hubPid === 'number' ? frame.hubPid : 0, onClose));
|
|
101
|
+
};
|
|
102
|
+
ws.on('message', onFirst);
|
|
103
|
+
ws.once('open', () => {
|
|
104
|
+
const hello = { type: 'peer_hello', v: protocol_1.PROTOCOL_VERSION, token, pid: process.pid };
|
|
105
|
+
ws.send(JSON.stringify(hello));
|
|
106
|
+
});
|
|
107
|
+
ws.once('error', () => done(null));
|
|
108
|
+
ws.once('close', () => done(null));
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
isOpen() {
|
|
112
|
+
return !this.closed && this.ws.readyState === ws_1.WebSocket.OPEN;
|
|
113
|
+
}
|
|
114
|
+
relay(method, params, opts) {
|
|
115
|
+
const timeoutMs = (opts?.timeoutMs ?? RELAY_DEFAULT_TIMEOUT_MS - RELAY_GRACE_MS) + RELAY_GRACE_MS;
|
|
116
|
+
return this.request({ type: 'relay', id: '', method, params, ...(opts ? { opts } : {}) }, timeoutMs);
|
|
117
|
+
}
|
|
118
|
+
rename(from, to) {
|
|
119
|
+
return this.request({ type: 'peer_rename', id: '', from, to }, exports.JOIN_TIMEOUT_MS * 2);
|
|
120
|
+
}
|
|
121
|
+
close() {
|
|
122
|
+
try {
|
|
123
|
+
this.ws.close(1001, 'peer stopping');
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
/* ignore */
|
|
127
|
+
}
|
|
128
|
+
this.handleClose();
|
|
129
|
+
}
|
|
130
|
+
request(frame, timeoutMs) {
|
|
131
|
+
if (!this.isOpen()) {
|
|
132
|
+
return Promise.reject(new types_1.ExecutorError('EXTENSION_DISCONNECTED', 'the shared chrome-mcp hub is not reachable'));
|
|
133
|
+
}
|
|
134
|
+
const id = String(++this.seq);
|
|
135
|
+
return new Promise((resolve, reject) => {
|
|
136
|
+
const timer = setTimeout(() => {
|
|
137
|
+
this.pending.delete(id);
|
|
138
|
+
reject(new types_1.ExecutorError('TIMEOUT', `the shared chrome-mcp hub did not answer ${frame.type} within ${timeoutMs} ms`));
|
|
139
|
+
}, timeoutMs);
|
|
140
|
+
timer.unref?.();
|
|
141
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
142
|
+
this.ws.send(JSON.stringify({ ...frame, id }));
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
handleMessage(raw) {
|
|
146
|
+
let frame;
|
|
147
|
+
try {
|
|
148
|
+
frame = JSON.parse(raw);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (frame.type === 'peer_state') {
|
|
154
|
+
this.profiles = Array.isArray(frame.profiles) ? frame.profiles : [];
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (frame.type !== 'relay_result')
|
|
158
|
+
return;
|
|
159
|
+
const p = this.pending.get(frame.id);
|
|
160
|
+
if (!p)
|
|
161
|
+
return;
|
|
162
|
+
this.pending.delete(frame.id);
|
|
163
|
+
clearTimeout(p.timer);
|
|
164
|
+
if (frame.ok)
|
|
165
|
+
p.resolve(frame.data);
|
|
166
|
+
else if (frame.code)
|
|
167
|
+
p.reject(new types_1.ExecutorError(frame.code, frame.message ?? 'hub error'));
|
|
168
|
+
else
|
|
169
|
+
p.reject(new Error(frame.message ?? 'hub error'));
|
|
170
|
+
}
|
|
171
|
+
handleClose() {
|
|
172
|
+
if (this.closed)
|
|
173
|
+
return;
|
|
174
|
+
this.closed = true;
|
|
175
|
+
this.profiles = [];
|
|
176
|
+
for (const [, p] of this.pending) {
|
|
177
|
+
clearTimeout(p.timer);
|
|
178
|
+
// Retryable: the dispatcher re-runs idempotent calls once, by which time a
|
|
179
|
+
// surviving server has usually taken the port over.
|
|
180
|
+
p.reject(new types_1.ExecutorError('EXTENSION_DISCONNECTED', 'the shared chrome-mcp hub went away mid-call'));
|
|
181
|
+
}
|
|
182
|
+
this.pending.clear();
|
|
183
|
+
this.onClose();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
exports.HubLink = HubLink;
|
|
187
|
+
//# sourceMappingURL=peer.js.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/bridge/profiles.ts — automatic profile names for paired browsers.
|
|
3
|
+
*
|
|
4
|
+
* Chrome gives an extension no way to learn which Chrome profile it runs in, so
|
|
5
|
+
* before this every browser that left Profile blank paired as "default" — and a
|
|
6
|
+
* second one silently superseded the first. Now each extension install sends a
|
|
7
|
+
* stable random `installId` (kept in that profile's chrome.storage.local), and
|
|
8
|
+
* the server gives each install its own name: "default" for the first,
|
|
9
|
+
* "profile-2", "profile-3", ... after that. The mapping persists in
|
|
10
|
+
* `<dataDir>/profiles.json`, so a browser keeps its name across restarts and
|
|
11
|
+
* `profile_rename` can give it a friendly one.
|
|
12
|
+
*
|
|
13
|
+
* A Profile typed into the extension's Options still wins — that label is the
|
|
14
|
+
* user's explicit choice and is never renamed from here.
|
|
15
|
+
*/
|
|
16
|
+
export declare const REGISTRY_FILE = "profiles.json";
|
|
17
|
+
/** The name the first auto-named browser gets — the pre-registry default. */
|
|
18
|
+
export declare const FIRST_AUTO_NAME = "default";
|
|
19
|
+
/** An installId is opaque, but it becomes a JSON key and a log line: keep it tame. */
|
|
20
|
+
export declare function isValidInstallId(id: unknown): id is string;
|
|
21
|
+
export declare class ProfileRegistry {
|
|
22
|
+
private readonly dataDir?;
|
|
23
|
+
private installs;
|
|
24
|
+
/** `dataDir` undefined → in-memory only (tests, and servers run without one). */
|
|
25
|
+
constructor(dataDir?: string | undefined);
|
|
26
|
+
/** The name already assigned to this install, if any. */
|
|
27
|
+
nameOf(installId: string): string | undefined;
|
|
28
|
+
/** The install that owns `name` in the registry, if any. */
|
|
29
|
+
ownerOf(name: string): string | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* Name for an install with no explicit label: its remembered name, else the
|
|
32
|
+
* first of "default", "profile-2", ... that no other install owns and
|
|
33
|
+
* `isLive(name)` doesn't report as held by someone else right now.
|
|
34
|
+
*/
|
|
35
|
+
assign(installId: string, isLive: (name: string) => boolean): string;
|
|
36
|
+
/**
|
|
37
|
+
* Give the install currently named `from` the name `to`. Throws when `from`
|
|
38
|
+
* isn't an auto-named install or `to` is already owned by another one; the
|
|
39
|
+
* caller checks live connections (explicitly labelled browsers aren't here).
|
|
40
|
+
*/
|
|
41
|
+
rename(from: string, to: string): string;
|
|
42
|
+
private load;
|
|
43
|
+
private save;
|
|
44
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* src/bridge/profiles.ts — automatic profile names for paired browsers.
|
|
4
|
+
*
|
|
5
|
+
* Chrome gives an extension no way to learn which Chrome profile it runs in, so
|
|
6
|
+
* before this every browser that left Profile blank paired as "default" — and a
|
|
7
|
+
* second one silently superseded the first. Now each extension install sends a
|
|
8
|
+
* stable random `installId` (kept in that profile's chrome.storage.local), and
|
|
9
|
+
* the server gives each install its own name: "default" for the first,
|
|
10
|
+
* "profile-2", "profile-3", ... after that. The mapping persists in
|
|
11
|
+
* `<dataDir>/profiles.json`, so a browser keeps its name across restarts and
|
|
12
|
+
* `profile_rename` can give it a friendly one.
|
|
13
|
+
*
|
|
14
|
+
* A Profile typed into the extension's Options still wins — that label is the
|
|
15
|
+
* user's explicit choice and is never renamed from here.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.ProfileRegistry = exports.FIRST_AUTO_NAME = exports.REGISTRY_FILE = void 0;
|
|
19
|
+
exports.isValidInstallId = isValidInstallId;
|
|
20
|
+
const node_fs_1 = require("node:fs");
|
|
21
|
+
const node_path_1 = require("node:path");
|
|
22
|
+
const config_1 = require("../config");
|
|
23
|
+
exports.REGISTRY_FILE = 'profiles.json';
|
|
24
|
+
/** The name the first auto-named browser gets — the pre-registry default. */
|
|
25
|
+
exports.FIRST_AUTO_NAME = 'default';
|
|
26
|
+
/** An installId is opaque, but it becomes a JSON key and a log line: keep it tame. */
|
|
27
|
+
function isValidInstallId(id) {
|
|
28
|
+
return typeof id === 'string' && /^[A-Za-z0-9-]{8,64}$/.test(id);
|
|
29
|
+
}
|
|
30
|
+
class ProfileRegistry {
|
|
31
|
+
dataDir;
|
|
32
|
+
installs = {};
|
|
33
|
+
/** `dataDir` undefined → in-memory only (tests, and servers run without one). */
|
|
34
|
+
constructor(dataDir) {
|
|
35
|
+
this.dataDir = dataDir;
|
|
36
|
+
this.load();
|
|
37
|
+
}
|
|
38
|
+
/** The name already assigned to this install, if any. */
|
|
39
|
+
nameOf(installId) {
|
|
40
|
+
return this.installs[installId]?.name;
|
|
41
|
+
}
|
|
42
|
+
/** The install that owns `name` in the registry, if any. */
|
|
43
|
+
ownerOf(name) {
|
|
44
|
+
for (const [id, rec] of Object.entries(this.installs))
|
|
45
|
+
if (rec.name === name)
|
|
46
|
+
return id;
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Name for an install with no explicit label: its remembered name, else the
|
|
51
|
+
* first of "default", "profile-2", ... that no other install owns and
|
|
52
|
+
* `isLive(name)` doesn't report as held by someone else right now.
|
|
53
|
+
*/
|
|
54
|
+
assign(installId, isLive) {
|
|
55
|
+
const now = new Date().toISOString();
|
|
56
|
+
const known = this.installs[installId];
|
|
57
|
+
if (known) {
|
|
58
|
+
known.lastSeen = now;
|
|
59
|
+
this.save();
|
|
60
|
+
return known.name;
|
|
61
|
+
}
|
|
62
|
+
let name = exports.FIRST_AUTO_NAME;
|
|
63
|
+
for (let n = 2; this.ownerOf(name) !== undefined || isLive(name); n++)
|
|
64
|
+
name = `profile-${n}`;
|
|
65
|
+
this.installs[installId] = { name, firstSeen: now, lastSeen: now };
|
|
66
|
+
this.save();
|
|
67
|
+
return name;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Give the install currently named `from` the name `to`. Throws when `from`
|
|
71
|
+
* isn't an auto-named install or `to` is already owned by another one; the
|
|
72
|
+
* caller checks live connections (explicitly labelled browsers aren't here).
|
|
73
|
+
*/
|
|
74
|
+
rename(from, to) {
|
|
75
|
+
const target = (0, config_1.sanitizeName)(to, 'profile');
|
|
76
|
+
const id = this.ownerOf(from);
|
|
77
|
+
if (id === undefined) {
|
|
78
|
+
throw new Error(`"${from}" is not an automatically named profile. A name typed into the ` +
|
|
79
|
+
`extension's Options can only be changed there.`);
|
|
80
|
+
}
|
|
81
|
+
if (target === from)
|
|
82
|
+
return target;
|
|
83
|
+
if (this.ownerOf(target) !== undefined)
|
|
84
|
+
throw new Error(`profile "${target}" is already taken`);
|
|
85
|
+
this.installs[id].name = target;
|
|
86
|
+
this.save();
|
|
87
|
+
return target;
|
|
88
|
+
}
|
|
89
|
+
load() {
|
|
90
|
+
if (!this.dataDir)
|
|
91
|
+
return;
|
|
92
|
+
const path = (0, node_path_1.join)(this.dataDir, exports.REGISTRY_FILE);
|
|
93
|
+
if (!(0, node_fs_1.existsSync)(path))
|
|
94
|
+
return;
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse((0, node_fs_1.readFileSync)(path, 'utf8'));
|
|
97
|
+
for (const [id, rec] of Object.entries(parsed.installs ?? {})) {
|
|
98
|
+
if (isValidInstallId(id) && rec && typeof rec.name === 'string')
|
|
99
|
+
this.installs[id] = rec;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// A corrupt file must not stop pairing: start empty and rewrite on next save.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
save() {
|
|
107
|
+
if (!this.dataDir)
|
|
108
|
+
return;
|
|
109
|
+
const path = (0, node_path_1.join)(this.dataDir, exports.REGISTRY_FILE);
|
|
110
|
+
const tmp = `${path}.tmp`;
|
|
111
|
+
try {
|
|
112
|
+
(0, node_fs_1.writeFileSync)(tmp, JSON.stringify({ installs: this.installs }, null, 2), { mode: 0o600 });
|
|
113
|
+
(0, node_fs_1.renameSync)(tmp, path);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Best-effort: names still work for this run, they just won't persist.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
exports.ProfileRegistry = ProfileRegistry;
|
|
121
|
+
//# sourceMappingURL=profiles.js.map
|