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