@remcp/runtime 0.2.93 → 0.2.94

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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.94 — 2026-09-22
4
+
5
+ - Replaced DOCX/PDF regex-heavy extraction with bounded scanners and added adversarial parser regressions.
6
+ - Browser actions pass user-controlled values through structured CDP arguments instead of interpolating them into executable JavaScript.
7
+ - File/search/screenshot/hash operations validate and consume the same open descriptor, using `O_NOFOLLOW` where supported to prevent symlink-swap/check-then-use races.
8
+ - Temporary desktop captures and telemetry marker creation are now descriptor-bound or atomic.
9
+ - Tool schemas and the 83 granular runtime operations remain unchanged.
10
+
3
11
  ## 0.2.93 — 2026-09-21
4
12
 
5
13
  - Lockstep release for the device client's fresher npm metadata revalidation during exact release updates.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.93",
3
+ "version": "0.2.94",
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",
package/src/documents.mjs CHANGED
@@ -55,17 +55,70 @@ function unzipEntry(buffer, wanted) {
55
55
 
56
56
  const DOCX_ENTITIES = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&apos;': "'" };
57
57
 
58
+ function xmlTagName(tag) {
59
+ let index = 0;
60
+ while (index < tag.length && /\s/.test(tag[index])) index += 1;
61
+ let closing = false;
62
+ if (tag[index] === '/') {
63
+ closing = true;
64
+ index += 1;
65
+ while (index < tag.length && /\s/.test(tag[index])) index += 1;
66
+ }
67
+ const start = index;
68
+ while (index < tag.length && !/\s|\/|>/.test(tag[index])) index += 1;
69
+ return { name: tag.slice(start, index), closing };
70
+ }
71
+
72
+ function wordXmlText(xml) {
73
+ let output = '';
74
+ let index = 0;
75
+ while (index < xml.length) {
76
+ if (xml[index] !== '<') {
77
+ output += xml[index];
78
+ index += 1;
79
+ continue;
80
+ }
81
+ if (xml.startsWith('<!--', index)) {
82
+ const end = xml.indexOf('-->', index + 4);
83
+ index = end < 0 ? xml.length : end + 3;
84
+ continue;
85
+ }
86
+ if (xml.startsWith('<![CDATA[', index)) {
87
+ const end = xml.indexOf(']]>', index + 9);
88
+ if (end < 0) break;
89
+ output += xml.slice(index + 9, end);
90
+ index = end + 3;
91
+ continue;
92
+ }
93
+ let quote = '';
94
+ let end = index + 1;
95
+ for (; end < xml.length; end += 1) {
96
+ const character = xml[end];
97
+ if (quote) {
98
+ if (character === quote) quote = '';
99
+ continue;
100
+ }
101
+ if (character === '"' || character === "'") {
102
+ quote = character;
103
+ continue;
104
+ }
105
+ if (character === '>') break;
106
+ }
107
+ if (end >= xml.length) break;
108
+ const { name, closing } = xmlTagName(xml.slice(index + 1, end));
109
+ if (closing && name === 'w:p') output += '\n';
110
+ else if (!closing && (name === 'w:br' || name === 'w:cr')) output += '\n';
111
+ else if (!closing && name === 'w:tab') output += '\t';
112
+ index = end + 1;
113
+ }
114
+ return output;
115
+ }
116
+
58
117
  export function readDocxText(buffer) {
59
118
  const document = unzipEntry(buffer, 'word/document.xml');
60
119
  if (!document) throw new Error('This file is not a readable .docx (its word/document.xml is missing or compressed in an unsupported way)');
61
120
  const xml = document.toString('utf8');
62
- // Paragraph and line breaks become newlines, tabs become tabs, everything else is text.
63
- const withBreaks = xml
64
- .replace(/<w:(?:br|cr)\b[^>]*\/?>/g, '\n')
65
- .replace(/<\/w:p>/g, '\n')
66
- .replace(/<w:tab\b[^>]*\/?>/g, '\t');
67
- const text = withBreaks.replace(/<[^>]+>/g, '');
68
- return text
121
+ return wordXmlText(xml)
69
122
  .replace(/&(amp|lt|gt|quot|apos);/g, match => DOCX_ENTITIES[match])
70
123
  .replace(/\n{3,}/g, '\n\n')
71
124
  .trim();
@@ -78,6 +131,132 @@ function decodePdfString(raw) {
78
131
  .replace(/\\([0-7]{1,3})/g, (_match, octal) => String.fromCharCode(Number.parseInt(octal, 8)));
79
132
  }
80
133
 
134
+ const PDF_HEX = '0123456789abcdefABCDEF';
135
+
136
+ function readPdfLiteral(content, start) {
137
+ let raw = '';
138
+ let depth = 1;
139
+ let index = start + 1;
140
+ while (index < content.length) {
141
+ const character = content[index];
142
+ if (character === '\\') {
143
+ raw += character;
144
+ index += 1;
145
+ if (index < content.length) {
146
+ raw += content[index];
147
+ index += 1;
148
+ }
149
+ continue;
150
+ }
151
+ if (character === '(') {
152
+ depth += 1;
153
+ raw += character;
154
+ index += 1;
155
+ continue;
156
+ }
157
+ if (character === ')') {
158
+ depth -= 1;
159
+ index += 1;
160
+ if (depth === 0) return { value: decodePdfString(raw), next: index };
161
+ raw += character;
162
+ continue;
163
+ }
164
+ raw += character;
165
+ index += 1;
166
+ }
167
+ return { value: '', next: content.length };
168
+ }
169
+
170
+ function readPdfHex(content, start) {
171
+ let hex = '';
172
+ let index = start + 1;
173
+ while (index < content.length && content[index] !== '>') {
174
+ if (PDF_HEX.includes(content[index])) hex += content[index];
175
+ index += 1;
176
+ }
177
+ if (hex.length % 2) hex += '0';
178
+ const value = Buffer.from(hex, 'hex').toString('latin1').replace(/\0/g, '');
179
+ return { value, next: index < content.length ? index + 1 : index };
180
+ }
181
+
182
+ function readPdfArray(content, start) {
183
+ const values = [];
184
+ let index = start + 1;
185
+ while (index < content.length) {
186
+ const character = content[index];
187
+ if (character === ']') return { value: values.join(''), next: index + 1 };
188
+ if (character === '(') {
189
+ const token = readPdfLiteral(content, index);
190
+ if (token.value) values.push(token.value);
191
+ index = token.next;
192
+ continue;
193
+ }
194
+ if (character === '<' && content[index + 1] !== '<') {
195
+ const token = readPdfHex(content, index);
196
+ if (token.value) values.push(token.value);
197
+ index = token.next;
198
+ continue;
199
+ }
200
+ index += 1;
201
+ }
202
+ return { value: values.join(''), next: index };
203
+ }
204
+
205
+ function pdfTextPieces(content) {
206
+ const pieces = [];
207
+ let operand = '';
208
+ let index = 0;
209
+ while (index < content.length) {
210
+ const character = content[index];
211
+ if (/\s/.test(character)) {
212
+ index += 1;
213
+ continue;
214
+ }
215
+ if (character === '%') {
216
+ const newline = content.indexOf('\n', index + 1);
217
+ index = newline < 0 ? content.length : newline + 1;
218
+ continue;
219
+ }
220
+ if (character === '(') {
221
+ const token = readPdfLiteral(content, index);
222
+ operand = token.value;
223
+ index = token.next;
224
+ continue;
225
+ }
226
+ if (character === '<' && content[index + 1] !== '<') {
227
+ const token = readPdfHex(content, index);
228
+ operand = token.value;
229
+ index = token.next;
230
+ continue;
231
+ }
232
+ if (character === '[') {
233
+ const token = readPdfArray(content, index);
234
+ operand = token.value;
235
+ index = token.next;
236
+ continue;
237
+ }
238
+ const start = index;
239
+ while (index < content.length && !/\s|[()[\]<>]/.test(content[index])) index += 1;
240
+ if (index === start) {
241
+ index += 1;
242
+ continue;
243
+ }
244
+ const operator = content.slice(start, index);
245
+ if (operator === 'Tj' || operator === 'TJ') {
246
+ if (operand) pieces.push(operand);
247
+ operand = '';
248
+ } else if (operator === "'" || operator === '"') {
249
+ pieces.push('\n');
250
+ if (operand) pieces.push(operand);
251
+ operand = '';
252
+ } else if (operator === 'T*' || operator === 'Td' || operator === 'TD' || operator === 'ET') {
253
+ pieces.push('\n');
254
+ operand = '';
255
+ }
256
+ }
257
+ return pieces;
258
+ }
259
+
81
260
  export function readPdfText(buffer) {
82
261
  const raw = buffer.toString('latin1');
83
262
  const chunks = [];
@@ -97,23 +276,7 @@ export function readPdfText(buffer) {
97
276
  }
98
277
  const content = chunks.join('\n');
99
278
 
100
- const pieces = [];
101
- const showText = /(?:\((?:\\.|[^\\()])*\)|<[0-9A-Fa-f\s]+>)\s*Tj|\[((?:[^\][]|\\.)*)\]\s*TJ|\((?:\\.|[^\\()])*\)\s*['"]|T\*|Td|TD|ET/g;
102
- for (const match of content.matchAll(showText)) {
103
- const token = match[0];
104
- if (/^T\*|Td|TD|ET$/.test(token)) { pieces.push('\n'); continue; }
105
- if (token.includes('TJ')) {
106
- const array = match[1] ?? '';
107
- for (const part of array.matchAll(/\((?:\\.|[^\\()])*\)|<[0-9A-Fa-f\s]+>/g)) {
108
- const value = part[0];
109
- if (value.startsWith('(')) pieces.push(decodePdfString(value.slice(1, -1)));
110
- else pieces.push(Buffer.from(value.slice(1, -1).replace(/\s+/g, ''), 'hex').toString('latin1').replace(/\0/g, ''));
111
- }
112
- continue;
113
- }
114
- if (token.startsWith('(')) pieces.push(decodePdfString(token.slice(1, token.lastIndexOf(')'))));
115
- else if (token.startsWith('<')) pieces.push(Buffer.from(token.slice(1, token.indexOf('>')).replace(/\s+/g, ''), 'hex').toString('latin1').replace(/\0/g, ''));
116
- }
279
+ const pieces = pdfTextPieces(content);
117
280
  const text = pieces.join('').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
118
281
  const printable = text.replace(/[^\p{L}\p{N}\p{P}\p{Zs}\n\t]/gu, '');
119
282
  if (text.length < 8 || printable.length / Math.max(1, text.length) < 0.7) {
@@ -294,6 +294,29 @@ async function evaluate(session, expression, awaitPromise = true) {
294
294
  return result.result?.value;
295
295
  }
296
296
 
297
+ async function callPageFunction(session, functionDeclaration, args = [], awaitPromise = true) {
298
+ const global = await session.send('Runtime.evaluate', {
299
+ expression: 'globalThis',
300
+ returnByValue: false,
301
+ awaitPromise: false,
302
+ });
303
+ if (global.exceptionDetails || !global.result?.objectId) {
304
+ throw new Error(global.exceptionDetails?.exception?.description || global.exceptionDetails?.text || 'Could not access the page execution context');
305
+ }
306
+ const result = await session.send('Runtime.callFunctionOn', {
307
+ objectId: global.result.objectId,
308
+ functionDeclaration,
309
+ arguments: args.map(value => ({ value })),
310
+ returnByValue: true,
311
+ awaitPromise,
312
+ userGesture: true,
313
+ });
314
+ if (result.exceptionDetails) {
315
+ throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text || 'JavaScript function call failed');
316
+ }
317
+ return result.result?.value;
318
+ }
319
+
297
320
  export async function browserTabs(args = {}) {
298
321
  const targets = await listBrowserTargets(args.endpoint);
299
322
  return jsonResult(targets.filter(item => item.type === 'page').slice(0, 500).map(item => ({
@@ -559,18 +582,41 @@ export async function browserFind(args) {
559
582
  });
560
583
  }
561
584
 
562
- function elementLookup(args) {
563
- const selector = optionalString(args.selector);
564
- const needle = optionalString(args.text);
565
- if (!selector && !needle) throw new Error('selector or text is required');
566
- return `(() => {
567
- const selector = ${JSON.stringify(selector)};
568
- const needle = ${JSON.stringify(needle)};
569
- let el = selector ? document.querySelector(selector) : null;
570
- if (!el && needle) el = Array.from(document.querySelectorAll('*')).find(x => ((x.innerText || x.textContent || '').trim().toLowerCase().includes(needle.toLowerCase())));
571
- return el;
572
- })()`;
573
- }
585
+ const PAGE_ELEMENT_ACTION = `function(selector, needle, action, value) {
586
+ let el = selector ? document.querySelector(selector) : null;
587
+ if (!el && needle) {
588
+ const lower = String(needle).toLowerCase();
589
+ el = Array.from(document.querySelectorAll('*')).find(node =>
590
+ String(node.innerText || node.textContent || '').trim().toLowerCase().includes(lower)
591
+ ) || null;
592
+ }
593
+ if (!el) throw new Error('Element not found');
594
+ if (action === 'click' || action === 'type' || action === 'scroll_into_view') {
595
+ el.scrollIntoView({block:'center', inline:'center'});
596
+ }
597
+ if (action === 'focus' || action === 'type') el.focus();
598
+ if (action === 'select') {
599
+ el.value = value;
600
+ el.dispatchEvent(new Event('input', {bubbles:true}));
601
+ el.dispatchEvent(new Event('change', {bubbles:true}));
602
+ } else if (action === 'set_value') {
603
+ const proto = Object.getPrototypeOf(el);
604
+ const own = Object.getOwnPropertyDescriptor(proto, 'value');
605
+ const parent = Object.getPrototypeOf(proto);
606
+ const inherited = parent ? Object.getOwnPropertyDescriptor(parent, 'value') : null;
607
+ const setter = own?.set || inherited?.set;
608
+ if (setter) setter.call(el, value); else el.value = value;
609
+ el.dispatchEvent(new Event('input', {bubbles:true}));
610
+ el.dispatchEvent(new Event('change', {bubbles:true}));
611
+ }
612
+ const b = el.getBoundingClientRect();
613
+ if (action === 'click' && (!b.width || !b.height)) throw new Error('Element has no clickable bounds');
614
+ return {
615
+ tag: el.tagName.toLowerCase(),
616
+ value: 'value' in el ? String(el.value) : '',
617
+ x: b.x, y: b.y, width: b.width, height: b.height,
618
+ };
619
+ }`;
574
620
 
575
621
  export function browserActionInputValue(args = {}, action = '') {
576
622
  // text is the public field models naturally use for type, while set_value/select historically
@@ -616,19 +662,14 @@ export async function browserAction(args) {
616
662
  await session.send('Input.dispatchKeyEvent', { type: 'keyUp', key });
617
663
  return jsonResult({ target_id: target.id, action, key });
618
664
  }
619
- const lookup = elementLookup(args);
665
+ const selector = optionalString(args.selector);
666
+ const needle = optionalString(args.text);
667
+ if (!selector && !needle) throw new Error('selector or text is required');
620
668
  const value = browserActionInputValue(args, action);
621
669
  const option = String(args.option ?? value);
622
670
 
623
671
  if (action === 'click') {
624
- const rect = await evaluate(session, `(() => {
625
- const el = ${lookup};
626
- if (!el) throw new Error('Element not found');
627
- el.scrollIntoView({block:'center',inline:'center'});
628
- const b = el.getBoundingClientRect();
629
- if (!b.width || !b.height) throw new Error('Element has no clickable bounds');
630
- return { tag:el.tagName.toLowerCase(), x:b.x, y:b.y, width:b.width, height:b.height };
631
- })()`);
672
+ const rect = await callPageFunction(session, PAGE_ELEMENT_ACTION, [selector, needle, 'click', '']);
632
673
  const x = Number(rect.x) + Number(rect.width) / 2;
633
674
  const y = Number(rect.y) + Number(rect.height) / 2;
634
675
  await session.send('Input.dispatchMouseEvent', { type:'mouseMoved', x, y, button:'none' });
@@ -638,31 +679,18 @@ export async function browserAction(args) {
638
679
  }
639
680
 
640
681
  if (action === 'type') {
641
- const focused = await evaluate(session, `(() => {
642
- const el = ${lookup};
643
- if (!el) throw new Error('Element not found');
644
- el.scrollIntoView({block:'center',inline:'center'});
645
- el.focus();
646
- const b = el.getBoundingClientRect();
647
- return { tag:el.tagName.toLowerCase(), value:'value' in el?String(el.value):'', x:b.x, y:b.y, width:b.width, height:b.height };
648
- })()`);
682
+ const focused = await callPageFunction(session, PAGE_ELEMENT_ACTION, [selector, needle, 'type', '']);
649
683
  await session.send('Input.insertText', { text:value });
650
- const current = await evaluate(session, `(() => { const el=${lookup}; return 'value' in el ? String(el.value) : ''; })()`);
651
- return jsonResult({ target_id:target.id, action, result:{ ...focused, value:current } });
684
+ const current = await callPageFunction(session, PAGE_ELEMENT_ACTION, [selector, needle, 'read_value', '']);
685
+ return jsonResult({ target_id:target.id, action, result:{ ...focused, value:current.value } });
652
686
  }
653
687
 
654
- const expression = `(() => {
655
- const el = ${lookup};
656
- if (!el) throw new Error('Element not found');
657
- ${action === 'focus' ? 'el.focus();' :
658
- action === 'scroll_into_view' ? "el.scrollIntoView({block:'center',inline:'center'});" :
659
- action === 'select' ? `el.value=${JSON.stringify(option)}; el.dispatchEvent(new Event('input',{bubbles:true})); el.dispatchEvent(new Event('change',{bubbles:true}));` :
660
- `const proto=Object.getPrototypeOf(el);const own=Object.getOwnPropertyDescriptor(proto,'value');const parent=Object.getPrototypeOf(proto);const inherited=parent?Object.getOwnPropertyDescriptor(parent,'value'):null;const setter=own?.set||inherited?.set;if(setter)setter.call(el,${JSON.stringify(value)});else el.value=${JSON.stringify(value)};el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));`
661
- }
662
- const b = el.getBoundingClientRect();
663
- return { tag: el.tagName.toLowerCase(), value: 'value' in el ? String(el.value) : '', x: b.x, y: b.y, width: b.width, height: b.height };
664
- })()`;
665
- return jsonResult({ target_id: target.id, action, result: await evaluate(session, expression) });
688
+ const pageValue = action === 'select' ? option : value;
689
+ return jsonResult({
690
+ target_id: target.id,
691
+ action,
692
+ result: await callPageFunction(session, PAGE_ELEMENT_ACTION, [selector, needle, action, pageValue]),
693
+ });
666
694
  });
667
695
  }
668
696
 
@@ -2,8 +2,8 @@ import os from 'node:os';
2
2
  import path from 'node:path';
3
3
  import process from 'node:process';
4
4
  import { execFile, spawn } from 'node:child_process';
5
- import { existsSync } from 'node:fs';
6
- import { mkdtemp, rm } from 'node:fs/promises';
5
+ import { constants, existsSync } from 'node:fs';
6
+ import { mkdtemp, open, rm } from 'node:fs/promises';
7
7
  import { StringDecoder } from 'node:string_decoder';
8
8
  import { promisify } from 'node:util';
9
9
  import { ToolError, fail, structured, text } from '../util.mjs';
@@ -259,6 +259,18 @@ export async function spawnDetachedWithInput(file, args = [], input = '', option
259
259
  });
260
260
  }
261
261
 
262
+ export async function readPrivateTempFile(file, maxBytes) {
263
+ const handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
264
+ try {
265
+ const info = await handle.stat();
266
+ if (!info.isFile()) throw new ToolError('Temporary capture is not a regular file');
267
+ if (info.size > maxBytes) throw new ToolError(`Captured file is ${info.size} bytes, above the ${maxBytes}-byte inline limit`);
268
+ return { info, data: await handle.readFile() };
269
+ } finally {
270
+ await handle.close();
271
+ }
272
+ }
273
+
262
274
  export async function tempDir(prefix = 'remcp-') {
263
275
  return mkdtemp(path.join(os.tmpdir(), prefix));
264
276
  }
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import process from 'node:process';
3
- import { readFile, stat } from 'node:fs/promises';
3
+
4
4
  import { image, multi, text } from '../util.mjs';
5
5
  import { capturePortalScreenshot, isWaylandSession } from '../screenshot-portal.mjs';
6
6
  import { hasWaylandRemoteDesktopGrant, portalPointerButton, portalPointerMotion, portalPointerMotionAbsolute, portalScroll, portalShortcut, portalTypeText, waylandPortalCandidate } from '../wayland-remote-desktop.mjs';
@@ -9,6 +9,7 @@ import {
9
9
  commandExists,
10
10
  jsonResult,
11
11
  optionalString,
12
+ readPrivateTempFile,
12
13
  removeTemp,
13
14
  requireEnum,
14
15
  runFile,
@@ -1274,9 +1275,7 @@ export async function screenshotRegion(args = {}) {
1274
1275
  } else {
1275
1276
  unavailable('Region screenshots', isWaylandSession() ? 'the XDG Desktop Portal plus ffmpeg, or grim, is required' : 'install ImageMagick import');
1276
1277
  }
1277
- const info = await stat(target);
1278
- if (info.size > 4 * 1024 * 1024) throw new Error(`captured region is ${info.size} bytes; request a smaller region so it fits the inline image limit`);
1279
- const data = await readFile(target);
1278
+ const {data} = await readPrivateTempFile(target, 4 * 1024 * 1024);
1280
1279
  return multi([{type:'text',text:`Captured ${width}x${height} at ${x},${y}.`}, image(data.toString('base64'),'image/png')]);
1281
1280
  } finally { await removeTemp(dir); }
1282
1281
  }
@@ -1,11 +1,12 @@
1
1
  import path from 'node:path';
2
- import { readFile, stat } from 'node:fs/promises';
2
+
3
3
  import { image, multi, text } from '../util.mjs';
4
4
  import {
5
5
  clamp,
6
6
  escapeAppleScript,
7
7
  jsonResult,
8
8
  optionalString,
9
+ readPrivateTempFile,
9
10
  removeTemp,
10
11
  requireEnum,
11
12
  runFile,
@@ -379,8 +380,7 @@ export async function screenshotRegion(args = {}) {
379
380
  const dir=await tempDir('remcp-region-'),target=path.join(dir,'region.png');
380
381
  try{
381
382
  await runFile('/usr/sbin/screencapture',['-x','-R',`${x},${y},${width},${height}`,target],{label:'screenshot region'});
382
- const info=await stat(target);if(info.size>4*1024*1024)throw new Error(`captured region is ${info.size} bytes; request a smaller region so it fits the inline image limit`);
383
- const data=await readFile(target);
383
+ const {data}=await readPrivateTempFile(target,4*1024*1024);
384
384
  return multi([{type:'text',text:`Captured ${width}x${height} at ${x},${y}.`},image(data.toString('base64'),'image/png')]);
385
385
  }finally{await removeTemp(dir);}
386
386
  }
@@ -1,12 +1,13 @@
1
1
  import path from 'node:path';
2
2
  import process from 'node:process';
3
- import { readFile, stat } from 'node:fs/promises';
3
+
4
4
  import { image, multi, text } from '../util.mjs';
5
5
  import {
6
6
  clamp,
7
7
  escapePowerShellSingle,
8
8
  jsonResult,
9
9
  optionalString,
10
+ readPrivateTempFile,
10
11
  removeTemp,
11
12
  requireEnum,
12
13
  runPowerShell,
@@ -486,8 +487,7 @@ export async function screenshotRegion(args = {}) {
486
487
  try{
487
488
  const safe=escapePowerShellSingle(target);
488
489
  await runPowerShell(`Add-Type -AssemblyName System.Drawing;$b=New-Object System.Drawing.Bitmap(${width},${height});$g=[System.Drawing.Graphics]::FromImage($b);$g.CopyFromScreen(${x},${y},0,0,$b.Size);$b.Save('${safe}',[System.Drawing.Imaging.ImageFormat]::Png);$g.Dispose();$b.Dispose()`,{label:'screenshot region'});
489
- const info=await stat(target);if(info.size>4*1024*1024)throw new Error(`captured region is ${info.size} bytes; request a smaller region so it fits the inline image limit`);
490
- const data=await readFile(target);
490
+ const {data}=await readPrivateTempFile(target,4*1024*1024);
491
491
  return multi([{type:'text',text:`Captured ${width}x${height} at ${x},${y}.`},image(data.toString('base64'),'image/png')]);
492
492
  }finally{await removeTemp(dir);}
493
493
  }
package/src/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import process from 'node:process';
3
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
3
+ import { mkdirSync, writeFileSync } from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { advertisedTools as advertisedCoreTools, toolDefinitions as coreToolDefinitions } from './catalog.mjs';
6
6
  import { allExtendedTools, advertisedExtendedTools, extendedToolDefinitions } from './extended/catalog.mjs';
@@ -76,10 +76,12 @@ function announceTelemetryOnce() {
76
76
  if (!telemetryEnabled()) return;
77
77
  const marker = path.join(runtimeConfigDir, '.telemetry-notice');
78
78
  try {
79
- if (existsSync(marker)) return;
80
79
  mkdirSync(runtimeConfigDir, { recursive: true, mode: 0o700 });
81
- writeFileSync(marker, `${new Date().toISOString()}\n`, { mode: 0o600 });
82
- } catch { return; }
80
+ writeFileSync(marker, `${new Date().toISOString()}\n`, { mode: 0o600, flag: 'wx' });
81
+ } catch (error) {
82
+ if (error?.code === 'EEXIST') return;
83
+ return;
84
+ }
83
85
  console.error('ReMCP runtime: anonymous usage metrics are on (tool names, timings, outcomes only - never file paths, commands or output). They go to your own ReMCP account through the paired agent. Disable with `remcp telemetry off`.');
84
86
  }
85
87
 
@@ -1,9 +1,9 @@
1
1
  import path from 'node:path';
2
2
  import os from 'node:os';
3
3
  import { spawnSync } from 'node:child_process';
4
- import { createHash } from 'node:crypto';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
5
  import { constants, createReadStream } from 'node:fs';
6
- import { access, chmod, chown, copyFile, cp, lstat, mkdir, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
6
+ import { access, chmod, chown, copyFile, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
7
7
  import { pipeline } from 'node:stream/promises';
8
8
  import { liveConfig, runtimeConfig } from '../config.mjs';
9
9
  import { documentKind, readDocxText, readPdfText } from '../documents.mjs';
@@ -40,6 +40,42 @@ function assertRegularFile(info, absolute) {
40
40
  return info;
41
41
  }
42
42
 
43
+ const READ_NOFOLLOW = constants.O_RDONLY | (constants.O_NOFOLLOW || 0);
44
+ const READWRITE_NOFOLLOW = constants.O_RDWR | (constants.O_NOFOLLOW || 0);
45
+
46
+ async function openRegularFile(absolute, flags = READ_NOFOLLOW) {
47
+ const handle = await open(absolute, flags);
48
+ try {
49
+ const info = assertRegularFile(await handle.stat(), absolute);
50
+ return { handle, info };
51
+ } catch (error) {
52
+ await handle.close().catch(() => {});
53
+ throw error;
54
+ }
55
+ }
56
+
57
+ async function readRegularBuffer(absolute, maxBytes = Infinity) {
58
+ const { handle, info } = await openRegularFile(absolute);
59
+ try {
60
+ if (info.size > maxBytes) fail(`File is too large to read inline (${info.size} bytes)`);
61
+ return { info, buffer: await handle.readFile() };
62
+ } finally {
63
+ await handle.close();
64
+ }
65
+ }
66
+
67
+ async function overwriteOpenFile(handle, content) {
68
+ const buffer = Buffer.from(content, 'utf8');
69
+ await handle.truncate(0);
70
+ let offset = 0;
71
+ while (offset < buffer.length) {
72
+ const { bytesWritten } = await handle.write(buffer, offset, buffer.length - offset, offset);
73
+ if (!bytesWritten) throw new Error('Could not finish writing the open file');
74
+ offset += bytesWritten;
75
+ }
76
+ await handle.truncate(buffer.length);
77
+ }
78
+
43
79
  // Traversal helper for every multi-file tool. A symbolic link inside an allowed root can point
44
80
  // anywhere, so links are never followed and each collected path is resolved through
45
81
  // resolveSafePath again before a tool reads or writes it. `stat` follows links, which is exactly
@@ -85,10 +121,13 @@ async function confineAll(paths) {
85
121
  }
86
122
 
87
123
  async function readTextFile(absolute) {
88
- const info = await stat(absolute).catch(() => fail(`File not found: ${displayPath(absolute)}`));
89
- assertRegularFile(info, absolute);
90
- if (info.size > MAX_INLINE_FILE_BYTES) fail(`File is too large to read inline (${info.size} bytes)`);
91
- const buffer = await readFile(absolute);
124
+ let opened;
125
+ try { opened = await readRegularBuffer(absolute, MAX_INLINE_FILE_BYTES); }
126
+ catch (error) {
127
+ if (error?.code === 'ENOENT') fail(`File not found: ${displayPath(absolute)}`);
128
+ throw error;
129
+ }
130
+ const { info, buffer } = opened;
92
131
  const decoded = decodeText(buffer);
93
132
  if (decoded.encoding === 'utf8' && looksBinary(buffer)) {
94
133
  fail(`${displayPath(absolute)} looks like a binary file and cannot be read as text. Use read_image for images, or get_file_info and hash_file for other binaries.`);
@@ -101,10 +140,7 @@ export async function readFileTool(args) {
101
140
  // Documents first: a .docx or .pdf is not text, and the binary guard below would refuse it.
102
141
  const kind = documentKind(absolute);
103
142
  if (kind) {
104
- const info = await stat(absolute);
105
- assertRegularFile(info, absolute);
106
- if (info.size > MAX_INLINE_FILE_BYTES) fail(`File is too large to read inline (${info.size} bytes)`);
107
- const buffer = await readFile(absolute);
143
+ const { buffer } = await readRegularBuffer(absolute, MAX_INLINE_FILE_BYTES);
108
144
  const extracted = kind === 'docx' ? readDocxText(buffer) : readPdfText(buffer);
109
145
  const documentLines = splitLines(extracted);
110
146
  const offset = Number.isFinite(Number(args.offset)) ? Math.trunc(Number(args.offset)) : 0;
@@ -157,15 +193,20 @@ export async function readMultipleFilesTool(args) {
157
193
 
158
194
  export async function readImageTool(args) {
159
195
  const absolute = await resolveSafePath(args.path);
160
- const info = assertRegularFile(await stat(absolute).catch(() => fail(`File not found: ${displayPath(absolute)}`)), absolute);
161
- if (info.size > MAX_IMAGE_BYTES) fail(`Image is ${info.size} bytes, above the ${MAX_IMAGE_BYTES}-byte inline limit`);
196
+ let opened;
197
+ try { opened = await readRegularBuffer(absolute, MAX_IMAGE_BYTES); }
198
+ catch (error) {
199
+ if (error?.code === 'ENOENT') fail(`File not found: ${displayPath(absolute)}`);
200
+ throw error;
201
+ }
202
+ const { info, buffer } = opened;
162
203
  const mimeType = IMAGE_TYPES.get(path.extname(absolute).toLowerCase());
163
204
  if (!mimeType) fail(`${displayPath(absolute)} is not a supported image type (${[...IMAGE_TYPES.keys()].join(', ')})`);
164
205
  if (mimeType === 'image/svg+xml') {
165
- const { content } = await readTextFile(absolute);
166
- return text(`SVG image ${displayPath(absolute)} (${info.size} bytes):\n${content}`);
206
+ const decoded = decodeText(buffer);
207
+ if (decoded.encoding === 'utf8' && looksBinary(buffer)) fail(`${displayPath(absolute)} looks like a binary file`);
208
+ return text(`SVG image ${displayPath(absolute)} (${info.size} bytes):\n${decoded.text}`);
167
209
  }
168
- const buffer = await readFile(absolute);
169
210
  return multi([
170
211
  { type: 'text', text: `${displayPath(absolute)} — ${mimeType}, ${info.size} bytes` },
171
212
  image(buffer.toString('base64'), mimeType),
@@ -174,12 +215,22 @@ export async function readImageTool(args) {
174
215
 
175
216
  export async function hashFileTool(args) {
176
217
  const absolute = await resolveSafePath(args.path);
177
- const info = assertRegularFile(await stat(absolute).catch(() => fail(`File not found: ${displayPath(absolute)}`)), absolute);
218
+ let opened;
219
+ try { opened = await openRegularFile(absolute); }
220
+ catch (error) {
221
+ if (error?.code === 'ENOENT') fail(`File not found: ${displayPath(absolute)}`);
222
+ throw error;
223
+ }
224
+ const { handle, info } = opened;
178
225
  const algorithm = String(args.algorithm || 'sha256').toLowerCase();
179
- if (!['sha256', 'sha1', 'md5'].includes(algorithm)) fail('algorithm must be sha256, sha1, or md5');
226
+ if (!['sha256', 'sha1', 'md5'].includes(algorithm)) { await handle.close(); fail('algorithm must be sha256, sha1, or md5'); }
180
227
  const hash = createHash(algorithm);
181
- await pipeline(createReadStream(absolute), hash);
182
- return text(`${algorithm} ${hash.digest('hex')} ${displayPath(absolute)} (${info.size} bytes)`);
228
+ try {
229
+ await pipeline(createReadStream(absolute, { fd: handle.fd, autoClose: false }), hash);
230
+ return text(`${algorithm} ${hash.digest('hex')} ${displayPath(absolute)} (${info.size} bytes)`);
231
+ } finally {
232
+ await handle.close();
233
+ }
183
234
  }
184
235
 
185
236
  async function listEntry(base, depth, maxDepth, prefix, pattern) {
@@ -230,11 +281,11 @@ export async function getFileInfoTool(args) {
230
281
  permissions: `0${(info.mode & 0o777).toString(8)}`,
231
282
  };
232
283
  if (info.isFile() && info.size <= MAX_INLINE_FILE_BYTES) {
233
- const buffer = await readFile(absolute).catch(() => null);
234
- if (buffer) {
235
- const decoded = decodeText(buffer);
284
+ const opened = await readRegularBuffer(absolute, MAX_INLINE_FILE_BYTES).catch(() => null);
285
+ if (opened) {
286
+ const decoded = decodeText(opened.buffer);
236
287
  if (decoded.encoding !== 'utf8') payload.encoding = decoded.encoding;
237
- if (decoded.encoding !== 'utf8' || !looksBinary(buffer)) {
288
+ if (decoded.encoding !== 'utf8' || !looksBinary(opened.buffer)) {
238
289
  const lines = splitLines(decoded.text);
239
290
  payload.lineCount = lines.length;
240
291
  payload.lastLine = Math.max(0, lines.length - 1);
@@ -284,12 +335,17 @@ export async function writeFileTool(args) {
284
335
  // large file is read as a sequence of base64 slices and written back the same way.
285
336
  export async function readBinaryTool(args) {
286
337
  const absolute = await resolveSafePath(args.path);
287
- const info = assertRegularFile(await stat(absolute).catch(() => fail(`File not found: ${displayPath(absolute)}`)), absolute);
338
+ let opened;
339
+ try { opened = await openRegularFile(absolute); }
340
+ catch (error) {
341
+ if (error?.code === 'ENOENT') fail(`File not found: ${displayPath(absolute)}`);
342
+ throw error;
343
+ }
344
+ const { handle, info } = opened;
288
345
  const offset = Math.max(0, Number.isFinite(Number(args.offset_bytes)) ? Math.trunc(Number(args.offset_bytes)) : 0);
289
346
  const length = clampInteger(args.length_bytes, MAX_BINARY_CHUNK_BYTES, 1, MAX_BINARY_CHUNK_BYTES);
290
347
  const start = Math.min(offset, info.size);
291
348
  const end = Math.min(info.size, start + length);
292
- const handle = await open(absolute, 'r');
293
349
  try {
294
350
  const buffer = Buffer.alloc(end - start);
295
351
  if (buffer.length) await handle.read(buffer, 0, buffer.length, start);
@@ -449,23 +505,29 @@ export async function replaceInFilesTool(args) {
449
505
  for (const file of files) {
450
506
  if (changed.length >= maxFiles) break;
451
507
  if (glob && !glob.test(path.basename(file))) continue;
452
- const fileInfo = await stat(file).catch(() => null);
453
- if (!fileInfo || fileInfo.size > MAX_INLINE_FILE_BYTES) continue;
454
- const buffer = await readFile(file).catch(() => null);
455
- if (!buffer) continue;
456
- const decoded = decodeText(buffer);
457
- if (decoded.encoding === 'utf8' && looksBinary(buffer)) continue;
458
- scanned += 1;
459
- const original = decoded.text;
460
- const count = isRegex ? (original.match(matcher) || []).length : original.split(pattern).length - 1;
461
- if (!count) continue;
462
- if (isRegex) matcher.lastIndex = 0;
463
- const updated = isRegex ? original.replace(matcher, replacement) : original.split(pattern).join(replacement);
464
- if (updated === original) continue;
465
- assertWritableSize(updated);
466
- if (!dryRun) await writeFile(file, updated, 'utf8');
467
- const stats = diffStats(original, updated);
468
- changed.push({ file: displayPath(file), replacements: count, added: stats.added, removed: stats.removed });
508
+ let opened;
509
+ try { opened = await openRegularFile(file, dryRun ? READ_NOFOLLOW : READWRITE_NOFOLLOW); }
510
+ catch { continue; }
511
+ const { handle, info: fileInfo } = opened;
512
+ try {
513
+ if (fileInfo.size > MAX_INLINE_FILE_BYTES) continue;
514
+ const buffer = await handle.readFile();
515
+ const decoded = decodeText(buffer);
516
+ if (decoded.encoding === 'utf8' && looksBinary(buffer)) continue;
517
+ scanned += 1;
518
+ const original = decoded.text;
519
+ const count = isRegex ? (original.match(matcher) || []).length : original.split(pattern).length - 1;
520
+ if (!count) continue;
521
+ if (isRegex) matcher.lastIndex = 0;
522
+ const updated = isRegex ? original.replace(matcher, replacement) : original.split(pattern).join(replacement);
523
+ if (updated === original) continue;
524
+ assertWritableSize(updated);
525
+ if (!dryRun) await overwriteOpenFile(handle, updated);
526
+ const stats = diffStats(original, updated);
527
+ changed.push({ file: displayPath(file), replacements: count, added: stats.added, removed: stats.removed });
528
+ } finally {
529
+ await handle.close();
530
+ }
469
531
  }
470
532
  if (!changed.length) return text(`No matches for ${JSON.stringify(pattern)} in ${displayPath(root)} (${scanned} text files scanned).`);
471
533
  const rows = changed.map(entry => `${dryRun ? 'would change' : 'changed'} ${entry.file} · ${entry.replacements} replacement(s) · +${entry.added}/-${entry.removed} lines`);
@@ -963,55 +1025,62 @@ function windowsScreenshotScript(file) {
963
1025
  }
964
1026
 
965
1027
  export async function takeScreenshotTool(args) {
966
- const directory = await resolveSafePath(args.directory || os.tmpdir(), 'directory');
967
- await mkdir(directory, { recursive: true });
968
- const file = path.join(directory, `remcp-screenshot-${Date.now()}.png`);
1028
+ const requestedDirectory = args.directory ? await resolveSafePath(args.directory, 'directory') : '';
1029
+ const temporaryDirectory = requestedDirectory ? '' : await mkdtemp(path.join(os.tmpdir(), 'remcp-screenshot-'));
1030
+ const directory = requestedDirectory || temporaryDirectory;
1031
+ if (requestedDirectory) await mkdir(directory, { recursive: true });
1032
+ const file = path.join(directory, `capture-${randomUUID()}.png`);
969
1033
  const attempts = [];
970
- if (process.platform === 'win32') {
971
- const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', windowsScreenshotScript(file)], { encoding: 'utf8', timeout: 30000 });
972
- attempts.push(`powershell: ${(result.stderr || '').trim() || `exit ${result.status}`}`);
973
- } else {
974
- // GNOME and other modern Wayland compositors intentionally prevent X11/wlroots
975
- // screenshot commands from reading the desktop. The freedesktop Screenshot portal
976
- // is the compositor-supported API and must run before command-line fallbacks.
977
- if (process.platform === 'linux' && isWaylandSession()) {
978
- try {
979
- await capturePortalScreenshot(file);
980
- } catch (error) {
981
- const message = error instanceof Error ? error.message : `xdg-desktop-portal: ${String(error)}`;
982
- attempts.push(message);
983
- if (error && typeof error === 'object' && error.code === 'PORTAL_CANCELLED') {
984
- fail(`Screen capture was cancelled in the desktop permission dialog (${message}).`);
1034
+ let preserveCapture = false;
1035
+ try {
1036
+ if (process.platform === 'win32') {
1037
+ const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', windowsScreenshotScript(file)], { encoding: 'utf8', timeout: 30000 });
1038
+ attempts.push(`powershell: ${(result.stderr || '').trim() || ('exit ' + result.status)}`);
1039
+ } else {
1040
+ if (process.platform === 'linux' && isWaylandSession()) {
1041
+ try {
1042
+ await capturePortalScreenshot(file);
1043
+ } catch (error) {
1044
+ const message = error instanceof Error ? error.message : `xdg-desktop-portal: ${String(error)}`;
1045
+ attempts.push(message);
1046
+ if (error && typeof error === 'object' && error.code === 'PORTAL_CANCELLED') {
1047
+ fail(`Screen capture was cancelled in the desktop permission dialog (${message}).`);
1048
+ }
1049
+ }
1050
+ }
1051
+ if (!await pathExists(file)) {
1052
+ for (const candidate of SCREENSHOT_COMMANDS) {
1053
+ if (spawnSync('which', [candidate.command], { encoding: 'utf8' }).status !== 0) continue;
1054
+ const result = spawnSync(candidate.command, candidate.args(file), { encoding: 'utf8', timeout: 30000 });
1055
+ if (result.status === 0 && await pathExists(file)) break;
1056
+ attempts.push(`${candidate.command}: ${(result.stderr || '').trim() || ('exit ' + result.status)}`);
985
1057
  }
986
1058
  }
987
1059
  }
988
- if (!await pathExists(file)) {
989
- for (const candidate of SCREENSHOT_COMMANDS) {
990
- if (spawnSync('which', [candidate.command], { encoding: 'utf8' }).status !== 0) continue;
991
- const result = spawnSync(candidate.command, candidate.args(file), { encoding: 'utf8', timeout: 30000 });
992
- if (result.status === 0 && await pathExists(file)) break;
993
- attempts.push(`${candidate.command}: ${(result.stderr || '').trim() || `exit ${result.status}`}`);
1060
+ if (!await pathExists(file)) fail(screenshotAdvice(attempts));
1061
+
1062
+ const { handle, info } = await openRegularFile(file);
1063
+ try {
1064
+ if (info.size <= MAX_IMAGE_BYTES) {
1065
+ const buffer = await handle.readFile();
1066
+ preserveCapture = args.keep === true;
1067
+ return multi([
1068
+ { type: 'text', text: `Screenshot of ${os.hostname()} (${info.size} bytes)${preserveCapture ? (' saved at ' + displayPath(file)) : ''}` },
1069
+ image(buffer.toString('base64'), 'image/png'),
1070
+ ]);
994
1071
  }
1072
+ preserveCapture = true;
1073
+ return text(`Screenshot of ${os.hostname()} captured: ${info.size} bytes, above the ${MAX_IMAGE_BYTES}-byte inline limit, so it is saved at ${displayPath(file)} instead of being returned as an image. Fetch it with read_binary using chunks of up to ${MAX_BINARY_CHUNK_BYTES} bytes (offset_bytes and length_bytes), or ask for a smaller region.`);
1074
+ } finally {
1075
+ await handle.close();
1076
+ }
1077
+ } finally {
1078
+ if (!preserveCapture) {
1079
+ if (temporaryDirectory) await rm(temporaryDirectory, { recursive: true, force: true }).catch(() => {});
1080
+ else await rm(file, { force: true }).catch(() => {});
995
1081
  }
996
1082
  }
997
- if (!await pathExists(file)) {
998
- fail(screenshotAdvice(attempts));
999
- }
1000
- const info = await stat(file);
1001
- if (info.size <= MAX_IMAGE_BYTES) {
1002
- const buffer = await readFile(file);
1003
- if (args.keep !== true) await rm(file, { force: true });
1004
- return multi([
1005
- { type: 'text', text: `Screenshot of ${os.hostname()} (${info.size} bytes)${args.keep === true ? ` saved at ${displayPath(file)}` : ''}` },
1006
- image(buffer.toString('base64'), 'image/png'),
1007
- ]);
1008
- }
1009
- // A big screen is not an error: the PNG stays on the computer and the model is told how to fetch it
1010
- // in chunks, which is the same path every other large file takes. Failing here used to lose the
1011
- // screenshot entirely.
1012
- return text(`Screenshot of ${os.hostname()} captured: ${info.size} bytes, above the ${MAX_IMAGE_BYTES}-byte inline limit, so it is saved at ${displayPath(file)} instead of being returned as an image. Fetch it with read_binary using chunks of up to ${MAX_BINARY_CHUNK_BYTES} bytes (offset_bytes and length_bytes), or ask for a smaller region.`);
1013
1083
  }
1014
-
1015
1084
  export const fileToolHandlers = {
1016
1085
  read_file: readFileTool,
1017
1086
  read_files: readFilesTool,
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
- import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import { open, readdir, stat } from 'node:fs/promises';
3
4
  import { spawn, spawnSync } from 'node:child_process';
4
5
  import { runtimeConfig } from '../config.mjs';
5
6
  import { countEvent, recordEvent } from '../telemetry.mjs';
@@ -152,10 +153,17 @@ async function runFallback(session, { path: target, matcher, searchType, filePat
152
153
  appendSearchResults(session, [displayPath(file)]);
153
154
  return;
154
155
  }
155
- const info = await stat(file).catch(() => null);
156
- if (!info || info.size > MAX_FALLBACK_FILE_BYTES) return;
157
- const buffer = await readFile(file).catch(() => null);
158
- if (!buffer || looksBinary(buffer)) return;
156
+ const handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW || 0)).catch(() => null);
157
+ if (!handle) return;
158
+ let buffer;
159
+ try {
160
+ const info = await handle.stat();
161
+ if (!info.isFile() || info.size > MAX_FALLBACK_FILE_BYTES) return;
162
+ buffer = await handle.readFile();
163
+ } finally {
164
+ await handle.close();
165
+ }
166
+ if (looksBinary(buffer)) return;
159
167
  const lines = splitLines(buffer.toString('utf8'));
160
168
  for (let index = 0; index < lines.length; index += 1) {
161
169
  if (session.status !== 'running' || collected >= maxResults) return;