@remcp/runtime 0.2.218 → 0.2.221

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.218",
3
+ "version": "0.2.221",
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",
@@ -25,7 +25,8 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@jellybrick/dbus-next": "^0.11.3",
28
- "@modelcontextprotocol/server": "^2.3.0"
28
+ "@modelcontextprotocol/server": "^2.3.0",
29
+ "pdf-lib": "^1.17.1"
29
30
  },
30
31
  "optionalDependencies": {
31
32
  "macos-temperature-sensor": "2.1.1"
@@ -405,6 +405,11 @@ export async function screenshotRegion(args = {}) {
405
405
  }
406
406
  if (!row) throw new Error('No matching window found for screenshot_region');
407
407
  x = Number(row.x); y = Number(row.y); width = Number(row.width); height = Number(row.height);
408
+ } else if (process.platform === 'win32' && optionalString(args.monitor)?.toLowerCase() === 'primary'
409
+ && args.monitor_index == null && clamp(args.padding, 0, 0, 200) === 0) {
410
+ // Windows GDI capture resolves primary geometry in the same PowerShell
411
+ // invocation. Avoid a second, expensive process on loaded Windows VMs.
412
+ return adapter.screenshotRegion({ monitor:'primary' });
408
413
  } else if (args.monitor != null || args.monitor_index != null) {
409
414
  const displays = resultValue(await displayInventory());
410
415
  if (!Array.isArray(displays)) throw new Error('This platform does not expose normalized monitor geometry for screenshot_region');
@@ -409,16 +409,30 @@ public static class RMDisplay{
409
409
  return jsonResult(Array.isArray(parsed)?parsed:[parsed]);
410
410
  }
411
411
 
412
+ export function windowsRegionCaptureScript({primary=false,x=0,y=0,width=0,height=0} = {}, target='') {
413
+ const safe=escapePowerShellSingle(target);
414
+ const bounds=primary
415
+ ? 'Add-Type -AssemblyName System.Windows.Forms,System.Drawing;$r=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds;'
416
+ : `Add-Type -AssemblyName System.Drawing;$r=[pscustomobject]@{X=${x};Y=${y};Width=${width};Height=${height}};`;
417
+ return `$ErrorActionPreference='Stop';${bounds}if($r.Width -le 0 -or $r.Height -le 0 -or $r.Width -gt 8192 -or $r.Height -gt 8192 -or ($r.Width*$r.Height) -gt 16777216){throw 'Invalid or oversized display bounds'};$b=New-Object System.Drawing.Bitmap $r.Width,$r.Height;$g=$null;try{$g=[System.Drawing.Graphics]::FromImage($b);$g.CopyFromScreen($r.X,$r.Y,0,0,$b.Size);$b.Save('${safe}',[System.Drawing.Imaging.ImageFormat]::Png)}finally{if($g){$g.Dispose()};$b.Dispose()}`;
418
+ }
419
+
412
420
  export async function screenshotRegion(args = {}) {
421
+ const primary=args.monitor==='primary' && args.x==null && args.y==null
422
+ && args.width==null && args.height==null;
413
423
  const x=Math.trunc(Number(args.x)),y=Math.trunc(Number(args.y)),width=Math.trunc(Number(args.width)),height=Math.trunc(Number(args.height));
414
- if(![x,y,width,height].every(Number.isFinite)||width<=0||height<=0)throw new Error('x, y, width and height are required; width/height must be positive');
415
- if(width>8192||height>8192||width*height>16_777_216)throw new Error('screenshot region is too large; width/height must be <= 8192 and area <= 16 megapixels');
424
+ if(!primary && (![x,y,width,height].every(Number.isFinite)||width<=0||height<=0))
425
+ throw new Error('x, y, width and height are required; width/height must be positive');
426
+ if(!primary && (width>8192||height>8192||width*height>16_777_216))
427
+ throw new Error('screenshot region is too large; width/height must be <= 8192 and area <= 16 megapixels');
416
428
  const dir=await tempDir('remcp-region-'),target=path.join(dir,'region.png');
417
429
  try{
418
- const safe=escapePowerShellSingle(target);
419
- 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'});
430
+ const script=windowsRegionCaptureScript({primary,x,y,width,height},target);
431
+ await runPowerShell(script,{label:'screenshot region',timeout:30_000});
420
432
  const {data}=await readPrivateTempFile(target,4*1024*1024);
421
- return multi([{type:'text',text:`Captured ${width}x${height} at ${x},${y}.`},image(data.toString('base64'),'image/png')]);
433
+ const description=primary ? `Captured primary display ${data.readUInt32BE(16)}x${data.readUInt32BE(20)}.`
434
+ : `Captured ${width}x${height} at ${x},${y}.`;
435
+ return multi([{type:'text',text:description},image(data.toString('base64'),'image/png')]);
422
436
  }finally{await removeTemp(dir);}
423
437
  }
424
438
 
@@ -275,6 +275,11 @@ function pdfAnnotations(buffer) {
275
275
  return rows;
276
276
  }
277
277
 
278
+ async function loadPdfLibrary() {
279
+ const { PDFDocument } = await import('pdf-lib');
280
+ return PDFDocument;
281
+ }
282
+
278
283
  export async function pdfAction(args) {
279
284
  const action = requireEnum(args.action,'action',['merge','split','extract_pages','annotations','info']);
280
285
  if (action === 'annotations') {
@@ -289,7 +294,9 @@ export async function pdfAction(args) {
289
294
  finally { await removeTemp(snapshot.dir); }
290
295
  }
291
296
  const data = await readDocumentFile(source);
292
- return jsonResult({ path:source, bytes:data.length, annotation_count:pdfAnnotations(data).length });
297
+ let pageCount = null;
298
+ try { const PDFDocument = await loadPdfLibrary(); pageCount = (await PDFDocument.load(data, { updateMetadata:false })).getPageCount(); } catch {}
299
+ return jsonResult({ path:source, bytes:data.length, ...(pageCount === null ? {} : { page_count:pageCount }), annotation_count:pdfAnnotations(data).length });
293
300
  }
294
301
  if (action === 'merge') {
295
302
  const requested = Array.isArray(args.paths) ? args.paths : [];
@@ -310,7 +317,15 @@ export async function pdfAction(args) {
310
317
  }
311
318
  if(commandExists('pdfunite')) await runFile('pdfunite',[...snapshots,staged],{label:'PDF merge',timeout:60_000});
312
319
  else if(commandExists('qpdf')) await runFile('qpdf',['--empty','--pages',...snapshots,'--',staged],{label:'PDF merge',timeout:60_000});
313
- else unavailable('PDF merge','install poppler-utils (pdfunite) or qpdf');
320
+ else {
321
+ const PDFDocument=await loadPdfLibrary();
322
+ const merged=await PDFDocument.create();
323
+ for(const snapshot of snapshots){
324
+ const sourceDoc=await PDFDocument.load(await readFile(snapshot),{updateMetadata:false});
325
+ for(const page of await merged.copyPages(sourceDoc,sourceDoc.getPageIndices())) merged.addPage(page);
326
+ }
327
+ await writeFile(staged,await merged.save());
328
+ }
314
329
  await commitDocumentOutput(staged,output);
315
330
  return jsonResult({action,output,inputs:sources.length,bytes:(await stat(output)).size});
316
331
  } finally {
@@ -331,7 +346,20 @@ export async function pdfAction(args) {
331
346
  try {
332
347
  const pattern=path.join(work,patternName);
333
348
  if(commandExists('pdfseparate')) await runFile('pdfseparate',[snapshot.path,pattern],{label:'PDF split',timeout:60_000});
334
- else unavailable('PDF split','install poppler-utils (pdfseparate)');
349
+ else {
350
+ const PDFDocument=await loadPdfLibrary();
351
+ const sourceDoc=await PDFDocument.load(await readFile(snapshot.path),{updateMetadata:false});
352
+ const total=sourceDoc.getPageCount();
353
+ for(let page=1;page<=total;page+=1){
354
+ const single=await PDFDocument.create();
355
+ const [copied]=await single.copyPages(sourceDoc,[page-1]);
356
+ single.addPage(copied);
357
+ const name=patternName.includes('%d')
358
+ ? patternName.replace(/%d/g,String(page))
359
+ : patternName.toLowerCase().endsWith('.pdf') ? `${patternName.slice(0,-4)}-${page}.pdf` : `${patternName}-${page}.pdf`;
360
+ await writeFile(path.join(work,name),await single.save());
361
+ }
362
+ }
335
363
  for(const name of await readdir(work)){
336
364
  if(!name.toLowerCase().endsWith('.pdf')) continue;
337
365
  const staged=path.join(stageDir,name);
@@ -378,7 +406,14 @@ export async function pdfAction(args) {
378
406
  }
379
407
  } finally { await removeTemp(work); }
380
408
  } else {
381
- unavailable('PDF page extraction','install qpdf, pdftk, or poppler-utils (pdfseparate; pdfunite for multiple pages)');
409
+ const PDFDocument=await loadPdfLibrary();
410
+ const sourceDoc=await PDFDocument.load(await readFile(snapshot.path),{updateMetadata:false});
411
+ const total=sourceDoc.getPageCount();
412
+ const indices=selectedPages.map(page=>page-1);
413
+ if(indices.some(index=>index>=total)) throw new Error(`Page selection exceeds ${total} pages`);
414
+ const extracted=await PDFDocument.create();
415
+ for(const page of await extracted.copyPages(sourceDoc,indices)) extracted.addPage(page);
416
+ await writeFile(staged,await extracted.save());
382
417
  }
383
418
  await commitDocumentOutput(staged,output);
384
419
  return jsonResult({action,source,pages:normalizedPages,output,bytes:(await stat(output)).size});
@@ -15,7 +15,7 @@ export function privilegeBoundary({
15
15
  if (output === undefined) {
16
16
  try {
17
17
  const result = spawnSync('whoami.exe', ['/groups'], {
18
- encoding: 'utf8', windowsHide: true, timeout: 3000, maxBuffer: 64 * 1024,
18
+ encoding: 'utf8', windowsHide: true, timeout: 5000, maxBuffer: 64 * 1024,
19
19
  });
20
20
  output = result.status === 0 && !result.error ? result.stdout : '';
21
21
  } catch { output = ''; }
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import os from 'node:os';
3
- import { spawnSync } from 'node:child_process';
3
+ import { execFile, spawnSync } from 'node:child_process';
4
+ import { promisify } from 'node:util';
4
5
  import { createHash, randomUUID } from 'node:crypto';
5
6
  import { constants, createReadStream } from 'node:fs';
6
7
  import { access, chmod, chown, copyFile, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, unlink } from 'node:fs/promises';
@@ -21,6 +22,9 @@ import { diffFilesTool, editBlockTool, moveToTrashTool, readBinaryTool, replaceI
21
22
  import { applyPatchTool, copyFileTool, copyPathsTool, createDirectoryTool, deletePathTool, deletePathsTool, moveFileTool, movePathsTool, pathExists, readFilesTool, setPermissionsTool, writeFilesTool } from './files-bulk-operations.mjs';
22
23
  import { createArchiveTool, extractArchiveTool } from './files-archive-actions.mjs';
23
24
 
25
+ const execFileAsync = promisify(execFile);
26
+ let windowsScreenshotInFlight = false;
27
+
24
28
  export const SCREENSHOT_COMMANDS = [
25
29
  { command: 'grim', args: file => [file] },
26
30
  { command: 'gnome-screenshot', args: file => ['-f', file] },
@@ -38,6 +42,9 @@ export function screenshotAdvice(attempts, { platform = process.platform, env =
38
42
  return `macOS refused the screen capture (${attempts.join('; ') || 'no capture command ran'}). Grant Screen Recording to the binary that runs the tools — System Settings → Privacy & Security → Screen Recording → + → ${execPath} — then restart the agent with \`remcp start\`. macOS requires it for screencapture even when the file itself is writable.`;
39
43
  }
40
44
  if (platform === 'win32') {
45
+ if (attempts.some(attempt => attempt.includes('A Windows screen capture is already in progress'))) {
46
+ return 'A Windows screen capture is already in progress. Retry after it finishes.';
47
+ }
41
48
  return `Windows refused the screen capture (${attempts.join('; ') || 'no capture command ran'}). Screen capture needs an interactive desktop session: a machine where nobody is signed in, or a locked session, cannot be captured. Sign in on that computer and try again.`;
42
49
  }
43
50
  const wayland = /wayland/i.test(String(env.XDG_SESSION_TYPE || '')) || Boolean(env.WAYLAND_DISPLAY);
@@ -58,13 +65,36 @@ export function screenshotAdvice(attempts, { platform = process.platform, env =
58
65
 
59
66
  export function windowsScreenshotScript(file) {
60
67
  return [
68
+ "$ErrorActionPreference = 'Stop'",
61
69
  'Add-Type -AssemblyName System.Windows.Forms,System.Drawing',
62
70
  '$b = [System.Windows.Forms.SystemInformation]::VirtualScreen',
63
- '$bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height',
64
- '$g = [System.Drawing.Graphics]::FromImage($bmp)',
65
- '$g.CopyFromScreen($b.Left, $b.Top, 0, 0, $bmp.Size)',
71
+ "if ($b.Width -le 0 -or $b.Height -le 0) { throw 'No valid desktop geometry for screenshot' }",
72
+ '$bmp = $null',
73
+ '$g = $null',
74
+ 'try {',
75
+ ' $bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height',
76
+ ' $g = [System.Drawing.Graphics]::FromImage($bmp)',
77
+ ' $g.CopyFromScreen($b.Left, $b.Top, 0, 0, $bmp.Size)',
66
78
  `$bmp.Save('${file.replace(/'/g, "''")}', [System.Drawing.Imaging.ImageFormat]::Png)`,
67
- ].join('; ');
79
+ '} finally {',
80
+ ' if ($null -ne $g) { $g.Dispose() }',
81
+ ' if ($null -ne $bmp) { $bmp.Dispose() }',
82
+ '}',
83
+ ].join('\n');
84
+ }
85
+
86
+ // Never block the runtime event loop while Windows initializes GDI/PowerShell.
87
+ // A slow or inaccessible interactive desktop must not starve ping or relay RPC.
88
+ export async function runWindowsScreenshot(file, runProcess = execFileAsync) {
89
+ if (windowsScreenshotInFlight) throw new Error('A Windows screen capture is already in progress; retry after it finishes');
90
+ windowsScreenshotInFlight = true;
91
+ try {
92
+ return await runProcess('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', windowsScreenshotScript(file)], {
93
+ encoding:'utf8', timeout:30_000, windowsHide:true, maxBuffer:64*1024,
94
+ });
95
+ } finally {
96
+ windowsScreenshotInFlight = false;
97
+ }
68
98
  }
69
99
 
70
100
  export async function takeScreenshotTool(args) {
@@ -86,8 +116,11 @@ export async function takeScreenshotTool(args) {
86
116
  let preserveCapture = false;
87
117
  try {
88
118
  if (process.platform === 'win32') {
89
- const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', windowsScreenshotScript(file)], { encoding: 'utf8', timeout: 30000 });
90
- attempts.push(`powershell: ${(result.stderr || '').trim() || ('exit ' + result.status)}`);
119
+ try {
120
+ await runWindowsScreenshot(file);
121
+ } catch (error) {
122
+ attempts.push(`powershell: ${String(error?.stderr || error?.message || error).trim().slice(0, 512)}`);
123
+ }
91
124
  } else {
92
125
  if (process.platform === 'linux' && isWaylandSession()) {
93
126
  try {
@@ -1,6 +1,6 @@
1
1
  // Best-effort host measurements. Keep interface names, mount paths and sensor labels bounded;
2
2
  // never expose raw process arguments or arbitrary kernel sensor metadata.
3
- import { readFile, readdir } from 'node:fs/promises';
3
+ import { readFile, readdir, statfs } from 'node:fs/promises';
4
4
  import { execFile } from 'node:child_process';
5
5
  import { promisify } from 'node:util';
6
6
  import os from 'node:os';
@@ -75,6 +75,25 @@ export function parseWindowsNetworkCounters(input, activeNames = null) {
75
75
  return [...found.values()];
76
76
  }
77
77
 
78
+ // netsh exposes cumulative interface bytes even on Windows hosts where the
79
+ // NetAdapterStatistics PowerShell provider returns no entries. Match columns
80
+ // rather than localized header words; reject unsafe aliases and fake rates.
81
+ export function parseWindowsNetshCounters(input, activeNames = null) {
82
+ const found = new Map();
83
+ for (const line of String(input).split(/\r?\n/).slice(0, 130)) {
84
+ const match = line.match(/^\s*\d{1,10}\s+1\s+(\d{1,16})\s+(\d{1,16})\s+(.+?)\s*$/u);
85
+ if (!match) continue;
86
+ const name = match[3];
87
+ if (!/^[\p{L}\p{N} _().:#-]{1,64}$/u.test(name) ||
88
+ (activeNames && !activeNames.has(name))) continue;
89
+ const rx = Number(match[1]), tx = Number(match[2]);
90
+ if (!numeric(rx) || !numeric(tx)) continue;
91
+ found.set(name, { name, rx, tx });
92
+ if (found.size >= 24) break;
93
+ }
94
+ return [...found.values()];
95
+ }
96
+
78
97
  export function calculateNetworkRates(current, previous, elapsedSeconds) {
79
98
  if (!Array.isArray(current) || !Array.isArray(previous) || !Number.isFinite(elapsedSeconds) ||
80
99
  elapsedSeconds < 1 || elapsedSeconds > 300) return null;
@@ -114,13 +133,21 @@ async function readNetworkCounters() {
114
133
  return parseDarwinNetworkCounters(stdout, active);
115
134
  }
116
135
  if (process.platform === 'win32') {
117
- const script = 'Get-NetAdapterStatistics | Select-Object Name,ReceivedBytes,SentBytes | ConvertTo-Json -Compress';
118
- const { stdout } = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script],
119
- { windowsHide: true, timeout: 5000, maxBuffer: 128 * 1024 });
120
136
  const active = new Set(Object.entries(os.networkInterfaces())
121
137
  .filter(([, addresses]) => addresses?.some(address => !address.internal))
122
138
  .map(([name]) => name));
123
- return parseWindowsNetworkCounters(stdout, active);
139
+ try {
140
+ const { stdout } = await run('netsh', ['interface', 'ipv4', 'show', 'subinterfaces'],
141
+ { windowsHide: true, timeout: 2500, maxBuffer: 128 * 1024 });
142
+ const counters = parseWindowsNetshCounters(stdout, active);
143
+ if (counters.length) return counters;
144
+ } catch { /* Fall through to the existing adapter provider. */ }
145
+ try {
146
+ const script = 'Get-NetAdapterStatistics | Select-Object Name,ReceivedBytes,SentBytes | ConvertTo-Json -Compress';
147
+ const { stdout } = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script],
148
+ { windowsHide: true, timeout: 5000, maxBuffer: 128 * 1024 });
149
+ return parseWindowsNetworkCounters(stdout, active);
150
+ } catch { return []; }
124
151
  }
125
152
  return [];
126
153
  }
@@ -185,8 +212,36 @@ export function parseDiskVolumes(input) {
185
212
  return volumes;
186
213
  }
187
214
 
215
+ async function windowsDiskVolumes() {
216
+ const volumes = [];
217
+ for (const letter of 'CDEFGHIJKLMNOPQRSTUVWXYZ') {
218
+ const mount = `${letter}:\\`;
219
+ let timer;
220
+ try {
221
+ const info = await Promise.race([
222
+ statfs(mount),
223
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('volume probe timeout')), 250); }),
224
+ ]);
225
+ const totalBytes = Number(info.blocks) * Number(info.bsize);
226
+ const freeBytes = Number(info.bfree) * Number(info.bsize);
227
+ const availableBytes = Number(info.bavail) * Number(info.bsize);
228
+ if (!Number.isFinite(totalBytes) || totalBytes <= 0) continue;
229
+ const usedBytes = Math.max(0, totalBytes - freeBytes);
230
+ volumes.push({
231
+ mount,
232
+ totalBytes,
233
+ usedBytes,
234
+ availableBytes,
235
+ usedRatio: Number((usedBytes / totalBytes).toFixed(3)),
236
+ });
237
+ } catch {}
238
+ finally { clearTimeout(timer); }
239
+ }
240
+ return volumes;
241
+ }
242
+
188
243
  export async function diskVolumes() {
189
- if (process.platform === 'win32') return [];
244
+ if (process.platform === 'win32') return windowsDiskVolumes();
190
245
  try {
191
246
  const { stdout } = await run('df', ['-kP'], { timeout: 2000, maxBuffer: 256 * 1024 });
192
247
  return parseDiskVolumes(stdout);
@@ -1,6 +1,8 @@
1
1
  import os from 'node:os';
2
+ import path from 'node:path';
2
3
  import process from 'node:process';
3
4
  import { execFile } from 'node:child_process';
5
+ import { statfs } from 'node:fs/promises';
4
6
  import { promisify } from 'node:util';
5
7
  import { runtimeConfig } from '../config.mjs';
6
8
  import { clampInteger, fail, requireInteger, text } from '../util.mjs';
@@ -18,7 +20,23 @@ export function redactSecrets(command) {
18
20
  }
19
21
 
20
22
  async function diskUsage() {
21
- if (process.platform === 'win32') return null;
23
+ if (process.platform === 'win32') {
24
+ const mount = path.parse(process.cwd()).root || 'C:\\';
25
+ let timer;
26
+ try {
27
+ const info = await Promise.race([
28
+ statfs(mount),
29
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('disk probe timeout')), 500); }),
30
+ ]);
31
+ const totalBytes = Number(info.blocks) * Number(info.bsize);
32
+ const freeBytes = Number(info.bfree) * Number(info.bsize);
33
+ const availableBytes = Number(info.bavail) * Number(info.bsize);
34
+ if (!Number.isFinite(totalBytes) || totalBytes <= 0) return null;
35
+ const usedBytes = Math.max(0, totalBytes - freeBytes);
36
+ return { mount, totalBytes, usedBytes, availableBytes, usedRatio: Number((usedBytes / totalBytes).toFixed(3)) };
37
+ } catch { return null; }
38
+ finally { clearTimeout(timer); }
39
+ }
22
40
  try {
23
41
  const { stdout } = await run('df', ['-kP', process.cwd()], { maxBuffer: 1024 * 1024 });
24
42
  const line = stdout.trim().split('\n')[1];