@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/audioTap.d.ts +23 -0
- package/dist/audioTap.js +147 -0
- package/dist/audioTap.js.map +1 -0
- package/dist/capture.d.ts +94 -0
- package/dist/capture.js +302 -0
- package/dist/capture.js.map +1 -0
- package/dist/gateway.d.ts +28 -0
- package/dist/gateway.js +722 -0
- package/dist/gateway.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +9 -2
- package/dist/index.js.map +1 -1
- package/dist/recording.d.ts +21 -0
- package/dist/recording.js +391 -0
- package/dist/recording.js.map +1 -0
- package/dist/resources.d.ts +19 -1
- package/dist/resources.js +258 -37
- package/dist/resources.js.map +1 -1
- package/dist/runtime.d.ts +58 -2
- package/dist/runtime.js +657 -80
- package/dist/runtime.js.map +1 -1
- package/dist/tools.js +579 -33
- package/dist/tools.js.map +1 -1
- package/dist/viewHtml.d.ts +11 -0
- package/dist/viewHtml.js +469 -0
- package/dist/viewHtml.js.map +1 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
|
23
|
-
let
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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 (
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
613
|
+
let page;
|
|
614
|
+
let closeCreatedPromise;
|
|
187
615
|
let cancelled = false;
|
|
188
|
-
const
|
|
616
|
+
const closeCreated = () => {
|
|
189
617
|
if (!context)
|
|
190
618
|
return Promise.resolve();
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
626
|
+
void closeCreated();
|
|
197
627
|
};
|
|
198
628
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
199
629
|
try {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
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', () =>
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
|
326
|
-
|
|
327
|
-
|
|
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;
|