mcp-browser-extension 0.9.29 → 0.9.31
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 +19 -4
- package/SETUP.md +13 -4
- package/dist/shared/page-fns.d.ts +7 -1
- package/dist/shared/page-fns.js +37 -2
- package/dist/shared/policy.js +10 -1
- package/dist/shared/screenshot.d.ts +4 -2
- package/dist/shared/screenshot.js +13 -8
- package/dist/shared/snapshot.js +15 -4
- package/dist/src/access-browser.d.ts +15 -0
- package/dist/src/access-browser.js +159 -0
- package/dist/src/access.d.ts +2 -0
- package/dist/src/access.js +91 -18
- package/dist/src/config.js +6 -0
- package/dist/src/executor/cdp-executor.d.ts +3 -0
- package/dist/src/executor/cdp-executor.js +14 -0
- package/dist/src/executor/extension-executor.js +14 -4
- package/dist/src/executor/types.d.ts +4 -0
- package/dist/src/mcp/helpers.js +11 -2
- package/dist/src/mcp/server.js +1 -1
- package/dist/src/mcp/tools.js +51 -9
- package/dist/src/security/policy.d.ts +4 -0
- package/dist/src/security/policy.js +2 -0
- package/extension-dist/background.js +319 -77
- package/extension-dist/manifest.json +3 -3
- package/package.json +2 -1
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,74 @@ 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 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.',
|
|
194
|
+
});
|
|
195
|
+
function startEmail(server, dataDir, state, input) {
|
|
196
|
+
const existing = pendingEmailStarts.get(dataDir);
|
|
197
|
+
if (existing)
|
|
198
|
+
return existing;
|
|
199
|
+
const sending = sendVerificationEmail(server, dataDir, state, input)
|
|
200
|
+
.finally(() => { pendingEmailStarts.delete(dataDir); });
|
|
201
|
+
pendingEmailStarts.set(dataDir, sending);
|
|
202
|
+
return sending;
|
|
203
|
+
}
|
|
204
|
+
async function sendVerificationEmail(server, dataDir, state, input) {
|
|
205
|
+
const email = input.trim().toLowerCase();
|
|
206
|
+
if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
207
|
+
return { status: 'denied', message: 'Enter a valid email address.' };
|
|
208
|
+
}
|
|
209
|
+
state.challengeId = (0, node_crypto_1.randomBytes)(32).toString('hex');
|
|
210
|
+
let started;
|
|
211
|
+
try {
|
|
212
|
+
started = await api('start', { email, challengeId: state.challengeId, deviceId: state.deviceId, deviceSecret: state.deviceSecret });
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
delete state.challengeId;
|
|
216
|
+
saveState(dataDir, state);
|
|
217
|
+
return { status: 'unavailable', message: 'The verification service could not be reached. Please try again.' };
|
|
218
|
+
}
|
|
219
|
+
if (!started.ok) {
|
|
220
|
+
delete state.challengeId;
|
|
221
|
+
saveState(dataDir, state);
|
|
222
|
+
const message = typeof started.data.error === 'string' ? started.data.error : 'The verification email could not be sent. Check your connection and try again.';
|
|
223
|
+
return { status: 'unavailable', message };
|
|
224
|
+
}
|
|
225
|
+
// Other tool calls and local processes must not poll a challenge before the
|
|
226
|
+
// backend creates it; a premature 410 would discard a valid sign-in request.
|
|
227
|
+
saveState(dataDir, state);
|
|
228
|
+
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.' });
|
|
229
|
+
return { challengeId: state.challengeId };
|
|
230
|
+
}
|
|
231
|
+
async function offerBrowserPrompt(server, dataDir, request) {
|
|
232
|
+
if (request.signal?.aborted)
|
|
233
|
+
return CANCELLED;
|
|
234
|
+
const existing = browserPrompts.get(dataDir);
|
|
235
|
+
if (existing)
|
|
236
|
+
return inputRequired(existing.url);
|
|
237
|
+
const prompt = await (0, access_browser_1.createBrowserEmailPrompt)(async (email) => {
|
|
238
|
+
const state = loadOrCreate(dataDir);
|
|
239
|
+
if (state.verified || state.challengeId)
|
|
240
|
+
return { ok: false, message: 'A verification request is already active. Return to your assistant and call chrome_status.' };
|
|
241
|
+
const outcome = await startEmail(server, dataDir, state, email);
|
|
242
|
+
if (outcome && 'challengeId' in outcome) {
|
|
243
|
+
watchForEmailApproval(server, dataDir, outcome.challengeId);
|
|
244
|
+
return { ok: true, message: '' };
|
|
245
|
+
}
|
|
246
|
+
return { ok: false, message: outcome?.message ?? UNAVAILABLE.message };
|
|
247
|
+
}, { onClose: () => { browserPrompts.delete(dataDir); } });
|
|
248
|
+
browserPrompts.set(dataDir, prompt);
|
|
249
|
+
if (request.signal?.aborted) {
|
|
250
|
+
prompt.close();
|
|
251
|
+
return CANCELLED;
|
|
252
|
+
}
|
|
253
|
+
(0, access_browser_1.openBrowserEmailPrompt)(prompt.url);
|
|
254
|
+
return inputRequired(prompt.url);
|
|
255
|
+
}
|
|
179
256
|
/** Returns null when authorized, otherwise a user-facing tool error. */
|
|
180
257
|
async function requireAccess(server, dataDir, request = {}) {
|
|
181
258
|
if (request.signal?.aborted)
|
|
@@ -214,11 +291,15 @@ async function requireAccess(server, dataDir, request = {}) {
|
|
|
214
291
|
const bypassEmailVerification = async () => null;
|
|
215
292
|
exports.bypassEmailVerification = bypassEmailVerification;
|
|
216
293
|
async function check(server, dataDir, request) {
|
|
294
|
+
const sending = pendingEmailStarts.get(dataDir);
|
|
295
|
+
if (sending)
|
|
296
|
+
return sending;
|
|
217
297
|
const state = loadOrCreate(dataDir);
|
|
218
298
|
try {
|
|
219
299
|
if (state.verified) {
|
|
220
300
|
const revalidated = await api('validate', { deviceId: state.deviceId, deviceSecret: state.deviceSecret });
|
|
221
301
|
if (revalidated.ok && revalidated.data.authorized === true) {
|
|
302
|
+
closeAccessBrowserPrompt(dataDir);
|
|
222
303
|
validUntil.set(dataDir, Date.now() + 60_000);
|
|
223
304
|
if (completionToReport.delete(dataDir))
|
|
224
305
|
return VERIFIED;
|
|
@@ -235,6 +316,9 @@ async function check(server, dataDir, request) {
|
|
|
235
316
|
return { status: 'unavailable', message: 'Access could not be checked right now. Retry the browser tool shortly.' };
|
|
236
317
|
}
|
|
237
318
|
if (!state.challengeId) {
|
|
319
|
+
const existing = browserPrompts.get(dataDir);
|
|
320
|
+
if (existing)
|
|
321
|
+
return inputRequired(existing.url);
|
|
238
322
|
let elicitation;
|
|
239
323
|
// The SDK retains the signal listener after an elicitation completes.
|
|
240
324
|
// Detach our forwarding listener so later cancellation cannot cancel a finished prompt.
|
|
@@ -253,28 +337,17 @@ async function check(server, dataDir, request) {
|
|
|
253
337
|
catch {
|
|
254
338
|
if (request.signal?.aborted)
|
|
255
339
|
return CANCELLED;
|
|
256
|
-
return
|
|
340
|
+
return await offerBrowserPrompt(server, dataDir, request);
|
|
257
341
|
}
|
|
258
342
|
finally {
|
|
259
343
|
request.signal?.removeEventListener('abort', cancelPrompt);
|
|
260
344
|
}
|
|
261
345
|
if (request.signal?.aborted)
|
|
262
346
|
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 };
|
|
347
|
+
if (elicitation.action !== 'accept' || typeof elicitation.content?.email !== 'string' || !elicitation.content.email.trim()) {
|
|
348
|
+
return await offerBrowserPrompt(server, dataDir, request);
|
|
276
349
|
}
|
|
277
|
-
await
|
|
350
|
+
return await startEmail(server, dataDir, state, elicitation.content.email);
|
|
278
351
|
}
|
|
279
352
|
return { challengeId: state.challengeId };
|
|
280
353
|
}
|
package/dist/src/config.js
CHANGED
|
@@ -157,6 +157,9 @@ function parseArgs(argv) {
|
|
|
157
157
|
case '--enable-observers':
|
|
158
158
|
policyFlags.allowObservers = true;
|
|
159
159
|
break;
|
|
160
|
+
case '--enable-cookie-values':
|
|
161
|
+
policyFlags.allowCookieValues = true;
|
|
162
|
+
break;
|
|
160
163
|
case '--redact':
|
|
161
164
|
policyFlags.redact = true;
|
|
162
165
|
break;
|
|
@@ -419,6 +422,9 @@ Security (default: deny-all safe mode):
|
|
|
419
422
|
in-page hook on allowlisted sites that records console
|
|
420
423
|
output, fetch/XHR traffic, and intercepts alert/confirm/
|
|
421
424
|
prompt (off by default: it patches page globals).
|
|
425
|
+
--enable-cookie-values Let get_cookies return cookie values. Off by default:
|
|
426
|
+
a session cookie is the login itself, so get_cookies
|
|
427
|
+
returns names and flags with every value hidden.
|
|
422
428
|
--redact Scrub secret-shaped strings (JWTs, cloud keys, bearer
|
|
423
429
|
tokens, private keys) out of page reads. Password field
|
|
424
430
|
values are always suppressed, with or without this.
|
|
@@ -97,6 +97,9 @@ export declare class CdpExecutor implements Executor {
|
|
|
97
97
|
selectOption(t: Target, values: string[], opts?: {
|
|
98
98
|
tabId?: TabId;
|
|
99
99
|
}): Promise<ActionOk>;
|
|
100
|
+
setChecked(t: Target, checked: boolean, opts?: {
|
|
101
|
+
tabId?: TabId;
|
|
102
|
+
}): Promise<ActionOk>;
|
|
100
103
|
fill(t: Target, value: string, opts?: {
|
|
101
104
|
tabId?: TabId;
|
|
102
105
|
}): Promise<ActionOk>;
|
|
@@ -272,8 +272,15 @@ class CdpExecutor {
|
|
|
272
272
|
catch (err) {
|
|
273
273
|
const msg = err.message ?? '';
|
|
274
274
|
if (CLOSED_TARGET.test(msg)) {
|
|
275
|
+
const stale = this.context;
|
|
275
276
|
this.context = null;
|
|
276
277
|
this.tabs.clear();
|
|
278
|
+
// A browser we launched is ours to close; otherwise the relaunch on the
|
|
279
|
+
// next call leaves this one running. Never close the user's Chrome.
|
|
280
|
+
if (this.opts.mode !== 'connect' && this.launched) {
|
|
281
|
+
this.launched = false;
|
|
282
|
+
void stale?.close().catch(() => undefined);
|
|
283
|
+
}
|
|
277
284
|
throw new types_1.ExecutorError('DETACHED', 'browser target closed or crashed; reconnect and retry');
|
|
278
285
|
}
|
|
279
286
|
throw err;
|
|
@@ -397,6 +404,13 @@ class CdpExecutor {
|
|
|
397
404
|
return this.ok;
|
|
398
405
|
});
|
|
399
406
|
}
|
|
407
|
+
async setChecked(t, checked, opts) {
|
|
408
|
+
return this.guard(async () => {
|
|
409
|
+
const p = await this.resolveTab(opts?.tabId);
|
|
410
|
+
await this.locator(p, t).setChecked(checked);
|
|
411
|
+
return this.ok;
|
|
412
|
+
});
|
|
413
|
+
}
|
|
400
414
|
async fill(t, value, opts) {
|
|
401
415
|
return this.guard(async () => {
|
|
402
416
|
const p = await this.resolveTab(opts?.tabId);
|
|
@@ -168,10 +168,20 @@ class ExtensionExecutor {
|
|
|
168
168
|
return (await this.send('type', { ...targetParams(t), ...frameParams(opts), text: value, clear: true, keyEvents: false }, { tabId: opts?.tabId }));
|
|
169
169
|
}
|
|
170
170
|
async fillFields(fields, opts) {
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
171
|
+
// No capability pre-check alone: a server relaying through another one (a
|
|
172
|
+
// second AI session) cannot see the extension's caps, so ask and read the
|
|
173
|
+
// answer. An extension that predates the op says it has no such method:
|
|
174
|
+
// then let the caller go field by field.
|
|
175
|
+
let res;
|
|
176
|
+
try {
|
|
177
|
+
res = (await this.send('fill_form', { ops: fields, ...frameParams(opts) }, { tabId: opts?.tabId, timeoutMs: (0, protocol_1.fillFormTimeoutMs)(fields.length) }));
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
if (!this.bridge.hasCap(this.activeProfile(), protocol_1.WIRE_CAP_FILL_FORM) && err instanceof types_1.ExecutorError && /unhandled method/.test(err.message)) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
175
185
|
if (res.error) {
|
|
176
186
|
// Say how far the batch got: the fields before this one DID land, and a
|
|
177
187
|
// blind retry of the whole form would write them a second time.
|
|
@@ -361,6 +361,10 @@ export interface Executor {
|
|
|
361
361
|
} & FrameOpts): Promise<{
|
|
362
362
|
filled: number;
|
|
363
363
|
} | null>;
|
|
364
|
+
/** Put a checkbox/radio into `checked` (no-op when it already is). */
|
|
365
|
+
setChecked?(t: Target, checked: boolean, opts?: {
|
|
366
|
+
tabId?: TabId;
|
|
367
|
+
} & FrameOpts): Promise<ActionOk>;
|
|
364
368
|
/** Every frame of a tab that the extension can inject into, with its URL. */
|
|
365
369
|
framesList?(opts?: {
|
|
366
370
|
tabId?: TabId;
|
package/dist/src/mcp/helpers.js
CHANGED
|
@@ -166,8 +166,17 @@ async function fillForm(ex, args) {
|
|
|
166
166
|
for (const { selector, value } of ops) {
|
|
167
167
|
const target = { selector };
|
|
168
168
|
if (typeof value === 'boolean') {
|
|
169
|
-
// Checkbox/radio:
|
|
170
|
-
|
|
169
|
+
// Checkbox/radio: set the state. A bare click toggles, so it is only
|
|
170
|
+
// right for a backend that cannot set it, and only to turn it on.
|
|
171
|
+
if (ex.setChecked)
|
|
172
|
+
await ex.setChecked(target, value, opts);
|
|
173
|
+
else if (value)
|
|
174
|
+
await ex.click(target, opts);
|
|
175
|
+
else {
|
|
176
|
+
// Unticking needs the current state, which this backend cannot read;
|
|
177
|
+
// a silent no-op would report a field as filled that was not.
|
|
178
|
+
throw new types_1.ExecutorError('UNSUPPORTED', `cannot set ${selector} to unchecked with this browser backend; update the MCP Browser Extension`);
|
|
179
|
+
}
|
|
171
180
|
}
|
|
172
181
|
else {
|
|
173
182
|
await ex.fill(target, value, opts);
|
package/dist/src/mcp/server.js
CHANGED
|
@@ -36,7 +36,7 @@ const SERVER_VERSION = '0.1.0';
|
|
|
36
36
|
* than retry.
|
|
37
37
|
*/
|
|
38
38
|
exports.SERVER_INSTRUCTIONS = "MCP Browser Extension drives the user's real, signed-in Chrome. " +
|
|
39
|
-
'On the first browser tool call, enter your email in the MCP input prompt and open the sign-in link sent to you. The tool waits for verification, reports the result, and continues automatically on success. If it returns ACCESS_PENDING, call chrome_status again to keep waiting; do not ask for a chat reply or another email. Only ask the user to click Connect if the result says Chrome is not paired. ' +
|
|
39
|
+
'On the first browser tool call, enter your email in the MCP input prompt and open the sign-in link sent to you, then click Verify this device on the website. If it returns ACCESS_INPUT_REQUIRED, the local verification form opens automatically in the browser; also show its URL as a backup so the user can enter their email. Do not fill it or read their email for them. The tool waits for verification, reports the result, and continues automatically on success. If it returns ACCESS_PENDING, call chrome_status again to keep waiting; do not ask for a chat reply or another email. Only ask the user to click Connect if the result says Chrome is not paired. ' +
|
|
40
40
|
'Start with chrome_status. If it reports no paired browser (or any tool fails with NO_BACKEND), do not retry: ' +
|
|
41
41
|
'give the user the steps in its `setup` field, which name what went wrong and how to fix it. ' +
|
|
42
42
|
'If a tool fails with POLICY_DENIED because a site is not allowed, ask the user to allow it from the extension\'s ' +
|
package/dist/src/mcp/tools.js
CHANGED
|
@@ -105,7 +105,7 @@ const maxBytesField = zod_1.z
|
|
|
105
105
|
const waitUntilField = zod_1.z.enum(['load', 'domcontentloaded', 'networkidle']).describe('When to consider navigation done').optional();
|
|
106
106
|
exports.TOOL_DEFINITIONS = [
|
|
107
107
|
{ name: 'tabs_list', description: 'List open browser tabs.', inputSchema: {} },
|
|
108
|
-
{ name: 'tab_select', description:
|
|
108
|
+
{ name: 'tab_select', description: "Make a tab the agent's tab by tabId, in the background: calls without tabId go to it and the user stays on their own tab. Pass show:true only to bring it to the front for the user.", inputSchema: { tabId: zod_1.z.string(), show: zod_1.z.boolean().optional() } },
|
|
109
109
|
{ name: 'tab_new', description: "Open a NEW tab (optionally at a URL) in the background, leaving the user's tab alone; it becomes the agent's tab, where calls without tabId go. Pass active:true to show it to the user.", inputSchema: { url: zod_1.z.string().optional(), active: zod_1.z.boolean().optional() } },
|
|
110
110
|
{ name: 'tab_close', description: 'Close a tab by tabId.', inputSchema: { tabId: zod_1.z.string() } },
|
|
111
111
|
{ name: 'navigate', description: "Navigate the agent's tab to a URL, replacing its page. With no agent's tab yet it opens one in the background: the user's own tab is only navigated when you pass its tabId.", inputSchema: { url: zod_1.z.string(), tabId: tabIdField, waitUntil: waitUntilField, failOnAuthWall: authWallField } },
|
|
@@ -124,7 +124,7 @@ exports.TOOL_DEFINITIONS = [
|
|
|
124
124
|
{ name: 'get_html', description: 'Get HTML of the page or an element. Output is capped (see maxBytes) and cut at a tag boundary; narrow it with `selector` rather than raising the cap when you can. Password field values are always blanked.', inputSchema: { ...TARGET_PROPS, ...FRAME_PROPS, outer: zod_1.z.boolean().optional(), tabId: tabIdField, maxBytes: maxBytesField } },
|
|
125
125
|
{ name: 'snapshot', description: 'Accessibility snapshot: interactive elements with refs to target by `ref` (more reliable than guessing CSS selectors). Pass diff:true to get only what changed since the last snapshot of this tab - far cheaper in a click/read loop. Password fields appear as secret:true with no value.', inputSchema: { interactiveOnly: zod_1.z.boolean().optional(), max: zod_1.z.number().optional(), diff: zod_1.z.boolean().describe('Return added/removed/changed elements since the previous snapshot of this tab instead of the whole tree').optional(), failOnAuthWall: authWallField, ...FRAME_PROPS, tabId: tabIdField } },
|
|
126
126
|
{ name: 'page_watch', description: 'Save a local page checkpoint and compare it later. Actions: create (name), compare (watchId), list, delete. Stores accessibility labels and state, never form values. Site allowlist still applies.', inputSchema: { action: zod_1.z.enum(['create', 'compare', 'list', 'delete']), name: zod_1.z.string().optional(), watchId: zod_1.z.string().optional(), tabId: tabIdField } },
|
|
127
|
-
{ name: 'get_cookies', description: "
|
|
127
|
+
{ name: 'get_cookies', description: "Cookies for the tab's URL (or url); values need --enable-cookie-values.", inputSchema: { url: zod_1.z.string().optional(), tabId: tabIdField } },
|
|
128
128
|
{ name: 'storage', description: 'Read/write localStorage (or sessionStorage). op: get|set|remove|clear.', inputSchema: { op: zod_1.z.enum(['get', 'set', 'remove', 'clear']), key: zod_1.z.string().optional(), value: zod_1.z.string().optional(), session: zod_1.z.boolean().optional(), tabId: tabIdField } },
|
|
129
129
|
{ name: 'eval', description: 'Evaluate JavaScript in the page (disabled in safe-mode).', inputSchema: { expression: zod_1.z.string(), awaitPromise: zod_1.z.boolean().optional(), ...FRAME_PROPS, tabId: tabIdField } },
|
|
130
130
|
{ name: 'wait_for', description: 'Wait for a selector or text to appear/disappear.', inputSchema: { selector: zod_1.z.string().optional(), textContains: zod_1.z.string().optional(), gone: zod_1.z.boolean().optional(), timeoutMs: zod_1.z.number().optional(), ...FRAME_PROPS, tabId: tabIdField, failOnAuthWall: authWallField } },
|
|
@@ -400,6 +400,8 @@ function enabledToolNames() {
|
|
|
400
400
|
const GATE_CONTEXT = 'cannot resolve the target tab URL for the policy gate';
|
|
401
401
|
/** How long the gate waits for a just-opened background tab to report its URL. */
|
|
402
402
|
const OPENING_TAB_WAIT_MS = 5_000;
|
|
403
|
+
/** What `get_cookies` returns in place of a value unless --enable-cookie-values is set. */
|
|
404
|
+
const COOKIE_VALUE_HIDDEN = '[hidden]';
|
|
403
405
|
/**
|
|
404
406
|
* Resolve the URL the policy should be evaluated against: the URL of the tab
|
|
405
407
|
* this very call will act on — the explicit `tabId` when the caller gave one,
|
|
@@ -707,14 +709,27 @@ exports.TOOL_HANDLERS = {
|
|
|
707
709
|
tabs_list: async (_a, ctx) => (0, envelopes_1.jsonResult)(await ctx.ex.tabsList()),
|
|
708
710
|
tab_select: async (a, ctx) => {
|
|
709
711
|
await gate(ctx, 'tab_select');
|
|
710
|
-
const
|
|
712
|
+
const id = (0, validators_1.requireString)(a, 'tabId');
|
|
713
|
+
// Showing the tab pulls the person off whatever they are doing, so only on request.
|
|
714
|
+
if ((0, validators_1.optionalBoolean)(a, 'show') === true) {
|
|
715
|
+
const tab = await ctx.ex.tabSelect(id);
|
|
716
|
+
workTab = tab.tabId; // an explicit pick: later calls follow it
|
|
717
|
+
return (0, envelopes_1.jsonResult)(tab);
|
|
718
|
+
}
|
|
719
|
+
const tab = (await ctx.ex.tabsList()).find((t) => t.tabId === id);
|
|
720
|
+
if (!tab)
|
|
721
|
+
throw new validators_1.McpToolError(`no open tab with tabId ${id}; call tabs_list for the current ids`);
|
|
711
722
|
workTab = tab.tabId; // an explicit pick: later calls follow it
|
|
712
|
-
return (0, envelopes_1.jsonResult)(tab);
|
|
723
|
+
return (0, envelopes_1.jsonResult)({ ...tab, shown: false });
|
|
713
724
|
},
|
|
714
725
|
tab_new: async (a, ctx) => {
|
|
715
726
|
await gate(ctx, 'tab_new');
|
|
727
|
+
// Opening a tab at a URL loads it like navigate does, so gate it the same way.
|
|
728
|
+
const url = (0, validators_1.optionalString)(a, 'url');
|
|
729
|
+
if (url !== undefined)
|
|
730
|
+
await gate(ctx, 'navigate', { url });
|
|
716
731
|
// Background unless asked: focusing it would pull the person off their tab.
|
|
717
|
-
const tab = await ctx.ex.tabNew(
|
|
732
|
+
const tab = await ctx.ex.tabNew(url, { active: (0, validators_1.optionalBoolean)(a, 'active') ?? false });
|
|
718
733
|
workTab = tab.tabId;
|
|
719
734
|
return (0, envelopes_1.jsonResult)(tab);
|
|
720
735
|
},
|
|
@@ -948,7 +963,20 @@ exports.TOOL_HANDLERS = {
|
|
|
948
963
|
},
|
|
949
964
|
get_cookies: async (a, ctx) => {
|
|
950
965
|
await gate(ctx, 'get_text', { tabId: tabId(a) }); // reads tab-scoped secrets; same domain gate as content reads
|
|
951
|
-
|
|
966
|
+
// Cookies for another URL are that site's secrets: it must be allowed too.
|
|
967
|
+
const url = (0, validators_1.optionalString)(a, 'url');
|
|
968
|
+
if (url !== undefined)
|
|
969
|
+
await gate(ctx, 'get_text', { url });
|
|
970
|
+
const result = await ctx.ex.getCookies({ tabId: tabId(a), url });
|
|
971
|
+
if (ctx.policy.allowCookieValues)
|
|
972
|
+
return (0, envelopes_1.jsonResult)(result);
|
|
973
|
+
// A session cookie is the login itself: whoever holds its value can use the
|
|
974
|
+
// account from anywhere. The browser stays signed in without the agent ever
|
|
975
|
+
// seeing it, so hide every value unless the operator opted in.
|
|
976
|
+
return (0, envelopes_1.jsonResult)({
|
|
977
|
+
cookies: result.cookies.map((c) => ({ ...c, value: COOKIE_VALUE_HIDDEN })),
|
|
978
|
+
valuesHidden: 'Cookie values are hidden. Ask the user to restart the server with --enable-cookie-values if they need them.',
|
|
979
|
+
});
|
|
952
980
|
},
|
|
953
981
|
storage: async (a, ctx) => {
|
|
954
982
|
const op = (0, validators_1.requireString)(a, 'op');
|
|
@@ -1054,11 +1082,14 @@ exports.TOOL_HANDLERS = {
|
|
|
1054
1082
|
return (0, envelopes_1.jsonResult)(res);
|
|
1055
1083
|
},
|
|
1056
1084
|
download_file: async (a, ctx) => {
|
|
1057
|
-
await gate(ctx, 'download_file');
|
|
1058
1085
|
const url = (0, validators_1.optionalString)(a, 'url');
|
|
1059
1086
|
const target = (0, validators_1.optionalTarget)(a);
|
|
1060
1087
|
if (!url && !target)
|
|
1061
1088
|
throw new validators_1.McpToolError('provide "url" or a target (selector|ref)');
|
|
1089
|
+
// By URL, the bytes come from that URL with the person's cookies: gate the
|
|
1090
|
+
// URL, not whichever tab the agent is on. By element, gate the tab it is in
|
|
1091
|
+
// (the extension then gates where the link leads).
|
|
1092
|
+
await gate(ctx, 'download_file', url ? { url } : { tabId: tabId(a) });
|
|
1062
1093
|
return (0, envelopes_1.jsonResult)(await ctx.ex.download({ url, target, tabId: tabId(a), suggestedName: (0, validators_1.optionalString)(a, 'suggestedName') }));
|
|
1063
1094
|
},
|
|
1064
1095
|
upload_file: async (a, ctx) => {
|
|
@@ -1074,9 +1105,20 @@ exports.TOOL_HANDLERS = {
|
|
|
1074
1105
|
if (!ctx.policy.uploadsDir) {
|
|
1075
1106
|
throw new validators_1.McpToolError('upload denied: uploads require an --uploads-dir to be configured (refusing unrestricted local-file access)');
|
|
1076
1107
|
}
|
|
1077
|
-
|
|
1108
|
+
// Compare real paths: Chrome reads a symlink's target, so a link inside the
|
|
1109
|
+
// dir pointing outside it must be judged by where it points.
|
|
1110
|
+
const real = (p) => {
|
|
1111
|
+
try {
|
|
1112
|
+
return (0, node_fs_1.realpathSync)(p);
|
|
1113
|
+
}
|
|
1114
|
+
catch {
|
|
1115
|
+
// Not there (yet): resolve its folder, so /tmp vs /private/tmp still compare.
|
|
1116
|
+
return p === (0, node_path_1.dirname)(p) ? p : (0, node_path_1.join)(real((0, node_path_1.dirname)(p)), (0, node_path_1.basename)(p));
|
|
1117
|
+
}
|
|
1118
|
+
};
|
|
1119
|
+
const dir = real((0, node_path_1.resolve)(ctx.policy.uploadsDir));
|
|
1078
1120
|
for (const f of files) {
|
|
1079
|
-
const abs = (0, node_path_1.resolve)(f);
|
|
1121
|
+
const abs = real((0, node_path_1.resolve)(f));
|
|
1080
1122
|
if (abs !== dir && !abs.startsWith(dir + node_path_1.sep)) {
|
|
1081
1123
|
throw new validators_1.McpToolError(`upload denied: "${f}" is outside the allowed uploads dir (${dir})`);
|
|
1082
1124
|
}
|
|
@@ -36,6 +36,10 @@ export interface Policy extends WirePolicy {
|
|
|
36
36
|
* `AUTH_REQUIRED` when the page it lands on is a high-confidence sign-in
|
|
37
37
|
* wall, so an expired session is never scored as some other failure. */
|
|
38
38
|
failOnAuthWall?: boolean;
|
|
39
|
+
/** `--enable-cookie-values`: `get_cookies` returns each cookie's value. Off by
|
|
40
|
+
* default, because a session cookie is the login itself: whoever holds it can
|
|
41
|
+
* use the account from anywhere, with no password and no browser. */
|
|
42
|
+
allowCookieValues?: boolean;
|
|
39
43
|
}
|
|
40
44
|
/** The SAFE default: deny everything until the user opts in. */
|
|
41
45
|
export declare const DEFAULT_POLICY: Readonly<Policy>;
|
|
@@ -45,6 +45,7 @@ exports.DEFAULT_POLICY = Object.freeze({
|
|
|
45
45
|
redact: false,
|
|
46
46
|
redactPatterns: [],
|
|
47
47
|
failOnAuthWall: false,
|
|
48
|
+
allowCookieValues: false,
|
|
48
49
|
});
|
|
49
50
|
/** Merge a partial (from a policy file and/or CLI flags) over the safe default. */
|
|
50
51
|
function resolvePolicy(partial) {
|
|
@@ -60,6 +61,7 @@ function resolvePolicy(partial) {
|
|
|
60
61
|
redact: partial?.redact ?? exports.DEFAULT_POLICY.redact,
|
|
61
62
|
redactPatterns: partial?.redactPatterns ?? [...(exports.DEFAULT_POLICY.redactPatterns ?? [])],
|
|
62
63
|
failOnAuthWall: partial?.failOnAuthWall ?? exports.DEFAULT_POLICY.failOnAuthWall,
|
|
64
|
+
allowCookieValues: partial?.allowCookieValues ?? exports.DEFAULT_POLICY.allowCookieValues,
|
|
63
65
|
};
|
|
64
66
|
}
|
|
65
67
|
/**
|