mcp-browser-extension 0.9.31 → 0.9.32
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 +58 -10
- package/SETUP.md +27 -3
- package/dist/shared/activity.d.ts +25 -0
- package/dist/shared/activity.js +72 -0
- package/dist/shared/browser-permissions.d.ts +20 -0
- package/dist/shared/browser-permissions.js +40 -0
- package/dist/shared/client-auth.d.ts +28 -0
- package/dist/shared/client-auth.js +38 -0
- package/dist/shared/observers.d.ts +1 -1
- package/dist/shared/observers.js +1 -3
- package/dist/shared/policy.d.ts +5 -7
- package/dist/shared/policy.js +11 -26
- package/dist/shared/protocol.d.ts +4 -0
- package/dist/shared/protocol.js +3 -3
- package/dist/src/access-browser.js +66 -23
- package/dist/src/access.js +2 -1
- package/dist/src/bridge/client-identity.d.ts +10 -0
- package/dist/src/bridge/client-identity.js +28 -0
- package/dist/src/bridge/connection.d.ts +5 -0
- package/dist/src/bridge/connection.js +4 -12
- package/dist/src/bridge/peer.d.ts +4 -19
- package/dist/src/bridge/peer.js +17 -8
- package/dist/src/bridge/server.d.ts +7 -16
- package/dist/src/bridge/server.js +64 -17
- package/dist/src/bridge/site-grants.d.ts +1 -2
- package/dist/src/bridge/site-grants.js +4 -3
- package/dist/src/cli.js +1 -3
- package/dist/src/config.js +10 -5
- package/dist/src/executor/cdp-executor.d.ts +3 -0
- package/dist/src/executor/cdp-executor.js +69 -25
- package/dist/src/mcp/server.js +5 -3
- package/dist/src/mcp/tools.d.ts +0 -12
- package/dist/src/mcp/tools.js +35 -20
- package/dist/src/remote/key.js +12 -2
- package/dist/src/security/policy.js +1 -1
- package/docs/BLUEPRINT.md +6 -1
- package/docs/CLIENT-APPROVAL.md +150 -0
- package/extension-dist/activity-dashboard.css +43 -0
- package/extension-dist/background.js +953 -58
- package/extension-dist/manifest.json +10 -9
- package/extension-dist/options.html +101 -32
- package/extension-dist/options.js +558 -70
- package/extension-dist/popup.html +164 -90
- package/extension-dist/popup.js +576 -79
- package/package.json +3 -2
|
@@ -22,27 +22,70 @@ function openBrowserEmailPrompt(url) {
|
|
|
22
22
|
catch { /* Keep the form available even if the operating system cannot open it. */ }
|
|
23
23
|
}
|
|
24
24
|
const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
25
|
+
// Native form submission still works without JavaScript. This only provides
|
|
26
|
+
// immediate feedback; the server also deduplicates concurrent submissions.
|
|
27
|
+
const FORM_SCRIPT = `
|
|
28
|
+
const form = document.querySelector('form');
|
|
29
|
+
if (form) {
|
|
30
|
+
const button = form.querySelector('button');
|
|
31
|
+
const email = form.querySelector('[name="email"]');
|
|
32
|
+
const status = document.getElementById('verification-status');
|
|
33
|
+
const originalStatus = status.textContent;
|
|
34
|
+
let sending = false;
|
|
35
|
+
form.addEventListener('submit', (event) => {
|
|
36
|
+
if (sending) { event.preventDefault(); return; }
|
|
37
|
+
sending = true;
|
|
38
|
+
button.disabled = true;
|
|
39
|
+
button.textContent = 'Sending…';
|
|
40
|
+
email.readOnly = true;
|
|
41
|
+
form.setAttribute('aria-busy', 'true');
|
|
42
|
+
status.textContent = 'Sending your sign-in link…';
|
|
43
|
+
});
|
|
44
|
+
window.addEventListener('pageshow', () => {
|
|
45
|
+
sending = false;
|
|
46
|
+
button.disabled = false;
|
|
47
|
+
button.textContent = 'Send verification link';
|
|
48
|
+
email.readOnly = false;
|
|
49
|
+
form.removeAttribute('aria-busy');
|
|
50
|
+
status.textContent = originalStatus;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
`;
|
|
54
|
+
const FORM_SCRIPT_HASH = (0, node_crypto_1.createHash)('sha256').update(FORM_SCRIPT).digest('base64');
|
|
25
55
|
function page(path, csrf, message, sent) {
|
|
26
56
|
return `<!doctype html><html lang="en"><meta charset="utf-8">
|
|
27
57
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
28
58
|
<title>Verify your email | MCP Browser Extension</title>
|
|
29
59
|
<style>
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
60
|
+
/* Match the extension popup's existing light/dark theme. */
|
|
61
|
+
:root{color-scheme:light dark;font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;
|
|
62
|
+
--bg:#ffffff;--panel:#f6f7f9;--fg:#0f172a;--muted:#64748b;--line:#e6e8ec;
|
|
63
|
+
--accent:oklch(0.58 0.19 255);--accent-foreground:oklch(0.99 0 0);--ring:oklch(0.58 0.19 255 / 35%)}
|
|
64
|
+
@media(prefers-color-scheme:dark){:root{--bg:#0b0d12;--panel:#11141b;--fg:#e8ebf2;--muted:#8b93a7;--line:#232836;
|
|
65
|
+
--accent:oklch(0.7 0.16 255);--accent-foreground:oklch(0.12 0.02 255);--ring:oklch(0.7 0.16 255 / 45%)}}
|
|
66
|
+
*{box-sizing:border-box}
|
|
67
|
+
body{max-width:488px;margin:12vh auto;padding:24px;background:var(--bg);color:var(--fg);line-height:1.5;-webkit-font-smoothing:antialiased}
|
|
68
|
+
h1{font-size:28px;line-height:1.2;font-weight:500;letter-spacing:-0.025em}
|
|
69
|
+
label{display:block;font-weight:500}
|
|
70
|
+
input,button{width:100%;font:inherit;padding:12px;margin-top:8px;border:1px solid var(--line);border-radius:9px}
|
|
71
|
+
input{background:var(--panel);color:var(--fg)}
|
|
72
|
+
input:focus-visible,button:focus-visible{outline:2px solid var(--accent);outline-offset:3px;box-shadow:0 0 0 4px var(--ring)}
|
|
73
|
+
button{margin-top:18px;background:var(--accent);color:var(--accent-foreground);border-color:transparent;font-weight:500;cursor:pointer}
|
|
74
|
+
button:hover:not(:disabled){background:color-mix(in oklch,var(--accent) 88%,var(--fg))}
|
|
75
|
+
button:disabled{cursor:wait}
|
|
76
|
+
p{margin:20px 0;color:var(--muted)} .brand{font-size:14px;color:var(--fg)}
|
|
77
|
+
small{display:block;margin-top:18px;color:var(--muted)}
|
|
36
78
|
</style>
|
|
37
79
|
<body><p class="brand">MCP Browser Extension</p><h1>${sent ? 'Check your inbox' : 'Verify your email'}</h1>
|
|
38
|
-
<p role="status">${escapeHtml(message)}</p>
|
|
80
|
+
<p id="verification-status" role="status">${escapeHtml(message)}</p>
|
|
39
81
|
${sent ? '<p>After opening the sign-in link, return to your assistant to continue.</p>' : `
|
|
40
82
|
<form method="post" action="${path}">
|
|
41
83
|
<input type="hidden" name="csrf" value="${csrf}">
|
|
42
84
|
<label for="email">Email address</label>
|
|
43
85
|
<input id="email" name="email" type="email" autocomplete="email" maxlength="254" required autofocus>
|
|
44
86
|
<button type="submit">Send verification link</button>
|
|
45
|
-
</form><small>Your email is sent to mcp-browser-extension.com to verify and manage access.</small
|
|
87
|
+
</form><small>Your email is sent to mcp-browser-extension.com to verify and manage access.</small>
|
|
88
|
+
<script>${FORM_SCRIPT}</script>`}
|
|
46
89
|
</body></html>`;
|
|
47
90
|
}
|
|
48
91
|
/** Only the person submitting this form can start an email. GET never sends one. */
|
|
@@ -51,7 +94,7 @@ async function createBrowserEmailPrompt(submit, options = {}) {
|
|
|
51
94
|
const csrf = (0, node_crypto_1.randomBytes)(32).toString('hex');
|
|
52
95
|
let origin = '';
|
|
53
96
|
let sent = false;
|
|
54
|
-
let
|
|
97
|
+
let sending;
|
|
55
98
|
let closed = false;
|
|
56
99
|
let expiry;
|
|
57
100
|
const sentMessage = 'A sign-in link has been sent. Open it from your email and click Verify this device to finish verification.';
|
|
@@ -64,7 +107,7 @@ async function createBrowserEmailPrompt(submit, options = {}) {
|
|
|
64
107
|
'referrer-policy': 'same-origin',
|
|
65
108
|
'x-content-type-options': 'nosniff',
|
|
66
109
|
'x-frame-options': 'DENY',
|
|
67
|
-
'content-security-policy':
|
|
110
|
+
'content-security-policy': `default-src 'none'; script-src 'sha256-${FORM_SCRIPT_HASH}'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`,
|
|
68
111
|
});
|
|
69
112
|
res.end(page(path, csrf, message, sent));
|
|
70
113
|
};
|
|
@@ -77,7 +120,10 @@ async function createBrowserEmailPrompt(submit, options = {}) {
|
|
|
77
120
|
return;
|
|
78
121
|
}
|
|
79
122
|
if (req.method === 'GET') {
|
|
80
|
-
|
|
123
|
+
// A reload during delivery must wait for the same result instead of
|
|
124
|
+
// restoring an email-entry form that is already being submitted.
|
|
125
|
+
const outcome = sending ? await sending : undefined;
|
|
126
|
+
respond(res, 200, sent ? sentMessage : outcome?.message ?? 'Enter your email below. We will send you a one-time sign-in link.');
|
|
81
127
|
return;
|
|
82
128
|
}
|
|
83
129
|
if (req.method !== 'POST') {
|
|
@@ -111,19 +157,16 @@ async function createBrowserEmailPrompt(submit, options = {}) {
|
|
|
111
157
|
respond(res, 200, sentMessage);
|
|
112
158
|
return;
|
|
113
159
|
}
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
sent = outcome.ok;
|
|
122
|
-
respond(res, outcome.ok ? 200 : 400, outcome.ok ? sentMessage : outcome.message);
|
|
123
|
-
}
|
|
124
|
-
finally {
|
|
125
|
-
busy = false;
|
|
160
|
+
if (!sending) {
|
|
161
|
+
// A repeated click replaces the browser's pending navigation. Share
|
|
162
|
+
// its result so the latest page shows the outcome of the one email.
|
|
163
|
+
sending = Promise.resolve()
|
|
164
|
+
.then(() => submit(fields.get('email') ?? ''))
|
|
165
|
+
.then((outcome) => { sent = outcome.ok; return outcome; })
|
|
166
|
+
.finally(() => { sending = undefined; });
|
|
126
167
|
}
|
|
168
|
+
const outcome = await sending;
|
|
169
|
+
respond(res, outcome.ok ? 200 : 400, outcome.ok ? sentMessage : outcome.message);
|
|
127
170
|
})().catch(() => { if (!res.headersSent)
|
|
128
171
|
respond(res, 503, 'The verification service could not be reached. Please try again.'); });
|
|
129
172
|
});
|
package/dist/src/access.js
CHANGED
|
@@ -190,7 +190,8 @@ const inputRequired = (url) => ({
|
|
|
190
190
|
message: '[ACCESS_INPUT_REQUIRED] Complete email verification in the browser form. It opens automatically when the client does not display its email prompt. ' +
|
|
191
191
|
`If no form appeared, open this local page on the computer running MCP Browser Extension:\n${url}\n` +
|
|
192
192
|
'Enter your email there, open the sign-in link from your inbox, and click Verify this device. This page expires in 10 minutes. ' +
|
|
193
|
-
'Show this link
|
|
193
|
+
'Show this link only as a backup. Do not open it again or ask for the email in chat while this form is active. ' +
|
|
194
|
+
'Do not fill the form or open their email for them. After they submit their email, call chrome_status to wait for verification. Access remains blocked until verification succeeds.',
|
|
194
195
|
});
|
|
195
196
|
function startEmail(server, dataDir, state, input) {
|
|
196
197
|
const existing = pendingEmailStarts.get(dataDir);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type ClientProof } from '../../shared/client-auth';
|
|
2
|
+
import type { CommandFrame } from '../../shared/protocol';
|
|
3
|
+
/** Private keys stay in this MCP session's memory. They never enter pairing files or the hub. */
|
|
4
|
+
export declare function createClientIdentity(name: () => string): {
|
|
5
|
+
id: string;
|
|
6
|
+
sign(cmd: Pick<CommandFrame, "v" | "method" | "params" | "tabId" | "timeoutMs">, challenge: string): ClientProof;
|
|
7
|
+
};
|
|
8
|
+
export type ClientIdentity = ReturnType<typeof createClientIdentity>;
|
|
9
|
+
export declare const withClientIdentity: <T>(identity: ClientIdentity, fn: () => T) => T;
|
|
10
|
+
export declare const currentClientIdentity: () => ClientIdentity | undefined;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.currentClientIdentity = exports.withClientIdentity = void 0;
|
|
4
|
+
exports.createClientIdentity = createClientIdentity;
|
|
5
|
+
const node_async_hooks_1 = require("node:async_hooks");
|
|
6
|
+
const node_crypto_1 = require("node:crypto");
|
|
7
|
+
const client_auth_1 = require("../../shared/client-auth");
|
|
8
|
+
/** Private keys stay in this MCP session's memory. They never enter pairing files or the hub. */
|
|
9
|
+
function createClientIdentity(name) {
|
|
10
|
+
const { privateKey, publicKey } = (0, node_crypto_1.generateKeyPairSync)('ec', { namedCurve: 'prime256v1' });
|
|
11
|
+
const der = publicKey.export({ type: 'spki', format: 'der' });
|
|
12
|
+
const encoded = der.toString('base64');
|
|
13
|
+
return {
|
|
14
|
+
id: (0, node_crypto_1.createHash)('sha256').update(der).digest('hex'),
|
|
15
|
+
sign(cmd, challenge) {
|
|
16
|
+
const proof = { publicKey: encoded, name: (0, client_auth_1.clientName)(name()), challenge, issuedAt: Date.now(), nonce: (0, node_crypto_1.randomUUID)() };
|
|
17
|
+
return { ...proof, signature: (0, node_crypto_1.sign)('sha256', Buffer.from((0, client_auth_1.clientSigningPayload)(cmd, proof)), {
|
|
18
|
+
key: privateKey, dsaEncoding: 'ieee-p1363',
|
|
19
|
+
}).toString('base64') };
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const current = new node_async_hooks_1.AsyncLocalStorage();
|
|
24
|
+
const withClientIdentity = (identity, fn) => current.run(identity, fn);
|
|
25
|
+
exports.withClientIdentity = withClientIdentity;
|
|
26
|
+
const currentClientIdentity = () => current.getStore();
|
|
27
|
+
exports.currentClientIdentity = currentClientIdentity;
|
|
28
|
+
//# sourceMappingURL=client-identity.js.map
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ClientProof } from '../../shared/client-auth';
|
|
1
2
|
/**
|
|
2
3
|
* src/bridge/connection.ts — one authenticated extension connection.
|
|
3
4
|
*
|
|
@@ -13,10 +14,12 @@
|
|
|
13
14
|
import type { WebSocket } from 'ws';
|
|
14
15
|
import { type ServerFrame, type WireEvent, type WireMethod } from '../../shared/protocol';
|
|
15
16
|
import { type ExecutorErrorCodeLocal } from '../executor/types';
|
|
17
|
+
export declare function defaultTimeoutFor(method: WireMethod): number;
|
|
16
18
|
/** Map a wire error code onto a local ExecutorError code (the wire enum is a
|
|
17
19
|
* near-superset; unknown codes degrade to CDP_ERROR while keeping the message). */
|
|
18
20
|
export declare function mapWireErrorCode(code: string): ExecutorErrorCodeLocal;
|
|
19
21
|
export interface ConnectionDeps {
|
|
22
|
+
approvalChallenge?: string;
|
|
20
23
|
ws: WebSocket;
|
|
21
24
|
extId: string;
|
|
22
25
|
sessionId: string;
|
|
@@ -30,6 +33,7 @@ export interface ConnectionDeps {
|
|
|
30
33
|
onLog?: (message: string) => void;
|
|
31
34
|
}
|
|
32
35
|
export declare class ExtensionConnection {
|
|
36
|
+
readonly approvalChallenge?: string;
|
|
33
37
|
readonly extId: string;
|
|
34
38
|
readonly sessionId: string;
|
|
35
39
|
private readonly ws;
|
|
@@ -57,6 +61,7 @@ export declare class ExtensionConnection {
|
|
|
57
61
|
sendCommand(method: WireMethod, params: Record<string, unknown>, opts?: {
|
|
58
62
|
tabId?: string;
|
|
59
63
|
timeoutMs?: number;
|
|
64
|
+
authorization?: ClientProof;
|
|
60
65
|
}): Promise<unknown>;
|
|
61
66
|
close(code: number, reason?: string): void;
|
|
62
67
|
/** Push an unsolicited frame (a policy change, a blocked-site notice). Best-effort. */
|
|
@@ -1,18 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* src/bridge/connection.ts — one authenticated extension connection.
|
|
4
|
-
*
|
|
5
|
-
* Owns the pending-request table: each `sendCommand` mints an id, sends a
|
|
6
|
-
* CommandFrame, and parks a {resolve,reject,timer} until the matching
|
|
7
|
-
* result/error frame arrives. Guarantees:
|
|
8
|
-
* - method-aware per-request timeout that rejects the ONE call (never closes
|
|
9
|
-
* the socket),
|
|
10
|
-
* - reject-ALL-pending with EXTENSION_DISCONNECTED on close,
|
|
11
|
-
* - backpressure rejection (screenshots are large; never queue unboundedly),
|
|
12
|
-
* - app-level ping/pong heartbeat (optional; disabled when heartbeatMs<=0).
|
|
13
|
-
*/
|
|
14
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
3
|
exports.ExtensionConnection = void 0;
|
|
4
|
+
exports.defaultTimeoutFor = defaultTimeoutFor;
|
|
16
5
|
exports.mapWireErrorCode = mapWireErrorCode;
|
|
17
6
|
const protocol_1 = require("../../shared/protocol");
|
|
18
7
|
const types_1 = require("../executor/types");
|
|
@@ -42,6 +31,7 @@ const ACTIVE_TAB_CHANGERS = new Set(['tab_select', 'tab_new', 'tab_close']);
|
|
|
42
31
|
/** Bound on the per-tab URL cache; entries beyond it are evicted oldest-first. */
|
|
43
32
|
const MAX_TAB_URL_ENTRIES = 256;
|
|
44
33
|
class ExtensionConnection {
|
|
34
|
+
approvalChallenge;
|
|
45
35
|
extId;
|
|
46
36
|
sessionId;
|
|
47
37
|
ws;
|
|
@@ -66,6 +56,7 @@ class ExtensionConnection {
|
|
|
66
56
|
onLog;
|
|
67
57
|
constructor(deps) {
|
|
68
58
|
this.ws = deps.ws;
|
|
59
|
+
this.approvalChallenge = deps.approvalChallenge;
|
|
69
60
|
this.extId = deps.extId;
|
|
70
61
|
this.sessionId = deps.sessionId;
|
|
71
62
|
this.caps = new Set(deps.caps ?? []);
|
|
@@ -103,6 +94,7 @@ class ExtensionConnection {
|
|
|
103
94
|
params,
|
|
104
95
|
tabId: opts?.tabId,
|
|
105
96
|
timeoutMs,
|
|
97
|
+
authorization: opts?.authorization,
|
|
106
98
|
};
|
|
107
99
|
return new Promise((resolve, reject) => {
|
|
108
100
|
const timer = setTimeout(() => {
|
|
@@ -1,21 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* src/bridge/peer.ts — let several mcp-browser-extension servers share one Chrome.
|
|
3
|
-
*
|
|
4
|
-
* Every MCP host session (each Claude terminal/tab) spawns its own mcp-browser-extension,
|
|
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 mcp-browser-extension 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, peer_blocked
|
|
17
|
-
* hub → peer: peer_welcome, peer_state (paired browsers), relay_result, peer_grant
|
|
18
|
-
*/
|
|
1
|
+
import type { ClientProof } from '../../shared/client-auth';
|
|
19
2
|
import { PROTOCOL_VERSION, type WireMethod } from '../../shared/protocol';
|
|
20
3
|
import type { PairedProfile } from './server';
|
|
21
4
|
/** How long a joining server waits for the hub's welcome before giving up. */
|
|
@@ -35,9 +18,11 @@ export interface RelayFrame {
|
|
|
35
18
|
tabId?: string;
|
|
36
19
|
timeoutMs?: number;
|
|
37
20
|
profile?: string;
|
|
21
|
+
authorization?: ClientProof;
|
|
38
22
|
};
|
|
39
23
|
}
|
|
40
24
|
export interface PeerRenameFrame {
|
|
25
|
+
authorization?: ClientProof;
|
|
41
26
|
type: 'peer_rename';
|
|
42
27
|
id: string;
|
|
43
28
|
from: string;
|
|
@@ -99,7 +84,7 @@ export declare class HubLink {
|
|
|
99
84
|
static join(host: string, port: number, token: string, onClose: () => void, onGrant?: (host: string, allow: boolean) => void): Promise<HubLink | null>;
|
|
100
85
|
isOpen(): boolean;
|
|
101
86
|
relay(method: WireMethod, params: Record<string, unknown>, opts?: RelayFrame['opts']): Promise<unknown>;
|
|
102
|
-
rename(from: string, to: string): Promise<unknown>;
|
|
87
|
+
rename(from: string, to: string, authorization: ClientProof): Promise<unknown>;
|
|
103
88
|
/** Tell the hub our gate refused `host`, so it can offer the person an Allow. */
|
|
104
89
|
blocked(host: string, method: string): void;
|
|
105
90
|
close(): void;
|
package/dist/src/bridge/peer.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HubLink = exports.JOIN_TIMEOUT_MS = void 0;
|
|
4
|
+
exports.relayError = relayError;
|
|
2
5
|
/**
|
|
3
6
|
* src/bridge/peer.ts — let several mcp-browser-extension servers share one Chrome.
|
|
4
7
|
*
|
|
@@ -17,9 +20,6 @@
|
|
|
17
20
|
* peer → hub: peer_hello, relay (a wire command), peer_rename, peer_blocked
|
|
18
21
|
* hub → peer: peer_welcome, peer_state (paired browsers), relay_result, peer_grant
|
|
19
22
|
*/
|
|
20
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
-
exports.HubLink = exports.JOIN_TIMEOUT_MS = void 0;
|
|
22
|
-
exports.relayError = relayError;
|
|
23
23
|
const ws_1 = require("ws");
|
|
24
24
|
const protocol_1 = require("../../shared/protocol");
|
|
25
25
|
const types_1 = require("../executor/types");
|
|
@@ -70,6 +70,7 @@ class HubLink {
|
|
|
70
70
|
static join(host, port, token, onClose, onGrant) {
|
|
71
71
|
return new Promise((resolve) => {
|
|
72
72
|
let settled = false;
|
|
73
|
+
let joined = null;
|
|
73
74
|
const ws = new ws_1.WebSocket(`ws://${host}:${port}`);
|
|
74
75
|
const done = (link) => {
|
|
75
76
|
if (settled)
|
|
@@ -97,9 +98,17 @@ class HubLink {
|
|
|
97
98
|
catch {
|
|
98
99
|
return done(null);
|
|
99
100
|
}
|
|
100
|
-
if (frame.type
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
if (!joined && frame.type === 'peer_welcome') {
|
|
102
|
+
joined = new HubLink(ws, typeof frame.hubPid === 'number' ? frame.hubPid : 0, onClose, onGrant);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (joined && frame.type === 'peer_state' && Array.isArray(frame.profiles)) {
|
|
106
|
+
// The first command needs the extension's signing challenge. Do not
|
|
107
|
+
// report a successful join before the initial browser state arrives.
|
|
108
|
+
joined.profiles = frame.profiles;
|
|
109
|
+
return done(joined);
|
|
110
|
+
}
|
|
111
|
+
done(null); // unauthorized or an incomplete/incompatible handshake
|
|
103
112
|
};
|
|
104
113
|
ws.on('message', onFirst);
|
|
105
114
|
ws.once('open', () => {
|
|
@@ -117,8 +126,8 @@ class HubLink {
|
|
|
117
126
|
const timeoutMs = (opts?.timeoutMs ?? RELAY_DEFAULT_TIMEOUT_MS - RELAY_GRACE_MS) + RELAY_GRACE_MS;
|
|
118
127
|
return this.request({ type: 'relay', id: '', method, params, ...(opts ? { opts } : {}) }, timeoutMs);
|
|
119
128
|
}
|
|
120
|
-
rename(from, to) {
|
|
121
|
-
return this.request({ type: 'peer_rename', id: '', from, to }, exports.JOIN_TIMEOUT_MS * 2);
|
|
129
|
+
rename(from, to, authorization) {
|
|
130
|
+
return this.request({ type: 'peer_rename', id: '', from, to, authorization }, exports.JOIN_TIMEOUT_MS * 2);
|
|
122
131
|
}
|
|
123
132
|
/** Tell the hub our gate refused `host`, so it can offer the person an Allow. */
|
|
124
133
|
blocked(host, method) {
|
|
@@ -1,18 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* src/bridge/server.ts — the localhost WebSocket bridge.
|
|
3
|
-
*
|
|
4
|
-
* The server is the WS SERVER; the extension dials in as the single privileged
|
|
5
|
-
* CLIENT. The token is the ONLY trust boundary (the loopback bind is merely
|
|
6
|
-
* defense-in-depth; Origin is NOT a gate). Flow:
|
|
7
|
-
* 1. Accept any loopback upgrade.
|
|
8
|
-
* 2. Require a valid `hello` (matching version + token) within HELLO_TIMEOUT;
|
|
9
|
-
* otherwise send `unauthorized` and close 4401.
|
|
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 mcp-browser-extension servers (one per Claude session) may join the same port as
|
|
14
|
-
* peers with `peer_hello` and relay their commands through us; see ./peer.
|
|
15
|
-
*/
|
|
16
1
|
import { type WireEvent, type WireMethod, type WirePolicy } from '../../shared/protocol';
|
|
17
2
|
/** Where a person gets the extension. */
|
|
18
3
|
export declare const WEB_STORE_URL: "https://chromewebstore.google.com/detail/mcp-browser-extension/jelfhdlkhbfmlpbghoeaepijllcnplgh";
|
|
@@ -29,6 +14,8 @@ export type PairState = 'ok' | 'profile_mismatch' | 'token_mismatch' | 'version_
|
|
|
29
14
|
export type ProfileNaming = 'options' | 'auto' | 'legacy';
|
|
30
15
|
/** One paired browser, as reported by chrome_status. */
|
|
31
16
|
export interface PairedProfile {
|
|
17
|
+
/** Public, extension-generated replay scope; not a credential. */
|
|
18
|
+
approvalChallenge?: string;
|
|
32
19
|
name: string;
|
|
33
20
|
/** options = typed in the extension's Options; auto = assigned by the server
|
|
34
21
|
* (renamable with profile_rename); legacy = an older extension, "default". */
|
|
@@ -86,6 +73,7 @@ export interface BridgeOptions {
|
|
|
86
73
|
export type BridgeRole = 'hub' | 'peer';
|
|
87
74
|
export declare class BridgeServer {
|
|
88
75
|
private readonly opts;
|
|
76
|
+
private readonly clientIdentity;
|
|
89
77
|
private wss;
|
|
90
78
|
/** Profile routing key → its live connection. Multiple browsers stay paired at
|
|
91
79
|
* once; a command is routed to the connection for its target profile. A new
|
|
@@ -167,6 +155,8 @@ export declare class BridgeServer {
|
|
|
167
155
|
timeoutMs?: number;
|
|
168
156
|
profile?: string;
|
|
169
157
|
}): Promise<unknown>;
|
|
158
|
+
/** Never sign a peer's request using the hub's identity. Chrome verifies its original proof. */
|
|
159
|
+
private forwardCommand;
|
|
170
160
|
/** Whether the browser paired as `profile` advertised capability `cap`. A peer
|
|
171
161
|
* cannot see the hub's handshake, so it answers false (the conservative path). */
|
|
172
162
|
hasCap(profile: string | undefined, cap: string): boolean;
|
|
@@ -201,13 +191,14 @@ export declare class BridgeServer {
|
|
|
201
191
|
/** How to pair a browser for `profile` — surfaced whenever that profile has no live connection. */
|
|
202
192
|
noPairMessage(profile: string): string;
|
|
203
193
|
/** Every paired browser with how it was named — for chrome_status. */
|
|
204
|
-
pairedProfiles(): PairedProfile[];
|
|
194
|
+
pairedProfiles(includeChallenge?: boolean): PairedProfile[];
|
|
205
195
|
/**
|
|
206
196
|
* Rename an automatically named browser. The new name sticks across restarts
|
|
207
197
|
* (it's stored against the extension install); a live connection is re-keyed
|
|
208
198
|
* in place, so it keeps working without re-pairing.
|
|
209
199
|
*/
|
|
210
200
|
renameProfile(from: string, to: string): Promise<string>;
|
|
201
|
+
private renameApprovedProfile;
|
|
211
202
|
status(): {
|
|
212
203
|
extensionConnected: boolean;
|
|
213
204
|
port: number;
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BridgeServer = exports.WEB_STORE_URL = void 0;
|
|
4
|
+
const client_auth_1 = require("../../shared/client-auth");
|
|
5
|
+
const client_identity_1 = require("./client-identity");
|
|
2
6
|
/**
|
|
3
7
|
* src/bridge/server.ts — the localhost WebSocket bridge.
|
|
4
8
|
*
|
|
5
9
|
* The server is the WS SERVER; the extension dials in as the single privileged
|
|
6
|
-
* CLIENT. The token
|
|
7
|
-
*
|
|
10
|
+
* CLIENT. The token authenticates this transport; the extension separately
|
|
11
|
+
* verifies each command against its approved client keys. Flow:
|
|
8
12
|
* 1. Accept any loopback upgrade.
|
|
9
13
|
* 2. Require a valid `hello` (matching version + token) within HELLO_TIMEOUT;
|
|
10
14
|
* otherwise send `unauthorized` and close 4401.
|
|
@@ -14,8 +18,6 @@
|
|
|
14
18
|
* Other mcp-browser-extension servers (one per Claude session) may join the same port as
|
|
15
19
|
* peers with `peer_hello` and relay their commands through us; see ./peer.
|
|
16
20
|
*/
|
|
17
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
-
exports.BridgeServer = exports.WEB_STORE_URL = void 0;
|
|
19
21
|
const ws_1 = require("ws");
|
|
20
22
|
const node_crypto_1 = require("node:crypto");
|
|
21
23
|
const protocol_1 = require("../../shared/protocol");
|
|
@@ -75,6 +77,12 @@ const MAX_PREAUTH_FRAMES = 10;
|
|
|
75
77
|
const PORT_WAIT_MS = 4_000;
|
|
76
78
|
/** Pause between port-bind retries while waiting for the old listener to exit. */
|
|
77
79
|
const PORT_RETRY_MS = 250;
|
|
80
|
+
/** Bridge frames are small JSON control messages; cap unauthenticated memory use. */
|
|
81
|
+
const MAX_BRIDGE_FRAME_BYTES = 4 * 1024 * 1024;
|
|
82
|
+
/** Native clients omit Origin; Chrome extensions use their own isolated origin. */
|
|
83
|
+
function allowBridgeOrigin(origin) {
|
|
84
|
+
return origin === undefined || /^chrome-extension:\/\/[a-p]{32}$/.test(origin);
|
|
85
|
+
}
|
|
78
86
|
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
79
87
|
/** A friendly, actionable message for the rare case the port stays busy past PORT_WAIT_MS. */
|
|
80
88
|
function portBusyMessage(host, port) {
|
|
@@ -88,6 +96,7 @@ function portBusyMessage(host, port) {
|
|
|
88
96
|
}
|
|
89
97
|
class BridgeServer {
|
|
90
98
|
opts;
|
|
99
|
+
clientIdentity = (0, client_identity_1.createClientIdentity)(() => 'Local browser client');
|
|
91
100
|
wss = null;
|
|
92
101
|
/** Profile routing key → its live connection. Multiple browsers stay paired at
|
|
93
102
|
* once; a command is routed to the connection for its target profile. A new
|
|
@@ -295,7 +304,13 @@ class BridgeServer {
|
|
|
295
304
|
/** One bind attempt. Resolves with a listening server or rejects with the listen error. */
|
|
296
305
|
listenOnce(host, port) {
|
|
297
306
|
return new Promise((resolve, reject) => {
|
|
298
|
-
const wss = new ws_1.WebSocketServer({
|
|
307
|
+
const wss = new ws_1.WebSocketServer({
|
|
308
|
+
host,
|
|
309
|
+
port,
|
|
310
|
+
maxPayload: MAX_BRIDGE_FRAME_BYTES,
|
|
311
|
+
perMessageDeflate: false,
|
|
312
|
+
verifyClient: ({ origin }) => allowBridgeOrigin(origin || undefined),
|
|
313
|
+
});
|
|
299
314
|
const onError = (err) => {
|
|
300
315
|
wss.off('listening', onListening);
|
|
301
316
|
// Close so the failed server doesn't linger and leak a handle on retry.
|
|
@@ -365,12 +380,33 @@ class BridgeServer {
|
|
|
365
380
|
* Rejects with an actionable message if that profile has no live browser.
|
|
366
381
|
*/
|
|
367
382
|
async sendCommand(method, params, opts) {
|
|
383
|
+
const profile = routeKey(opts?.profile);
|
|
384
|
+
const timeoutMs = opts?.timeoutMs ?? (0, connection_1.defaultTimeoutFor)(method);
|
|
385
|
+
const intent = { v: protocol_1.PROTOCOL_VERSION, method, params, tabId: opts?.tabId, timeoutMs };
|
|
386
|
+
const challenge = this.hub
|
|
387
|
+
? this.hub.profiles.find((p) => p.name === profile)?.approvalChallenge
|
|
388
|
+
: this.conns.get(profile)?.approvalChallenge;
|
|
389
|
+
let authorization;
|
|
390
|
+
if (!(0, client_auth_1.isHealthProbe)(intent)) {
|
|
391
|
+
if (!this.hasConnection(profile))
|
|
392
|
+
throw new types_1.ExecutorError('EXTENSION_DISCONNECTED', this.noPairMessage(profile));
|
|
393
|
+
if (!challenge)
|
|
394
|
+
throw new types_1.ExecutorError('POLICY_DENIED', 'Update the extension and MCP server together: this browser cannot enforce client approval yet.');
|
|
395
|
+
authorization = ((0, client_identity_1.currentClientIdentity)() ?? this.clientIdentity).sign(intent, challenge);
|
|
396
|
+
}
|
|
397
|
+
const options = { ...opts, timeoutMs, authorization };
|
|
368
398
|
if (this.hub)
|
|
369
|
-
return this.hub.relay(method, params,
|
|
399
|
+
return this.hub.relay(method, params, options);
|
|
400
|
+
return this.forwardCommand(method, params, options);
|
|
401
|
+
}
|
|
402
|
+
/** Never sign a peer's request using the hub's identity. Chrome verifies its original proof. */
|
|
403
|
+
async forwardCommand(method, params, opts) {
|
|
370
404
|
const profile = routeKey(opts?.profile);
|
|
371
405
|
const conn = this.conns.get(profile);
|
|
372
|
-
if (!conn
|
|
406
|
+
if (!conn?.isOpen())
|
|
373
407
|
throw new types_1.ExecutorError('EXTENSION_DISCONNECTED', this.noPairMessage(profile));
|
|
408
|
+
if (!(0, client_auth_1.isHealthProbe)({ method, params }) && (!conn.hasCap(client_auth_1.CLIENT_APPROVAL_CAP) || !opts?.authorization)) {
|
|
409
|
+
throw new types_1.ExecutorError('POLICY_DENIED', 'Signed client authorization and an updated extension are required.');
|
|
374
410
|
}
|
|
375
411
|
return conn.sendCommand(method, params, opts);
|
|
376
412
|
}
|
|
@@ -478,19 +514,18 @@ class BridgeServer {
|
|
|
478
514
|
return steps.length > 0 ? `${head} ${steps.join(' ')}` : head;
|
|
479
515
|
}
|
|
480
516
|
/** Every paired browser with how it was named — for chrome_status. */
|
|
481
|
-
pairedProfiles() {
|
|
517
|
+
pairedProfiles(includeChallenge = false) {
|
|
482
518
|
if (this.hub)
|
|
483
|
-
return
|
|
519
|
+
return this.hub.profiles.map(({ approvalChallenge, ...p }) => includeChallenge ? { ...p, approvalChallenge } : p);
|
|
484
520
|
const out = [];
|
|
485
521
|
for (const [name, conn] of this.conns) {
|
|
486
522
|
if (!conn.isOpen())
|
|
487
523
|
continue;
|
|
488
|
-
const activeUrl = conn.lastActiveUrl(Number.POSITIVE_INFINITY) ?? undefined;
|
|
489
524
|
const o = this.origins.get(name);
|
|
490
525
|
out.push({
|
|
491
526
|
name,
|
|
492
527
|
naming: o?.naming ?? 'legacy',
|
|
493
|
-
...(
|
|
528
|
+
...(includeChallenge && conn.approvalChallenge ? { approvalChallenge: conn.approvalChallenge } : {}),
|
|
494
529
|
...(o?.extVersion ? { extVersion: o.extVersion } : {}),
|
|
495
530
|
...(o?.extId || o?.install ? { extSource: extensionSource(o) } : {}),
|
|
496
531
|
});
|
|
@@ -503,8 +538,18 @@ class BridgeServer {
|
|
|
503
538
|
* in place, so it keeps working without re-pairing.
|
|
504
539
|
*/
|
|
505
540
|
async renameProfile(from, to) {
|
|
506
|
-
if (this.hub)
|
|
507
|
-
|
|
541
|
+
if (this.hub) {
|
|
542
|
+
const challenge = this.hub.profiles.find((p) => p.name === routeKey(from))?.approvalChallenge;
|
|
543
|
+
if (!challenge)
|
|
544
|
+
throw new types_1.ExecutorError('POLICY_DENIED', 'Connect an updated browser before renaming it.');
|
|
545
|
+
const intent = { v: protocol_1.PROTOCOL_VERSION, method: 'ping_probe', params: { requireClientApproval: true, renameProfile: { from, to } }, timeoutMs: (0, connection_1.defaultTimeoutFor)('ping_probe') };
|
|
546
|
+
const authorization = ((0, client_identity_1.currentClientIdentity)() ?? this.clientIdentity).sign(intent, challenge);
|
|
547
|
+
return String(await this.hub.rename(from, to, authorization));
|
|
548
|
+
}
|
|
549
|
+
await this.sendCommand('ping_probe', { requireClientApproval: true, renameProfile: { from, to } }, { profile: from });
|
|
550
|
+
return this.renameApprovedProfile(from, to);
|
|
551
|
+
}
|
|
552
|
+
async renameApprovedProfile(from, to) {
|
|
508
553
|
const src = routeKey(from);
|
|
509
554
|
const live = this.conns.get(src);
|
|
510
555
|
if (live?.isOpen() && this.origins.get(src)?.naming === 'options') {
|
|
@@ -600,6 +645,7 @@ class BridgeServer {
|
|
|
600
645
|
this.promote(ws, frame.ext ?? { id: 'unknown', version: '0', chrome: '0' }, profile, Array.isArray(frame.caps) ? frame.caps : undefined, {
|
|
601
646
|
naming,
|
|
602
647
|
installId,
|
|
648
|
+
approvalChallenge: typeof frame.approvalChallenge === 'string' && /^[a-f0-9-]{36}$/.test(frame.approvalChallenge) ? frame.approvalChallenge : undefined,
|
|
603
649
|
...(typeof frame.ext?.version === 'string' ? { extVersion: frame.ext.version.slice(0, 32) } : {}),
|
|
604
650
|
...(typeof frame.ext?.id === 'string' ? { extId: frame.ext.id.slice(0, 64) } : {}),
|
|
605
651
|
...(typeof frame.install === 'string' ? { install: frame.install.slice(0, 32) } : {}),
|
|
@@ -634,7 +680,7 @@ class BridgeServer {
|
|
|
634
680
|
this.peers.add(ws);
|
|
635
681
|
this.log(`mcp-browser-extension pid ${pid ?? '?'} joined as a peer (${this.peers.size} sharing these browsers)`);
|
|
636
682
|
this.sendPeer(ws, { type: 'peer_welcome', hubPid: process.pid });
|
|
637
|
-
this.sendPeer(ws, { type: 'peer_state', profiles: this.pairedProfiles() });
|
|
683
|
+
this.sendPeer(ws, { type: 'peer_state', profiles: this.pairedProfiles(true) });
|
|
638
684
|
ws.on('message', (raw) => this.handlePeerFrame(ws, raw.toString()));
|
|
639
685
|
ws.on('close', () => this.peers.delete(ws));
|
|
640
686
|
ws.on('error', () => {
|
|
@@ -653,9 +699,9 @@ class BridgeServer {
|
|
|
653
699
|
work.then((data) => this.sendPeer(ws, { type: 'relay_result', id, ok: true, data }), (err) => this.sendPeer(ws, (0, peer_1.relayError)(id, err)));
|
|
654
700
|
};
|
|
655
701
|
if (frame.type === 'relay')
|
|
656
|
-
answer(frame.id, this.
|
|
702
|
+
answer(frame.id, this.forwardCommand(frame.method, frame.params ?? {}, frame.opts));
|
|
657
703
|
else if (frame.type === 'peer_rename')
|
|
658
|
-
answer(frame.id, this.renameProfile(frame.from, frame.to));
|
|
704
|
+
answer(frame.id, this.forwardCommand('ping_probe', { requireClientApproval: true, renameProfile: { from: frame.from, to: frame.to } }, { profile: frame.from, timeoutMs: (0, connection_1.defaultTimeoutFor)('ping_probe'), authorization: frame.authorization }).then(() => this.renameApprovedProfile(frame.from, frame.to)));
|
|
659
705
|
else if (frame.type === 'peer_blocked' && typeof frame.host === 'string')
|
|
660
706
|
this.noteBlocked(frame.host, String(frame.method ?? ''));
|
|
661
707
|
}
|
|
@@ -663,7 +709,7 @@ class BridgeServer {
|
|
|
663
709
|
broadcastState() {
|
|
664
710
|
if (this.peers.size === 0)
|
|
665
711
|
return;
|
|
666
|
-
const state = { type: 'peer_state', profiles: this.pairedProfiles() };
|
|
712
|
+
const state = { type: 'peer_state', profiles: this.pairedProfiles(true) };
|
|
667
713
|
for (const peer of this.peers)
|
|
668
714
|
this.sendPeer(peer, state);
|
|
669
715
|
}
|
|
@@ -709,6 +755,7 @@ class BridgeServer {
|
|
|
709
755
|
sessionId,
|
|
710
756
|
heartbeatMs: this.heartbeatMs,
|
|
711
757
|
caps,
|
|
758
|
+
approvalChallenge: origin.approvalChallenge,
|
|
712
759
|
onEvent: this.opts.onEvent,
|
|
713
760
|
onSiteGrant: (host, allow) => this.applySiteGrant(host, allow),
|
|
714
761
|
onLog: (m) => this.log(m),
|
|
@@ -12,8 +12,7 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Grants only ever ADD to the flags' list, and only grants can be removed again:
|
|
14
14
|
* a site that came from `--allow-domain` stays until the flags change. A grant
|
|
15
|
-
* is always one concrete host (or `*.host`), never
|
|
16
|
-
* every site stays a deliberate flag (`--unsafe-all-domains`).
|
|
15
|
+
* is always one concrete host (or `*.host`), never an all-site catch-all.
|
|
17
16
|
*/
|
|
18
17
|
export declare const GRANTS_FILE = "allowed-sites.json";
|
|
19
18
|
/**
|
|
@@ -13,8 +13,7 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Grants only ever ADD to the flags' list, and only grants can be removed again:
|
|
15
15
|
* a site that came from `--allow-domain` stays until the flags change. A grant
|
|
16
|
-
* is always one concrete host (or `*.host`), never
|
|
17
|
-
* every site stays a deliberate flag (`--unsafe-all-domains`).
|
|
16
|
+
* is always one concrete host (or `*.host`), never an all-site catch-all.
|
|
18
17
|
*/
|
|
19
18
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
19
|
exports.SiteGrants = exports.GRANTS_FILE = void 0;
|
|
@@ -59,7 +58,9 @@ class SiteGrants {
|
|
|
59
58
|
constructor(allowDomains, dataDir) {
|
|
60
59
|
this.allowDomains = allowDomains;
|
|
61
60
|
this.dataDir = dataDir;
|
|
62
|
-
|
|
61
|
+
const namedSites = allowDomains.map(policy_1.normalizeDomainPattern).filter(Boolean);
|
|
62
|
+
allowDomains.splice(0, allowDomains.length, ...namedSites);
|
|
63
|
+
this.base = new Set(namedSites);
|
|
63
64
|
for (const site of this.load())
|
|
64
65
|
this.add(site);
|
|
65
66
|
}
|