mcp-browser-extension 0.9.30 → 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 +77 -14
- package/SETUP.md +40 -7
- 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/shared/screenshot.d.ts +4 -2
- package/dist/shared/screenshot.js +13 -8
- package/dist/src/access-browser.d.ts +15 -0
- package/dist/src/access-browser.js +202 -0
- package/dist/src/access.d.ts +2 -0
- package/dist/src/access.js +92 -18
- 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 +16 -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 +48 -22
- package/dist/src/remote/key.js +12 -2
- package/dist/src/security/policy.d.ts +4 -0
- package/dist/src/security/policy.js +3 -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 +1036 -73
- 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
package/dist/shared/protocol.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WIRE_METHODS = exports.FILL_FORM_MAX_TIMEOUT_MS = exports.FILL_FORM_PER_FIELD_MS = exports.FILL_FORM_MIN_TIMEOUT_MS = exports.WIRE_CAP_PROXY = exports.WIRE_CAP_FILL_FORM = exports.WIRE_CAP_TAB_URL = exports.CLOSE_SUPERSEDED = exports.CLOSE_UNAUTHORIZED = exports.BRIDGE_HOST = exports.STORE_EXTENSION_URL = exports.STORE_EXTENSION_ID = exports.DEFAULT_WS_PORT = exports.PROTOCOL_VERSION = void 0;
|
|
4
|
+
exports.fillFormTimeoutMs = fillFormTimeoutMs;
|
|
2
5
|
/**
|
|
3
6
|
* shared/protocol.ts — THE SINGLE SOURCE OF TRUTH for the wire contract.
|
|
4
7
|
*
|
|
@@ -14,9 +17,6 @@
|
|
|
14
17
|
* beyond the primitives, and so is `fill_form` (a batch of `type`/`click` writes
|
|
15
18
|
* in one round-trip; capability-gated, see `WIRE_CAP_FILL_FORM`).
|
|
16
19
|
*/
|
|
17
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
-
exports.WIRE_METHODS = exports.FILL_FORM_MAX_TIMEOUT_MS = exports.FILL_FORM_PER_FIELD_MS = exports.FILL_FORM_MIN_TIMEOUT_MS = exports.WIRE_CAP_PROXY = exports.WIRE_CAP_FILL_FORM = exports.WIRE_CAP_TAB_URL = exports.CLOSE_SUPERSEDED = exports.CLOSE_UNAUTHORIZED = exports.BRIDGE_HOST = exports.STORE_EXTENSION_URL = exports.STORE_EXTENSION_ID = exports.DEFAULT_WS_PORT = exports.PROTOCOL_VERSION = void 0;
|
|
19
|
-
exports.fillFormTimeoutMs = fillFormTimeoutMs;
|
|
20
20
|
/** Bumped on any breaking change to the frames below. */
|
|
21
21
|
exports.PROTOCOL_VERSION = 1;
|
|
22
22
|
/**
|
|
@@ -14,8 +14,10 @@ export interface PageDims {
|
|
|
14
14
|
/** Full content box (document) width/height. */
|
|
15
15
|
fullW: number;
|
|
16
16
|
fullH: number;
|
|
17
|
-
/** window.devicePixelRatio (default 1).
|
|
17
|
+
/** window.devicePixelRatio, including page zoom (default 1). */
|
|
18
18
|
dpr?: number;
|
|
19
|
+
/** Chrome tab zoom: CSS pixels to device-independent pixels (default 1). */
|
|
20
|
+
zoom?: number;
|
|
19
21
|
/** Current scroll offset of the top document (default 0,0); a viewport clip starts here. */
|
|
20
22
|
scrollX?: number;
|
|
21
23
|
scrollY?: number;
|
|
@@ -35,7 +37,7 @@ export interface ElementRect {
|
|
|
35
37
|
w: number;
|
|
36
38
|
h: number;
|
|
37
39
|
}
|
|
38
|
-
/** A CDP `Page.captureScreenshot` clip (
|
|
40
|
+
/** A CDP `Page.captureScreenshot` clip (device-independent px, not CSS px). */
|
|
39
41
|
export interface CaptureClip {
|
|
40
42
|
x: number;
|
|
41
43
|
y: number;
|
|
@@ -27,15 +27,20 @@ exports.MAX_CAPTURE_PX = 16384;
|
|
|
27
27
|
* viewport capture. Heights are clamped to MAX_CAPTURE_PX with `truncated` set.
|
|
28
28
|
*/
|
|
29
29
|
function planScreenshot(dims, opts = {}) {
|
|
30
|
-
//
|
|
31
|
-
// by the DPR
|
|
30
|
+
// Page.Viewport uses device-independent pixels, so convert the CSS geometry
|
|
31
|
+
// by the tab zoom. DPR already includes that zoom: dividing the output scale
|
|
32
|
+
// by DPR then yields exactly `opts.scale` output pixels per CSS pixel.
|
|
32
33
|
const dpr = dims.dpr && dims.dpr > 0 ? dims.dpr : 1;
|
|
34
|
+
const zoom = dims.zoom && dims.zoom > 0 ? dims.zoom : 1;
|
|
33
35
|
const scale = (opts.scale && opts.scale > 0 ? opts.scale : exports.DEFAULT_SCREENSHOT_SCALE) / dpr;
|
|
36
|
+
const clip = (x, y, width, height) => ({
|
|
37
|
+
x: x * zoom, y: y * zoom, width: width * zoom, height: height * zoom, scale,
|
|
38
|
+
});
|
|
34
39
|
if (opts.element) {
|
|
35
40
|
const realH = Math.max(1, Math.round(opts.element.h));
|
|
36
41
|
const clipH = Math.min(opts.element.h, exports.MAX_CAPTURE_PX);
|
|
37
42
|
return {
|
|
38
|
-
clip:
|
|
43
|
+
clip: clip(opts.element.x, opts.element.y, opts.element.w, clipH),
|
|
39
44
|
captureBeyondViewport: true,
|
|
40
45
|
width: Math.max(1, Math.round(opts.element.w)),
|
|
41
46
|
height: Math.min(realH, exports.MAX_CAPTURE_PX),
|
|
@@ -46,7 +51,7 @@ function planScreenshot(dims, opts = {}) {
|
|
|
46
51
|
if (opts.fullPage) {
|
|
47
52
|
const clipH = Math.min(dims.fullH, exports.MAX_CAPTURE_PX);
|
|
48
53
|
return {
|
|
49
|
-
clip:
|
|
54
|
+
clip: clip(0, 0, dims.fullW, clipH),
|
|
50
55
|
captureBeyondViewport: true,
|
|
51
56
|
width: dims.fullW,
|
|
52
57
|
height: clipH,
|
|
@@ -54,11 +59,11 @@ function planScreenshot(dims, opts = {}) {
|
|
|
54
59
|
fullHeight: dims.fullH,
|
|
55
60
|
};
|
|
56
61
|
}
|
|
57
|
-
// Plain viewport:
|
|
58
|
-
//
|
|
62
|
+
// Plain viewport: a clip is needed to apply an output scale or page zoom.
|
|
63
|
+
// The caller can also force a fresh paint with a viewport clip at 100%.
|
|
59
64
|
return {
|
|
60
|
-
...(scale !== 1
|
|
61
|
-
? { clip:
|
|
65
|
+
...(scale !== 1 || zoom !== 1
|
|
66
|
+
? { clip: clip(dims.scrollX ?? 0, dims.scrollY ?? 0, dims.w, dims.h) }
|
|
62
67
|
: {}),
|
|
63
68
|
captureBeyondViewport: false,
|
|
64
69
|
width: dims.w,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface BrowserEmailPrompt {
|
|
2
|
+
url: string;
|
|
3
|
+
close(): void;
|
|
4
|
+
}
|
|
5
|
+
export interface EmailSubmission {
|
|
6
|
+
ok: boolean;
|
|
7
|
+
message: string;
|
|
8
|
+
}
|
|
9
|
+
/** Best effort: the tool also returns the link for remote/headless computers. */
|
|
10
|
+
export declare function openBrowserEmailPrompt(url: string): void;
|
|
11
|
+
/** Only the person submitting this form can start an email. GET never sends one. */
|
|
12
|
+
export declare function createBrowserEmailPrompt(submit: (email: string) => Promise<EmailSubmission>, options?: {
|
|
13
|
+
onClose?: () => void;
|
|
14
|
+
ttlMs?: number;
|
|
15
|
+
}): Promise<BrowserEmailPrompt>;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.openBrowserEmailPrompt = openBrowserEmailPrompt;
|
|
4
|
+
exports.createBrowserEmailPrompt = createBrowserEmailPrompt;
|
|
5
|
+
/** A temporary loopback email form for clients that cannot display elicitation. */
|
|
6
|
+
const node_crypto_1 = require("node:crypto");
|
|
7
|
+
const node_http_1 = require("node:http");
|
|
8
|
+
const node_child_process_1 = require("node:child_process");
|
|
9
|
+
/** Best effort: the tool also returns the link for remote/headless computers. */
|
|
10
|
+
function openBrowserEmailPrompt(url) {
|
|
11
|
+
// Only launch a form we host, never a URL received from an MCP request.
|
|
12
|
+
if (!/^http:\/\/127\.0\.0\.1:\d+\/verify\/[a-f0-9]{64}$/.test(url))
|
|
13
|
+
return;
|
|
14
|
+
const command = process.platform === 'darwin' ? 'open'
|
|
15
|
+
: process.platform === 'win32' ? 'rundll32.exe' : 'xdg-open';
|
|
16
|
+
const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
|
|
17
|
+
try {
|
|
18
|
+
const child = (0, node_child_process_1.spawn)(command, args, { detached: true, stdio: 'ignore', windowsHide: true });
|
|
19
|
+
child.on('error', () => { });
|
|
20
|
+
child.unref();
|
|
21
|
+
}
|
|
22
|
+
catch { /* Keep the form available even if the operating system cannot open it. */ }
|
|
23
|
+
}
|
|
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');
|
|
55
|
+
function page(path, csrf, message, sent) {
|
|
56
|
+
return `<!doctype html><html lang="en"><meta charset="utf-8">
|
|
57
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
58
|
+
<title>Verify your email | MCP Browser Extension</title>
|
|
59
|
+
<style>
|
|
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)}
|
|
78
|
+
</style>
|
|
79
|
+
<body><p class="brand">MCP Browser Extension</p><h1>${sent ? 'Check your inbox' : 'Verify your email'}</h1>
|
|
80
|
+
<p id="verification-status" role="status">${escapeHtml(message)}</p>
|
|
81
|
+
${sent ? '<p>After opening the sign-in link, return to your assistant to continue.</p>' : `
|
|
82
|
+
<form method="post" action="${path}">
|
|
83
|
+
<input type="hidden" name="csrf" value="${csrf}">
|
|
84
|
+
<label for="email">Email address</label>
|
|
85
|
+
<input id="email" name="email" type="email" autocomplete="email" maxlength="254" required autofocus>
|
|
86
|
+
<button type="submit">Send verification link</button>
|
|
87
|
+
</form><small>Your email is sent to mcp-browser-extension.com to verify and manage access.</small>
|
|
88
|
+
<script>${FORM_SCRIPT}</script>`}
|
|
89
|
+
</body></html>`;
|
|
90
|
+
}
|
|
91
|
+
/** Only the person submitting this form can start an email. GET never sends one. */
|
|
92
|
+
async function createBrowserEmailPrompt(submit, options = {}) {
|
|
93
|
+
const path = `/verify/${(0, node_crypto_1.randomBytes)(32).toString('hex')}`;
|
|
94
|
+
const csrf = (0, node_crypto_1.randomBytes)(32).toString('hex');
|
|
95
|
+
let origin = '';
|
|
96
|
+
let sent = false;
|
|
97
|
+
let sending;
|
|
98
|
+
let closed = false;
|
|
99
|
+
let expiry;
|
|
100
|
+
const sentMessage = 'A sign-in link has been sent. Open it from your email and click Verify this device to finish verification.';
|
|
101
|
+
const respond = (res, status, message) => {
|
|
102
|
+
res.writeHead(status, {
|
|
103
|
+
'content-type': 'text/html; charset=utf-8',
|
|
104
|
+
'cache-control': 'no-store',
|
|
105
|
+
// Keep Origin on same-origin form POSTs; no-referrer makes Chromium send
|
|
106
|
+
// Origin: null. Cross-origin destinations still receive no referrer.
|
|
107
|
+
'referrer-policy': 'same-origin',
|
|
108
|
+
'x-content-type-options': 'nosniff',
|
|
109
|
+
'x-frame-options': 'DENY',
|
|
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'`,
|
|
111
|
+
});
|
|
112
|
+
res.end(page(path, csrf, message, sent));
|
|
113
|
+
};
|
|
114
|
+
const server = (0, node_http_1.createServer)((req, res) => {
|
|
115
|
+
void (async () => {
|
|
116
|
+
// Exact Host + Origin checks also prevent DNS rebinding and cross-site
|
|
117
|
+
// submissions. The random path is independent of every device credential.
|
|
118
|
+
if (req.headers.host !== origin.slice('http://'.length) || req.url !== path) {
|
|
119
|
+
res.writeHead(404).end();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (req.method === 'GET') {
|
|
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.');
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (req.method !== 'POST') {
|
|
130
|
+
res.writeHead(405, { allow: 'GET, POST' }).end();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (req.headers.origin !== origin) {
|
|
134
|
+
res.writeHead(403).end();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (req.headers['content-type']?.split(';')[0] !== 'application/x-www-form-urlencoded') {
|
|
138
|
+
res.writeHead(415).end();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
let body = '';
|
|
142
|
+
let bytes = 0;
|
|
143
|
+
for await (const chunk of req) {
|
|
144
|
+
bytes += Buffer.byteLength(chunk);
|
|
145
|
+
if (bytes > 2048) {
|
|
146
|
+
res.writeHead(413).end();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
body += chunk.toString();
|
|
150
|
+
}
|
|
151
|
+
const fields = new URLSearchParams(body);
|
|
152
|
+
if (fields.get('csrf') !== csrf) {
|
|
153
|
+
res.writeHead(403).end();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (sent) {
|
|
157
|
+
respond(res, 200, sentMessage);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
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; });
|
|
167
|
+
}
|
|
168
|
+
const outcome = await sending;
|
|
169
|
+
respond(res, outcome.ok ? 200 : 400, outcome.ok ? sentMessage : outcome.message);
|
|
170
|
+
})().catch(() => { if (!res.headersSent)
|
|
171
|
+
respond(res, 503, 'The verification service could not be reached. Please try again.'); });
|
|
172
|
+
});
|
|
173
|
+
server.requestTimeout = 10_000;
|
|
174
|
+
server.headersTimeout = 10_000;
|
|
175
|
+
const close = () => {
|
|
176
|
+
if (closed)
|
|
177
|
+
return;
|
|
178
|
+
closed = true;
|
|
179
|
+
clearTimeout(expiry);
|
|
180
|
+
server.close();
|
|
181
|
+
server.closeAllConnections();
|
|
182
|
+
options.onClose?.();
|
|
183
|
+
};
|
|
184
|
+
await new Promise((resolve, reject) => {
|
|
185
|
+
server.once('error', reject);
|
|
186
|
+
server.listen(0, '127.0.0.1', () => {
|
|
187
|
+
server.removeListener('error', reject);
|
|
188
|
+
resolve();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
const address = server.address();
|
|
192
|
+
if (!address || typeof address === 'string') {
|
|
193
|
+
close();
|
|
194
|
+
throw new Error('No loopback address');
|
|
195
|
+
}
|
|
196
|
+
origin = `http://127.0.0.1:${address.port}`;
|
|
197
|
+
expiry = setTimeout(close, options.ttlMs ?? 10 * 60_000);
|
|
198
|
+
expiry.unref();
|
|
199
|
+
server.unref();
|
|
200
|
+
return { url: origin + path, close };
|
|
201
|
+
}
|
|
202
|
+
//# sourceMappingURL=access-browser.js.map
|
package/dist/src/access.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export type AccessRequest = {
|
|
|
9
9
|
};
|
|
10
10
|
/** @internal Test seam for the verification watcher. */
|
|
11
11
|
export declare function setAccessPollIntervalForTesting(ms: number, waitMs?: number): void;
|
|
12
|
+
/** Close a pending form when verification finishes or its owner shuts down. */
|
|
13
|
+
export declare function closeAccessBrowserPrompt(dataDir: string): void;
|
|
12
14
|
declare function result(message: string): {
|
|
13
15
|
content: {
|
|
14
16
|
type: "text";
|
package/dist/src/access.js
CHANGED
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.bypassEmailVerification = void 0;
|
|
4
4
|
exports.setAccessPollIntervalForTesting = setAccessPollIntervalForTesting;
|
|
5
|
+
exports.closeAccessBrowserPrompt = closeAccessBrowserPrompt;
|
|
5
6
|
exports.requireAccess = requireAccess;
|
|
6
7
|
/** Email-link access gate for the MCP tool surface. */
|
|
7
8
|
const node_crypto_1 = require("node:crypto");
|
|
8
9
|
const node_fs_1 = require("node:fs");
|
|
9
10
|
const node_path_1 = require("node:path");
|
|
10
11
|
const compat_1 = require("./compat");
|
|
12
|
+
const access_browser_1 = require("./access-browser");
|
|
11
13
|
const SITE = ((0, compat_1.runtimeEnv)('ACCESS_API') ?? 'https://mcp-browser-extension.com/api/access').replace(/\/$/, '');
|
|
12
14
|
let accessPollMs = 2_000;
|
|
13
15
|
let accessWaitMs = 45_000;
|
|
@@ -18,10 +20,12 @@ const EMAIL_SCHEMA = {
|
|
|
18
20
|
};
|
|
19
21
|
const validUntil = new Map();
|
|
20
22
|
const currentChecks = new Map();
|
|
23
|
+
const pendingEmailStarts = new Map();
|
|
21
24
|
const pollingChallenges = new Map();
|
|
22
25
|
const completionToReport = new Map();
|
|
26
|
+
const browserPrompts = new Map();
|
|
23
27
|
const VERIFIED = { status: 'verified', message: 'Email verified. MCP Browser Extension access is active.' };
|
|
24
|
-
const PENDING = { status: 'pending', message: '[ACCESS_PENDING] Email verification is still pending. Open the sign-in link sent to your email. Continue waiting by calling chrome_status; no new email or chat reply is needed.' };
|
|
28
|
+
const PENDING = { status: 'pending', message: '[ACCESS_PENDING] Email verification is still pending. Open the sign-in link sent to your email and click Verify this device. Continue waiting by calling chrome_status; no new email or chat reply is needed.' };
|
|
25
29
|
const CANCELLED = { status: 'cancelled', message: 'Email verification wait was cancelled. The browser action was not run.' };
|
|
26
30
|
const UNAVAILABLE = { status: 'unavailable', message: 'Email verification could not be confirmed because the verification service is unavailable. Access remains blocked. Retry chrome_status shortly.' };
|
|
27
31
|
/** @internal Test seam for the verification watcher. */
|
|
@@ -29,6 +33,10 @@ function setAccessPollIntervalForTesting(ms, waitMs = 45_000) {
|
|
|
29
33
|
accessPollMs = Math.max(1, ms);
|
|
30
34
|
accessWaitMs = Math.max(1, waitMs);
|
|
31
35
|
}
|
|
36
|
+
/** Close a pending form when verification finishes or its owner shuts down. */
|
|
37
|
+
function closeAccessBrowserPrompt(dataDir) {
|
|
38
|
+
browserPrompts.get(dataDir)?.close();
|
|
39
|
+
}
|
|
32
40
|
function statePath(dataDir) { return (0, node_path_1.join)(dataDir, 'access.json'); }
|
|
33
41
|
function loadOrCreate(dataDir) {
|
|
34
42
|
(0, node_fs_1.mkdirSync)(dataDir, { recursive: true, mode: 0o700 });
|
|
@@ -68,6 +76,7 @@ function watchForEmailApproval(server, dataDir, challengeId) {
|
|
|
68
76
|
let delayMs = accessPollMs;
|
|
69
77
|
const finish = (outcome) => {
|
|
70
78
|
pollingChallenges.delete(challengeId);
|
|
79
|
+
closeAccessBrowserPrompt(dataDir);
|
|
71
80
|
completionToReport.set(dataDir, outcome);
|
|
72
81
|
void notifyAccess(server, outcome);
|
|
73
82
|
resolve(outcome);
|
|
@@ -133,10 +142,10 @@ async function waitForEmailApproval(server, dataDir, challengeId, request) {
|
|
|
133
142
|
}
|
|
134
143
|
catch { /* Progress is optional; the tool result still carries the outcome. */ }
|
|
135
144
|
};
|
|
136
|
-
await report('Sign-in link sent. Open it in your email; this tool is waiting for verification automatically.');
|
|
145
|
+
await report('Sign-in link sent. Open it in your email and click Verify this device; this tool is waiting for verification automatically.');
|
|
137
146
|
let timeout;
|
|
138
147
|
let onAbort;
|
|
139
|
-
const heartbeat = setInterval(() => { void report('Waiting for email verification. Open the sign-in link sent to your email.'); }, 5_000);
|
|
148
|
+
const heartbeat = setInterval(() => { void report('Waiting for email verification. Open the sign-in link sent to your email and click Verify this device.'); }, 5_000);
|
|
140
149
|
try {
|
|
141
150
|
const outcome = await Promise.race([
|
|
142
151
|
approval.promise,
|
|
@@ -176,6 +185,75 @@ async function api(path, body) {
|
|
|
176
185
|
function result(message) {
|
|
177
186
|
return { content: [{ type: 'text', text: message }], isError: true };
|
|
178
187
|
}
|
|
188
|
+
const inputRequired = (url) => ({
|
|
189
|
+
status: 'input-required',
|
|
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
|
+
`If no form appeared, open this local page on the computer running MCP Browser Extension:\n${url}\n` +
|
|
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 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.',
|
|
195
|
+
});
|
|
196
|
+
function startEmail(server, dataDir, state, input) {
|
|
197
|
+
const existing = pendingEmailStarts.get(dataDir);
|
|
198
|
+
if (existing)
|
|
199
|
+
return existing;
|
|
200
|
+
const sending = sendVerificationEmail(server, dataDir, state, input)
|
|
201
|
+
.finally(() => { pendingEmailStarts.delete(dataDir); });
|
|
202
|
+
pendingEmailStarts.set(dataDir, sending);
|
|
203
|
+
return sending;
|
|
204
|
+
}
|
|
205
|
+
async function sendVerificationEmail(server, dataDir, state, input) {
|
|
206
|
+
const email = input.trim().toLowerCase();
|
|
207
|
+
if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
208
|
+
return { status: 'denied', message: 'Enter a valid email address.' };
|
|
209
|
+
}
|
|
210
|
+
state.challengeId = (0, node_crypto_1.randomBytes)(32).toString('hex');
|
|
211
|
+
let started;
|
|
212
|
+
try {
|
|
213
|
+
started = await api('start', { email, challengeId: state.challengeId, deviceId: state.deviceId, deviceSecret: state.deviceSecret });
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
delete state.challengeId;
|
|
217
|
+
saveState(dataDir, state);
|
|
218
|
+
return { status: 'unavailable', message: 'The verification service could not be reached. Please try again.' };
|
|
219
|
+
}
|
|
220
|
+
if (!started.ok) {
|
|
221
|
+
delete state.challengeId;
|
|
222
|
+
saveState(dataDir, state);
|
|
223
|
+
const message = typeof started.data.error === 'string' ? started.data.error : 'The verification email could not be sent. Check your connection and try again.';
|
|
224
|
+
return { status: 'unavailable', message };
|
|
225
|
+
}
|
|
226
|
+
// Other tool calls and local processes must not poll a challenge before the
|
|
227
|
+
// backend creates it; a premature 410 would discard a valid sign-in request.
|
|
228
|
+
saveState(dataDir, state);
|
|
229
|
+
await notifyAccess(server, { status: 'pending', message: 'A sign-in link has been sent. Open it from your email; the browser tool is waiting for verification.' });
|
|
230
|
+
return { challengeId: state.challengeId };
|
|
231
|
+
}
|
|
232
|
+
async function offerBrowserPrompt(server, dataDir, request) {
|
|
233
|
+
if (request.signal?.aborted)
|
|
234
|
+
return CANCELLED;
|
|
235
|
+
const existing = browserPrompts.get(dataDir);
|
|
236
|
+
if (existing)
|
|
237
|
+
return inputRequired(existing.url);
|
|
238
|
+
const prompt = await (0, access_browser_1.createBrowserEmailPrompt)(async (email) => {
|
|
239
|
+
const state = loadOrCreate(dataDir);
|
|
240
|
+
if (state.verified || state.challengeId)
|
|
241
|
+
return { ok: false, message: 'A verification request is already active. Return to your assistant and call chrome_status.' };
|
|
242
|
+
const outcome = await startEmail(server, dataDir, state, email);
|
|
243
|
+
if (outcome && 'challengeId' in outcome) {
|
|
244
|
+
watchForEmailApproval(server, dataDir, outcome.challengeId);
|
|
245
|
+
return { ok: true, message: '' };
|
|
246
|
+
}
|
|
247
|
+
return { ok: false, message: outcome?.message ?? UNAVAILABLE.message };
|
|
248
|
+
}, { onClose: () => { browserPrompts.delete(dataDir); } });
|
|
249
|
+
browserPrompts.set(dataDir, prompt);
|
|
250
|
+
if (request.signal?.aborted) {
|
|
251
|
+
prompt.close();
|
|
252
|
+
return CANCELLED;
|
|
253
|
+
}
|
|
254
|
+
(0, access_browser_1.openBrowserEmailPrompt)(prompt.url);
|
|
255
|
+
return inputRequired(prompt.url);
|
|
256
|
+
}
|
|
179
257
|
/** Returns null when authorized, otherwise a user-facing tool error. */
|
|
180
258
|
async function requireAccess(server, dataDir, request = {}) {
|
|
181
259
|
if (request.signal?.aborted)
|
|
@@ -214,11 +292,15 @@ async function requireAccess(server, dataDir, request = {}) {
|
|
|
214
292
|
const bypassEmailVerification = async () => null;
|
|
215
293
|
exports.bypassEmailVerification = bypassEmailVerification;
|
|
216
294
|
async function check(server, dataDir, request) {
|
|
295
|
+
const sending = pendingEmailStarts.get(dataDir);
|
|
296
|
+
if (sending)
|
|
297
|
+
return sending;
|
|
217
298
|
const state = loadOrCreate(dataDir);
|
|
218
299
|
try {
|
|
219
300
|
if (state.verified) {
|
|
220
301
|
const revalidated = await api('validate', { deviceId: state.deviceId, deviceSecret: state.deviceSecret });
|
|
221
302
|
if (revalidated.ok && revalidated.data.authorized === true) {
|
|
303
|
+
closeAccessBrowserPrompt(dataDir);
|
|
222
304
|
validUntil.set(dataDir, Date.now() + 60_000);
|
|
223
305
|
if (completionToReport.delete(dataDir))
|
|
224
306
|
return VERIFIED;
|
|
@@ -235,6 +317,9 @@ async function check(server, dataDir, request) {
|
|
|
235
317
|
return { status: 'unavailable', message: 'Access could not be checked right now. Retry the browser tool shortly.' };
|
|
236
318
|
}
|
|
237
319
|
if (!state.challengeId) {
|
|
320
|
+
const existing = browserPrompts.get(dataDir);
|
|
321
|
+
if (existing)
|
|
322
|
+
return inputRequired(existing.url);
|
|
238
323
|
let elicitation;
|
|
239
324
|
// The SDK retains the signal listener after an elicitation completes.
|
|
240
325
|
// Detach our forwarding listener so later cancellation cannot cancel a finished prompt.
|
|
@@ -253,28 +338,17 @@ async function check(server, dataDir, request) {
|
|
|
253
338
|
catch {
|
|
254
339
|
if (request.signal?.aborted)
|
|
255
340
|
return CANCELLED;
|
|
256
|
-
return
|
|
341
|
+
return await offerBrowserPrompt(server, dataDir, request);
|
|
257
342
|
}
|
|
258
343
|
finally {
|
|
259
344
|
request.signal?.removeEventListener('abort', cancelPrompt);
|
|
260
345
|
}
|
|
261
346
|
if (request.signal?.aborted)
|
|
262
347
|
return CANCELLED;
|
|
263
|
-
if (elicitation.action !== 'accept' || typeof elicitation.content?.email !== 'string')
|
|
264
|
-
return
|
|
265
|
-
const email = elicitation.content.email.trim().toLowerCase();
|
|
266
|
-
if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
|
|
267
|
-
return { status: 'denied', message: 'That email address is invalid. Retry the browser tool and enter a valid address.' };
|
|
268
|
-
state.challengeId = (0, node_crypto_1.randomBytes)(32).toString('hex');
|
|
269
|
-
saveState(dataDir, state);
|
|
270
|
-
const started = await api('start', { email, challengeId: state.challengeId, deviceId: state.deviceId, deviceSecret: state.deviceSecret });
|
|
271
|
-
if (!started.ok) {
|
|
272
|
-
delete state.challengeId;
|
|
273
|
-
saveState(dataDir, state);
|
|
274
|
-
const message = typeof started.data.error === 'string' ? started.data.error : 'The verification email could not be sent. Check your connection and retry the browser tool.';
|
|
275
|
-
return { status: 'unavailable', message };
|
|
348
|
+
if (elicitation.action !== 'accept' || typeof elicitation.content?.email !== 'string' || !elicitation.content.email.trim()) {
|
|
349
|
+
return await offerBrowserPrompt(server, dataDir, request);
|
|
276
350
|
}
|
|
277
|
-
await
|
|
351
|
+
return await startEmail(server, dataDir, state, elicitation.content.email);
|
|
278
352
|
}
|
|
279
353
|
return { challengeId: state.challengeId };
|
|
280
354
|
}
|
|
@@ -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(() => {
|