@remcp/runtime 0.2.108 → 0.2.109

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.109 — 2026-09-25
4
+
5
+ - Linux file reads and recursive permission changes retain descriptor-bound parent traversal for the full operation; proc-fd anchored captures remain readable without reopening the path lexically.
6
+ - Linux directory walks, listings, and search sessions pin allowed roots and parent descriptors, including bulk reads and edits, so ancestor swaps cannot redirect traversal.
7
+ - `record_screen` publishes from private staging and anchors Linux destination parents; XDG portal screenshots use no-follow copying, reject symlink source/destination paths, and no longer delete an untrusted portal-returned URI.
8
+ - Production CDP sessions reject dead sockets, recursively guard child targets and evict failed guarded new-tab sessions. Runtime tool names and schemas are unchanged.
9
+
3
10
  ## 0.2.108 — 2026-09-25
4
11
 
5
12
  - Standalone production runtimes keep browser control disabled unless explicitly enabled; the authenticated paired agent enables its owned local runtime. IP-literal production allowlisting and persistent CDP guards cover page and worker sessions, delayed navigation, redirects, and subresources.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.108",
3
+ "version": "0.2.109",
4
4
  "description": "First-party ReMCP local device runtime: filesystem, terminal, computer-use, browser, diagnostics and document tools over MCP.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -495,15 +495,16 @@ class CdpSession {
495
495
  }
496
496
  }
497
497
 
498
- async function installBrowserRequestGuard(session) {
498
+ async function installBrowserRequestGuard(session, { closeRootTarget = async () => {} } = {}) {
499
499
  const guardedSessions = new Set();
500
500
  const childSetup = new Map();
501
- const enableSession = async sessionId => {
501
+ const enableSession = async (sessionId, { autoAttach = false } = {}) => {
502
502
  if (guardedSessions.has(sessionId)) return;
503
503
  try {
504
504
  await session.send('Network.enable', {}, sessionId, 2000);
505
505
  await session.send('Network.setBlockedURLs', { urls:['ws://*', 'wss://*'] }, sessionId, 2000);
506
506
  await session.send('Fetch.enable', { patterns:[{ urlPattern:'*' }] }, sessionId, 2000);
507
+ if (autoAttach) await session.send('Target.setAutoAttach', { autoAttach:true, waitForDebuggerOnStart:true, flatten:true }, sessionId, 2000);
507
508
  guardedSessions.add(sessionId);
508
509
  } catch (error) {
509
510
  await session.send('Fetch.disable', {}, sessionId, 1000).catch(() => {});
@@ -516,11 +517,18 @@ async function installBrowserRequestGuard(session) {
516
517
  if (childSetup.has(childSessionId)) return childSetup.get(childSessionId);
517
518
  const setup = (async () => {
518
519
  try {
519
- await enableSession(childSessionId);
520
+ await enableSession(childSessionId, { autoAttach:process.env.NODE_ENV === 'production' });
520
521
  await session.send('Runtime.runIfWaitingForDebugger', {}, childSessionId, 2000);
521
- } catch {
522
+ } catch (error) {
522
523
  const targetId = params?.targetInfo?.targetId;
523
- if (targetId) await session.send('Target.closeTarget', { targetId }, '', 2000).catch(() => {});
524
+ let closed = false;
525
+ if (targetId) {
526
+ try {
527
+ await session.send('Target.closeTarget', { targetId }, '', 2000);
528
+ closed = true;
529
+ } catch {}
530
+ }
531
+ if (!closed) throw new Error(`Browser child guard failed: ${error instanceof Error ? error.message : String(error)}`);
524
532
  }
525
533
  })();
526
534
  childSetup.set(childSessionId, setup);
@@ -543,16 +551,31 @@ async function installBrowserRequestGuard(session) {
543
551
  })();
544
552
  });
545
553
  const removeAttached = session.observe('Target.attachedToTarget', params => {
546
- void guardChild(params);
554
+ void guardChild(params).catch(async () => {
555
+ await closeRootTarget().catch(() => {});
556
+ session.close();
557
+ });
547
558
  });
548
559
  await enableSession('');
549
560
  if (process.env.NODE_ENV === 'production') {
550
561
  await session.send('Target.setAutoAttach', { autoAttach:true, waitForDebuggerOnStart:true, flatten:true }, '', 2000);
551
- const existing = await session.send('Target.getTargets').catch(() => ({ targetInfos:[] }));
562
+ let existing;
563
+ try {
564
+ existing = await session.send('Target.getTargets', {}, '', 2000);
565
+ } catch (error) {
566
+ throw new Error(`Could not enumerate browser child targets safely: ${error instanceof Error ? error.message : String(error)}`);
567
+ }
552
568
  for (const targetInfo of existing.targetInfos || []) {
553
569
  if (!['worker', 'service_worker', 'shared_worker', 'iframe', 'webview'].includes(targetInfo.type)) continue;
554
- const attached = await session.send('Target.attachToTarget', { targetId:targetInfo.targetId, flatten:true }, '', 2000).catch(() => null);
570
+ const attached = await session.send('Target.attachToTarget', { targetId:targetInfo.targetId, flatten:true }, '', 2000).catch(async error => {
571
+ await session.send('Target.closeTarget', { targetId:targetInfo.targetId }, '', 2000).catch(() => {});
572
+ throw new Error(`Could not attach browser child target safely: ${error instanceof Error ? error.message : String(error)}`);
573
+ });
555
574
  if (attached?.sessionId) await guardChild({ sessionId:attached.sessionId, targetInfo });
575
+ else {
576
+ await session.send('Target.closeTarget', { targetId:targetInfo.targetId }, '', 2000).catch(() => {});
577
+ throw new Error('Browser child target did not expose a guard session');
578
+ }
556
579
  }
557
580
  }
558
581
  return () => {
@@ -562,6 +585,17 @@ async function installBrowserRequestGuard(session) {
562
585
  };
563
586
  }
564
587
 
588
+ async function closeBrowserTarget(endpoint, targetId) {
589
+ if (!targetId) return false;
590
+ try {
591
+ const base = endpoint instanceof URL ? endpoint.href : endpoint;
592
+ await requestJson(`/json/close/${encodeURIComponent(String(targetId))}`, base, { timeout_ms:2000 });
593
+ return true;
594
+ } catch {
595
+ return false;
596
+ }
597
+ }
598
+
565
599
  function productionSessionKey(endpoint, target) {
566
600
  return `${endpoint.origin}|${String(target.id)}`;
567
601
  }
@@ -570,15 +604,19 @@ function closeProductionSession(key, record) {
570
604
  if (!record) return;
571
605
  if (record.timer) clearTimeout(record.timer);
572
606
  record.removeGuard();
607
+ if (record.ownedTarget && record.targetId) {
608
+ record.session.send('Target.closeTarget', { targetId:record.targetId }, '', 2000).catch(() => {});
609
+ if (record.endpoint) void closeBrowserTarget(record.endpoint, record.targetId);
610
+ }
573
611
  record.session.send('Fetch.disable').catch(() => {});
574
612
  record.session.close();
575
613
  if (productionBrowserSessions.get(key) === record) productionBrowserSessions.delete(key);
576
614
  }
577
615
 
578
- function retainProductionSession(key, session, removeGuard) {
616
+ function retainProductionSession(key, session, removeGuard, targetId, endpoint, ownedTarget = false) {
579
617
  const existing = productionBrowserSessions.get(key);
580
618
  if (existing) closeProductionSession(key, existing);
581
- const record = { session, removeGuard, timer:null };
619
+ const record = { session, removeGuard, targetId, endpoint, ownedTarget, timer:null };
582
620
  productionBrowserSessions.set(key, record);
583
621
  const schedule = () => {
584
622
  if (record.timer) clearTimeout(record.timer);
@@ -595,26 +633,46 @@ async function createGuardedNewTab(url, args) {
595
633
  const created = await requestJson('/json/new', args.endpoint, { method:'PUT', timeout_ms:args.timeout_ms });
596
634
  if (!created?.id) throw new Error('Browser did not return a new page target');
597
635
  const endpoint = endpointUrl(args.endpoint);
598
- const target = { ...created, webSocketDebuggerUrl: validatedTargetWebSocketUrl(created, endpoint) };
599
- const session = await new CdpSession(target.webSocketDebuggerUrl, clamp(args.timeout_ms, 10_000, 100, 120_000)).open();
636
+ let target;
637
+ try {
638
+ target = { ...created, webSocketDebuggerUrl: validatedTargetWebSocketUrl(created, endpoint) };
639
+ } catch (error) {
640
+ await closeBrowserTarget(endpoint, created.id);
641
+ throw error;
642
+ }
643
+ let session;
644
+ try {
645
+ session = await new CdpSession(target.webSocketDebuggerUrl, clamp(args.timeout_ms, 10_000, 100, 120_000)).open();
646
+ } catch (error) {
647
+ await closeBrowserTarget(endpoint, target.id);
648
+ throw error;
649
+ }
600
650
  let removeGuard = () => {};
601
651
  let productionRecord = null;
602
652
  let productionKey = '';
653
+ let operationFailed = false;
603
654
  try {
604
- removeGuard = await installBrowserRequestGuard(session);
655
+ removeGuard = await installBrowserRequestGuard(session, { closeRootTarget:() => closeBrowserTarget(endpoint, target.id) });
605
656
  if (process.env.NODE_ENV === 'production') {
606
657
  productionKey = productionSessionKey(endpoint, target);
607
- productionRecord = retainProductionSession(productionKey, session, removeGuard);
658
+ productionRecord = retainProductionSession(productionKey, session, removeGuard, target.id, endpoint, true);
608
659
  }
609
660
  await session.send('Page.enable');
610
661
  const loaded = session.waitFor('Page.loadEventFired', clamp(args.timeout_ms, 15_000, 500, 120_000)).catch(() => null);
611
662
  const navigation = await session.send('Page.navigate', { url });
612
663
  if (loaded) await loaded;
613
- const finalUrl = await evaluate(session, 'location.href').catch(() => url);
664
+ const finalUrl = await evaluate(session, 'location.href');
614
665
  await assertResolvedPageUrl(finalUrl);
615
666
  return { target, navigation, finalUrl };
667
+ } catch (error) {
668
+ operationFailed = true;
669
+ if (!productionRecord) await closeBrowserTarget(endpoint, target.id);
670
+ throw error;
616
671
  } finally {
617
- if (productionRecord) productionRecord.schedule();
672
+ if (productionRecord) {
673
+ if (operationFailed) closeProductionSession(productionKey, productionRecord);
674
+ else productionRecord.schedule();
675
+ }
618
676
  else {
619
677
  removeGuard();
620
678
  await session.send('Fetch.disable').catch(() => {});
@@ -637,26 +695,35 @@ async function withTarget(args, callback) {
637
695
  let session = record?.session;
638
696
  let removeGuard = record?.removeGuard || (() => {});
639
697
  if (!session) {
640
- session = await new CdpSession(target.webSocketDebuggerUrl, clamp(args.timeout_ms, 10_000, 100, 120_000)).open();
641
698
  try {
699
+ session = await new CdpSession(target.webSocketDebuggerUrl, clamp(args.timeout_ms, 10_000, 100, 120_000)).open();
642
700
  removeGuard = await installBrowserRequestGuard(session);
643
- if (production) record = retainProductionSession(key, session, removeGuard);
701
+ if (production) record = retainProductionSession(key, session, removeGuard, target.id, endpoint);
644
702
  } catch (error) {
645
703
  removeGuard();
646
- await session.send('Fetch.disable').catch(() => {});
647
- session.close();
704
+ if (session) {
705
+ await session.send('Fetch.disable').catch(() => {});
706
+ session.close();
707
+ }
648
708
  throw error;
649
709
  }
650
710
  }
711
+ let operationFailed = false;
651
712
  try {
652
- const currentUrl = await evaluate(session, 'location.href').catch(() => target.url);
713
+ const currentUrl = await evaluate(session, 'location.href');
653
714
  await assertResolvedPageUrl(currentUrl);
654
715
  const result = await callback(session, target);
655
716
  const finalUrl = await evaluate(session, 'location.href').catch(() => { throw new Error('Browser page URL could not be verified after the operation'); });
656
717
  await assertResolvedPageUrl(finalUrl);
657
718
  return result;
719
+ } catch (error) {
720
+ operationFailed = true;
721
+ throw error;
658
722
  } finally {
659
- if (production && record) record.schedule();
723
+ if (production && record) {
724
+ if (operationFailed) closeProductionSession(key, record);
725
+ else record.schedule();
726
+ }
660
727
  else {
661
728
  removeGuard();
662
729
  await session.send('Fetch.disable').catch(() => {});
@@ -3,10 +3,12 @@ import net from 'node:net';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import process from 'node:process';
6
- import { existsSync } from 'node:fs';
7
- import { stat } from 'node:fs/promises';
6
+ import { randomUUID } from 'node:crypto';
7
+ import { constants, existsSync } from 'node:fs';
8
+ import { lstat, mkdtemp, open, rename, rm, stat, unlink } from 'node:fs/promises';
8
9
  import { assertAllowedCommand } from '../policy.mjs';
9
10
  import { isWaylandSession } from '../screenshot-portal.mjs';
11
+ import { openDirectoryPath } from '../tools/files.mjs';
10
12
  import { resolveSafePath, text } from '../util.mjs';
11
13
  import {
12
14
  clamp,
@@ -297,38 +299,97 @@ export function recordScreenAvailable({
297
299
  return false;
298
300
  }
299
301
 
302
+ async function openRecordingParent(destination) {
303
+ return openDirectoryPath(path.dirname(destination));
304
+ }
305
+
306
+ async function copyRecordingFile(source, destination) {
307
+ let input;
308
+ let output;
309
+ let parent;
310
+ let temporary;
311
+ try {
312
+ input = await open(source, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
313
+ const sourceInfo = await input.stat();
314
+ if (!sourceInfo.isFile()) throw new Error('Screen recording staging path is not a regular file');
315
+ parent = await openRecordingParent(destination);
316
+ const anchoredDestination = path.join(parent.anchor, path.basename(destination));
317
+ const existing = await lstat(anchoredDestination).catch(() => null);
318
+ if (existing?.isSymbolicLink()) throw new Error('Screen recording destination cannot be a symbolic link');
319
+ if (existing && !existing.isFile()) throw new Error('Screen recording destination is not a regular file');
320
+ temporary = path.join(parent.anchor, `.${path.basename(destination)}.${randomUUID()}.tmp`);
321
+ output = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
322
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
323
+ let offset = 0;
324
+ for (;;) {
325
+ const { bytesRead } = await input.read(buffer, 0, buffer.length, offset);
326
+ if (!bytesRead) break;
327
+ let written = 0;
328
+ while (written < bytesRead) {
329
+ const result = await output.write(buffer, written, bytesRead - written, offset + written);
330
+ if (!result.bytesWritten) throw new Error('Could not finish writing the screen recording');
331
+ written += result.bytesWritten;
332
+ }
333
+ offset += bytesRead;
334
+ }
335
+ await output.sync();
336
+ await output.close();
337
+ output = null;
338
+ await rename(temporary, anchoredDestination);
339
+ temporary = null;
340
+ } finally {
341
+ if (output) await output.close().catch(() => {});
342
+ if (temporary) await unlink(temporary).catch(() => {});
343
+ if (input) await input.close().catch(() => {});
344
+ if (parent) await parent.close().catch(() => {});
345
+ }
346
+ }
347
+
300
348
  export async function recordScreen(args) {
301
349
  const seconds = clamp(args.duration_seconds, 5, 1, 120);
302
350
  const fps = clamp(args.fps, 15, 1, 60);
303
- const destination = args.destination ? await resolveSafePath(args.destination, 'destination') : path.join(os.tmpdir(), `remcp-screen-${Date.now()}.mp4`);
351
+ const requestedDestination = args.destination ? await resolveSafePath(args.destination, 'destination') : '';
352
+ if (requestedDestination) {
353
+ const existing = await lstat(requestedDestination).catch(() => null);
354
+ if (existing?.isSymbolicLink()) throw new Error('Screen recording destination cannot be a symbolic link');
355
+ if (existing && !existing.isFile()) throw new Error('Screen recording destination is not a regular file');
356
+ }
357
+ const stagingDirectory = await mkdtemp(path.join(os.tmpdir(), 'remcp-screen-'));
358
+ const staged = path.join(stagingDirectory, `capture-${randomUUID()}.mp4`);
304
359
  const wayland = process.platform === 'linux' && isWaylandSession();
305
- if (wayland && commandExists('wf-recorder') && commandExists('timeout')) {
306
- const result = await runFile('timeout', ['--signal=INT', `${seconds}s`, 'wf-recorder', '-f',destination,'-r',String(fps),'-c','libx264'], { label:'screen recording', timeout:(seconds+10)*1000, allowFailure:true });
307
- if (![0, 124, 130].includes(Number(result.code))) throw new Error(result.stderr.trim() || `wf-recorder exited ${result.code}`);
308
- } else {
309
- const ffmpeg = resolveRecordScreenFfmpeg();
310
- if (wayland) {
311
- unavailable('Screen recording', 'wf-recorder and timeout are required on Wayland');
312
- }
313
- if (process.platform === 'linux' && !String(process.env.DISPLAY || '').trim()) {
314
- unavailable('Screen recording', 'an active X11 DISPLAY is required on Linux X11');
315
- }
316
- if (!ffmpeg) {
317
- if (process.platform === 'darwin') unavailable('Screen recording', 'ffmpeg is required on macOS');
318
- if (process.platform === 'win32') unavailable('Screen recording', 'ffmpeg is required on Windows');
319
- unavailable('Screen recording', 'ffmpeg is required on X11');
360
+ let keepStaging = false;
361
+ try {
362
+ if (wayland && commandExists('wf-recorder') && commandExists('timeout')) {
363
+ const result = await runFile('timeout', ['--signal=INT', `${seconds}s`, 'wf-recorder', '-f',staged,'-r',String(fps),'-c','libx264'], { label:'screen recording', timeout:(seconds+10)*1000, allowFailure:true });
364
+ if (![0, 124, 130].includes(Number(result.code))) throw new Error(result.stderr.trim() || `wf-recorder exited ${result.code}`);
365
+ } else {
366
+ const ffmpeg = resolveRecordScreenFfmpeg();
367
+ if (wayland) unavailable('Screen recording', 'wf-recorder and timeout are required on Wayland');
368
+ if (process.platform === 'linux' && !String(process.env.DISPLAY || '').trim()) unavailable('Screen recording', 'an active X11 DISPLAY is required on Linux X11');
369
+ if (!ffmpeg) {
370
+ if (process.platform === 'darwin') unavailable('Screen recording', 'ffmpeg is required on macOS');
371
+ if (process.platform === 'win32') unavailable('Screen recording', 'ffmpeg is required on Windows');
372
+ unavailable('Screen recording', 'ffmpeg is required on X11');
373
+ }
374
+ let argv;
375
+ if (process.platform === 'win32') argv=['-y','-f','gdigrab','-framerate',String(fps),'-i','desktop','-t',String(seconds),'-pix_fmt','yuv420p',staged];
376
+ else if (process.platform === 'darwin') {
377
+ const input = await avfoundationScreenInput(ffmpeg);
378
+ argv=['-y','-f','avfoundation','-framerate',String(fps),'-i',`${input}:none`,'-t',String(seconds),'-pix_fmt','yuv420p',staged];
379
+ } else argv=['-y','-f','x11grab','-framerate',String(fps),'-i',process.env.DISPLAY,'-t',String(seconds),'-pix_fmt','yuv420p',staged];
380
+ await runFile(ffmpeg, argv, { label:'screen recording', timeout:(seconds+20)*1000, maxBuffer:8*1024*1024 });
320
381
  }
321
- let argv;
322
- if (process.platform === 'win32') argv=['-y','-f','gdigrab','-framerate',String(fps),'-i','desktop','-t',String(seconds),'-pix_fmt','yuv420p',destination];
323
- else if (process.platform === 'darwin') {
324
- const input = await avfoundationScreenInput(ffmpeg);
325
- argv=['-y','-f','avfoundation','-framerate',String(fps),'-i',`${input}:none`,'-t',String(seconds),'-pix_fmt','yuv420p',destination];
382
+ if (requestedDestination) {
383
+ await copyRecordingFile(staged, requestedDestination);
384
+ await rm(stagingDirectory, { recursive:true, force:true });
326
385
  }
327
- else argv=['-y','-f','x11grab','-framerate',String(fps),'-i',process.env.DISPLAY,'-t',String(seconds),'-pix_fmt','yuv420p',destination];
328
- await runFile(ffmpeg, argv, { label:'screen recording', timeout:(seconds+20)*1000, maxBuffer:8*1024*1024 });
386
+ const output = requestedDestination || staged;
387
+ const info = await stat(output);
388
+ keepStaging = !requestedDestination;
389
+ return jsonResult({ path:output, bytes:info.size, duration_seconds:seconds, format:path.extname(output).slice(1) || 'mp4' });
390
+ } finally {
391
+ if (!keepStaging) await rm(stagingDirectory, { recursive:true, force:true }).catch(() => {});
329
392
  }
330
- const info = await stat(destination);
331
- return jsonResult({ path:destination, bytes:info.size, duration_seconds:seconds, format:path.extname(destination).slice(1) || 'mp4' });
332
393
  }
333
394
 
334
395
  export const diagnosticHandlers = {
@@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto';
4
4
  import { constants } from 'node:fs';
5
5
  import { copyFile, link, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
6
6
  import { readDocxText, readPdfText } from '../documents.mjs';
7
+ import { openDirectoryPath } from '../tools/files.mjs';
7
8
  import { resolveSafePath, text } from '../util.mjs';
8
9
  import {
9
10
  clamp,
@@ -30,7 +31,6 @@ const MAX_OOXML_ENTRIES = 10_000;
30
31
  const MAX_OOXML_UNCOMPRESSED_BYTES = 512 * 1024 * 1024;
31
32
  const MAX_DOCUMENT_BYTES = 128 * 1024 * 1024;
32
33
  const DOCUMENT_READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0);
33
- const DOCUMENT_DIRECTORY_FLAGS = constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0);
34
34
 
35
35
  function descriptorAnchorsAvailable() {
36
36
  return process.platform === 'linux' && Boolean(constants.O_NOFOLLOW) && Boolean(constants.O_DIRECTORY);
@@ -48,93 +48,29 @@ async function openDocumentSource(filePath) {
48
48
  throw error;
49
49
  }
50
50
  }
51
- const root = path.parse(filePath).root;
52
- const parts = filePath.slice(root.length).split(path.sep).filter(Boolean);
53
- if (!parts.length) throw new Error('Document path must be a regular file');
54
- const directories = [];
55
- let current = root;
56
- const closeDirectories = async () => {
57
- for (const handle of directories.reverse()) await handle.close().catch(() => {});
58
- };
51
+ const parent = await openDirectoryPath(path.dirname(filePath));
52
+ let handle;
59
53
  try {
60
- for (const part of parts.slice(0, -1)) {
61
- const handle = await open(path.join(current, part), DOCUMENT_DIRECTORY_FLAGS);
62
- const info = await handle.stat();
63
- if (!info.isDirectory()) {
64
- await handle.close().catch(() => {});
65
- throw new Error('Document path parent is not a directory');
66
- }
67
- directories.push(handle);
68
- current = `/proc/self/fd/${handle.fd}`;
69
- }
70
- const handle = await open(path.join(current, parts.at(-1)), DOCUMENT_READ_FLAGS);
54
+ handle = await open(path.join(parent.anchor, path.basename(filePath)), DOCUMENT_READ_FLAGS);
55
+ const info = await handle.stat();
56
+ if (!info.isFile()) throw new Error('Document path must be a regular file');
71
57
  return {
72
58
  handle,
73
59
  close: async () => {
74
60
  try { await handle.close(); }
75
- finally { await closeDirectories(); }
61
+ finally { await parent.close().catch(() => {}); }
76
62
  },
77
63
  };
78
64
  } catch (error) {
79
- await closeDirectories();
65
+ if (handle) await handle.close().catch(() => {});
66
+ await parent.close().catch(() => {});
80
67
  throw error;
81
68
  }
82
69
  }
83
70
 
84
71
  async function openDocumentDirectoryPath(directory, { create = false } = {}) {
85
- if (!descriptorAnchorsAvailable()) {
86
- if (create) await mkdir(directory, { recursive:true, mode:0o700 });
87
- const handle = await open(directory, DOCUMENT_DIRECTORY_FLAGS);
88
- try {
89
- const info = await handle.stat();
90
- if (!info.isDirectory()) throw new Error('Document path parent is not a directory');
91
- return { handle, anchor:directory, descriptorBound:false, close: () => handle.close() };
92
- } catch (error) {
93
- await handle.close().catch(() => {});
94
- throw error;
95
- }
96
- }
97
- const root = path.parse(directory).root;
98
- const parts = directory.slice(root.length).split(path.sep).filter(Boolean);
99
- if (!parts.length) {
100
- const handle = await open(root, DOCUMENT_DIRECTORY_FLAGS);
101
- return { anchor: `/proc/self/fd/${handle.fd}`, close: () => handle.close() };
102
- }
103
- const directories = [];
104
- let current = root;
105
- const closeDirectories = async () => {
106
- for (const handle of directories.reverse()) await handle.close().catch(() => {});
107
- };
108
- try {
109
- for (const part of parts) {
110
- const candidate = path.join(current, part);
111
- if (create) {
112
- await mkdir(candidate, { mode:0o700 }).catch(error => {
113
- if (error?.code !== 'EEXIST') throw error;
114
- });
115
- }
116
- const handle = await open(candidate, DOCUMENT_DIRECTORY_FLAGS);
117
- const info = await handle.stat();
118
- if (!info.isDirectory()) {
119
- await handle.close().catch(() => {});
120
- throw new Error('Document path parent is not a directory');
121
- }
122
- directories.push(handle);
123
- current = `/proc/self/fd/${handle.fd}`;
124
- }
125
- const handle = directories.pop();
126
- if (!handle) throw new Error('Document directory path is empty');
127
- return {
128
- anchor: current,
129
- close: async () => {
130
- try { await handle.close(); }
131
- finally { await closeDirectories(); }
132
- },
133
- };
134
- } catch (error) {
135
- await closeDirectories();
136
- throw error;
137
- }
72
+ const opened = await openDirectoryPath(directory, { create });
73
+ return { ...opened, descriptorBound:opened.anchor !== directory || process.platform === 'linux' };
138
74
  }
139
75
 
140
76
  async function createAnchoredTempDirectory(parent, prefix) {
@@ -1,5 +1,8 @@
1
1
  import process from 'node:process';
2
- import { copyFile, rm } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { constants } from 'node:fs';
5
+ import { lstat, open, rename, unlink } from 'node:fs/promises';
3
6
  import { fileURLToPath } from 'node:url';
4
7
 
5
8
  const PORTAL_NAME = 'org.freedesktop.portal.Desktop';
@@ -133,6 +136,115 @@ function createResponseCollector(bus, dbus, initialPath, timeoutMs) {
133
136
  };
134
137
  }
135
138
 
139
+ async function openDirectoryNoFollow(directory) {
140
+ if (process.platform !== 'linux' || !constants.O_NOFOLLOW || !constants.O_DIRECTORY) {
141
+ const info = await lstat(directory);
142
+ if (info.isSymbolicLink() || !info.isDirectory()) throw new Error('screenshot path parent is not a safe directory');
143
+ return { anchor:directory, close:async() => {} };
144
+ }
145
+ const descriptor = directory.match(/^(\/proc\/(?:self|\d+)\/fd\/\d+)(?:\/(.*))?$/);
146
+ const directories = [];
147
+ let current;
148
+ try {
149
+ if (descriptor) {
150
+ const base = await open(`${descriptor[1]}/.`, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
151
+ directories.push(base);
152
+ current = `/proc/self/fd/${base.fd}`;
153
+ } else {
154
+ const root = path.parse(directory).root;
155
+ current = root;
156
+ const parts = directory.slice(root.length).split(path.sep).filter(Boolean);
157
+ if (!parts.length) {
158
+ const handle = await open(root, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
159
+ return { anchor:`/proc/self/fd/${handle.fd}`, close:() => handle.close() };
160
+ }
161
+ for (const part of parts) {
162
+ const handle = await open(path.join(current, part), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
163
+ const info = await handle.stat();
164
+ if (!info.isDirectory()) {
165
+ await handle.close().catch(() => {});
166
+ throw new Error('screenshot path parent is not a safe directory');
167
+ }
168
+ directories.push(handle);
169
+ current = `/proc/self/fd/${handle.fd}`;
170
+ }
171
+ const handle = directories.pop();
172
+ return {
173
+ anchor:current,
174
+ close:async() => {
175
+ try { await handle.close(); }
176
+ finally { for (const entry of directories.reverse()) await entry.close().catch(() => {}); }
177
+ },
178
+ };
179
+ }
180
+ for (const part of (descriptor?.[2] || '').split(path.sep).filter(Boolean)) {
181
+ const handle = await open(path.join(current, part), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
182
+ const info = await handle.stat();
183
+ if (!info.isDirectory()) {
184
+ await handle.close().catch(() => {});
185
+ throw new Error('screenshot path parent is not a safe directory');
186
+ }
187
+ directories.push(handle);
188
+ current = `/proc/self/fd/${handle.fd}`;
189
+ }
190
+ const handle = directories.pop();
191
+ return {
192
+ anchor:current,
193
+ close:async() => {
194
+ try { await handle.close(); }
195
+ finally { for (const entry of directories.reverse()) await entry.close().catch(() => {}); }
196
+ },
197
+ };
198
+ } catch (error) {
199
+ for (const entry of directories.reverse()) await entry.close().catch(() => {});
200
+ throw error;
201
+ }
202
+ }
203
+
204
+ async function copyPortalFile(source, destination) {
205
+ let sourceParent;
206
+ let destinationParent;
207
+ let input;
208
+ let output;
209
+ let temporary;
210
+ try {
211
+ sourceParent = await openDirectoryNoFollow(path.dirname(source));
212
+ destinationParent = await openDirectoryNoFollow(path.dirname(destination));
213
+ input = await open(path.join(sourceParent.anchor, path.basename(source)), constants.O_RDONLY | constants.O_NOFOLLOW);
214
+ const info = await input.stat();
215
+ if (!info.isFile()) throw new Error('desktop portal returned a non-regular screenshot file');
216
+ const destinationPath = path.join(destinationParent.anchor, path.basename(destination));
217
+ const existing = await lstat(destinationPath).catch(() => null);
218
+ if (existing?.isSymbolicLink()) throw new Error('desktop portal screenshot destination cannot be a symbolic link');
219
+ temporary = path.join(destinationParent.anchor, `.${path.basename(destination)}.${randomUUID()}.tmp`);
220
+ output = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
221
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
222
+ let offset = 0;
223
+ for (;;) {
224
+ const { bytesRead } = await input.read(buffer, 0, buffer.length, offset);
225
+ if (!bytesRead) break;
226
+ let written = 0;
227
+ while (written < bytesRead) {
228
+ const result = await output.write(buffer, written, bytesRead - written, offset + written);
229
+ if (!result.bytesWritten) throw new Error('Could not finish copying the desktop portal screenshot');
230
+ written += result.bytesWritten;
231
+ }
232
+ offset += bytesRead;
233
+ }
234
+ await output.sync();
235
+ await output.close();
236
+ output = null;
237
+ await rename(temporary, destinationPath);
238
+ temporary = null;
239
+ } finally {
240
+ if (output) await output.close().catch(() => {});
241
+ if (temporary) await unlink(temporary).catch(() => {});
242
+ if (input) await input.close().catch(() => {});
243
+ if (destinationParent) await destinationParent.close().catch(() => {});
244
+ if (sourceParent) await sourceParent.close().catch(() => {});
245
+ }
246
+ }
247
+
136
248
  export async function capturePortalScreenshot(destination, {
137
249
  timeoutMs = DEFAULT_TIMEOUT_MS,
138
250
  loadDbus = () => import('@jellybrick/dbus-next'),
@@ -184,12 +296,9 @@ export async function capturePortalScreenshot(destination, {
184
296
  const uri = results?.uri?.value;
185
297
  if (typeof uri !== 'string') throw new Error('desktop portal returned no screenshot URI');
186
298
  const sourceUrl = new URL(uri);
187
- if (sourceUrl.protocol !== 'file:') throw new Error(`desktop portal returned unsupported URI scheme ${sourceUrl.protocol}`);
299
+ if (sourceUrl.protocol !== 'file:' || (sourceUrl.hostname && sourceUrl.hostname !== 'localhost')) throw new Error('desktop portal returned an unsupported file URI');
188
300
  const source = fileURLToPath(sourceUrl);
189
- await copyFile(source, destination);
190
- // GNOME commonly writes the portal result to ~/Pictures. The runtime owns this capture, so remove
191
- // that portal-created intermediate after copying it to the caller-selected temporary location.
192
- if (source !== destination) await rm(source, { force: true }).catch(() => {});
301
+ await copyPortalFile(source, destination);
193
302
  return destination;
194
303
  } catch (error) {
195
304
  const wrapped = new Error(`xdg-desktop-portal: ${cleanError(error)}`);
package/src/sessions.mjs CHANGED
@@ -190,6 +190,7 @@ export function createSearchSession({ type, pattern, path, filePattern }) {
190
190
  error: null,
191
191
  warning: null,
192
192
  cancel: null,
193
+ release: null,
193
194
  waiters: [],
194
195
  lastActivityAt: Date.now(),
195
196
  };
@@ -209,6 +210,9 @@ export function finishSearchSession(session, status, error = null) {
209
210
  session.error = error ? String(error) : null;
210
211
  session.finishedAt = Date.now();
211
212
  session.cancel = null;
213
+ const release = session.release;
214
+ session.release = null;
215
+ if (release) void Promise.resolve(release()).catch(() => {});
212
216
  notify(session);
213
217
  }
214
218