@mario.andreschak/mcp-browser 3.42.0 → 3.43.0

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/dist/runtime.js CHANGED
@@ -11,25 +11,92 @@ const DEFAULT_IDLE_MS = 10 * 60_000;
11
11
  const DEFAULT_MAX_SESSIONS = 4;
12
12
  const DEFAULT_MAX_REDIRECTS = 10;
13
13
  const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
14
+ export function failureCategoryForCode(code) {
15
+ if (code === 'CANCELLED')
16
+ return 'cancelled';
17
+ if (code === 'INVALID_ARGUMENT' || code === 'NOT_FOUND' || code === 'SESSION_LIMIT')
18
+ return 'input';
19
+ if (code === 'NAVIGATION_BLOCKED')
20
+ return 'policy';
21
+ return 'runtime';
22
+ }
14
23
  export class BrowserMcpError extends Error {
15
24
  code;
16
- constructor(code, message) {
25
+ category;
26
+ constructor(code, message, category = failureCategoryForCode(code)) {
17
27
  super(message);
18
28
  this.code = code;
29
+ this.category = category;
19
30
  this.name = 'BrowserMcpError';
20
31
  }
21
32
  }
22
- let browser;
23
- let browserPromise;
33
+ let sandboxBrowser;
34
+ let sandboxBrowserPromise;
35
+ let trustedContext;
36
+ let trustedContextPromise;
37
+ const launchStates = {};
24
38
  let runtimeRoot;
39
+ let lastSessionId;
25
40
  const sessions = new Map();
26
- function integerEnv(name, fallback, min, max) {
41
+ export function integerEnv(name, fallback, min, max) {
27
42
  const raw = Number.parseInt(process.env[name] ?? '', 10);
28
43
  return Number.isFinite(raw) ? Math.min(max, Math.max(min, raw)) : fallback;
29
44
  }
30
- function enabledEnv(name) {
45
+ export function enabledEnv(name) {
31
46
  return /^(1|true|yes|on)$/i.test(process.env[name]?.trim() ?? '');
32
47
  }
48
+ function booleanEnv(name) {
49
+ const raw = process.env[name]?.trim();
50
+ if (!raw)
51
+ return undefined;
52
+ if (/^(1|true|yes|on)$/i.test(raw))
53
+ return true;
54
+ if (/^(0|false|no|off)$/i.test(raw))
55
+ return false;
56
+ return undefined;
57
+ }
58
+ export function browserMode() {
59
+ return process.env.FLUJO_BROWSER_MODE?.trim().toLowerCase() === 'trusted'
60
+ ? 'trusted'
61
+ : 'sandbox';
62
+ }
63
+ function browserLocale() {
64
+ const configured = process.env.FLUJO_BROWSER_LOCALE?.trim();
65
+ if (configured)
66
+ return configured;
67
+ return Intl.DateTimeFormat().resolvedOptions().locale || 'en-US';
68
+ }
69
+ function browserTimezone() {
70
+ const configured = process.env.FLUJO_BROWSER_TIMEZONE_ID?.trim();
71
+ if (configured)
72
+ return configured;
73
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
74
+ }
75
+ function headed(mode) {
76
+ return booleanEnv('FLUJO_BROWSER_HEADED') ?? mode === 'trusted';
77
+ }
78
+ function allowServiceWorkers(mode) {
79
+ return booleanEnv('FLUJO_BROWSER_ALLOW_SERVICE_WORKERS') ?? mode === 'trusted';
80
+ }
81
+ function browserWindowVisibility() {
82
+ const configured = process.env.FLUJO_BROWSER_WINDOW_VISIBILITY?.trim().toLowerCase();
83
+ if (configured === 'offscreen' || configured === 'minimized')
84
+ return configured;
85
+ return 'visible';
86
+ }
87
+ function extensionDirectoryInputs() {
88
+ const raw = process.env.FLUJO_BROWSER_EXTENSION_DIRS?.trim();
89
+ if (!raw)
90
+ return [];
91
+ return [...new Set(raw.split(path.delimiter).map((entry) => entry.trim()).filter(Boolean))];
92
+ }
93
+ /** Viewport the live view starts at before the app reports its real size. */
94
+ export function defaultViewport() {
95
+ return {
96
+ width: integerEnv('FLUJO_BROWSER_VIEWPORT_WIDTH', 1280, 320, 3840),
97
+ height: integerEnv('FLUJO_BROWSER_VIEWPORT_HEIGHT', 720, 240, 2160),
98
+ };
99
+ }
33
100
  export function timeoutMs(value) {
34
101
  if (value === undefined)
35
102
  return DEFAULT_TIMEOUT_MS;
@@ -74,6 +141,27 @@ function isPrivateAddress(address) {
74
141
  || (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127)
75
142
  || parts[0] >= 224;
76
143
  }
144
+ const DNS_CACHE_TTL_MS = 60_000;
145
+ const DNS_CACHE_MAX_ENTRIES = 512;
146
+ const dnsCache = new Map();
147
+ /**
148
+ * Resolve a hostname for the SSRF check, memoised for a minute.
149
+ *
150
+ * Every subresource passes through the route handler, so an uncached lookup per
151
+ * request meant a media-heavy page (an HLS player fetching one segment per few
152
+ * seconds, or any CDN-backed site) paid a DNS round trip per asset and stalled.
153
+ */
154
+ async function resolveHostAddresses(hostname) {
155
+ const now = Date.now();
156
+ const cached = dnsCache.get(hostname);
157
+ if (cached && cached.expiresAt > now)
158
+ return cached.addresses;
159
+ const addresses = (await lookup(hostname, { all: true, verbatim: true })).map(({ address }) => address);
160
+ if (dnsCache.size >= DNS_CACHE_MAX_ENTRIES)
161
+ dnsCache.clear();
162
+ dnsCache.set(hostname, { expiresAt: now + DNS_CACHE_TTL_MS, addresses });
163
+ return addresses;
164
+ }
77
165
  export async function assertNavigationAllowed(input) {
78
166
  let url;
79
167
  try {
@@ -98,10 +186,8 @@ export async function assertNavigationAllowed(input) {
98
186
  throw new BrowserMcpError('NAVIGATION_BLOCKED', 'Private and local network destinations are blocked.');
99
187
  }
100
188
  try {
101
- const addresses = isIP(hostname)
102
- ? [{ address: hostname }]
103
- : await lookup(hostname, { all: true, verbatim: true });
104
- if (addresses.length === 0 || addresses.some(({ address }) => isPrivateAddress(address))) {
189
+ const addresses = isIP(hostname) ? [hostname] : await resolveHostAddresses(hostname);
190
+ if (addresses.length === 0 || addresses.some((address) => isPrivateAddress(address))) {
105
191
  throw new BrowserMcpError('NAVIGATION_BLOCKED', 'Private and local network destinations are blocked.');
106
192
  }
107
193
  }
@@ -118,26 +204,199 @@ async function ensureRuntimeRoot() {
118
204
  runtimeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'flujo-browser-'));
119
205
  return runtimeRoot;
120
206
  }
121
- async function acquireBrowser() {
122
- if (browser?.isConnected())
123
- return browser;
124
- if (browserPromise)
125
- return browserPromise;
126
- browserPromise = (async () => {
207
+ function screenshotRoot() {
208
+ const configured = process.env.FLUJO_BROWSER_SCREENSHOT_DIR?.trim();
209
+ if (configured)
210
+ return path.resolve(configured);
211
+ const dataRoot = process.env.FLUJO_DATA_DIR?.trim() || process.cwd();
212
+ return path.resolve(dataRoot, 'screenshots', 'browser');
213
+ }
214
+ /** Persistence root for `browser_record_*` artifacts (WebM/WAV/muxed output). */
215
+ export function recordingRoot() {
216
+ const configured = process.env.FLUJO_BROWSER_RECORD_DIR?.trim();
217
+ if (configured)
218
+ return path.resolve(configured);
219
+ const dataRoot = process.env.FLUJO_DATA_DIR?.trim() || process.cwd();
220
+ return path.resolve(dataRoot, 'recordings', 'browser');
221
+ }
222
+ /** A fresh scratch directory under the runtime root, used for Playwright's `recordVideo` output before it is copied out. */
223
+ export async function ensureScratchDir(prefix) {
224
+ const root = await ensureRuntimeRoot();
225
+ const dir = path.join(root, prefix);
226
+ await fs.mkdir(dir, { recursive: true });
227
+ return dir;
228
+ }
229
+ /** Persist the latest screenshot and return the absolute host path reported to MCP clients. */
230
+ export async function writeScreenshotArtifact(sessionId, fullPage, png) {
231
+ if (!SESSION_ID_PATTERN.test(sessionId)) {
232
+ throw new BrowserMcpError('INVALID_ARGUMENT', 'A valid sessionId is required for screenshot storage.');
233
+ }
234
+ const filePath = path.join(screenshotRoot(), sessionId, fullPage ? 'full-page.png' : 'viewport.png');
235
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
236
+ await fs.writeFile(filePath, png);
237
+ return path.resolve(filePath);
238
+ }
239
+ /**
240
+ * Chromium flags that make embedded media behave the way a user expects.
241
+ *
242
+ * Without an explicit autoplay policy, headless Chromium requires a real user
243
+ * gesture before it will start `<video>`/`<audio>` playback, so the live view
244
+ * only ever showed the poster frame.
245
+ */
246
+ const MEDIA_LAUNCH_ARGS = [
247
+ '--autoplay-policy=no-user-gesture-required',
248
+ ];
249
+ function launchOptions(downloadsPath, channel, mode) {
250
+ const args = [...MEDIA_LAUNCH_ARGS];
251
+ if (headed(mode)) {
252
+ const visibility = browserWindowVisibility();
253
+ if (visibility === 'offscreen') {
254
+ args.push('--window-position=-32000,-32000', `--window-size=${defaultViewport().width},${defaultViewport().height}`);
255
+ }
256
+ else if (visibility === 'minimized') {
257
+ args.push('--start-minimized');
258
+ }
259
+ }
260
+ const options = {
261
+ headless: !headed(mode),
262
+ downloadsPath,
263
+ args,
264
+ };
265
+ if (channel)
266
+ options.channel = channel;
267
+ // Patchright mutes audio by default. Unmuting only matters where the operator
268
+ // captures host audio, so it stays opt-in.
269
+ if (enabledEnv('FLUJO_BROWSER_AUDIO'))
270
+ options.ignoreDefaultArgs = ['--mute-audio'];
271
+ if (process.env.FLUJO_BROWSER_EXECUTABLE_PATH) {
272
+ options.executablePath = process.env.FLUJO_BROWSER_EXECUTABLE_PATH;
273
+ }
274
+ return options;
275
+ }
276
+ /**
277
+ * Preferred Chromium channel.
278
+ *
279
+ * `headless: true` alone resolves to `chrome-headless-shell`, the reduced build
280
+ * with no real compositor — which is why animation and video looked frozen.
281
+ * The full `chromium` channel runs modern headless instead, so screencast
282
+ * frames advance like they do in a headed browser.
283
+ */
284
+ function preferredChannel(mode) {
285
+ const configured = process.env.FLUJO_BROWSER_CHANNEL?.trim();
286
+ if (configured)
287
+ return configured === 'default' ? undefined : configured;
288
+ if (process.env.FLUJO_BROWSER_EXECUTABLE_PATH)
289
+ return undefined;
290
+ return mode === 'trusted' && extensionDirectoryInputs().length === 0 ? 'chrome' : 'chromium';
291
+ }
292
+ function trustedProfileDir() {
293
+ const configured = process.env.FLUJO_BROWSER_PROFILE_DIR?.trim();
294
+ if (configured)
295
+ return path.resolve(configured);
296
+ const dataRoot = process.env.FLUJO_DATA_DIR?.trim() || process.cwd();
297
+ return path.resolve(dataRoot, 'browser-profile', 'trusted');
298
+ }
299
+ async function configuredExtensions() {
300
+ const inputs = extensionDirectoryInputs();
301
+ if (inputs.length > 16) {
302
+ throw new BrowserMcpError('INVALID_ARGUMENT', 'FLUJO_BROWSER_EXTENSION_DIRS accepts at most 16 unpacked extension directories.');
303
+ }
304
+ const extensions = [];
305
+ for (const input of inputs) {
306
+ if (!path.isAbsolute(input)) {
307
+ throw new BrowserMcpError('INVALID_ARGUMENT', 'Every FLUJO_BROWSER_EXTENSION_DIRS entry must be an absolute directory.');
308
+ }
309
+ let directory;
310
+ let manifest;
311
+ try {
312
+ directory = await fs.realpath(input);
313
+ const stat = await fs.stat(directory);
314
+ if (!stat.isDirectory())
315
+ throw new Error('not a directory');
316
+ const raw = await fs.readFile(path.join(directory, 'manifest.json'), 'utf8');
317
+ if (raw.length > 1_000_000)
318
+ throw new Error('manifest too large');
319
+ manifest = JSON.parse(raw);
320
+ }
321
+ catch {
322
+ throw new BrowserMcpError('INVALID_ARGUMENT', `Extension directory is unreadable or has no valid manifest.json: ${input}`);
323
+ }
324
+ const manifestVersion = Number(manifest.manifest_version);
325
+ const name = typeof manifest.name === 'string' ? manifest.name : path.basename(directory);
326
+ const version = typeof manifest.version === 'string' ? manifest.version : '';
327
+ if ((manifestVersion !== 2 && manifestVersion !== 3) || !version) {
328
+ throw new BrowserMcpError('INVALID_ARGUMENT', `Extension manifest must declare manifest_version 2/3 and a version: ${input}`);
329
+ }
330
+ extensions.push({ directory, manifestVersion, name, version });
331
+ }
332
+ return extensions;
333
+ }
334
+ function persistentLaunchOptions(downloadsPath, channel, extensions) {
335
+ const base = launchOptions(downloadsPath, channel, 'trusted');
336
+ const extensionPaths = extensions.map(({ directory }) => directory);
337
+ const extensionArgs = extensionPaths.length > 0
338
+ ? [
339
+ `--disable-extensions-except=${extensionPaths.join(',')}`,
340
+ `--load-extension=${extensionPaths.join(',')}`,
341
+ ]
342
+ : [];
343
+ return {
344
+ ...base,
345
+ args: [...(base.args ?? []), ...extensionArgs],
346
+ acceptDownloads: false,
347
+ locale: browserLocale(),
348
+ serviceWorkers: allowServiceWorkers('trusted') ? 'allow' : 'block',
349
+ timezoneId: browserTimezone(),
350
+ viewport: defaultViewport(),
351
+ };
352
+ }
353
+ async function launchSandboxBrowser(downloadsPath) {
354
+ const requested = preferredChannel('sandbox');
355
+ try {
356
+ return {
357
+ browser: await chromium.launch(launchOptions(downloadsPath, requested, 'sandbox')),
358
+ channel: requested ?? 'default',
359
+ };
360
+ }
361
+ catch (error) {
362
+ if (!requested)
363
+ throw error;
364
+ return {
365
+ browser: await chromium.launch(launchOptions(downloadsPath, undefined, 'sandbox')),
366
+ channel: 'default',
367
+ };
368
+ }
369
+ }
370
+ /** Exported for the recording/capture modules, which need their own contexts on the same browser instance. */
371
+ export async function acquireBrowser() {
372
+ if (sandboxBrowser?.isConnected())
373
+ return sandboxBrowser;
374
+ if (sandboxBrowserPromise)
375
+ return sandboxBrowserPromise;
376
+ sandboxBrowserPromise = (async () => {
127
377
  const downloadsPath = await ensureRuntimeRoot();
128
378
  try {
129
- const launched = await chromium.launch({
130
- headless: true,
131
- downloadsPath,
132
- ...(process.env.FLUJO_BROWSER_EXECUTABLE_PATH
133
- ? { executablePath: process.env.FLUJO_BROWSER_EXECUTABLE_PATH }
134
- : {}),
135
- });
136
- browser = launched;
379
+ const { browser: launched, channel } = await launchSandboxBrowser(downloadsPath);
380
+ sandboxBrowser = launched;
381
+ launchStates.sandbox = {
382
+ mode: 'sandbox',
383
+ channel,
384
+ headless: !headed('sandbox'),
385
+ persistent: false,
386
+ windowVisibility: browserWindowVisibility(),
387
+ extensionDirectories: [],
388
+ version: typeof launched.version === 'function' ? launched.version() : undefined,
389
+ };
137
390
  launched.once('disconnected', () => {
138
- if (browser === launched)
139
- browser = undefined;
140
- sessions.clear();
391
+ if (sandboxBrowser === launched)
392
+ sandboxBrowser = undefined;
393
+ delete launchStates.sandbox;
394
+ for (const [id, session] of sessions) {
395
+ if (session.mode === 'sandbox')
396
+ sessions.delete(id);
397
+ }
398
+ if (lastSessionId && !sessions.has(lastSessionId))
399
+ lastSessionId = undefined;
141
400
  });
142
401
  return launched;
143
402
  }
@@ -145,10 +404,73 @@ async function acquireBrowser() {
145
404
  throw new BrowserMcpError('BROWSER_UNAVAILABLE', 'Patchright could not start Chromium. Install the managed browser binary and check the server platform prerequisites.');
146
405
  }
147
406
  finally {
148
- browserPromise = undefined;
407
+ sandboxBrowserPromise = undefined;
408
+ }
409
+ })();
410
+ return sandboxBrowserPromise;
411
+ }
412
+ async function acquireTrustedContext() {
413
+ if (trustedContext)
414
+ return trustedContext;
415
+ if (trustedContextPromise)
416
+ return trustedContextPromise;
417
+ trustedContextPromise = (async () => {
418
+ try {
419
+ const downloadsPath = await ensureRuntimeRoot();
420
+ const profileDir = trustedProfileDir();
421
+ await fs.mkdir(profileDir, { recursive: true });
422
+ const extensions = await configuredExtensions();
423
+ const requested = preferredChannel('trusted');
424
+ if (extensions.length > 0 && requested !== 'chromium') {
425
+ throw new BrowserMcpError('INVALID_ARGUMENT', 'Unpacked extensions require FLUJO_BROWSER_CHANNEL=chromium because current Google Chrome/Edge releases removed the side-load command-line flags.');
426
+ }
427
+ let context;
428
+ let channel = requested ?? 'default';
429
+ try {
430
+ context = await chromium.launchPersistentContext(profileDir, persistentLaunchOptions(downloadsPath, requested, extensions));
431
+ }
432
+ catch (error) {
433
+ if (!requested)
434
+ throw error;
435
+ const fallbackChannel = requested === 'chromium' ? undefined : 'chromium';
436
+ context = await chromium.launchPersistentContext(profileDir, persistentLaunchOptions(downloadsPath, fallbackChannel, extensions));
437
+ channel = fallbackChannel ?? 'default';
438
+ }
439
+ trustedContext = context;
440
+ launchStates.trusted = {
441
+ mode: 'trusted',
442
+ channel,
443
+ headless: !headed('trusted'),
444
+ persistent: true,
445
+ windowVisibility: browserWindowVisibility(),
446
+ extensionDirectories: extensions.map(({ directory }) => directory),
447
+ profileDir,
448
+ version: context.browser()?.version(),
449
+ };
450
+ await installRequestPolicy(context);
451
+ context.once('close', () => {
452
+ if (trustedContext === context)
453
+ trustedContext = undefined;
454
+ delete launchStates.trusted;
455
+ for (const [id, session] of sessions) {
456
+ if (session.mode === 'trusted')
457
+ sessions.delete(id);
458
+ }
459
+ if (lastSessionId && !sessions.has(lastSessionId))
460
+ lastSessionId = undefined;
461
+ });
462
+ return context;
463
+ }
464
+ catch (error) {
465
+ if (error instanceof BrowserMcpError)
466
+ throw error;
467
+ throw new BrowserMcpError('BROWSER_UNAVAILABLE', 'Patchright could not start the trusted Chrome profile. Install Chrome (or configure FLUJO_BROWSER_EXECUTABLE_PATH), ensure the profile is not in use, and check that a desktop display is available.');
468
+ }
469
+ finally {
470
+ trustedContextPromise = undefined;
149
471
  }
150
472
  })();
151
- return browserPromise;
473
+ return trustedContextPromise;
152
474
  }
153
475
  function validateSessionId(value) {
154
476
  if (value === undefined || value === '')
@@ -158,92 +480,193 @@ function validateSessionId(value) {
158
480
  }
159
481
  return value;
160
482
  }
483
+ function touchSession(session) {
484
+ session.touchedAt = Date.now();
485
+ lastSessionId = session.id;
486
+ return session;
487
+ }
488
+ function lastLiveSession(mode) {
489
+ const remembered = lastSessionId ? sessions.get(lastSessionId) : undefined;
490
+ if (remembered && !remembered.page.isClosed() && (!mode || remembered.mode === mode))
491
+ return remembered;
492
+ let latest;
493
+ for (const session of sessions.values()) {
494
+ if (session.page.isClosed()) {
495
+ sessions.delete(session.id);
496
+ continue;
497
+ }
498
+ if ((!mode || session.mode === mode) && (!latest || session.touchedAt >= latest.touchedAt))
499
+ latest = session;
500
+ }
501
+ lastSessionId = latest?.id;
502
+ return latest;
503
+ }
161
504
  async function closeSessionInternal(id) {
162
505
  const session = sessions.get(id);
163
506
  if (!session)
164
507
  return false;
165
508
  sessions.delete(id);
166
- await session.context.close().catch(() => undefined);
509
+ if (lastSessionId === id)
510
+ lastSessionId = undefined;
511
+ if (session.mode === 'trusted') {
512
+ await session.page.close().catch(() => undefined);
513
+ }
514
+ else {
515
+ await session.context.close().catch(() => undefined);
516
+ }
167
517
  return true;
168
518
  }
519
+ function policyDisplayUrl(rawUrl) {
520
+ try {
521
+ const url = new URL(rawUrl);
522
+ url.username = '';
523
+ url.password = '';
524
+ url.search = '';
525
+ url.hash = '';
526
+ return url.href;
527
+ }
528
+ catch {
529
+ return '';
530
+ }
531
+ }
532
+ function sessionForPage(page) {
533
+ if (!page)
534
+ return undefined;
535
+ for (const session of sessions.values()) {
536
+ if (session.page === page)
537
+ return session;
538
+ }
539
+ return undefined;
540
+ }
541
+ /** Enforce the same SSRF/navigation policy on sessions, recordings, and capture contexts. */
542
+ export async function installRequestPolicy(context) {
543
+ await context.route('**/*', async (route) => {
544
+ const request = route.request();
545
+ let session;
546
+ let mainDocument = false;
547
+ try {
548
+ const frame = request.frame();
549
+ session = sessionForPage(frame.page());
550
+ mainDocument = Boolean(session
551
+ && request.resourceType() === 'document'
552
+ && frame === session.page.mainFrame());
553
+ }
554
+ catch {
555
+ // Service-worker and pre-frame requests still receive the URL policy, but
556
+ // cannot be attributed to a user-facing tab.
557
+ }
558
+ try {
559
+ if (mainDocument && session) {
560
+ session.documentRequests += 1;
561
+ const maxRedirects = integerEnv('FLUJO_BROWSER_MAX_REDIRECTS', DEFAULT_MAX_REDIRECTS, 0, 50);
562
+ if (session.documentRequests > maxRedirects + 1) {
563
+ throw new BrowserMcpError('NAVIGATION_BLOCKED', 'The navigation exceeded the redirect limit.');
564
+ }
565
+ }
566
+ await assertNavigationAllowed(request.url());
567
+ await route.continue();
568
+ }
569
+ catch (error) {
570
+ if (session) {
571
+ session.blockedRequestCount += 1;
572
+ session.lastPolicyBlock = {
573
+ url: policyDisplayUrl(request.url()),
574
+ reason: error instanceof BrowserMcpError
575
+ ? error.message
576
+ : 'The request was blocked by browser policy.',
577
+ topLevel: mainDocument,
578
+ };
579
+ // A blocked tracker/CDN must not turn the next click into a bogus
580
+ // top-level NAVIGATION_BLOCKED result.
581
+ if (mainDocument)
582
+ session.navigationBlocked = true;
583
+ }
584
+ await route.abort('blockedbyclient').catch(() => undefined);
585
+ }
586
+ });
587
+ }
588
+ function reusableTrustedPage(context) {
589
+ const used = new Set([...sessions.values()].map((session) => session.page));
590
+ return context.pages().find((page) => !used.has(page) && !page.isClosed() && page.url() === 'about:blank');
591
+ }
169
592
  export async function openSession(requestedId, signal) {
170
593
  if (signal.aborted)
171
594
  throw new BrowserMcpError('CANCELLED', 'The browser request was cancelled.');
595
+ const mode = browserMode();
596
+ if (requestedId === undefined || requestedId === '') {
597
+ const latest = lastLiveSession(mode);
598
+ if (latest)
599
+ return touchSession(latest);
600
+ }
172
601
  const id = validateSessionId(requestedId);
173
602
  const existing = sessions.get(id);
174
- if (existing) {
175
- existing.touchedAt = Date.now();
176
- return existing;
177
- }
603
+ if (existing)
604
+ return touchSession(existing);
178
605
  const maxSessions = integerEnv('FLUJO_BROWSER_MAX_SESSIONS', DEFAULT_MAX_SESSIONS, 1, 32);
179
606
  if (sessions.size >= maxSessions) {
180
607
  throw new BrowserMcpError('SESSION_LIMIT', `The browser session limit (${maxSessions}) has been reached.`);
181
608
  }
182
- const activeBrowser = await acquireBrowser();
183
- if (signal.aborted)
184
- throw new BrowserMcpError('CANCELLED', 'The browser request was cancelled.');
185
609
  let context;
186
- let contextClosePromise;
610
+ let page;
611
+ let closeCreatedPromise;
187
612
  let cancelled = false;
188
- const closeContext = () => {
613
+ const closeCreated = () => {
189
614
  if (!context)
190
615
  return Promise.resolve();
191
- contextClosePromise ??= context.close().catch(() => undefined);
192
- return contextClosePromise;
616
+ closeCreatedPromise ??= mode === 'trusted'
617
+ ? (page?.close().catch(() => undefined) ?? Promise.resolve())
618
+ : context.close().catch(() => undefined);
619
+ return closeCreatedPromise;
193
620
  };
194
621
  const onAbort = () => {
195
622
  cancelled = true;
196
- void closeContext();
623
+ void closeCreated();
197
624
  };
198
625
  signal.addEventListener('abort', onAbort, { once: true });
199
626
  try {
200
- context = await activeBrowser.newContext({
201
- acceptDownloads: false,
202
- serviceWorkers: 'block',
203
- viewport: { width: 1280, height: 720 },
204
- });
205
- if (cancelled || signal.aborted) {
206
- throw new BrowserMcpError('CANCELLED', 'The browser request was cancelled.');
627
+ if (mode === 'trusted') {
628
+ context = await acquireTrustedContext();
629
+ page = reusableTrustedPage(context) ?? await context.newPage();
630
+ }
631
+ else {
632
+ const activeBrowser = await acquireBrowser();
633
+ if (cancelled || signal.aborted) {
634
+ throw new BrowserMcpError('CANCELLED', 'The browser request was cancelled.');
635
+ }
636
+ context = await activeBrowser.newContext({
637
+ acceptDownloads: false,
638
+ locale: browserLocale(),
639
+ serviceWorkers: allowServiceWorkers('sandbox') ? 'allow' : 'block',
640
+ timezoneId: browserTimezone(),
641
+ viewport: defaultViewport(),
642
+ });
643
+ page = await context.newPage();
644
+ await installRequestPolicy(context);
207
645
  }
208
646
  const session = {
209
647
  id,
648
+ mode,
210
649
  context,
211
- page: await context.newPage(),
650
+ page,
212
651
  touchedAt: Date.now(),
213
652
  documentRequests: 0,
214
653
  navigationBlocked: false,
654
+ blockedRequestCount: 0,
215
655
  };
216
656
  if (cancelled || signal.aborted) {
217
657
  throw new BrowserMcpError('CANCELLED', 'The browser request was cancelled.');
218
658
  }
219
- const maxRedirects = integerEnv('FLUJO_BROWSER_MAX_REDIRECTS', DEFAULT_MAX_REDIRECTS, 0, 50);
220
- await context.route('**/*', async (route) => {
221
- const request = route.request();
222
- try {
223
- if (request.resourceType() === 'document') {
224
- session.documentRequests += 1;
225
- if (session.documentRequests > maxRedirects + 1) {
226
- throw new BrowserMcpError('NAVIGATION_BLOCKED', 'The navigation exceeded the redirect limit.');
227
- }
228
- }
229
- await assertNavigationAllowed(request.url());
230
- await route.continue();
231
- }
232
- catch {
233
- session.navigationBlocked = true;
234
- await route.abort('blockedbyclient').catch(() => undefined);
235
- }
236
- });
237
- if (cancelled || signal.aborted) {
238
- throw new BrowserMcpError('CANCELLED', 'The browser request was cancelled.');
239
- }
240
659
  session.page.on('download', (download) => void download.cancel().catch(() => undefined));
241
- session.page.on('close', () => sessions.delete(id));
660
+ session.page.on('close', () => {
661
+ sessions.delete(id);
662
+ if (lastSessionId === id)
663
+ lastSessionId = undefined;
664
+ });
242
665
  sessions.set(id, session);
243
- return session;
666
+ return touchSession(session);
244
667
  }
245
668
  catch (error) {
246
- await closeContext();
669
+ await closeCreated();
247
670
  if (cancelled || signal.aborted) {
248
671
  throw new BrowserMcpError('CANCELLED', 'The browser request was cancelled.');
249
672
  }
@@ -253,7 +676,52 @@ export async function openSession(requestedId, signal) {
253
676
  signal.removeEventListener('abort', onAbort);
254
677
  }
255
678
  }
679
+ /** Register a session created outside `openSession()` (used by the recording module, which owns its own context lifecycle). */
680
+ export function registerSession(session) {
681
+ sessions.set(session.id, session);
682
+ return touchSession(session);
683
+ }
684
+ /**
685
+ * An ephemeral, isolated context for still capture: not the user-facing
686
+ * session map, no `lastSessionId` side effects, always closed by the caller
687
+ * in a `finally`. `reducedMotion: 'reduce'` plus the caller's `animations:
688
+ * 'disabled'` screenshot option are the two halves of the determinism ladder.
689
+ */
690
+ export async function createCaptureContext(signal, viewport) {
691
+ if (signal.aborted)
692
+ throw new BrowserMcpError('CANCELLED', 'The browser request was cancelled.');
693
+ const activeBrowser = await acquireBrowser();
694
+ if (signal.aborted)
695
+ throw new BrowserMcpError('CANCELLED', 'The browser request was cancelled.');
696
+ const context = await activeBrowser.newContext({
697
+ viewport: { width: viewport.width, height: viewport.height },
698
+ deviceScaleFactor: viewport.deviceScaleFactor ?? 1,
699
+ colorScheme: viewport.colorScheme ?? 'light',
700
+ locale: browserLocale(),
701
+ timezoneId: browserTimezone(),
702
+ reducedMotion: 'reduce',
703
+ acceptDownloads: false,
704
+ serviceWorkers: 'block',
705
+ });
706
+ try {
707
+ await installRequestPolicy(context);
708
+ const page = await context.newPage();
709
+ if (signal.aborted)
710
+ throw new BrowserMcpError('CANCELLED', 'The browser request was cancelled.');
711
+ return { context, page };
712
+ }
713
+ catch (error) {
714
+ await context.close().catch(() => undefined);
715
+ throw error;
716
+ }
717
+ }
256
718
  export function getSession(value) {
719
+ if (value === undefined || value === '') {
720
+ const latest = lastLiveSession();
721
+ if (!latest)
722
+ throw new BrowserMcpError('NOT_FOUND', 'No active browser session exists.');
723
+ return touchSession(latest);
724
+ }
257
725
  if (typeof value !== 'string' || !SESSION_ID_PATTERN.test(value)) {
258
726
  throw new BrowserMcpError('INVALID_ARGUMENT', 'A valid sessionId is required.');
259
727
  }
@@ -262,10 +730,15 @@ export function getSession(value) {
262
730
  sessions.delete(value);
263
731
  throw new BrowserMcpError('NOT_FOUND', 'The browser session does not exist or has expired.');
264
732
  }
265
- session.touchedAt = Date.now();
266
- return session;
733
+ return touchSession(session);
267
734
  }
268
735
  export async function closeSession(value) {
736
+ if (value === undefined || value === '') {
737
+ const latest = lastLiveSession();
738
+ if (!latest)
739
+ return false;
740
+ return closeSessionInternal(latest.id);
741
+ }
269
742
  if (typeof value !== 'string' || !SESSION_ID_PATTERN.test(value)) {
270
743
  throw new BrowserMcpError('INVALID_ARGUMENT', 'A valid sessionId is required.');
271
744
  }
@@ -305,6 +778,8 @@ export async function runCancellable(session, signal, operation) {
305
778
  export function resetNavigationCounter(session) {
306
779
  session.documentRequests = 0;
307
780
  session.navigationBlocked = false;
781
+ session.blockedRequestCount = 0;
782
+ session.lastPolicyBlock = undefined;
308
783
  }
309
784
  export function publicPageState(session) {
310
785
  let url = session.page.url();
@@ -317,14 +792,113 @@ export function publicPageState(session) {
317
792
  catch {
318
793
  url = url === 'about:blank' ? url : '';
319
794
  }
320
- return { sessionId: session.id, url };
795
+ return {
796
+ sessionId: session.id,
797
+ url,
798
+ mode: session.mode,
799
+ policy: {
800
+ blockedRequestCount: session.blockedRequestCount,
801
+ ...(session.lastPolicyBlock ? { lastBlockedRequest: session.lastPolicyBlock } : {}),
802
+ },
803
+ };
804
+ }
805
+ export async function browserDiagnostics(session) {
806
+ const mode = session?.mode ?? browserMode();
807
+ const state = launchStates[mode];
808
+ const page = session?.page;
809
+ let fingerprint;
810
+ if (page && !page.isClosed()) {
811
+ fingerprint = await page.evaluate(() => {
812
+ const nav = navigator;
813
+ return {
814
+ userAgent: nav.userAgent,
815
+ userAgentBrands: nav.userAgentData?.brands ?? [],
816
+ userAgentPlatform: nav.userAgentData?.platform,
817
+ webdriver: nav.webdriver,
818
+ language: nav.language,
819
+ languages: [...nav.languages],
820
+ platform: nav.platform,
821
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
822
+ };
823
+ }).catch(() => undefined);
824
+ }
825
+ return {
826
+ success: true,
827
+ mode,
828
+ launched: Boolean(state),
829
+ channel: state?.channel ?? preferredChannel(mode) ?? 'default',
830
+ headless: state?.headless ?? !headed(mode),
831
+ persistentProfile: state?.persistent ?? mode === 'trusted',
832
+ windowVisibility: state?.windowVisibility ?? browserWindowVisibility(),
833
+ version: state?.version,
834
+ locale: browserLocale(),
835
+ timezone: browserTimezone(),
836
+ serviceWorkers: allowServiceWorkers(mode) ? 'allow' : 'block',
837
+ privateHosts: enabledEnv('FLUJO_BROWSER_ALLOW_PRIVATE_HOSTS') ? 'allow' : 'block',
838
+ allowedOrigins: [...allowedOrigins()],
839
+ ...(session ? { session: publicPageState(session) } : {}),
840
+ ...(fingerprint ? { fingerprint } : {}),
841
+ };
842
+ }
843
+ async function installedProfileExtensions() {
844
+ const preferencesPath = path.join(trustedProfileDir(), 'Default', 'Preferences');
845
+ try {
846
+ const stat = await fs.stat(preferencesPath);
847
+ if (!stat.isFile() || stat.size > 50_000_000)
848
+ return [];
849
+ const preferences = JSON.parse(await fs.readFile(preferencesPath, 'utf8'));
850
+ const settings = preferences.extensions?.settings ?? {};
851
+ return Object.entries(settings).flatMap(([id, entry]) => {
852
+ const manifest = entry.manifest;
853
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest))
854
+ return [];
855
+ const record = manifest;
856
+ return [{
857
+ id,
858
+ name: typeof record.name === 'string' ? record.name : id,
859
+ version: typeof record.version === 'string' ? record.version : '',
860
+ enabled: entry.state === 1,
861
+ source: 'profile',
862
+ }];
863
+ });
864
+ }
865
+ catch {
866
+ return [];
867
+ }
868
+ }
869
+ export async function browserExtensions() {
870
+ const configured = await configuredExtensions();
871
+ const activeUrls = trustedContext
872
+ ? [
873
+ ...trustedContext.serviceWorkers().map((worker) => worker.url()),
874
+ ...trustedContext.backgroundPages().map((page) => page.url()),
875
+ ]
876
+ : [];
877
+ const activeIds = [...new Set(activeUrls.flatMap((url) => {
878
+ const match = /^chrome-extension:\/\/([a-p]{32})(?:\/|$)/i.exec(url);
879
+ return match ? [match[1]] : [];
880
+ }))];
881
+ return {
882
+ success: true,
883
+ profile: trustedProfileDir(),
884
+ configuredUnpacked: configured,
885
+ installed: await installedProfileExtensions(),
886
+ activeExtensionIds: activeIds,
887
+ note: 'Extensions belong only to FLUJO\'s dedicated trusted profile. Unpacked directories are operator allowlisted; FLUJO never copies extensions from the personal Chrome profile.',
888
+ };
321
889
  }
322
890
  export async function shutdownBrowserRuntime() {
323
891
  const ids = [...sessions.keys()];
324
892
  await Promise.all(ids.map((id) => closeSessionInternal(id)));
325
- const active = browser;
326
- browser = undefined;
327
- await active?.close().catch(() => undefined);
893
+ const activeSandbox = sandboxBrowser;
894
+ const activeTrusted = trustedContext;
895
+ sandboxBrowser = undefined;
896
+ trustedContext = undefined;
897
+ lastSessionId = undefined;
898
+ delete launchStates.sandbox;
899
+ delete launchStates.trusted;
900
+ await activeTrusted?.close().catch(() => undefined);
901
+ await activeSandbox?.close().catch(() => undefined);
328
902
  if (runtimeRoot) {
329
903
  const root = runtimeRoot;
330
904
  runtimeRoot = undefined;