@mario.andreschak/mcp-browser 3.44.0 → 3.45.1

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/recording.js CHANGED
@@ -24,15 +24,17 @@ import { promises as fs } from 'node:fs';
24
24
  import path from 'node:path';
25
25
  import { randomUUID } from 'node:crypto';
26
26
  import { spawn } from 'node:child_process';
27
- import { getDataDir, isInside } from './capture.js';
27
+ import { getDataDir, isInside, navigateCaptureSource, normalizeResolution, resolutionFallbacks, resolveCaptureSource, } from './capture.js';
28
28
  import { audioTapSource } from './audioTap.js';
29
- import { BrowserMcpError, acquireBrowser, defaultViewport, ensureScratchDir, closeSession, integerEnv, installRequestPolicy, recordingRoot, registerSession, } from './runtime.js';
29
+ import { BrowserMcpError, acquireBrowser, defaultViewport, effectiveBrowserOwnerScope, ensureScratchDir, closeSession, integerEnv, installRequestPolicy, recordingRoot, registerSession, releaseSessionReservation, reserveSession, } from './runtime.js';
30
30
  const MAX_CONCURRENT_RECORDINGS = 2;
31
31
  const DEFAULT_RECORD_MAX_MS = 120_000;
32
32
  /** Hard memory cap for buffered PCM per recording (~roughly 17 minutes of stereo 16-bit 48kHz audio). */
33
33
  const MAX_AUDIO_BYTES = 200_000_000;
34
34
  const FFMPEG_TIMEOUT_MS = 60_000;
35
35
  const AUDIO_BINDING_PREFIX = '__flujoRecordAudio_';
36
+ const COMPLETED_RECORDING_TTL_MS = 30 * 60_000;
37
+ const MAX_COMPLETED_RECORDINGS = 16;
36
38
  function createDeferred() {
37
39
  let resolve;
38
40
  const promise = new Promise((res) => {
@@ -41,6 +43,10 @@ function createDeferred() {
41
43
  return { promise, resolve };
42
44
  }
43
45
  const recordings = new Map();
46
+ const recordingReservations = new Set();
47
+ const finalizingRecordings = new Map();
48
+ const completedRecordings = new Map();
49
+ const latestCompletedIds = new Map();
44
50
  function wavHeader(dataLength, sampleRate, channels, bitsPerSample) {
45
51
  const blockAlign = (channels * bitsPerSample) / 8;
46
52
  const byteRate = sampleRate * blockAlign;
@@ -84,10 +90,19 @@ async function attachAudioTap(state) {
84
90
  }
85
91
  });
86
92
  const source = audioTapSource(binding);
93
+ // BrowserContext.addInitScript is the durable main-world hook. The old CDP-
94
+ // only install could remain in an isolated/default execution context and
95
+ // disappear when about:blank navigated to the actual page.
96
+ await state.context.addInitScript({ content: source });
87
97
  await cdp.send('Runtime.enable');
88
98
  await cdp.send('Runtime.addBinding', { name: binding });
89
- await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source });
90
- await cdp.send('Runtime.evaluate', { expression: source }).catch(() => undefined);
99
+ await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source }).catch(() => undefined);
100
+ const evaluated = await cdp.send('Runtime.evaluate', { expression: source });
101
+ if ('exceptionDetails' in evaluated && evaluated.exceptionDetails) {
102
+ throw new Error('Chromium rejected the recording audio initialization script.');
103
+ }
104
+ // about:blank can use a different execution world from the destination. The
105
+ // durable context init script above is verified after the real navigation.
91
106
  }
92
107
  async function detachAudioTap(state) {
93
108
  if (!state.cdp)
@@ -170,28 +185,189 @@ async function confineOutputPath(outputPath) {
170
185
  await fs.mkdir(path.dirname(resolved), { recursive: true });
171
186
  return resolved;
172
187
  }
173
- function resolveRecordingState(args, allowMissing) {
188
+ function resolveRecordingState(args, allowMissing, ownerScopeInput) {
189
+ const ownerScope = effectiveBrowserOwnerScope(ownerScopeInput);
174
190
  const id = typeof args.recordingId === 'string' && args.recordingId
175
191
  ? args.recordingId
176
192
  : (typeof args.sessionId === 'string' && args.sessionId ? args.sessionId : undefined);
177
193
  if (id) {
178
194
  const state = recordings.get(id);
179
- if (!state) {
195
+ if (!state || state.ownerScope !== ownerScope) {
180
196
  if (allowMissing)
181
197
  return undefined;
182
198
  throw new BrowserMcpError('NOT_FOUND', 'No matching recording is running.');
183
199
  }
184
200
  return state;
185
201
  }
186
- if (recordings.size === 1)
187
- return [...recordings.values()][0];
188
- if (recordings.size === 0) {
202
+ const owned = [...recordings.values()].filter((state) => state.ownerScope === ownerScope);
203
+ if (owned.length === 1)
204
+ return owned[0];
205
+ if (owned.length === 0) {
189
206
  if (allowMissing)
190
207
  return undefined;
191
208
  throw new BrowserMcpError('NOT_FOUND', 'No recording is running.');
192
209
  }
193
210
  throw new BrowserMcpError('INVALID_ARGUMENT', 'Multiple recordings are running; specify recordingId.');
194
211
  }
212
+ function rememberCompleted(result) {
213
+ const id = typeof result.recordingId === 'string' ? result.recordingId : undefined;
214
+ if (!id)
215
+ return;
216
+ const now = Date.now();
217
+ for (const [recordingId, entry] of completedRecordings) {
218
+ if (now - entry.completedAt > COMPLETED_RECORDING_TTL_MS)
219
+ completedRecordings.delete(recordingId);
220
+ }
221
+ while (completedRecordings.size >= MAX_COMPLETED_RECORDINGS) {
222
+ const oldest = completedRecordings.keys().next().value;
223
+ if (!oldest)
224
+ break;
225
+ completedRecordings.delete(oldest);
226
+ }
227
+ const state = finalizingRecordings.get(id);
228
+ const ownerScope = state?.ownerScope ?? effectiveBrowserOwnerScope();
229
+ completedRecordings.set(id, { result, completedAt: now, ownerScope });
230
+ latestCompletedIds.set(ownerScope, id);
231
+ }
232
+ function completedRecording(args, ownerScopeInput) {
233
+ const ownerScope = effectiveBrowserOwnerScope(ownerScopeInput);
234
+ const id = typeof args.recordingId === 'string' && args.recordingId
235
+ ? args.recordingId
236
+ : (typeof args.sessionId === 'string' && args.sessionId ? args.sessionId : latestCompletedIds.get(ownerScope));
237
+ const entry = id ? completedRecordings.get(id) : undefined;
238
+ return entry?.ownerScope === ownerScope ? entry.result : undefined;
239
+ }
240
+ function finalizingRecording(args, ownerScopeInput) {
241
+ const ownerScope = effectiveBrowserOwnerScope(ownerScopeInput);
242
+ const id = typeof args.recordingId === 'string' && args.recordingId
243
+ ? args.recordingId
244
+ : (typeof args.sessionId === 'string' && args.sessionId ? args.sessionId : undefined);
245
+ if (id) {
246
+ const state = finalizingRecordings.get(id);
247
+ return state?.ownerScope === ownerScope ? state : undefined;
248
+ }
249
+ const owned = [...finalizingRecordings.values()].filter((state) => state.ownerScope === ownerScope);
250
+ if (owned.length === 1 && ![...recordings.values()].some((state) => state.ownerScope === ownerScope))
251
+ return owned[0];
252
+ return undefined;
253
+ }
254
+ function mimeForPath(filePath) {
255
+ if (!filePath)
256
+ return undefined;
257
+ const extension = path.extname(filePath).toLowerCase();
258
+ if (extension === '.mp4')
259
+ return 'video/mp4';
260
+ if (extension === '.mov')
261
+ return 'video/quicktime';
262
+ if (extension === '.webm')
263
+ return 'video/webm';
264
+ if (extension === '.wav')
265
+ return 'audio/wav';
266
+ return undefined;
267
+ }
268
+ async function readWebmDimensions(filePath) {
269
+ if (!filePath)
270
+ return null;
271
+ let handle;
272
+ try {
273
+ handle = await fs.open(filePath, 'r');
274
+ const stat = await handle.stat();
275
+ const bytes = Math.min(stat.size, 2 * 1024 * 1024);
276
+ const buffer = Buffer.alloc(bytes);
277
+ await handle.read(buffer, 0, bytes, 0);
278
+ let width;
279
+ let height;
280
+ for (let index = 0; index < buffer.length - 2 && (!width || !height); index += 1) {
281
+ const id = buffer[index];
282
+ if (id !== 0xb0 && id !== 0xba)
283
+ continue;
284
+ const first = buffer[index + 1];
285
+ let length = 1;
286
+ let mask = 0x80;
287
+ while (length <= 4 && (first & mask) === 0) {
288
+ length += 1;
289
+ mask >>= 1;
290
+ }
291
+ if (length > 4 || index + 1 + length >= buffer.length)
292
+ continue;
293
+ let size = first & (mask - 1);
294
+ for (let cursor = 1; cursor < length; cursor += 1)
295
+ size = (size << 8) | buffer[index + 1 + cursor];
296
+ if (size < 1 || size > 4 || index + 1 + length + size > buffer.length)
297
+ continue;
298
+ let value = 0;
299
+ for (let cursor = 0; cursor < size; cursor += 1) {
300
+ value = (value << 8) | buffer[index + 1 + length + cursor];
301
+ }
302
+ if (value < 1 || value > 16_384)
303
+ continue;
304
+ if (id === 0xb0)
305
+ width = value;
306
+ else
307
+ height = value;
308
+ }
309
+ return width && height ? { width, height } : null;
310
+ }
311
+ catch {
312
+ return null;
313
+ }
314
+ finally {
315
+ await handle?.close().catch(() => undefined);
316
+ }
317
+ }
318
+ function geometryPayload(state, encoded = null) {
319
+ const viewportMismatch = state.actualViewport.width !== state.effectiveResolution.width
320
+ || state.actualViewport.height !== state.effectiveResolution.height
321
+ || state.deviceScaleFactor !== 1;
322
+ const encodedMismatch = encoded !== null
323
+ && (encoded.width !== state.effectiveResolution.width || encoded.height !== state.effectiveResolution.height);
324
+ return {
325
+ requestedViewport: state.requestedResolution,
326
+ actualViewport: state.actualViewport,
327
+ deviceScaleFactor: state.deviceScaleFactor,
328
+ configuredVideoResolution: state.effectiveResolution,
329
+ actualEncodedVideoResolution: encoded,
330
+ contentBounds: {
331
+ x: 0,
332
+ y: 0,
333
+ width: state.effectiveResolution.width,
334
+ height: state.effectiveResolution.height,
335
+ },
336
+ letterboxInsets: { top: 0, right: 0, bottom: 0, left: 0 },
337
+ geometryMismatch: viewportMismatch || encodedMismatch,
338
+ };
339
+ }
340
+ function runFfmpegTranscode(ffmpeg, source, dest) {
341
+ const extension = path.extname(dest).toLowerCase();
342
+ const codecArgs = extension === '.mp4' || extension === '.mov'
343
+ ? ['-c:v', 'libx264', '-preset', 'veryfast', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart']
344
+ : ['-c:v', 'copy', '-c:a', 'libopus'];
345
+ return new Promise((resolve, reject) => {
346
+ let child;
347
+ try {
348
+ child = spawn(ffmpeg, ['-y', '-i', source, ...codecArgs, dest], { stdio: 'ignore' });
349
+ }
350
+ catch (error) {
351
+ reject(error);
352
+ return;
353
+ }
354
+ const timer = setTimeout(() => {
355
+ child.kill('SIGKILL');
356
+ reject(new Error('ffmpeg timed out.'));
357
+ }, FFMPEG_TIMEOUT_MS);
358
+ child.on('error', (error) => {
359
+ clearTimeout(timer);
360
+ reject(error);
361
+ });
362
+ child.on('close', (code) => {
363
+ clearTimeout(timer);
364
+ if (code === 0)
365
+ resolve();
366
+ else
367
+ reject(new Error(`ffmpeg exited with code ${code}.`));
368
+ });
369
+ });
370
+ }
195
371
  async function finalizeRecording(state, outputPathOverride) {
196
372
  if (state.stopping)
197
373
  return state.done.promise;
@@ -199,32 +375,56 @@ async function finalizeRecording(state, outputPathOverride) {
199
375
  if (state.durationTimer)
200
376
  clearTimeout(state.durationTimer);
201
377
  recordings.delete(state.id);
378
+ finalizingRecordings.set(state.id, state);
379
+ const warnings = [...state.warnings];
202
380
  await detachAudioTap(state).catch(() => undefined);
203
381
  const durationMs = Date.now() - state.startedAt;
204
382
  let videoPath;
205
383
  const video = state.page.video();
206
- await closeSession(state.id).catch(() => undefined);
384
+ await closeSession(state.id, state.ownerScope).catch((error) => {
385
+ warnings.push(`Closing the recording context reported: ${error instanceof Error ? error.message : 'unknown error'}.`);
386
+ });
207
387
  if (video) {
388
+ const dest = path.join(recordingRoot(), `${state.id}.webm`);
208
389
  try {
209
- const dest = path.join(recordingRoot(), `${state.id}.webm`);
210
390
  await fs.mkdir(path.dirname(dest), { recursive: true });
211
391
  await video.saveAs(dest);
212
392
  videoPath = dest;
213
393
  }
214
- catch {
215
- videoPath = undefined;
394
+ catch (error) {
395
+ warnings.push(`The normal video save failed; trying Chromium's raw recording file (${error instanceof Error ? error.message : 'unknown error'}).`);
396
+ try {
397
+ const rawPath = await video.path();
398
+ await fs.copyFile(rawPath, dest);
399
+ videoPath = dest;
400
+ warnings.push('Recovered the video from Chromium\'s raw recording file.');
401
+ }
402
+ catch (fallbackError) {
403
+ warnings.push(`Raw video recovery also failed: ${fallbackError instanceof Error ? fallbackError.message : 'unknown error'}.`);
404
+ }
216
405
  }
217
406
  }
407
+ else {
408
+ warnings.push('Chromium did not expose a video artifact for this recording context.');
409
+ }
218
410
  await fs.rm(state.videoDir, { recursive: true, force: true }).catch(() => undefined);
219
411
  let audioPath;
220
412
  if (state.audio && state.audioBytes > 0) {
221
- const rate = state.audioRate ?? 48_000;
222
- const pcm = Buffer.concat(state.audioChunks);
223
- const header = wavHeader(pcm.length, rate, 2, 16);
224
- const dest = path.join(recordingRoot(), `${state.id}.wav`);
225
- await fs.mkdir(path.dirname(dest), { recursive: true });
226
- await fs.writeFile(dest, Buffer.concat([header, pcm]));
227
- audioPath = dest;
413
+ try {
414
+ const rate = state.audioRate ?? 48_000;
415
+ const pcm = Buffer.concat(state.audioChunks);
416
+ const header = wavHeader(pcm.length, rate, 2, 16);
417
+ const dest = path.join(recordingRoot(), `${state.id}.wav`);
418
+ await fs.mkdir(path.dirname(dest), { recursive: true });
419
+ await fs.writeFile(dest, Buffer.concat([header, pcm]));
420
+ audioPath = dest;
421
+ }
422
+ catch (error) {
423
+ warnings.push(`Audio was captured but could not be saved: ${error instanceof Error ? error.message : 'unknown error'}.`);
424
+ }
425
+ }
426
+ else if (state.audio) {
427
+ warnings.push('No audible page signal was captured; returning the video without an audio track.');
228
428
  }
229
429
  let mergedPath;
230
430
  let muxed = false;
@@ -238,12 +438,14 @@ async function finalizeRecording(state, outputPathOverride) {
238
438
  mergedPath = dest;
239
439
  muxed = true;
240
440
  }
241
- catch {
441
+ catch (error) {
242
442
  reason = 'mux-failed';
443
+ warnings.push(`ffmpeg could not mux audio and video; both original files remain available (${error instanceof Error ? error.message : 'unknown error'}).`);
243
444
  }
244
445
  }
245
446
  else {
246
447
  reason = 'ffmpeg-not-available';
448
+ warnings.push('ffmpeg was not available, so video and audio are returned as separate artifacts.');
247
449
  }
248
450
  }
249
451
  else if (videoPath && !audioPath) {
@@ -255,91 +457,233 @@ async function finalizeRecording(state, outputPathOverride) {
255
457
  let outputPath = mergedPath ?? videoPath;
256
458
  if (outputPathOverride && outputPath) {
257
459
  try {
258
- const dest = await confineOutputPath(outputPathOverride);
259
- await fs.copyFile(outputPath, dest);
260
- outputPath = dest;
460
+ let requested = outputPathOverride;
461
+ if (!path.extname(requested)) {
462
+ requested += '.webm';
463
+ warnings.push(`outputPath had no extension; wrote ${requested} instead.`);
464
+ }
465
+ const dest = await confineOutputPath(requested);
466
+ const destinationExtension = path.extname(dest).toLowerCase();
467
+ const sourceExtension = path.extname(outputPath).toLowerCase();
468
+ if (destinationExtension === sourceExtension) {
469
+ await fs.copyFile(outputPath, dest);
470
+ outputPath = dest;
471
+ }
472
+ else if (['.mp4', '.mov', '.webm'].includes(destinationExtension)) {
473
+ const ffmpeg = await resolveFfmpeg();
474
+ if (ffmpeg) {
475
+ await runFfmpegTranscode(ffmpeg, outputPath, dest);
476
+ outputPath = dest;
477
+ warnings.push(`ffmpeg converted the recording to ${destinationExtension}.`);
478
+ }
479
+ else {
480
+ warnings.push(`Could not create ${destinationExtension} because ffmpeg is unavailable; keeping ${sourceExtension || '.webm'}.`);
481
+ }
482
+ }
483
+ else {
484
+ warnings.push(`Unsupported output extension ${destinationExtension}; keeping the WebM artifact instead.`);
485
+ }
261
486
  }
262
- catch {
263
- // Keep the artifact under the recording root if the override is rejected or fails.
487
+ catch (error) {
488
+ warnings.push(`The requested outputPath could not be used; the safe recording-root artifact was kept (${error instanceof Error ? error.message : 'unknown error'}).`);
264
489
  }
265
490
  }
266
491
  const stat = outputPath ? await fs.stat(outputPath).catch(() => undefined) : undefined;
492
+ const actualEncodedVideoResolution = await readWebmDimensions(videoPath);
493
+ if (!actualEncodedVideoResolution) {
494
+ warnings.push('The finalized WebM dimensions could not be verified in-process; actualEncodedVideoResolution is null.');
495
+ }
496
+ else if (actualEncodedVideoResolution.width !== state.effectiveResolution.width
497
+ || actualEncodedVideoResolution.height !== state.effectiveResolution.height) {
498
+ warnings.push(`Encoded geometry mismatch: configured ${state.effectiveResolution.width}x${state.effectiveResolution.height}, observed ${actualEncodedVideoResolution.width}x${actualEncodedVideoResolution.height}. Exact evidence is invalid.`);
499
+ }
500
+ const success = Boolean(outputPath && stat?.isFile() && stat.size > 0);
501
+ const artifacts = [
502
+ outputPath ? { kind: 'video', path: outputPath, mimeType: mimeForPath(outputPath) ?? 'video/webm', bytes: stat?.size ?? 0 } : undefined,
503
+ audioPath ? { kind: 'audio', path: audioPath, mimeType: 'audio/wav' } : undefined,
504
+ ].filter(Boolean);
267
505
  const result = {
268
- success: true,
506
+ success,
269
507
  recordingId: state.id,
270
508
  sessionId: state.id,
271
- status: 'stopped',
509
+ status: success ? 'stopped' : 'failed',
272
510
  durationMs,
511
+ requestedResolution: state.requestedResolution,
512
+ effectiveResolution: state.effectiveResolution,
513
+ ...geometryPayload(state, actualEncodedVideoResolution),
273
514
  videoPath,
274
515
  audioPath,
275
516
  mergedPath,
276
517
  outputPath,
277
518
  muxed,
278
519
  bytes: stat?.size ?? 0,
520
+ artifacts,
521
+ warnings,
522
+ ...(state.setupAttempts.length ? { attempts: state.setupAttempts } : {}),
279
523
  ...(reason ? { reason } : {}),
524
+ ...(!success ? {
525
+ error: {
526
+ code: 'RECORDING_FAILED',
527
+ message: 'Chromium did not produce a usable video after the normal save and raw-file recovery attempts.',
528
+ recovery: { suggestedResolution: '1280x720', audio: false },
529
+ },
530
+ } : {}),
531
+ nextAction: success
532
+ ? 'Use outputPath directly; the video is also returned as MCP media when it is small enough.'
533
+ : 'Retry browser_record_start with resolution="720p" and audio=false; warnings contains every attempted recovery.',
280
534
  };
281
535
  state.done.resolve(result);
536
+ rememberCompleted(result);
537
+ finalizingRecordings.delete(state.id);
282
538
  return result;
283
539
  }
284
- export async function startRecording(args, signal) {
285
- if (recordings.size >= MAX_CONCURRENT_RECORDINGS) {
540
+ export async function startRecording(args, signal, ownerScopeInput) {
541
+ const ownerScope = effectiveBrowserOwnerScope(ownerScopeInput);
542
+ if (recordings.size + recordingReservations.size >= MAX_CONCURRENT_RECORDINGS) {
286
543
  throw new BrowserMcpError('SESSION_LIMIT', `The recording limit (${MAX_CONCURRENT_RECORDINGS}) has been reached.`);
287
544
  }
288
545
  const defaults = defaultViewport();
289
- const width = typeof args.width === 'number' && Number.isFinite(args.width) ? Math.trunc(args.width) : defaults.width;
290
- const height = typeof args.height === 'number' && Number.isFinite(args.height) ? Math.trunc(args.height) : defaults.height;
291
- if (width < 320 || width > 3840 || height < 240 || height > 2160) {
292
- throw new BrowserMcpError('INVALID_ARGUMENT', 'width/height must be within the supported viewport range (320-3840 x 240-2160).');
293
- }
546
+ const maxWidth = integerEnv('FLUJO_BROWSER_RECORD_MAX_WIDTH', 1920, 640, 3840);
547
+ const maxHeight = integerEnv('FLUJO_BROWSER_RECORD_MAX_HEIGHT', 1080, 360, 2160);
548
+ const resolution = normalizeResolution(args.resolution, args.width, args.height, {
549
+ defaultValue: defaults,
550
+ minWidth: 320,
551
+ minHeight: 240,
552
+ maxWidth,
553
+ maxHeight,
554
+ even: true,
555
+ });
294
556
  const maxMs = integerEnv('FLUJO_BROWSER_RECORD_MAX_MS', DEFAULT_RECORD_MAX_MS, 1_000, 30 * 60_000);
295
557
  let durationMs;
296
558
  if (args.durationMs !== undefined) {
297
- if (typeof args.durationMs !== 'number' || !Number.isFinite(args.durationMs) || args.durationMs < 250) {
298
- throw new BrowserMcpError('INVALID_ARGUMENT', 'durationMs must be a finite number of at least 250ms.');
559
+ const parsed = typeof args.durationMs === 'number' ? args.durationMs : Number(args.durationMs);
560
+ if (!Number.isFinite(parsed)) {
561
+ resolution.warnings.push('durationMs was not numeric, so auto-stop was disabled.');
562
+ }
563
+ else {
564
+ durationMs = Math.min(maxMs, Math.max(250, Math.trunc(parsed)));
565
+ if (durationMs !== Math.trunc(parsed)) {
566
+ resolution.warnings.push(`durationMs was adjusted to ${durationMs}ms.`);
567
+ }
299
568
  }
300
- durationMs = Math.min(maxMs, Math.trunc(args.durationMs));
301
569
  }
302
- const audioRequested = args.audio !== false;
570
+ const audioRequested = !(args.audio === false || (typeof args.audio === 'string' && /^(0|false|no|off)$/i.test(args.audio.trim())));
303
571
  const outputPath = typeof args.outputPath === 'string' && args.outputPath.length > 0 ? args.outputPath : undefined;
304
- if (signal.aborted)
305
- throw new BrowserMcpError('CANCELLED', 'The browser request was cancelled.');
306
- const browser = await acquireBrowser();
307
572
  if (signal.aborted)
308
573
  throw new BrowserMcpError('CANCELLED', 'The browser request was cancelled.');
309
574
  const id = randomUUID();
310
- const videoDir = await ensureScratchDir(path.join('recordings', id));
311
- const context = await browser.newContext({
312
- viewport: { width, height },
313
- acceptDownloads: false,
314
- recordVideo: { dir: videoDir, size: { width, height } },
315
- });
316
- let page;
575
+ recordingReservations.add(id);
317
576
  try {
318
- page = await context.newPage();
319
- await page.goto('about:blank', { waitUntil: 'load' }).catch(() => undefined);
577
+ reserveSession(id, ownerScope, 'recording');
320
578
  }
321
579
  catch (error) {
322
- await context.close().catch(() => undefined);
580
+ recordingReservations.delete(id);
581
+ throw error;
582
+ }
583
+ let browser;
584
+ try {
585
+ browser = await acquireBrowser();
586
+ }
587
+ catch (error) {
588
+ recordingReservations.delete(id);
589
+ releaseSessionReservation(id, ownerScope);
323
590
  throw error;
324
591
  }
325
592
  if (signal.aborted) {
593
+ recordingReservations.delete(id);
594
+ releaseSessionReservation(id, ownerScope);
595
+ throw new BrowserMcpError('CANCELLED', 'The browser request was cancelled.');
596
+ }
597
+ const setupAttempts = [];
598
+ let context;
599
+ let page;
600
+ let videoDir = '';
601
+ let effectiveResolution;
602
+ let actualViewport;
603
+ const candidates = resolutionFallbacks(resolution.effective)
604
+ .filter(({ width, height }) => width <= maxWidth && height <= maxHeight);
605
+ for (const candidate of candidates) {
606
+ try {
607
+ videoDir = await ensureScratchDir(path.join('recordings', `${id}-${candidate.width}x${candidate.height}`));
608
+ context = await browser.newContext({
609
+ viewport: candidate,
610
+ deviceScaleFactor: 1,
611
+ acceptDownloads: false,
612
+ recordVideo: { dir: videoDir, size: candidate },
613
+ });
614
+ page = await context.newPage();
615
+ const observed = await page.evaluate('({ width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio })');
616
+ if (observed.width !== candidate.width || observed.height !== candidate.height || observed.dpr !== 1) {
617
+ throw new BrowserMcpError('BROWSER_UNAVAILABLE', `Recording geometry mismatch during startup: requested ${candidate.width}x${candidate.height} DPR 1, observed ${observed.width}x${observed.height} DPR ${observed.dpr}.`);
618
+ }
619
+ effectiveResolution = candidate;
620
+ actualViewport = { width: observed.width, height: observed.height };
621
+ break;
622
+ }
623
+ catch (error) {
624
+ setupAttempts.push(`${candidate.width}x${candidate.height}: ${error instanceof Error ? error.message : 'context creation failed'}`);
625
+ await context?.close().catch(() => undefined);
626
+ await fs.rm(videoDir, { recursive: true, force: true }).catch(() => undefined);
627
+ context = undefined;
628
+ page = undefined;
629
+ }
630
+ }
631
+ if (!context || !page || !effectiveResolution || !actualViewport) {
632
+ recordingReservations.delete(id);
633
+ releaseSessionReservation(id, ownerScope);
634
+ throw new BrowserMcpError('BROWSER_UNAVAILABLE', `Could not start video recording after safe resolution fallbacks. ${setupAttempts.join(' | ')}`);
635
+ }
636
+ if (setupAttempts.length > 0) {
637
+ resolution.warnings.push(`Recording recovered at ${effectiveResolution.width}x${effectiveResolution.height} after ${setupAttempts.length} failed resolution attempt(s).`);
638
+ }
639
+ if (signal.aborted) {
640
+ recordingReservations.delete(id);
641
+ releaseSessionReservation(id, ownerScope);
326
642
  await context.close().catch(() => undefined);
327
643
  throw new BrowserMcpError('CANCELLED', 'The browser request was cancelled.');
328
644
  }
645
+ const now = Date.now();
329
646
  const session = {
330
647
  id,
331
648
  mode: 'sandbox',
649
+ ownerScope,
650
+ purpose: 'recording',
651
+ lifecycleState: 'active',
652
+ viewportPolicy: 'fixed',
653
+ createdAt: now,
654
+ gatewayToken: randomUUID(),
655
+ recordingId: id,
656
+ configuredViewport: effectiveResolution,
657
+ configuredVideoResolution: effectiveResolution,
658
+ deviceScaleFactor: 1,
332
659
  context,
333
660
  page,
334
- touchedAt: Date.now(),
661
+ touchedAt: now,
335
662
  documentRequests: 0,
336
663
  navigationBlocked: false,
337
664
  blockedRequestCount: 0,
338
665
  };
339
- registerSession(session);
340
- await installRequestPolicy(context);
666
+ try {
667
+ registerSession(session, ownerScope);
668
+ }
669
+ catch (error) {
670
+ recordingReservations.delete(id);
671
+ releaseSessionReservation(id, ownerScope);
672
+ await context.close().catch(() => undefined);
673
+ throw error;
674
+ }
675
+ try {
676
+ await installRequestPolicy(context);
677
+ }
678
+ catch (error) {
679
+ recordingReservations.delete(id);
680
+ await closeSession(id, ownerScope).catch(() => undefined);
681
+ await fs.rm(videoDir, { recursive: true, force: true }).catch(() => undefined);
682
+ throw error;
683
+ }
341
684
  const state = {
342
685
  id,
686
+ ownerScope,
343
687
  context,
344
688
  page,
345
689
  videoDir,
@@ -349,31 +693,116 @@ export async function startRecording(args, signal) {
349
693
  audioBytes: 0,
350
694
  stopping: false,
351
695
  done: createDeferred(),
696
+ requestedResolution: resolution.requested,
697
+ effectiveResolution,
698
+ actualViewport,
699
+ deviceScaleFactor: 1,
700
+ warnings: [...resolution.warnings],
701
+ setupAttempts,
352
702
  };
703
+ session.onExpire = () => { void finalizeRecording(state); };
704
+ recordingReservations.delete(id);
353
705
  recordings.set(id, state);
354
706
  if (audioRequested) {
355
- await attachAudioTap(state).catch(() => undefined);
707
+ try {
708
+ await attachAudioTap(state);
709
+ }
710
+ catch (error) {
711
+ state.audio = false;
712
+ state.warnings.push(`Audio capture could not initialize; video recording continues without audio (${error instanceof Error ? error.message : 'unknown error'}).`);
713
+ }
356
714
  }
715
+ let sourceLoaded = false;
716
+ const hasSource = [args.source, args.url, args.html, args.filePath]
717
+ .some((value) => typeof value === 'string' && value.trim().length > 0);
718
+ if (hasSource) {
719
+ try {
720
+ const source = await resolveCaptureSource({
721
+ source: args.source,
722
+ url: typeof args.url === 'string' ? args.url : undefined,
723
+ html: typeof args.html === 'string' ? args.html : undefined,
724
+ filePath: typeof args.filePath === 'string' ? args.filePath : undefined,
725
+ });
726
+ state.warnings.push(...source.warnings);
727
+ const rawTimeout = typeof args.timeoutMs === 'number' ? args.timeoutMs : Number(args.timeoutMs);
728
+ const navigationTimeout = Number.isFinite(rawTimeout) ? Math.min(60_000, Math.max(1_000, Math.trunc(rawTimeout))) : 30_000;
729
+ await navigateCaptureSource(page, source, navigationTimeout);
730
+ sourceLoaded = true;
731
+ }
732
+ catch (error) {
733
+ state.warnings.push(`The initial source could not be loaded; the recording session remains open for browser_navigate (${error instanceof Error ? error.message : 'unknown error'}).`);
734
+ }
735
+ }
736
+ let autoStopAt;
357
737
  if (durationMs !== undefined) {
738
+ autoStopAt = Date.now() + durationMs;
358
739
  state.durationTimer = setTimeout(() => {
359
740
  void finalizeRecording(state, outputPath);
360
741
  }, durationMs);
361
742
  state.durationTimer.unref?.();
362
- return state.done.promise;
363
743
  }
364
- return { success: true, recordingId: id, sessionId: id, status: 'recording', startedAt: state.startedAt };
744
+ return {
745
+ success: true,
746
+ recordingId: id,
747
+ sessionId: id,
748
+ status: 'recording',
749
+ startedAt: state.startedAt,
750
+ requestedResolution: state.requestedResolution,
751
+ effectiveResolution: state.effectiveResolution,
752
+ ...geometryPayload(state),
753
+ audio: state.audio,
754
+ sourceLoaded,
755
+ ...(autoStopAt ? { autoStopAt, durationMs } : {}),
756
+ warnings: state.warnings,
757
+ ...(setupAttempts.length ? { attempts: setupAttempts } : {}),
758
+ nextAction: autoStopAt
759
+ ? 'Drive this session now; after autoStopAt, call browser_record_stop or browser_record_status with recordingId to retrieve the artifact.'
760
+ : 'Drive this session with browser_navigate/click/type, then call browser_record_stop with recordingId.',
761
+ };
365
762
  }
366
- export async function stopRecording(args) {
367
- const state = resolveRecordingState(args, false);
368
- if (!state)
369
- throw new BrowserMcpError('NOT_FOUND', 'No recording is running.');
763
+ export async function stopRecording(args, ownerScopeInput) {
764
+ const existing = completedRecording(args, ownerScopeInput);
765
+ const state = resolveRecordingState(args, true, ownerScopeInput);
766
+ if (!state) {
767
+ const finalizing = finalizingRecording(args, ownerScopeInput);
768
+ if (finalizing)
769
+ return finalizing.done.promise;
770
+ if (existing)
771
+ return existing;
772
+ throw new BrowserMcpError('NOT_FOUND', 'No matching recording is running or recently completed. Start one with browser_record_start.');
773
+ }
370
774
  const outputPath = typeof args.outputPath === 'string' && args.outputPath.length > 0 ? args.outputPath : undefined;
371
775
  return finalizeRecording(state, outputPath);
372
776
  }
373
- export function recordingStatus(args) {
374
- const state = resolveRecordingState(args, true);
375
- if (!state)
376
- return { success: true, recordingId: null, running: false };
777
+ export function recordingStatus(args, ownerScopeInput) {
778
+ const state = resolveRecordingState(args, true, ownerScopeInput);
779
+ if (!state) {
780
+ const finalizing = finalizingRecording(args, ownerScopeInput);
781
+ if (finalizing) {
782
+ return {
783
+ success: true,
784
+ recordingId: finalizing.id,
785
+ sessionId: finalizing.id,
786
+ running: false,
787
+ status: 'finalizing',
788
+ elapsedMs: Date.now() - finalizing.startedAt,
789
+ audioBytes: finalizing.audioBytes,
790
+ effectiveResolution: finalizing.effectiveResolution,
791
+ ...geometryPayload(finalizing),
792
+ warnings: finalizing.warnings,
793
+ nextAction: 'Call browser_record_stop with this recordingId; it will wait for finalization and return the artifact.',
794
+ };
795
+ }
796
+ const completed = completedRecording(args, ownerScopeInput);
797
+ if (completed)
798
+ return { ...completed, running: false };
799
+ return {
800
+ success: true,
801
+ recordingId: null,
802
+ running: false,
803
+ nextAction: 'Call browser_record_start; source and durationMs can make this a one-call setup.',
804
+ };
805
+ }
377
806
  return {
378
807
  success: true,
379
808
  recordingId: state.id,
@@ -381,11 +810,28 @@ export function recordingStatus(args) {
381
810
  running: !state.stopping,
382
811
  elapsedMs: Date.now() - state.startedAt,
383
812
  audioBytes: state.audioBytes,
813
+ effectiveResolution: state.effectiveResolution,
814
+ ...geometryPayload(state),
815
+ warnings: state.warnings,
816
+ nextAction: 'Continue driving the session, or call browser_record_stop to finalize and retrieve the video.',
384
817
  };
385
818
  }
819
+ export async function releaseRecordingsForOwner(ownerScopeInput) {
820
+ const ownerScope = effectiveBrowserOwnerScope(ownerScopeInput);
821
+ const states = [...recordings.values()].filter((state) => state.ownerScope === ownerScope);
822
+ await Promise.all(states.map((state) => finalizeRecording(state).catch(() => undefined)));
823
+ return states.length;
824
+ }
386
825
  /** Finalise (never silently drop) every in-flight recording during process shutdown. */
387
826
  export async function shutdownAllRecordings() {
388
827
  const states = [...recordings.values()];
389
- await Promise.all(states.map((state) => finalizeRecording(state).catch(() => undefined)));
828
+ await Promise.all([
829
+ ...states.map((state) => finalizeRecording(state).catch(() => undefined)),
830
+ ...[...finalizingRecordings.values()].map((state) => state.done.promise.catch(() => undefined)),
831
+ ]);
832
+ recordingReservations.clear();
833
+ finalizingRecordings.clear();
834
+ completedRecordings.clear();
835
+ latestCompletedIds.clear();
390
836
  }
391
837
  //# sourceMappingURL=recording.js.map