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.
Files changed (45) hide show
  1. package/README.md +58 -10
  2. package/SETUP.md +27 -3
  3. package/dist/shared/activity.d.ts +25 -0
  4. package/dist/shared/activity.js +72 -0
  5. package/dist/shared/browser-permissions.d.ts +20 -0
  6. package/dist/shared/browser-permissions.js +40 -0
  7. package/dist/shared/client-auth.d.ts +28 -0
  8. package/dist/shared/client-auth.js +38 -0
  9. package/dist/shared/observers.d.ts +1 -1
  10. package/dist/shared/observers.js +1 -3
  11. package/dist/shared/policy.d.ts +5 -7
  12. package/dist/shared/policy.js +11 -26
  13. package/dist/shared/protocol.d.ts +4 -0
  14. package/dist/shared/protocol.js +3 -3
  15. package/dist/src/access-browser.js +66 -23
  16. package/dist/src/access.js +2 -1
  17. package/dist/src/bridge/client-identity.d.ts +10 -0
  18. package/dist/src/bridge/client-identity.js +28 -0
  19. package/dist/src/bridge/connection.d.ts +5 -0
  20. package/dist/src/bridge/connection.js +4 -12
  21. package/dist/src/bridge/peer.d.ts +4 -19
  22. package/dist/src/bridge/peer.js +17 -8
  23. package/dist/src/bridge/server.d.ts +7 -16
  24. package/dist/src/bridge/server.js +64 -17
  25. package/dist/src/bridge/site-grants.d.ts +1 -2
  26. package/dist/src/bridge/site-grants.js +4 -3
  27. package/dist/src/cli.js +1 -3
  28. package/dist/src/config.js +10 -5
  29. package/dist/src/executor/cdp-executor.d.ts +3 -0
  30. package/dist/src/executor/cdp-executor.js +69 -25
  31. package/dist/src/mcp/server.js +5 -3
  32. package/dist/src/mcp/tools.d.ts +0 -12
  33. package/dist/src/mcp/tools.js +35 -20
  34. package/dist/src/remote/key.js +12 -2
  35. package/dist/src/security/policy.js +1 -1
  36. package/docs/BLUEPRINT.md +6 -1
  37. package/docs/CLIENT-APPROVAL.md +150 -0
  38. package/extension-dist/activity-dashboard.css +43 -0
  39. package/extension-dist/background.js +953 -58
  40. package/extension-dist/manifest.json +10 -9
  41. package/extension-dist/options.html +101 -32
  42. package/extension-dist/options.js +558 -70
  43. package/extension-dist/popup.html +164 -90
  44. package/extension-dist/popup.js +576 -79
  45. 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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
- :root{color-scheme:light dark;font-family:system-ui,sans-serif}
31
- body{max-width:440px;margin:12vh auto;padding:24px;line-height:1.5}
32
- h1{font-size:28px;line-height:1.2} label{display:block;font-weight:600}
33
- input,button{box-sizing:border-box;width:100%;font:inherit;padding:12px;margin-top:8px;border:1px solid #888;border-radius:6px}
34
- button{margin-top:18px;background:#175cd3;color:white;border-color:#175cd3;cursor:pointer}
35
- p{margin:20px 0} .brand{font-size:14px} small{display:block;margin-top:18px}
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 busy = false;
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': "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
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
- respond(res, 200, sent ? sentMessage : 'Enter your email below. We will send you a one-time sign-in link.');
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 (busy) {
115
- respond(res, 409, 'Your request is already being sent. Please wait.');
116
- return;
117
- }
118
- busy = true;
119
- try {
120
- const outcome = await submit(fields.get('email') ?? '');
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
  });
@@ -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 to the user; 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.',
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;
@@ -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 !== 'peer_welcome')
101
- return done(null); // 'unauthorized' or anything else
102
- done(new HubLink(ws, typeof frame.hubPid === 'number' ? frame.hubPid : 0, onClose, onGrant));
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 is the ONLY trust boundary (the loopback bind is merely
7
- * defense-in-depth; Origin is NOT a gate). Flow:
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({ host, port });
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, opts);
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 || !conn.isOpen()) {
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 [...this.hub.profiles];
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
- ...(activeUrl ? { activeUrl } : {}),
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
- return String(await this.hub.rename(from, to));
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.sendCommand(frame.method, frame.params ?? {}, frame.opts));
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 the `*` catch-all — allowing
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 the `*` catch-all — allowing
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
- this.base = new Set(allowDomains.map((d) => d.toLowerCase()));
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
  }