@mario.andreschak/mcp-browser 3.45.0 → 3.45.2

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
@@ -4,6 +4,7 @@ import { lookup } from 'node:dns/promises';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { randomUUID } from 'node:crypto';
7
+ import { pathToFileURL } from 'node:url';
7
8
  import { chromium } from 'patchright';
8
9
  const DEFAULT_TIMEOUT_MS = 30_000;
9
10
  const MAX_TIMEOUT_MS = 60_000;
@@ -11,6 +12,7 @@ const DEFAULT_IDLE_MS = 10 * 60_000;
11
12
  const DEFAULT_MAX_SESSIONS = 4;
12
13
  const DEFAULT_MAX_REDIRECTS = 10;
13
14
  const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
15
+ export const LEGACY_BROWSER_OWNER_SCOPE = 'legacy:anonymous';
14
16
  export function failureCategoryForCode(code) {
15
17
  if (code === 'CANCELLED')
16
18
  return 'cancelled';
@@ -36,8 +38,12 @@ let trustedContext;
36
38
  let trustedContextPromise;
37
39
  const launchStates = {};
38
40
  let runtimeRoot;
39
- let lastSessionId;
40
41
  const sessions = new Map();
42
+ const reservations = new Map();
43
+ const lastSessionIds = new Map();
44
+ export function effectiveBrowserOwnerScope(ownerScope) {
45
+ return ownerScope?.trim() || LEGACY_BROWSER_OWNER_SCOPE;
46
+ }
41
47
  export function integerEnv(name, fallback, min, max) {
42
48
  const raw = Number.parseInt(process.env[name] ?? '', 10);
43
49
  return Number.isFinite(raw) ? Math.min(max, Math.max(min, raw)) : fallback;
@@ -81,6 +87,15 @@ function allowServiceWorkers(mode) {
81
87
  function allowPrivateHosts() {
82
88
  return booleanEnv('FLUJO_BROWSER_ALLOW_PRIVATE_HOSTS') ?? true;
83
89
  }
90
+ /**
91
+ * Browser navigation is deliberately open by default: this is an operator-run
92
+ * browser, and its primary job is to inspect the operator's local and remote
93
+ * applications. Deployments that need the former SSRF/origin policy can opt
94
+ * back into it explicitly.
95
+ */
96
+ function navigationRestricted() {
97
+ return enabledEnv('FLUJO_BROWSER_RESTRICT_NAVIGATION');
98
+ }
84
99
  function browserWindowVisibility() {
85
100
  const configured = process.env.FLUJO_BROWSER_WINDOW_VISIBILITY?.trim().toLowerCase();
86
101
  if (configured === 'offscreen' || configured === 'minimized')
@@ -103,10 +118,10 @@ export function defaultViewport() {
103
118
  export function timeoutMs(value) {
104
119
  if (value === undefined)
105
120
  return DEFAULT_TIMEOUT_MS;
106
- if (typeof value !== 'number' || !Number.isFinite(value)) {
107
- throw new BrowserMcpError('INVALID_ARGUMENT', 'timeoutMs must be a finite number.');
108
- }
109
- return Math.min(MAX_TIMEOUT_MS, Math.max(1_000, Math.trunc(value)));
121
+ const parsed = typeof value === 'number' ? value : Number(value);
122
+ if (!Number.isFinite(parsed))
123
+ return DEFAULT_TIMEOUT_MS;
124
+ return Math.min(MAX_TIMEOUT_MS, Math.max(1_000, Math.trunc(parsed)));
110
125
  }
111
126
  function allowedOrigins() {
112
127
  const origins = new Set();
@@ -165,20 +180,54 @@ async function resolveHostAddresses(hostname) {
165
180
  dnsCache.set(hostname, { expiresAt: now + DNS_CACHE_TTL_MS, addresses });
166
181
  return addresses;
167
182
  }
183
+ async function normalizeNavigationTarget(input) {
184
+ const raw = input.trim();
185
+ if (!raw)
186
+ throw new BrowserMcpError('INVALID_ARGUMENT', 'Provide a URL or local file path to navigate to.');
187
+ const windowsPath = /^[A-Za-z]:[\\/]/.test(raw);
188
+ const explicitPath = windowsPath || path.isAbsolute(raw) || raw.startsWith('./') || raw.startsWith('../');
189
+ if (explicitPath)
190
+ return pathToFileURL(path.resolve(raw));
191
+ try {
192
+ await fs.access(path.resolve(raw));
193
+ return pathToFileURL(path.resolve(raw));
194
+ }
195
+ catch {
196
+ // It is a URL or hostname, not an existing local path.
197
+ }
198
+ // A hostname followed by a numeric port (localhost:4200, app.test:3000)
199
+ // is not a URL scheme. Treat only :// or a non-numeric scheme payload as an
200
+ // explicit protocol.
201
+ if (!/^[A-Za-z][A-Za-z\d+.-]*:(?:\/\/|[^\d])/.test(raw)) {
202
+ const local = /^(?:localhost|127(?:\.\d{1,3}){3}|10(?:\.\d{1,3}){3}|192\.168(?:\.\d{1,3}){2}|172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}|\[?::1\]?|[^/\s]+\.local)(?::\d+)?(?:\/|$)/i.test(raw)
203
+ || /^[^/\s]+:\d+(?:\/|$)/.test(raw);
204
+ return new URL(`${local ? 'http' : 'https'}://${raw}`);
205
+ }
206
+ try {
207
+ return new URL(raw);
208
+ }
209
+ catch {
210
+ throw new BrowserMcpError('INVALID_ARGUMENT', `Could not understand the browser target ${JSON.stringify(raw)}.`);
211
+ }
212
+ }
168
213
  export async function assertNavigationAllowed(input) {
169
214
  let url;
170
215
  try {
171
- url = new URL(input);
216
+ url = await normalizeNavigationTarget(input);
172
217
  }
173
- catch {
174
- throw new BrowserMcpError('NAVIGATION_BLOCKED', 'The URL is malformed.');
218
+ catch (error) {
219
+ if (error instanceof BrowserMcpError)
220
+ throw error;
221
+ throw new BrowserMcpError('INVALID_ARGUMENT', `Could not understand the browser target ${JSON.stringify(input)}.`);
175
222
  }
176
- if (url.protocol !== 'http:' && url.protocol !== 'https:') {
177
- throw new BrowserMcpError('NAVIGATION_BLOCKED', 'Only HTTP and HTTPS URLs are allowed.');
223
+ if (!['http:', 'https:', 'file:'].includes(url.protocol) && !(url.protocol === 'about:' && url.href === 'about:blank')) {
224
+ throw new BrowserMcpError('INVALID_ARGUMENT', `The browser cannot navigate to ${url.protocol} targets. Use HTTP(S), a local file path, file://, or about:blank.`);
178
225
  }
179
226
  if (url.username || url.password) {
180
- throw new BrowserMcpError('NAVIGATION_BLOCKED', 'URLs containing credentials are not allowed.');
227
+ throw new BrowserMcpError('INVALID_ARGUMENT', 'Remove embedded credentials from the URL and authenticate in the page instead.');
181
228
  }
229
+ if (!navigationRestricted() || url.protocol === 'file:' || url.protocol === 'about:')
230
+ return url;
182
231
  const configuredOrigins = allowedOrigins();
183
232
  if (configuredOrigins.size > 0 && !configuredOrigins.has(url.origin)) {
184
233
  throw new BrowserMcpError('NAVIGATION_BLOCKED', 'The URL origin is not allowed by browser policy.');
@@ -207,13 +256,6 @@ async function ensureRuntimeRoot() {
207
256
  runtimeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'flujo-browser-'));
208
257
  return runtimeRoot;
209
258
  }
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
259
  /** Persistence root for `browser_record_*` artifacts (WebM/WAV/muxed output). */
218
260
  export function recordingRoot() {
219
261
  const configured = process.env.FLUJO_BROWSER_RECORD_DIR?.trim();
@@ -229,16 +271,6 @@ export async function ensureScratchDir(prefix) {
229
271
  await fs.mkdir(dir, { recursive: true });
230
272
  return dir;
231
273
  }
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
274
  /**
243
275
  * Chromium flags that make embedded media behave the way a user expects.
244
276
  *
@@ -395,11 +427,13 @@ export async function acquireBrowser() {
395
427
  sandboxBrowser = undefined;
396
428
  delete launchStates.sandbox;
397
429
  for (const [id, session] of sessions) {
398
- if (session.mode === 'sandbox')
430
+ if (session.mode === 'sandbox') {
399
431
  sessions.delete(id);
432
+ if (lastSessionIds.get(effectiveBrowserOwnerScope(session.ownerScope)) === id) {
433
+ lastSessionIds.delete(effectiveBrowserOwnerScope(session.ownerScope));
434
+ }
435
+ }
400
436
  }
401
- if (lastSessionId && !sessions.has(lastSessionId))
402
- lastSessionId = undefined;
403
437
  });
404
438
  return launched;
405
439
  }
@@ -456,11 +490,13 @@ async function acquireTrustedContext() {
456
490
  trustedContext = undefined;
457
491
  delete launchStates.trusted;
458
492
  for (const [id, session] of sessions) {
459
- if (session.mode === 'trusted')
493
+ if (session.mode === 'trusted') {
460
494
  sessions.delete(id);
495
+ if (lastSessionIds.get(effectiveBrowserOwnerScope(session.ownerScope)) === id) {
496
+ lastSessionIds.delete(effectiveBrowserOwnerScope(session.ownerScope));
497
+ }
498
+ }
461
499
  }
462
- if (lastSessionId && !sessions.has(lastSessionId))
463
- lastSessionId = undefined;
464
500
  });
465
501
  return context;
466
502
  }
@@ -483,40 +519,99 @@ function validateSessionId(value) {
483
519
  }
484
520
  return value;
485
521
  }
522
+ function sessionIdleMs() {
523
+ return integerEnv('FLUJO_BROWSER_IDLE_TIMEOUT_MS', DEFAULT_IDLE_MS, 10_000, 24 * 60 * 60_000);
524
+ }
525
+ function sessionOwner(session) {
526
+ return effectiveBrowserOwnerScope(session.ownerScope);
527
+ }
486
528
  function touchSession(session) {
487
- session.touchedAt = Date.now();
488
- lastSessionId = session.id;
529
+ const now = Date.now();
530
+ session.touchedAt = now;
531
+ session.expiresAt = now + sessionIdleMs();
532
+ lastSessionIds.set(sessionOwner(session), session.id);
489
533
  return session;
490
534
  }
491
- function lastLiveSession(mode) {
492
- const remembered = lastSessionId ? sessions.get(lastSessionId) : undefined;
493
- if (remembered && !remembered.page.isClosed() && (!mode || remembered.mode === mode))
535
+ function removeSessionEntry(session) {
536
+ if (sessions.get(session.id) !== session)
537
+ return;
538
+ session.lifecycleState = 'closing';
539
+ sessions.delete(session.id);
540
+ const ownerScope = sessionOwner(session);
541
+ if (lastSessionIds.get(ownerScope) === session.id)
542
+ lastSessionIds.delete(ownerScope);
543
+ }
544
+ async function shutdownSession(session) {
545
+ if (session.mode === 'trusted')
546
+ await session.page.close().catch(() => undefined);
547
+ else
548
+ await session.context.close().catch(() => undefined);
549
+ }
550
+ function purgeUnavailableSessions() {
551
+ const now = Date.now();
552
+ for (const session of sessions.values()) {
553
+ if (session.page.isClosed() || (session.expiresAt ?? (session.touchedAt + sessionIdleMs())) <= now) {
554
+ removeSessionEntry(session);
555
+ if (session.onExpire)
556
+ session.onExpire();
557
+ else
558
+ void shutdownSession(session);
559
+ }
560
+ }
561
+ for (const [id, reservation] of reservations) {
562
+ if (reservation.expiresAt <= now)
563
+ reservations.delete(id);
564
+ }
565
+ }
566
+ function lastLiveSession(ownerScope, mode) {
567
+ purgeUnavailableSessions();
568
+ const rememberedId = lastSessionIds.get(ownerScope);
569
+ const remembered = rememberedId ? sessions.get(rememberedId) : undefined;
570
+ if (remembered && sessionOwner(remembered) === ownerScope && (!mode || remembered.mode === mode))
494
571
  return remembered;
495
572
  let latest;
496
573
  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))
574
+ if (sessionOwner(session) === ownerScope && (!mode || session.mode === mode) && (!latest || session.touchedAt >= latest.touchedAt)) {
502
575
  latest = session;
576
+ }
503
577
  }
504
- lastSessionId = latest?.id;
578
+ if (latest)
579
+ lastSessionIds.set(ownerScope, latest.id);
580
+ else
581
+ lastSessionIds.delete(ownerScope);
505
582
  return latest;
506
583
  }
507
- async function closeSessionInternal(id) {
584
+ export function reserveSession(id, ownerScopeInput, purpose = 'interactive') {
585
+ const ownerScope = effectiveBrowserOwnerScope(ownerScopeInput);
586
+ purgeUnavailableSessions();
587
+ const occupied = sessions.get(id);
588
+ if (occupied) {
589
+ if (sessionOwner(occupied) !== ownerScope)
590
+ throw new BrowserMcpError('NOT_FOUND', 'The browser session does not exist or has expired.');
591
+ throw new BrowserMcpError('INVALID_ARGUMENT', 'The browser session is already active.');
592
+ }
593
+ if (reservations.has(id))
594
+ throw new BrowserMcpError('SESSION_LIMIT', 'The browser session is already being opened.');
595
+ const maxSessions = integerEnv('FLUJO_BROWSER_MAX_SESSIONS', DEFAULT_MAX_SESSIONS, 1, 32);
596
+ if (sessions.size + reservations.size >= maxSessions) {
597
+ throw new BrowserMcpError('SESSION_LIMIT', `The browser session limit (${maxSessions}) has been reached.`);
598
+ }
599
+ const now = Date.now();
600
+ reservations.set(id, { id, ownerScope, purpose, createdAt: now, expiresAt: now + sessionIdleMs() });
601
+ }
602
+ export function releaseSessionReservation(id, ownerScopeInput) {
603
+ const reservation = reservations.get(id);
604
+ if (reservation && reservation.ownerScope === effectiveBrowserOwnerScope(ownerScopeInput))
605
+ reservations.delete(id);
606
+ }
607
+ async function closeSessionInternal(id, ownerScopeInput) {
508
608
  const session = sessions.get(id);
509
609
  if (!session)
510
610
  return false;
511
- sessions.delete(id);
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
- }
611
+ if (ownerScopeInput !== undefined && sessionOwner(session) !== effectiveBrowserOwnerScope(ownerScopeInput))
612
+ return false;
613
+ removeSessionEntry(session);
614
+ await shutdownSession(session);
520
615
  return true;
521
616
  }
522
617
  function policyDisplayUrl(rawUrl) {
@@ -543,6 +638,8 @@ function sessionForPage(page) {
543
638
  }
544
639
  /** Enforce the same SSRF/navigation policy on sessions, recordings, and capture contexts. */
545
640
  export async function installRequestPolicy(context) {
641
+ if (!navigationRestricted())
642
+ return;
546
643
  await context.route('**/*', async (route) => {
547
644
  const request = route.request();
548
645
  let session;
@@ -592,23 +689,20 @@ function reusableTrustedPage(context) {
592
689
  const used = new Set([...sessions.values()].map((session) => session.page));
593
690
  return context.pages().find((page) => !used.has(page) && !page.isClosed() && page.url() === 'about:blank');
594
691
  }
595
- export async function openSession(requestedId, signal) {
692
+ export async function openSession(requestedId, signal, ownerScopeInput) {
596
693
  if (signal.aborted)
597
694
  throw new BrowserMcpError('CANCELLED', 'The browser request was cancelled.');
598
695
  const mode = browserMode();
599
- if (requestedId === undefined || requestedId === '') {
600
- const latest = lastLiveSession(mode);
601
- if (latest)
602
- return touchSession(latest);
603
- }
696
+ const ownerScope = effectiveBrowserOwnerScope(ownerScopeInput);
604
697
  const id = validateSessionId(requestedId);
698
+ purgeUnavailableSessions();
605
699
  const existing = sessions.get(id);
606
- if (existing)
700
+ if (existing) {
701
+ if (sessionOwner(existing) !== ownerScope)
702
+ throw new BrowserMcpError('NOT_FOUND', 'The browser session does not exist or has expired.');
607
703
  return touchSession(existing);
608
- const maxSessions = integerEnv('FLUJO_BROWSER_MAX_SESSIONS', DEFAULT_MAX_SESSIONS, 1, 32);
609
- if (sessions.size >= maxSessions) {
610
- throw new BrowserMcpError('SESSION_LIMIT', `The browser session limit (${maxSessions}) has been reached.`);
611
704
  }
705
+ reserveSession(id, ownerScope, 'interactive');
612
706
  let context;
613
707
  let page;
614
708
  let closeCreatedPromise;
@@ -646,12 +740,20 @@ export async function openSession(requestedId, signal) {
646
740
  page = await context.newPage();
647
741
  await installRequestPolicy(context);
648
742
  }
743
+ const now = Date.now();
649
744
  const session = {
650
745
  id,
651
746
  mode,
747
+ ownerScope,
748
+ purpose: 'interactive',
749
+ lifecycleState: 'active',
750
+ viewportPolicy: 'resizable',
751
+ createdAt: now,
752
+ expiresAt: now + sessionIdleMs(),
753
+ gatewayToken: randomUUID(),
652
754
  context,
653
755
  page,
654
- touchedAt: Date.now(),
756
+ touchedAt: now,
655
757
  documentRequests: 0,
656
758
  navigationBlocked: false,
657
759
  blockedRequestCount: 0,
@@ -660,11 +762,8 @@ export async function openSession(requestedId, signal) {
660
762
  throw new BrowserMcpError('CANCELLED', 'The browser request was cancelled.');
661
763
  }
662
764
  session.page.on('download', (download) => void download.cancel().catch(() => undefined));
663
- session.page.on('close', () => {
664
- sessions.delete(id);
665
- if (lastSessionId === id)
666
- lastSessionId = undefined;
667
- });
765
+ session.page.on('close', () => removeSessionEntry(session));
766
+ reservations.delete(id);
668
767
  sessions.set(id, session);
669
768
  return touchSession(session);
670
769
  }
@@ -676,11 +775,29 @@ export async function openSession(requestedId, signal) {
676
775
  throw error;
677
776
  }
678
777
  finally {
778
+ releaseSessionReservation(id, ownerScope);
679
779
  signal.removeEventListener('abort', onAbort);
680
780
  }
681
781
  }
682
- /** Register a session created outside `openSession()` (used by the recording module, which owns its own context lifecycle). */
683
- export function registerSession(session) {
782
+ /** Register a session created outside `openSession()` after reserving capacity synchronously. */
783
+ export function registerSession(session, ownerScopeInput) {
784
+ const ownerScope = effectiveBrowserOwnerScope(ownerScopeInput ?? session.ownerScope);
785
+ const reservation = reservations.get(session.id);
786
+ if (!reservation)
787
+ reserveSession(session.id, ownerScope, session.purpose ?? 'interactive');
788
+ else if (reservation.ownerScope !== ownerScope) {
789
+ throw new BrowserMcpError('NOT_FOUND', 'The browser session reservation belongs to another owner.');
790
+ }
791
+ const now = Date.now();
792
+ session.ownerScope = ownerScope;
793
+ session.purpose ??= 'interactive';
794
+ session.lifecycleState = 'active';
795
+ session.viewportPolicy ??= 'resizable';
796
+ session.createdAt ??= now;
797
+ session.expiresAt = now + sessionIdleMs();
798
+ session.gatewayToken ??= randomUUID();
799
+ reservations.delete(session.id);
800
+ session.page.on('close', () => removeSessionEntry(session));
684
801
  sessions.set(session.id, session);
685
802
  return touchSession(session);
686
803
  }
@@ -718,9 +835,11 @@ export async function createCaptureContext(signal, viewport) {
718
835
  throw error;
719
836
  }
720
837
  }
721
- export function getSession(value) {
838
+ export function getSession(value, ownerScopeInput) {
839
+ const ownerScope = effectiveBrowserOwnerScope(ownerScopeInput);
840
+ purgeUnavailableSessions();
722
841
  if (value === undefined || value === '') {
723
- const latest = lastLiveSession();
842
+ const latest = lastLiveSession(ownerScope);
724
843
  if (!latest)
725
844
  throw new BrowserMcpError('NOT_FOUND', 'No active browser session exists.');
726
845
  return touchSession(latest);
@@ -729,23 +848,82 @@ export function getSession(value) {
729
848
  throw new BrowserMcpError('INVALID_ARGUMENT', 'A valid sessionId is required.');
730
849
  }
731
850
  const session = sessions.get(value);
732
- if (!session || session.page.isClosed()) {
733
- sessions.delete(value);
851
+ if (!session || sessionOwner(session) !== ownerScope) {
852
+ throw new BrowserMcpError('NOT_FOUND', 'The browser session does not exist or has expired.');
853
+ }
854
+ return touchSession(session);
855
+ }
856
+ export function getSessionForGateway(value, gatewayToken) {
857
+ purgeUnavailableSessions();
858
+ if (typeof value !== 'string' || !SESSION_ID_PATTERN.test(value) || typeof gatewayToken !== 'string') {
859
+ throw new BrowserMcpError('NOT_FOUND', 'The browser session does not exist or has expired.');
860
+ }
861
+ const session = sessions.get(value);
862
+ if (!session?.gatewayToken || session.gatewayToken !== gatewayToken) {
734
863
  throw new BrowserMcpError('NOT_FOUND', 'The browser session does not exist or has expired.');
735
864
  }
736
865
  return touchSession(session);
737
866
  }
738
- export async function closeSession(value) {
867
+ export async function closeSession(value, ownerScopeInput) {
868
+ const ownerScope = effectiveBrowserOwnerScope(ownerScopeInput);
739
869
  if (value === undefined || value === '') {
740
- const latest = lastLiveSession();
870
+ const latest = lastLiveSession(ownerScope);
741
871
  if (!latest)
742
872
  return false;
743
- return closeSessionInternal(latest.id);
873
+ return closeSessionInternal(latest.id, ownerScope);
744
874
  }
745
875
  if (typeof value !== 'string' || !SESSION_ID_PATTERN.test(value)) {
746
876
  throw new BrowserMcpError('INVALID_ARGUMENT', 'A valid sessionId is required.');
747
877
  }
748
- return closeSessionInternal(value);
878
+ return closeSessionInternal(value, ownerScope);
879
+ }
880
+ export function listSessions(ownerScopeInput) {
881
+ const ownerScope = effectiveBrowserOwnerScope(ownerScopeInput);
882
+ purgeUnavailableSessions();
883
+ const now = Date.now();
884
+ const owned = [...sessions.values()].filter((session) => sessionOwner(session) === ownerScope);
885
+ const reserved = [...reservations.values()].filter((entry) => entry.ownerScope === ownerScope);
886
+ const maxSessions = integerEnv('FLUJO_BROWSER_MAX_SESSIONS', DEFAULT_MAX_SESSIONS, 1, 32);
887
+ return {
888
+ success: true,
889
+ sessions: [
890
+ ...owned.map((session) => ({
891
+ sessionId: session.id,
892
+ purpose: session.purpose ?? 'interactive',
893
+ mode: session.mode,
894
+ state: session.lifecycleState ?? 'active',
895
+ viewportPolicy: session.viewportPolicy ?? 'resizable',
896
+ ageMs: now - (session.createdAt ?? session.touchedAt),
897
+ idleMs: now - session.touchedAt,
898
+ expiresAt: session.expiresAt ?? (session.touchedAt + sessionIdleMs()),
899
+ })),
900
+ ...reserved.map((entry) => ({
901
+ sessionId: entry.id,
902
+ purpose: entry.purpose,
903
+ state: 'reserved',
904
+ ageMs: now - entry.createdAt,
905
+ idleMs: 0,
906
+ expiresAt: entry.expiresAt,
907
+ })),
908
+ ],
909
+ capacity: {
910
+ used: sessions.size + reservations.size,
911
+ owned: owned.length + reserved.length,
912
+ limit: maxSessions,
913
+ },
914
+ };
915
+ }
916
+ export async function releaseOwnerScope(ownerScopeInput) {
917
+ const ownerScope = effectiveBrowserOwnerScope(ownerScopeInput);
918
+ const owned = [...sessions.values()].filter((session) => sessionOwner(session) === ownerScope);
919
+ for (const session of owned)
920
+ removeSessionEntry(session);
921
+ for (const [id, reservation] of reservations) {
922
+ if (reservation.ownerScope === ownerScope)
923
+ reservations.delete(id);
924
+ }
925
+ await Promise.all(owned.map((session) => shutdownSession(session)));
926
+ return { success: true, ownerScope, closed: owned.length };
749
927
  }
750
928
  export async function runCancellable(session, signal, operation) {
751
929
  if (signal.aborted) {
@@ -897,7 +1075,8 @@ export async function shutdownBrowserRuntime() {
897
1075
  const activeTrusted = trustedContext;
898
1076
  sandboxBrowser = undefined;
899
1077
  trustedContext = undefined;
900
- lastSessionId = undefined;
1078
+ reservations.clear();
1079
+ lastSessionIds.clear();
901
1080
  delete launchStates.sandbox;
902
1081
  delete launchStates.trusted;
903
1082
  await activeTrusted?.close().catch(() => undefined);
@@ -909,12 +1088,7 @@ export async function shutdownBrowserRuntime() {
909
1088
  }
910
1089
  }
911
1090
  const idleTimer = setInterval(() => {
912
- const idleMs = integerEnv('FLUJO_BROWSER_IDLE_TIMEOUT_MS', DEFAULT_IDLE_MS, 10_000, 24 * 60 * 60_000);
913
- const cutoff = Date.now() - idleMs;
914
- for (const session of sessions.values()) {
915
- if (session.touchedAt < cutoff)
916
- void closeSessionInternal(session.id);
917
- }
1091
+ purgeUnavailableSessions();
918
1092
  }, 30_000);
919
1093
  idleTimer.unref();
920
1094
  //# sourceMappingURL=runtime.js.map