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 CHANGED
@@ -174,13 +174,24 @@ the way to install it.
174
174
  **3. Verify your email.** In your MCP client, ask the agent to call
175
175
  `chrome_status`. On this first browser tool call, the client shows an email
176
176
  prompt. Enter an address you can access, then open the sign-in link sent to
177
- that address. The waiting tool reports **Email verified** and continues.
177
+ that address and click **Verify this device**. The waiting tool reports
178
+ **Email verified** and continues.
178
179
 
179
180
  If the tool returns `ACCESS_PENDING`, ask the agent to call `chrome_status`
180
181
  again to keep waiting on the same link. You do not need another email or a
181
- verification code. The MCP client must support interactive input prompts
182
- (MCP elicitation). If no prompt appears, enable interactive prompts in your
183
- client and retry; installing the extension alone does not activate access.
182
+ verification code.
183
+
184
+ If your client suppresses the prompt (for example, in YOLO/bypass mode), does
185
+ not support MCP elicitation, or the dialog is dismissed, the server automatically
186
+ opens a temporary local verification form in your browser. The tool also returns
187
+ `ACCESS_INPUT_REQUIRED` with the link, so you can open it yourself if necessary
188
+ on the computer running the MCP server. Enter your email in the browser form,
189
+ and open the sign-in link from your inbox. Click **Verify this device**, then
190
+ call `chrome_status` to continue.
191
+ The local form expires after 10 minutes; call the tool again for a fresh one.
192
+ Opening the form does not send an email, and access remains blocked until the
193
+ email link and device grant are verified. Installing the extension alone does
194
+ not activate access.
184
195
 
185
196
  **4. Click Connect if Chrome is not already paired.** A fresh install opens the
186
197
  extension's Settings with a
@@ -675,6 +686,10 @@ what the flags say.
675
686
  it says nothing about what is on them. A logged-in page routinely renders a
676
687
  session token into a script tag or an API key onto a settings screen.
677
688
 
689
+ - **Cookie values are hidden by default.** `get_cookies` returns each cookie's
690
+ name, domain, path and flags with the value replaced by `[hidden]`. A session
691
+ cookie can grant access to an account without its password.
692
+ Pass `--enable-cookie-values` if a task really needs them.
678
693
  - **Password field values are always suppressed** — in `get_html`, and in
679
694
  `snapshot`, where the field still appears (so you can type into it) flagged
680
695
  `secret: true` with no value. No flag, no opt-in: nobody wants those characters.
package/SETUP.md CHANGED
@@ -128,13 +128,24 @@ the Web Store build passes review separately and can trail it by a version.
128
128
 
129
129
  The first browser tool call opens an email prompt in the MCP client. Ask the
130
130
  human to enter an email address. The website sends a Firebase sign-in link;
131
- the human opens it in their browser and returns to the MCP client. Do not ask
131
+ the human opens it in their browser, clicks **Verify this device**, and returns
132
+ to the MCP client. Do not ask
132
133
  them to copy or paste a verification code. The server detects the verified
133
134
  link automatically and reports the result to the waiting tool call. On success,
134
135
  the result includes **Email verified** and the requested browser action continues.
135
136
  The user does not need to send another chat message. Progress updates and status
136
137
  notifications are also sent to clients that display them.
137
138
 
139
+ If the client suppresses or cannot display the prompt, or it is dismissed,
140
+ the server automatically opens a temporary local browser form and returns
141
+ `ACCESS_INPUT_REQUIRED` with its link. Show the link as a backup if the form
142
+ did not appear on the computer running the server.
143
+ The human enters their email, opens the sign-in link from their inbox, and clicks
144
+ **Verify this device**. Do not fill
145
+ the form or open their email for them. The form expires after 10 minutes.
146
+ After they submit it, call `chrome_status` to wait for verification.
147
+ YOLO/bypass permission mode does not skip email verification.
148
+
138
149
  If the human explicitly configured `--no-email-verification`, there is no email
139
150
  step; continue to pairing. This option is intended only for trusted local
140
151
  clients and cannot be combined with a public tunnel.
@@ -146,9 +157,7 @@ remain blocked until access is confirmed. Cancellation stops the waiting action,
146
157
  while background verification can still activate the local installation.
147
158
 
148
159
  Click **Connect** in the extension if the result says Chrome is not already
149
- paired. This requires the MCP client to
150
- support elicitation; clients that cannot show the prompt cannot activate
151
- through this flow.
160
+ paired.
152
161
 
153
162
  The server also writes a `pairing.json` file for Load unpacked installs, which
154
163
  may connect automatically. For the Chrome Web Store copy, or whenever Chrome
@@ -16,12 +16,18 @@
16
16
  * One resolver, used by every op, is what closes it.
17
17
  */
18
18
  /** Ops the page-side dispatcher understands. */
19
- export type PageOpName = 'probe' | 'text' | 'html' | 'click' | 'type' | 'focus' | 'point' | 'hover' | 'select' | 'measure' | 'scroll' | 'storage' | 'waitSelector' | 'waitFor';
19
+ export type PageOpName = 'probe' | 'text' | 'html' | 'click' | 'check' | 'type' | 'focus' | 'point' | 'hover' | 'select' | 'measure' | 'scroll' | 'storage' | 'waitSelector' | 'waitFor';
20
20
  export interface PageOpArgs {
21
21
  op: PageOpName;
22
22
  selector?: string | null;
23
23
  text?: string;
24
24
  clear?: boolean;
25
+ /** type: send keydown/keypress/keyup around each character. */
26
+ keyEvents?: boolean;
27
+ /** type: press Enter after the text (submits the field's form like a person would). */
28
+ pressEnter?: boolean;
29
+ /** check: the state a checkbox/radio should end up in. */
30
+ checked?: boolean;
25
31
  outer?: boolean;
26
32
  values?: string[];
27
33
  x?: number | null;
@@ -208,6 +208,18 @@ function pageOp(a) {
208
208
  el.click();
209
209
  return { found: true };
210
210
  }
211
+ case 'check': {
212
+ if (!el)
213
+ return { found: false };
214
+ // Click only when the state differs: a click toggles, so clicking an
215
+ // already-checked box to "check" it would clear it.
216
+ const box = el;
217
+ if (box.checked !== (a.checked !== false)) {
218
+ el.scrollIntoView({ block: 'center' });
219
+ el.click();
220
+ }
221
+ return { found: true, checked: box.checked };
222
+ }
211
223
  case 'type': {
212
224
  if (!el)
213
225
  return { found: false };
@@ -226,9 +238,32 @@ function pageOp(a) {
226
238
  if (!node)
227
239
  return { found: false };
228
240
  node.focus();
229
- const next = (a.clear ? '' : node.value ?? '') + (a.text ?? '');
230
- setValue(node, next);
241
+ if (a.keyEvents) {
242
+ // One character at a time, each inside its own key events, for pages
243
+ // that react to keystrokes rather than to the value.
244
+ if (a.clear)
245
+ setValue(node, '');
246
+ for (const ch of a.text ?? '') {
247
+ const ev = { key: ch, bubbles: true, cancelable: true };
248
+ const go = node.dispatchEvent(new KeyboardEvent('keydown', ev));
249
+ if (go && node.dispatchEvent(new KeyboardEvent('keypress', ev)))
250
+ setValue(node, (node.value ?? '') + ch);
251
+ node.dispatchEvent(new KeyboardEvent('keyup', ev));
252
+ }
253
+ }
254
+ else {
255
+ setValue(node, (a.clear ? '' : node.value ?? '') + (a.text ?? ''));
256
+ }
231
257
  node.dispatchEvent(new Event('change', { bubbles: true }));
258
+ if (a.pressEnter) {
259
+ const ev = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true };
260
+ const go = node.dispatchEvent(new KeyboardEvent('keydown', ev)) && node.dispatchEvent(new KeyboardEvent('keypress', ev));
261
+ node.dispatchEvent(new KeyboardEvent('keyup', ev));
262
+ // Synthetic key events never trigger implicit submission; do what the
263
+ // browser would for Enter in a single-line field of a form.
264
+ if (go && node instanceof HTMLInputElement && node.form)
265
+ node.form.requestSubmit();
266
+ }
232
267
  return { found: true };
233
268
  }
234
269
  case 'focus': {
@@ -34,6 +34,12 @@ const READ_CONTENT = new Set([
34
34
  'frames_list',
35
35
  'observers',
36
36
  'print_pdf',
37
+ // The page's accessibility tree, its cookies (session secrets), and its
38
+ // web storage are content too. A storage write is refused in safe mode by
39
+ // the extension's storage handler; the domain gate here covers every op.
40
+ 'snapshot',
41
+ 'get_cookies',
42
+ 'storage',
37
43
  ]);
38
44
  /** Content-mutating actions — URL-gated AND mutation-gated. */
39
45
  const MUTATE_CONTENT = new Set([
@@ -44,6 +50,7 @@ const MUTATE_CONTENT = new Set([
44
50
  'hover',
45
51
  'scroll',
46
52
  'fill_form',
53
+ 'select_option',
47
54
  ]);
48
55
  /** Navigation — URL-gated by the DESTINATION url, and mutation-gated. */
49
56
  const NAVIGATION = new Set([
@@ -72,7 +79,9 @@ function isUrlGated(method) {
72
79
  MUTATE_CONTENT.has(method) ||
73
80
  NAVIGATION.has(method) ||
74
81
  method === 'eval' ||
75
- method === 'upload_file');
82
+ method === 'upload_file' ||
83
+ // Gated on where the bytes come from: the URL when one is given, else the tab.
84
+ method === 'download_file');
76
85
  }
77
86
  // ---------------------------------------------------------------------------
78
87
  // Domain matching
@@ -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). Output pixels = CSS px * dpr * clip.scale. */
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 (CSS px; `scale` multiplies output). */
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
- // CDP's clip.scale multiplies on top of the device scale factor, so dividing
31
- // by the DPR yields exactly `opts.scale` output pixels per CSS pixel.
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: { x: opts.element.x, y: opts.element.y, width: opts.element.w, height: clipH, scale },
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: { x: 0, y: 0, width: dims.fullW, height: clipH, scale },
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: capture what's visible. A clip is only needed to apply a
58
- // scale; at scale 1 on a 1x display the bare capture is identical and cheaper.
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: { x: dims.scrollX ?? 0, y: dims.scrollY ?? 0, width: dims.w, height: dims.h, scale } }
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,
@@ -72,6 +72,13 @@ function collectSnapshot(interactiveOnly = true, max = 200, locator = null) {
72
72
  if (t)
73
73
  return t;
74
74
  }
75
+ // <label for> or a wrapping <label>: how ordinary forms name their fields.
76
+ const labels = el.labels;
77
+ if (labels && labels.length > 0) {
78
+ const t = Array.from(labels).map((l) => l.innerText).join(' ').replace(/\s+/g, ' ').trim();
79
+ if (t)
80
+ return t;
81
+ }
75
82
  const ph = el.getAttribute('placeholder');
76
83
  if (ph)
77
84
  return ph.trim();
@@ -113,8 +120,11 @@ function collectSnapshot(interactiveOnly = true, max = 200, locator = null) {
113
120
  // recursively. Defensive against null/closed shadow roots and re-visits.
114
121
  const seen = new Set();
115
122
  const candidates = [];
123
+ // Gather past `max` so hidden candidates dropped below still leave enough to
124
+ // fill it, and so a page with more than `max` can be reported as truncated.
125
+ const cap = Math.max(max * 2, max + 50);
116
126
  const collect = (root) => {
117
- if (candidates.length >= max)
127
+ if (candidates.length >= cap)
118
128
  return;
119
129
  let matched;
120
130
  try {
@@ -124,7 +134,7 @@ function collectSnapshot(interactiveOnly = true, max = 200, locator = null) {
124
134
  matched = [];
125
135
  }
126
136
  for (let i = 0; i < matched.length; i++) {
127
- if (candidates.length >= max)
137
+ if (candidates.length >= cap)
128
138
  break;
129
139
  const el = matched[i];
130
140
  if (seen.has(el))
@@ -142,7 +152,7 @@ function collectSnapshot(interactiveOnly = true, max = 200, locator = null) {
142
152
  hosts = [];
143
153
  }
144
154
  for (let i = 0; i < hosts.length; i++) {
145
- if (candidates.length >= max)
155
+ if (candidates.length >= cap)
146
156
  break;
147
157
  const sr = hosts[i].shadowRoot;
148
158
  if (sr)
@@ -230,7 +240,8 @@ function collectSnapshot(interactiveOnly = true, max = 200, locator = null) {
230
240
  break;
231
241
  picked.push(read(el));
232
242
  }
233
- truncated = els.length > picked.length;
243
+ // At the cap there may be more on the page than were even looked at.
244
+ truncated = els.length > picked.length || candidates.length >= cap;
234
245
  }
235
246
  // -- phase 2: writes (one attribute per returned node) ----------------------
236
247
  const nodes = [];
@@ -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,159 @@
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
25
+ function page(path, csrf, message, sent) {
26
+ return `<!doctype html><html lang="en"><meta charset="utf-8">
27
+ <meta name="viewport" content="width=device-width,initial-scale=1">
28
+ <title>Verify your email | MCP Browser Extension</title>
29
+ <style>
30
+ :root{color-scheme:light dark;font-family:system-ui,sans-serif}
31
+ body{max-width:440px;margin:12vh auto;padding:24px;line-height:1.5}
32
+ h1{font-size:28px;line-height:1.2} label{display:block;font-weight:600}
33
+ input,button{box-sizing:border-box;width:100%;font:inherit;padding:12px;margin-top:8px;border:1px solid #888;border-radius:6px}
34
+ button{margin-top:18px;background:#175cd3;color:white;border-color:#175cd3;cursor:pointer}
35
+ p{margin:20px 0} .brand{font-size:14px} small{display:block;margin-top:18px}
36
+ </style>
37
+ <body><p class="brand">MCP Browser Extension</p><h1>${sent ? 'Check your inbox' : 'Verify your email'}</h1>
38
+ <p role="status">${escapeHtml(message)}</p>
39
+ ${sent ? '<p>After opening the sign-in link, return to your assistant to continue.</p>' : `
40
+ <form method="post" action="${path}">
41
+ <input type="hidden" name="csrf" value="${csrf}">
42
+ <label for="email">Email address</label>
43
+ <input id="email" name="email" type="email" autocomplete="email" maxlength="254" required autofocus>
44
+ <button type="submit">Send verification link</button>
45
+ </form><small>Your email is sent to mcp-browser-extension.com to verify and manage access.</small>`}
46
+ </body></html>`;
47
+ }
48
+ /** Only the person submitting this form can start an email. GET never sends one. */
49
+ async function createBrowserEmailPrompt(submit, options = {}) {
50
+ const path = `/verify/${(0, node_crypto_1.randomBytes)(32).toString('hex')}`;
51
+ const csrf = (0, node_crypto_1.randomBytes)(32).toString('hex');
52
+ let origin = '';
53
+ let sent = false;
54
+ let busy = false;
55
+ let closed = false;
56
+ let expiry;
57
+ const sentMessage = 'A sign-in link has been sent. Open it from your email and click Verify this device to finish verification.';
58
+ const respond = (res, status, message) => {
59
+ res.writeHead(status, {
60
+ 'content-type': 'text/html; charset=utf-8',
61
+ 'cache-control': 'no-store',
62
+ // Keep Origin on same-origin form POSTs; no-referrer makes Chromium send
63
+ // Origin: null. Cross-origin destinations still receive no referrer.
64
+ 'referrer-policy': 'same-origin',
65
+ 'x-content-type-options': 'nosniff',
66
+ 'x-frame-options': 'DENY',
67
+ 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
68
+ });
69
+ res.end(page(path, csrf, message, sent));
70
+ };
71
+ const server = (0, node_http_1.createServer)((req, res) => {
72
+ void (async () => {
73
+ // Exact Host + Origin checks also prevent DNS rebinding and cross-site
74
+ // submissions. The random path is independent of every device credential.
75
+ if (req.headers.host !== origin.slice('http://'.length) || req.url !== path) {
76
+ res.writeHead(404).end();
77
+ return;
78
+ }
79
+ if (req.method === 'GET') {
80
+ respond(res, 200, sent ? sentMessage : 'Enter your email below. We will send you a one-time sign-in link.');
81
+ return;
82
+ }
83
+ if (req.method !== 'POST') {
84
+ res.writeHead(405, { allow: 'GET, POST' }).end();
85
+ return;
86
+ }
87
+ if (req.headers.origin !== origin) {
88
+ res.writeHead(403).end();
89
+ return;
90
+ }
91
+ if (req.headers['content-type']?.split(';')[0] !== 'application/x-www-form-urlencoded') {
92
+ res.writeHead(415).end();
93
+ return;
94
+ }
95
+ let body = '';
96
+ let bytes = 0;
97
+ for await (const chunk of req) {
98
+ bytes += Buffer.byteLength(chunk);
99
+ if (bytes > 2048) {
100
+ res.writeHead(413).end();
101
+ return;
102
+ }
103
+ body += chunk.toString();
104
+ }
105
+ const fields = new URLSearchParams(body);
106
+ if (fields.get('csrf') !== csrf) {
107
+ res.writeHead(403).end();
108
+ return;
109
+ }
110
+ if (sent) {
111
+ respond(res, 200, sentMessage);
112
+ return;
113
+ }
114
+ if (busy) {
115
+ respond(res, 409, 'Your request is already being sent. Please wait.');
116
+ return;
117
+ }
118
+ busy = true;
119
+ try {
120
+ const outcome = await submit(fields.get('email') ?? '');
121
+ sent = outcome.ok;
122
+ respond(res, outcome.ok ? 200 : 400, outcome.ok ? sentMessage : outcome.message);
123
+ }
124
+ finally {
125
+ busy = false;
126
+ }
127
+ })().catch(() => { if (!res.headersSent)
128
+ respond(res, 503, 'The verification service could not be reached. Please try again.'); });
129
+ });
130
+ server.requestTimeout = 10_000;
131
+ server.headersTimeout = 10_000;
132
+ const close = () => {
133
+ if (closed)
134
+ return;
135
+ closed = true;
136
+ clearTimeout(expiry);
137
+ server.close();
138
+ server.closeAllConnections();
139
+ options.onClose?.();
140
+ };
141
+ await new Promise((resolve, reject) => {
142
+ server.once('error', reject);
143
+ server.listen(0, '127.0.0.1', () => {
144
+ server.removeListener('error', reject);
145
+ resolve();
146
+ });
147
+ });
148
+ const address = server.address();
149
+ if (!address || typeof address === 'string') {
150
+ close();
151
+ throw new Error('No loopback address');
152
+ }
153
+ origin = `http://127.0.0.1:${address.port}`;
154
+ expiry = setTimeout(close, options.ttlMs ?? 10 * 60_000);
155
+ expiry.unref();
156
+ server.unref();
157
+ return { url: origin + path, close };
158
+ }
159
+ //# sourceMappingURL=access-browser.js.map
@@ -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";