@remcp/runtime 0.2.107 → 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.
@@ -2,8 +2,9 @@ import path from 'node:path';
2
2
  import process from 'node:process';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { constants } from 'node:fs';
5
- import { copyFile, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
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,
@@ -29,28 +30,118 @@ function xmlUnescape(value) {
29
30
  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;
33
+ const DOCUMENT_READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0);
34
+
35
+ function descriptorAnchorsAvailable() {
36
+ return process.platform === 'linux' && Boolean(constants.O_NOFOLLOW) && Boolean(constants.O_DIRECTORY);
37
+ }
38
+
39
+ async function openDocumentSource(filePath) {
40
+ if (!descriptorAnchorsAvailable()) {
41
+ const handle = await open(filePath, DOCUMENT_READ_FLAGS);
42
+ try {
43
+ const info = await handle.stat();
44
+ if (!info.isFile()) throw new Error('Document path must be a regular file');
45
+ return { handle, close: () => handle.close() };
46
+ } catch (error) {
47
+ await handle.close().catch(() => {});
48
+ throw error;
49
+ }
50
+ }
51
+ const parent = await openDirectoryPath(path.dirname(filePath));
52
+ let handle;
53
+ try {
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');
57
+ return {
58
+ handle,
59
+ close: async () => {
60
+ try { await handle.close(); }
61
+ finally { await parent.close().catch(() => {}); }
62
+ },
63
+ };
64
+ } catch (error) {
65
+ if (handle) await handle.close().catch(() => {});
66
+ await parent.close().catch(() => {});
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ async function openDocumentDirectoryPath(directory, { create = false } = {}) {
72
+ const opened = await openDirectoryPath(directory, { create });
73
+ return { ...opened, descriptorBound:opened.anchor !== directory || process.platform === 'linux' };
74
+ }
75
+
76
+ async function createAnchoredTempDirectory(parent, prefix) {
77
+ const directory = await openDocumentDirectoryPath(parent, { create:true });
78
+ try {
79
+ const childAnchor = directory.descriptorBound === false
80
+ ? directory.anchor
81
+ : directory.anchor.replace('/proc/self/fd/', `/proc/${process.pid}/fd/`);
82
+ const directoryPath = await mkdtemp(path.join(childAnchor, prefix));
83
+ return { path:directoryPath, close:directory.close };
84
+ } catch (error) {
85
+ await directory.close().catch(() => {});
86
+ throw error;
87
+ }
88
+ }
89
+
90
+ async function readBounded(handle, maximum, label) {
91
+ const chunks = [];
92
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
93
+ let offset = 0;
94
+ for (;;) {
95
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
96
+ if (!bytesRead) break;
97
+ if (!Number.isSafeInteger(offset + bytesRead) || offset + bytesRead > maximum) {
98
+ throw new Error(`${label} exceeds the ${maximum}-byte safety limit`);
99
+ }
100
+ chunks.push(Buffer.from(buffer.subarray(0, bytesRead)));
101
+ offset += bytesRead;
102
+ }
103
+ return Buffer.concat(chunks, offset);
104
+ }
32
105
 
33
106
  async function readDocumentFile(filePath) {
34
- const info = await lstat(filePath);
35
- if (!info.isFile()) throw new Error('Document path must be a regular file');
36
- if (info.size > MAX_DOCUMENT_BYTES) throw new Error(`Document is too large (maximum ${MAX_DOCUMENT_BYTES} bytes)`);
37
- return readFile(filePath);
107
+ const opened = await openDocumentSource(filePath);
108
+ try {
109
+ const info = await opened.handle.stat();
110
+ if (!info.isFile()) throw new Error('Document path must be a regular file');
111
+ if (info.size > MAX_DOCUMENT_BYTES) throw new Error(`Document is too large (maximum ${MAX_DOCUMENT_BYTES} bytes)`);
112
+ return await readBounded(opened.handle, MAX_DOCUMENT_BYTES, 'Document');
113
+ } finally {
114
+ await opened.close();
115
+ }
38
116
  }
39
117
 
40
118
  async function copyRegularNoFollow(source, destination) {
41
- const sourceHandle = await open(source, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
119
+ const opened = await openDocumentSource(source);
42
120
  try {
43
- const info = await sourceHandle.stat();
121
+ const info = await opened.handle.stat();
44
122
  if (!info.isFile()) throw new Error('Document path must be a regular file');
45
123
  if (info.size > MAX_DOCUMENT_BYTES) throw new Error(`Document is too large (maximum ${MAX_DOCUMENT_BYTES} bytes)`);
46
124
  const destinationHandle = await open(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
47
125
  try {
48
- await destinationHandle.writeFile(await sourceHandle.readFile());
126
+ const data = await readBounded(opened.handle, MAX_DOCUMENT_BYTES, 'Document');
127
+ await destinationHandle.writeFile(data);
49
128
  } finally {
50
129
  await destinationHandle.close();
51
130
  }
52
131
  } finally {
53
- await sourceHandle.close();
132
+ await opened.close();
133
+ }
134
+ }
135
+
136
+ async function snapshotDocumentFile(filePath) {
137
+ const dir = await tempDir('remcp-pdf-input-');
138
+ const snapshot = path.join(dir, `input-${randomUUID()}.pdf`);
139
+ try {
140
+ await copyRegularNoFollow(filePath, snapshot);
141
+ return { dir, path: snapshot };
142
+ } catch (error) {
143
+ await removeTemp(dir);
144
+ throw error;
54
145
  }
55
146
  }
56
147
 
@@ -92,6 +183,8 @@ async function validateZipArchive(source) {
92
183
  if (entries.length > MAX_OOXML_ENTRIES) throw new Error(`OOXML archive has too many entries (${entries.length}; max ${MAX_OOXML_ENTRIES})`);
93
184
  const unsafe = entries.find(name => !safeArchiveEntry(name));
94
185
  if (unsafe) throw new Error(`OOXML archive contains an unsafe path: ${unsafe}`);
186
+ const details = await runFile('unzip', ['-Z','-v',source], { label:'inspect OOXML entry types', timeout:30_000, maxBuffer:16 * 1024 * 1024 });
187
+ if (/symbolic link|Unix file attributes \([^)]*\b12\d{4}/i.test(details.stdout)) throw new Error('OOXML archive contains a symbolic link');
95
188
  const totals = await runFile('unzip', ['-Z','-t',source], { label:'inspect OOXML archive size', timeout:30_000 });
96
189
  const uncompressed = Number(totals.stdout.match(/(?:^|,\s)([0-9]+) bytes uncompressed/)?.[1] || 0);
97
190
  if (uncompressed > MAX_OOXML_UNCOMPRESSED_BYTES) throw new Error(`OOXML archive expands to ${uncompressed} bytes; max ${MAX_OOXML_UNCOMPRESSED_BYTES}`);
@@ -126,23 +219,35 @@ async function createZip(sourceDir, destination) {
126
219
  await runFile('zip', ['-qr',destination,'.'], { cwd:sourceDir, label:'create OOXML archive', timeout:30_000 });
127
220
  }
128
221
 
129
- async function commitDocumentOutput(staged, destination) {
222
+ async function commitDocumentOutput(staged, destination, { noReplace = false } = {}) {
223
+ const parentDirectory = await openDocumentDirectoryPath(path.dirname(destination));
224
+ const parentAnchor = parentDirectory.anchor;
225
+ const destinationPath = path.join(parentAnchor, path.basename(destination));
130
226
  try {
131
- await rename(staged, destination);
132
- return;
133
- } catch (error) {
134
- if (!['EEXIST', 'EPERM', 'ENOTEMPTY'].includes(error?.code)) throw error;
135
- }
136
- const backup = `${destination}.remcp-old-${randomUUID()}`;
137
- let moved = false;
138
- try {
139
- await rename(destination, backup);
140
- moved = true;
141
- await rename(staged, destination);
142
- await rm(backup, { force: true }).catch(() => {});
143
- } catch (error) {
144
- if (moved && !await lstat(destination).catch(() => null)) await rename(backup, destination).catch(() => {});
145
- throw error;
227
+ if (noReplace) {
228
+ await link(staged, destinationPath);
229
+ await unlink(staged);
230
+ return;
231
+ }
232
+ try {
233
+ await rename(staged, destinationPath);
234
+ return;
235
+ } catch (error) {
236
+ if (!['EEXIST', 'EPERM', 'ENOTEMPTY'].includes(error?.code)) throw error;
237
+ }
238
+ const backup = path.join(parentAnchor, `.${path.basename(destination)}.remcp-old-${randomUUID()}`);
239
+ let moved = false;
240
+ try {
241
+ await rename(destinationPath, backup);
242
+ moved = true;
243
+ await rename(staged, destinationPath);
244
+ await rm(backup, { force: true }).catch(() => {});
245
+ } catch (error) {
246
+ if (moved && !await lstat(destinationPath).catch(() => null)) await rename(backup, destinationPath).catch(() => {});
247
+ throw error;
248
+ }
249
+ } finally {
250
+ await parentDirectory.close().catch(() => {});
146
251
  }
147
252
  }
148
253
 
@@ -229,9 +334,10 @@ function readWorksheetCells(xml, sharedStrings, maxCells) {
229
334
  }
230
335
 
231
336
  async function readXlsx(filePath, args) {
337
+ const source = await snapshotDocumentFile(filePath);
232
338
  const dir = await tempDir('remcp-xlsx-read-');
233
339
  try {
234
- await extractZip(filePath, dir);
340
+ await extractZip(source.path, dir);
235
341
  const workbook = await readFile(path.join(dir,'xl','workbook.xml'),'utf8');
236
342
  const rels = await readFile(path.join(dir,'xl','_rels','workbook.xml.rels'),'utf8');
237
343
  const sheet = workbookSheetPath(workbook, rels, optionalString(args.sheet));
@@ -240,21 +346,31 @@ async function readXlsx(filePath, args) {
240
346
  try { shared = readSharedStrings(await readFile(path.join(dir,'xl','sharedStrings.xml'),'utf8')); } catch {}
241
347
  const cells = readWorksheetCells(worksheet, shared, clamp(args.max_cells,5000,1,50_000));
242
348
  return { kind:'xlsx', sheet:sheet.name, count:cells.length, cells };
243
- } finally { await removeTemp(dir); }
349
+ } finally {
350
+ await removeTemp(source.dir);
351
+ await removeTemp(dir);
352
+ }
244
353
  }
245
354
 
246
- async function readPdfDocument(filePath, data) {
355
+ async function readPdfDocument(data) {
247
356
  try {
248
357
  return readPdfText(data);
249
358
  } catch (builtinError) {
250
359
  if (commandExists('pdftotext')) {
251
- const extracted = await runFile('pdftotext', ['-enc','UTF-8',filePath,'-'], {
252
- label:'PDF text extraction',
253
- timeout:60_000,
254
- allowFailure:true,
255
- });
256
- const fallbackText = String(extracted.stdout || '').replace(/\f/g, '\n').trim();
257
- if (extracted.code === 0 && fallbackText) return fallbackText;
360
+ const dir = await tempDir('remcp-pdf-read-');
361
+ try {
362
+ const snapshot = path.join(dir, 'input.pdf');
363
+ await writeFile(snapshot, data, { mode: 0o600 });
364
+ const extracted = await runFile('pdftotext', ['-enc','UTF-8',snapshot,'-'], {
365
+ label:'PDF text extraction',
366
+ timeout:60_000,
367
+ allowFailure:true,
368
+ });
369
+ const fallbackText = String(extracted.stdout || '').replace(/\f/g, '\n').trim();
370
+ if (extracted.code === 0 && fallbackText) return fallbackText;
371
+ } finally {
372
+ await removeTemp(dir);
373
+ }
258
374
  }
259
375
  throw builtinError;
260
376
  }
@@ -265,7 +381,7 @@ export async function readDocument(args) {
265
381
  const lower = filePath.toLowerCase();
266
382
  const data = await readDocumentFile(filePath);
267
383
  if (lower.endsWith('.docx')) return text(readDocxText(data));
268
- if (lower.endsWith('.pdf')) return text(await readPdfDocument(filePath,data));
384
+ if (lower.endsWith('.pdf')) return text(await readPdfDocument(data));
269
385
  if (lower.endsWith('.xlsx')) return jsonResult(await readXlsx(filePath,args));
270
386
  if (/\.(txt|md|csv|json|xml|yaml|yml)$/i.test(lower)) return text(data.toString('utf8'));
271
387
  throw new Error('read_document supports PDF, DOCX, XLSX, TXT, Markdown, CSV, JSON, XML, YAML');
@@ -351,10 +467,11 @@ export async function editSpreadsheet(args) {
351
467
  const output = args.output ? await resolveSafePath(args.output,'output') : filePath;
352
468
  const dir = await tempDir(create ? 'remcp-xlsx-create-' : 'remcp-xlsx-edit-');
353
469
  let outputStage = '';
470
+ let outputStageDirectory = null;
354
471
  let tempOut = '';
355
472
  try {
356
- await mkdir(path.dirname(output), { recursive:true });
357
- outputStage = await mkdtemp(path.join(path.dirname(output), '.remcp-xlsx-output-'));
473
+ outputStageDirectory = await createAnchoredTempDirectory(path.dirname(output), '.remcp-xlsx-output-');
474
+ outputStage = outputStageDirectory.path;
358
475
  tempOut = path.join(outputStage, path.basename(output));
359
476
  if (create) {
360
477
  await ensureCreateTargetIsNew(filePath);
@@ -370,11 +487,13 @@ export async function editSpreadsheet(args) {
370
487
  for (const edit of edits) xml = setWorksheetCell(xml,String(edit.cell || '').toUpperCase(),edit.value,edit.formula);
371
488
  await writeFile(worksheetPath,xml,'utf8');
372
489
  await createZip(dir,tempOut);
373
- await commitDocumentOutput(tempOut,output);
374
- return jsonResult({ path:output, sheet:sheet.name, edited_cells:edits.length, bytes:(await stat(output)).size, created:create });
490
+ await commitDocumentOutput(tempOut,output,{ noReplace:create });
491
+ return jsonResult({ path:output, sheet:sheet.name, edited_cells:edits.length, bytes:(await stat(output)).size, created:create });
492
+
375
493
  } finally {
376
494
  if (tempOut) await rm(tempOut,{force:true}).catch(() => {});
377
495
  if (outputStage) await rm(outputStage,{recursive:true, force:true}).catch(() => {});
496
+ if (outputStageDirectory) await outputStageDirectory.close().catch(() => {});
378
497
  await removeTemp(dir);
379
498
  }
380
499
  }
@@ -436,11 +555,12 @@ export async function editDocument(args) {
436
555
  const output = args.output ? await resolveSafePath(args.output,'output') : filePath;
437
556
  const dir = await tempDir(create ? 'remcp-docx-create-' : 'remcp-docx-edit-');
438
557
  let outputStage = '';
558
+ let outputStageDirectory = null;
439
559
  let tempOut = '';
440
560
  let changes = 0;
441
561
  try {
442
- await mkdir(path.dirname(output), { recursive:true });
443
- outputStage = await mkdtemp(path.join(path.dirname(output), '.remcp-docx-output-'));
562
+ outputStageDirectory = await createAnchoredTempDirectory(path.dirname(output), '.remcp-docx-output-');
563
+ outputStage = outputStageDirectory.path;
444
564
  tempOut = path.join(outputStage, path.basename(output));
445
565
  if (create) {
446
566
  await ensureCreateTargetIsNew(filePath);
@@ -467,11 +587,13 @@ export async function editDocument(args) {
467
587
  }
468
588
  await writeFile(documentPath,xml,'utf8');
469
589
  await createZip(dir,tempOut);
470
- await commitDocumentOutput(tempOut,output);
471
- return jsonResult({ path:output, operations:operations.length, changes, bytes:(await stat(output)).size, created:create });
590
+ await commitDocumentOutput(tempOut,output,{ noReplace:create });
591
+ return jsonResult({ path:output, operations:operations.length, changes, bytes:(await stat(output)).size, created:create });
592
+
472
593
  } finally {
473
594
  if (tempOut) await rm(tempOut,{force:true}).catch(() => {});
474
595
  if (outputStage) await rm(outputStage,{recursive:true, force:true}).catch(() => {});
596
+ if (outputStageDirectory) await outputStageDirectory.close().catch(() => {});
475
597
  await removeTemp(dir);
476
598
  }
477
599
  }
@@ -524,62 +646,110 @@ export async function pdfAction(args) {
524
646
  }
525
647
  if (action === 'info') {
526
648
  const source = await resolveSafePath(args.path,'path');
527
- if (commandExists('pdfinfo')) return text((await runFile('pdfinfo',[source],{label:'pdfinfo'})).stdout);
528
- const data = await readDocumentFile(source); return jsonResult({ path:source, bytes:data.length, annotation_count:pdfAnnotations(data).length });
649
+ if (commandExists('pdfinfo')) {
650
+ const snapshot = await snapshotDocumentFile(source);
651
+ try { return text((await runFile('pdfinfo',[snapshot.path],{label:'pdfinfo'})).stdout); }
652
+ finally { await removeTemp(snapshot.dir); }
653
+ }
654
+ const data = await readDocumentFile(source);
655
+ return jsonResult({ path:source, bytes:data.length, annotation_count:pdfAnnotations(data).length });
529
656
  }
530
657
  if (action === 'merge') {
531
658
  const requested = Array.isArray(args.paths) ? args.paths : [];
532
659
  if (requested.length < 2 || requested.length > 100) throw new Error('merge requires 2..100 PDFs in paths');
533
- const sources=[]; for(const value of requested) sources.push(await resolveSafePath(value,'paths'));
660
+ const sources=[];
661
+ for(const value of requested) sources.push(await resolveSafePath(value,'paths'));
534
662
  const output=await resolveSafePath(args.output,'output');
535
- if(commandExists('pdfunite')) await runFile('pdfunite',[...sources,output],{label:'PDF merge',timeout:60_000});
536
- else if(commandExists('qpdf')) await runFile('qpdf',['--empty','--pages',...sources,'--',output],{label:'PDF merge',timeout:60_000});
537
- else unavailable('PDF merge','install poppler-utils (pdfunite) or qpdf');
538
- return jsonResult({action,output,inputs:sources.length,bytes:(await stat(output)).size});
663
+ const inputDir=await tempDir('remcp-pdf-merge-input-');
664
+ const outputStageDirectory=await createAnchoredTempDirectory(path.dirname(output), '.remcp-pdf-output-');
665
+ const outputDir=outputStageDirectory.path;
666
+ const staged=path.join(outputDir,path.basename(output));
667
+ try {
668
+ const snapshots=[];
669
+ for(let index=0;index<sources.length;index+=1){
670
+ const snapshot=path.join(inputDir,`input-${index}.pdf`);
671
+ await copyRegularNoFollow(sources[index],snapshot);
672
+ snapshots.push(snapshot);
673
+ }
674
+ if(commandExists('pdfunite')) await runFile('pdfunite',[...snapshots,staged],{label:'PDF merge',timeout:60_000});
675
+ else if(commandExists('qpdf')) await runFile('qpdf',['--empty','--pages',...snapshots,'--',staged],{label:'PDF merge',timeout:60_000});
676
+ else unavailable('PDF merge','install poppler-utils (pdfunite) or qpdf');
677
+ await commitDocumentOutput(staged,output);
678
+ return jsonResult({action,output,inputs:sources.length,bytes:(await stat(output)).size});
679
+ } finally {
680
+ await removeTemp(inputDir);
681
+ await rm(outputDir,{recursive:true,force:true}).catch(()=>{});
682
+ await outputStageDirectory.close().catch(()=>{});
683
+ }
539
684
  }
540
685
  const source=await resolveSafePath(args.path,'path');
541
686
  if(action==='split'){
542
687
  const requestedOutputDir=await resolveSafePath(args.output_dir || path.dirname(source),'output_dir');
543
- await mkdir(requestedOutputDir,{recursive:true});
544
688
  const outputDir=await resolveSafePath(requestedOutputDir,'output_dir');
545
689
  const patternName=safePdfPattern(optionalString(args.pattern),`${path.basename(source,path.extname(source))}-%d.pdf`);
546
- const pattern=path.join(outputDir,patternName);
547
- if(commandExists('pdfseparate')) await runFile('pdfseparate',[source,pattern],{label:'PDF split',timeout:60_000});
548
- else unavailable('PDF split','install poppler-utils (pdfseparate)');
549
- return jsonResult({action,output_dir:outputDir,files:(await readdir(outputDir)).filter(name=>name.toLowerCase().endsWith('.pdf')).sort()});
690
+ const snapshot=await snapshotDocumentFile(source);
691
+ const work=await tempDir('remcp-pdf-split-');
692
+ const stageDirectory=await createAnchoredTempDirectory(outputDir, '.remcp-pdf-output-');
693
+ const stageDir=stageDirectory.path;
694
+ try {
695
+ const pattern=path.join(work,patternName);
696
+ if(commandExists('pdfseparate')) await runFile('pdfseparate',[snapshot.path,pattern],{label:'PDF split',timeout:60_000});
697
+ else unavailable('PDF split','install poppler-utils (pdfseparate)');
698
+ for(const name of await readdir(work)){
699
+ if(!name.toLowerCase().endsWith('.pdf')) continue;
700
+ const staged=path.join(stageDir,name);
701
+ await copyFile(path.join(work,name),staged,constants.COPYFILE_EXCL);
702
+ await commitDocumentOutput(staged,path.join(outputDir,name));
703
+ }
704
+ return jsonResult({action,output_dir:outputDir,files:(await readdir(outputDir)).filter(name=>name.toLowerCase().endsWith('.pdf')).sort()});
705
+ } finally {
706
+ await removeTemp(snapshot.dir);
707
+ await removeTemp(work);
708
+ await rm(stageDir,{recursive:true,force:true}).catch(()=>{});
709
+ await stageDirectory.close().catch(()=>{});
710
+ }
550
711
  }
551
712
  const pages=optionalString(args.pages);
552
713
  const selectedPages=expandPdfPages(pages);
553
714
  const normalizedPages=selectedPages.join(',');
554
715
  const output=await resolveSafePath(args.output,'output');
555
- if(commandExists('qpdf')) {
556
- await runFile('qpdf',[source,'--pages','.',pages.replace(/\s+/g,''),'--',output],{label:'PDF extract pages',timeout:60_000});
557
- } else if(commandExists('pdftk')) {
558
- await runFile('pdftk',[source,'cat',...pages.replace(/\s+/g,'').split(','),'output',output],{label:'PDF extract pages',timeout:60_000});
559
- } else if(commandExists('pdfseparate')) {
560
- const dir=await tempDir('remcp-pdf-pages-');
561
- try {
562
- const extracted=[];
563
- for(let index=0;index<selectedPages.length;index+=1){
564
- const page=selectedPages[index];
565
- const pattern=path.join(dir,`selection-${index+1}-%d.pdf`);
566
- await runFile('pdfseparate',['-f',String(page),'-l',String(page),source,pattern],{label:`PDF extract page ${page}`,timeout:60_000});
567
- extracted.push(path.join(dir,`selection-${index+1}-${page}.pdf`));
568
- }
569
- if(extracted.length===1) {
570
- await copyFile(extracted[0],output);
571
- } else if(commandExists('pdfunite')) {
572
- await runFile('pdfunite',[...extracted,output],{label:'PDF assemble extracted pages',timeout:60_000});
573
- } else {
574
- unavailable('PDF page extraction','pdfunite is required with pdfseparate when extracting multiple pages');
575
- }
576
- } finally {
577
- await removeTemp(dir);
716
+ const snapshot=await snapshotDocumentFile(source);
717
+ const stageDirectory=await createAnchoredTempDirectory(path.dirname(output), '.remcp-pdf-output-');
718
+ const stageDir=stageDirectory.path;
719
+ const staged=path.join(stageDir,path.basename(output));
720
+ try {
721
+ if(commandExists('qpdf')) {
722
+ await runFile('qpdf',[snapshot.path,'--pages','.',pages.replace(/\s+/g,''),'--',staged],{label:'PDF extract pages',timeout:60_000});
723
+ } else if(commandExists('pdftk')) {
724
+ await runFile('pdftk',[snapshot.path,'cat',...pages.replace(/\s+/g,'').split(','),'output',staged],{label:'PDF extract pages',timeout:60_000});
725
+ } else if(commandExists('pdfseparate')) {
726
+ const work=await tempDir('remcp-pdf-pages-');
727
+ try {
728
+ const extracted=[];
729
+ for(let index=0;index<selectedPages.length;index+=1){
730
+ const page=selectedPages[index];
731
+ const pattern=path.join(work,`selection-${index+1}-%d.pdf`);
732
+ await runFile('pdfseparate',['-f',String(page),'-l',String(page),snapshot.path,pattern],{label:`PDF extract page ${page}`,timeout:60_000});
733
+ extracted.push(path.join(work,`selection-${index+1}-${page}.pdf`));
734
+ }
735
+ if(extracted.length===1) {
736
+ await copyFile(extracted[0],staged,constants.COPYFILE_EXCL);
737
+ } else if(commandExists('pdfunite')) {
738
+ await runFile('pdfunite',[...extracted,staged],{label:'PDF assemble extracted pages',timeout:60_000});
739
+ } else {
740
+ unavailable('PDF page extraction','pdfunite is required with pdfseparate when extracting multiple pages');
741
+ }
742
+ } finally { await removeTemp(work); }
743
+ } else {
744
+ unavailable('PDF page extraction','install qpdf, pdftk, or poppler-utils (pdfseparate; pdfunite for multiple pages)');
578
745
  }
579
- } else {
580
- unavailable('PDF page extraction','install qpdf, pdftk, or poppler-utils (pdfseparate; pdfunite for multiple pages)');
746
+ await commitDocumentOutput(staged,output);
747
+ return jsonResult({action,source,pages:normalizedPages,output,bytes:(await stat(output)).size});
748
+ } finally {
749
+ await removeTemp(snapshot.dir);
750
+ await rm(stageDir,{recursive:true,force:true}).catch(()=>{});
751
+ await stageDirectory.close().catch(()=>{});
581
752
  }
582
- return jsonResult({action,source,pages:normalizedPages,output,bytes:(await stat(output)).size});
583
753
  }
584
754
 
585
755
  export const documentHandlers = {
package/src/index.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  import process from 'node:process';
3
3
  import { mkdirSync, writeFileSync } from 'node:fs';
4
4
  import path from 'node:path';
5
- import { advertisedTools as advertisedCoreTools, toolDefinitions as coreToolDefinitions } from './catalog.mjs';
5
+ import { advertisedTools as advertisedCoreTools, supportedCoreTools, toolDefinitions as coreToolDefinitions } from './catalog.mjs';
6
6
  import { allExtendedTools, advertisedExtendedTools, extendedToolDefinitions } from './extended/catalog.mjs';
7
7
  import { describeConfig, configurationError, runtimeConfigDir } from './config.mjs';
8
8
  import { shutdownSessions, startSessionSweeper } from './sessions.mjs';
@@ -13,7 +13,7 @@ const args = process.argv.slice(2);
13
13
  const allToolDefinitions = [...coreToolDefinitions, ...extendedToolDefinitions];
14
14
 
15
15
  async function advertisedRuntimeTools() {
16
- return [...advertisedCoreTools(), ...await advertisedExtendedTools()];
16
+ return [...supportedCoreTools(), ...await advertisedExtendedTools()];
17
17
  }
18
18
 
19
19
  async function writeStdout(value) {
package/src/mcp-v2.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import process from 'node:process';
2
- import { toolDefinitions, TEXT_OUTPUT_SCHEMA } from './catalog.mjs';
2
+ import { supportedCoreTools, toolDefinitions, TEXT_OUTPUT_SCHEMA } from './catalog.mjs';
3
3
  import { advertisedExtendedTools, extendedToolDefinitions } from './extended/catalog.mjs';
4
4
  import { invokeTool } from './invoke.mjs';
5
5
 
@@ -26,7 +26,10 @@ export async function startRuntimeMcpServer({ version, instructions, onError = (
26
26
  import('@modelcontextprotocol/server/stdio'),
27
27
  ]);
28
28
 
29
- const initiallySupported = new Set((await advertisedExtendedTools()).map(tool => tool.name));
29
+ const initiallySupported = new Set([
30
+ ...supportedCoreTools().map(tool => tool.name),
31
+ ...(await advertisedExtendedTools()).map(tool => tool.name),
32
+ ]);
30
33
  let activeServer = null;
31
34
  let capabilityTimer = null;
32
35
  let closed = false;
@@ -56,7 +59,8 @@ export async function startRuntimeMcpServer({ version, instructions, onError = (
56
59
  async (args, context) => invokeTool(definition.name, args || {}, { signal: context?.mcpReq?.signal }),
57
60
  );
58
61
  registrations.set(definition.name, registered);
59
- if (extendedToolDefinitions.includes(definition) && !initiallySupported.has(definition.name)) registered.disable();
62
+ if (!initiallySupported.has(definition.name)) registered.disable();
63
+
60
64
  }
61
65
 
62
66
  let lastSupported = initiallySupported;
@@ -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)}`);