@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/README.md +52 -0
- package/dist/capture.d.ts +34 -10
- package/dist/capture.js +202 -102
- package/dist/capture.js.map +1 -1
- package/dist/gateway.d.ts +1 -1
- package/dist/gateway.js +23 -10
- package/dist/gateway.js.map +1 -1
- package/dist/index.js +15 -1
- package/dist/index.js.map +1 -1
- package/dist/recording.d.ts +10 -3
- package/dist/recording.js +513 -67
- package/dist/recording.js.map +1 -1
- package/dist/resources.js +45 -8
- package/dist/resources.js.map +1 -1
- package/dist/runtime.d.ts +31 -7
- package/dist/runtime.js +262 -88
- package/dist/runtime.js.map +1 -1
- package/dist/tools.d.ts +1 -1
- package/dist/tools.js +283 -157
- package/dist/tools.js.map +1 -1
- package/dist/viewHtml.js +8 -2
- package/dist/viewHtml.js.map +1 -1
- package/package.json +4 -3
- package/scripts/install-browser.mjs +166 -0
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
return Math.min(MAX_TIMEOUT_MS, Math.max(1_000, Math.trunc(
|
|
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 =
|
|
216
|
+
url = await normalizeNavigationTarget(input);
|
|
172
217
|
}
|
|
173
|
-
catch {
|
|
174
|
-
|
|
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
|
|
177
|
-
throw new BrowserMcpError('
|
|
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('
|
|
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
|
-
|
|
488
|
-
|
|
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
|
|
492
|
-
|
|
493
|
-
|
|
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.
|
|
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
|
-
|
|
578
|
+
if (latest)
|
|
579
|
+
lastSessionIds.set(ownerScope, latest.id);
|
|
580
|
+
else
|
|
581
|
+
lastSessionIds.delete(ownerScope);
|
|
505
582
|
return latest;
|
|
506
583
|
}
|
|
507
|
-
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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()`
|
|
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
|
|
733
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|