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