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.
@@ -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 { status: 'unavailable', message: 'Your MCP client did not display the email prompt. This access flow requires an MCP client that supports elicitation. Enable elicitation or connect from a supported client, then retry.' };
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 { status: 'cancelled', message: 'Email verification was not completed. Retry the browser tool to start again.' };
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 notifyAccess(server, { status: 'pending', message: 'A sign-in link has been sent. Open it from your email; the browser tool is waiting for verification.' });
350
+ return await startEmail(server, dataDir, state, elicitation.content.email);
278
351
  }
279
352
  return { challengeId: state.challengeId };
280
353
  }
@@ -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
- // An extension that predates the op never advertised it: let the caller go field by field.
172
- if (!this.bridge.hasCap(this.activeProfile(), protocol_1.WIRE_CAP_FILL_FORM))
173
- return null;
174
- const res = (await this.send('fill_form', { ops: fields, ...frameParams(opts) }, { tabId: opts?.tabId, timeoutMs: (0, protocol_1.fillFormTimeoutMs)(fields.length) }));
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;
@@ -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: a click toggles it.
170
- await ex.click(target, opts);
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);
@@ -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 ' +
@@ -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: 'Make a tab active by tabId.', inputSchema: { tabId: zod_1.z.string() } },
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: "Read cookies visible to the tab's URL (or a given url).", inputSchema: { url: zod_1.z.string().optional(), tabId: tabIdField } },
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 tab = await ctx.ex.tabSelect((0, validators_1.requireString)(a, 'tabId'));
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((0, validators_1.optionalString)(a, 'url'), { active: (0, validators_1.optionalBoolean)(a, 'active') ?? false });
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
- return (0, envelopes_1.jsonResult)(await ctx.ex.getCookies({ tabId: tabId(a), url: (0, validators_1.optionalString)(a, 'url') }));
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
- const dir = (0, node_path_1.resolve)(ctx.policy.uploadsDir);
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
  /**