@remcp/runtime 0.2.212 → 0.2.214

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.212",
3
+ "version": "0.2.214",
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",
@@ -51,8 +51,10 @@ export const PAGE_SCROLL_ACTION = `function(selector, needle, deltaX, deltaY) {
51
51
 
52
52
  const beforeX = Number(scroller.scrollLeft || 0);
53
53
  const beforeY = Number(scroller.scrollTop || 0);
54
- scroller.scrollLeft = beforeX + Number(deltaX || 0);
55
- scroller.scrollTop = beforeY + Number(deltaY || 0);
54
+ // CSS scroll-behavior:smooth can defer these changes indefinitely in a
55
+ // background target. Apply the requested move before reading its result.
56
+ scroller.scrollTo({left:beforeX + Number(deltaX || 0),
57
+ top:beforeY + Number(deltaY || 0), behavior:'instant'});
56
58
  return {
57
59
  target: target ? target.tagName.toLowerCase() : 'page',
58
60
  scroller: scroller === document.scrollingElement || scroller === document.documentElement ? 'page' : scroller.tagName.toLowerCase(),
@@ -35,6 +35,7 @@ export function browserExecutable() {
35
35
  path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'),
36
36
  path.join(process.env.LOCALAPPDATA || '', 'Google', 'Chrome', 'Application', 'chrome.exe'),
37
37
  path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
38
+ path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
38
39
  ]
39
40
  : ['/opt/google/chrome/chrome','/usr/bin/google-chrome','/usr/bin/google-chrome-stable','chromium','chromium-browser','microsoft-edge','microsoft-edge-stable'];
40
41
  return candidates.find(candidate => path.isAbsolute(candidate) ? existsSync(candidate) : commandExists(candidate)) || null;
@@ -197,11 +197,15 @@ export async function runFileHeadLines(file, args = [], lineLimit = 200, options
197
197
  });
198
198
  }
199
199
 
200
+ // Windows PowerShell writes redirected stdout in the OEM code page by default,
201
+ // which corrupts Unicode names and clipboard text. Emit UTF-8 without a BOM.
202
+ const WINDOWS_UTF8_OUTPUT = '[Console]::OutputEncoding=New-Object System.Text.UTF8Encoding $false;';
203
+
200
204
  export async function runPowerShell(script, options = {}) {
201
205
  const executable = process.env.SystemRoot
202
206
  ? path.join(process.env.SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
203
207
  : 'powershell.exe';
204
- return runFile(executable, ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], {
208
+ return runFile(executable, ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', WINDOWS_UTF8_OUTPUT + script], {
205
209
  ...options,
206
210
  label: options.label || 'PowerShell',
207
211
  });
@@ -16,15 +16,31 @@ import { findOcrText, ocrImage } from './ocr.mjs';
16
16
  import { boundedComputerSnapshot, clipboard, cursorPosition, displayInventory, listWindows, objectRecord, resultValue, screenshotRegion, snapshotError, suggestedUiAction, toolResultMessage, uiSnapshot } from './desktop-core.mjs';
17
17
  import { activeWindowCaptureBounds, browserTitleHint, computerScreenshotTask, hasExplicitComputerScreenshotTarget, ocrScreenshotResult } from './desktop-vision.mjs';
18
18
 
19
+ // Several simultaneous Windows PowerShell starts can stall until their shared
20
+ // timeout on low-core hosts, which made the whole snapshot report every probe
21
+ // as unavailable. Queue the probes on Windows; other platforms stay parallel.
22
+ export async function settleComputerSnapshotProbes(tasks, { serial = process.platform === 'win32' } = {}) {
23
+ if (!serial) return Promise.allSettled(tasks.map((task) => task()));
24
+ const results = [];
25
+ for (const task of tasks) {
26
+ try {
27
+ results.push({ status: 'fulfilled', value: await task() });
28
+ } catch (reason) {
29
+ results.push({ status: 'rejected', reason });
30
+ }
31
+ }
32
+ return results;
33
+ }
34
+
19
35
  export async function computerSnapshot(args = {}) {
20
36
  const desktopAvailable = desktopSessionAvailable();
21
37
  const screenshotRequested = args.include_screenshot !== false || args.include_ocr === true;
22
38
  const captureScreenshot = desktopAvailable && screenshotRequested;
23
39
  const includeUi = args.include_ui !== false;
24
- const [windowsResult, displaysResult, uiResult, clipboardResult, cursorResult, shotResult, readinessResult] = await Promise.allSettled([
25
- desktopAvailable ? listWindows() : Promise.resolve(null),
26
- desktopAvailable ? displayInventory() : Promise.resolve(null),
27
- includeUi && desktopAvailable
40
+ const [windowsResult, displaysResult, uiResult, clipboardResult, cursorResult, shotResult, readinessResult] = await settleComputerSnapshotProbes([
41
+ () => desktopAvailable ? listWindows() : Promise.resolve(null),
42
+ () => desktopAvailable ? displayInventory() : Promise.resolve(null),
43
+ () => includeUi && desktopAvailable
28
44
  ? uiSnapshot({
29
45
  max_nodes:args.max_ui_nodes || 800,
30
46
  max_depth:args.max_ui_depth || 12,
@@ -33,10 +49,10 @@ export async function computerSnapshot(args = {}) {
33
49
  ...(args.ui_browser_dom === true ? { browser_dom:true } : {}),
34
50
  })
35
51
  : Promise.resolve(null),
36
- desktopAvailable ? clipboard({ action:'read' }) : Promise.resolve(null),
37
- desktopAvailable ? cursorPosition() : Promise.resolve(null),
38
- captureScreenshot ? computerScreenshotTask(args) : Promise.resolve(null),
39
- args.browser_readiness === true ? probeBrowserReadiness() : Promise.resolve(null),
52
+ () => desktopAvailable ? clipboard({ action:'read' }) : Promise.resolve(null),
53
+ () => desktopAvailable ? cursorPosition() : Promise.resolve(null),
54
+ () => captureScreenshot ? computerScreenshotTask(args) : Promise.resolve(null),
55
+ () => args.browser_readiness === true ? probeBrowserReadiness() : Promise.resolve(null),
40
56
  ]);
41
57
  const parse = (result, fallback = null) => {
42
58
  if (result.status !== 'fulfilled' || !result.value || result.value.isError === true) return fallback;
@@ -197,7 +197,7 @@ while($q.Count -gt 0 -and $seen -lt ${maxNodes}){
197
197
  return jsonResult(parsed);
198
198
  }
199
199
 
200
- export async function uiAction(args = {}) {
200
+ export async function uiAction(args = {}, { runPowerShellFn = runPowerShell } = {}) {
201
201
  const action = requireEnum(args.action, 'action', [
202
202
  'click','invoke','focus','set_value','select','toggle','expand','collapse',
203
203
  'scroll_into_view','set_range_value','add_to_selection','remove_from_selection',
@@ -207,7 +207,8 @@ export async function uiAction(args = {}) {
207
207
  const role = escapePowerShellSingle(optionalString(args.role) || '');
208
208
  const automationId = escapePowerShellSingle(optionalString(args.automation_id || args.automationId) || '');
209
209
  const value = escapePowerShellSingle(String(args.value ?? ''));
210
- const numericValue = Number(args.value);
210
+ // The entire switch must parse even when the selected action has no numeric value.
211
+ const numericValue = action === 'set_range_value' ? Number(args.value) : 0;
211
212
  if (action === 'set_range_value' && !Number.isFinite(numericValue)) throw new Error('set_range_value requires a finite numeric value');
212
213
  const script = String.raw`
213
214
  Add-Type -AssemblyName UIAutomationClient;Add-Type -AssemblyName UIAutomationTypes
@@ -278,16 +279,40 @@ switch('${action}'){
278
279
  }
279
280
  }
280
281
  [pscustomobject]@{id=('win:'+($found.GetRuntimeId()-join '.'));name=$found.Current.Name;role=$found.Current.ControlType.ProgrammaticName.Replace('ControlType.','');action='${action}';backend=$used}|ConvertTo-Json -Compress`;
281
- const raw = (await runPowerShell(script, { label:'UI action', timeout:30_000 })).stdout.trim();
282
+ const raw = (await runPowerShellFn(script, { label:'UI action', timeout:30_000 })).stdout.trim();
282
283
  return jsonResult(raw ? JSON.parse(raw) : { action });
283
284
  }
284
285
 
286
+ export function buildWindowsClipboardPowerShell(action, value = '') {
287
+ // Set-Clipboard rejects an empty value in Windows PowerShell 5.1.
288
+ // Clear the clipboard using the OS clipboard API instead of treating it as a write.
289
+ if (action === 'clear' || value.length === 0) {
290
+ return 'Add-Type -AssemblyName System.Windows.Forms;[System.Windows.Forms.Clipboard]::Clear()';
291
+ }
292
+ const encoded = Buffer.from(value, 'utf16le').toString('base64');
293
+ return `$b=[Convert]::FromBase64String('${encoded}');Set-Clipboard -Value ([Text.Encoding]::Unicode.GetString($b))`;
294
+ }
295
+
296
+ export function buildWindowsClipboardReadPowerShell() {
297
+ // Windows PowerShell writes redirected stdout in the OEM code page, which
298
+ // corrupts non-ASCII clipboard text. Return UTF-8 bytes as base64 instead.
299
+ return "$v=Get-Clipboard -Raw;if($null -eq $v){$v=''};[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($v))";
300
+ }
301
+
302
+ export function decodeWindowsClipboardRead(stdout = '') {
303
+ const encoded = String(stdout).trim();
304
+ if (!encoded) return '';
305
+ return Buffer.from(encoded, 'base64').toString('utf8').replace(/\r?\n$/, '');
306
+ }
307
+
285
308
  export async function clipboard(args = {}) {
286
309
  const action=requireEnum(args.action,'action',['read','write','clear']);
287
- if(action==='read')return text((await runPowerShell("$v=Get-Clipboard -Raw;if($null -eq $v){$v=''};$v",{label:'clipboard read'})).stdout.replace(/\r?\n$/,''));
310
+ if(action==='read'){
311
+ const {stdout}=await runPowerShell(buildWindowsClipboardReadPowerShell(),{label:'clipboard read'});
312
+ return text(decodeWindowsClipboardRead(stdout));
313
+ }
288
314
  const value=action==='clear'?'':String(args.text??args.value??'');
289
- const encoded=Buffer.from(value,'utf16le').toString('base64');
290
- await runPowerShell(`$b=[Convert]::FromBase64String('${encoded}');Set-Clipboard -Value ([Text.Encoding]::Unicode.GetString($b))`,{label:'clipboard write'});
315
+ await runPowerShell(buildWindowsClipboardPowerShell(action, value),{label: action === 'clear' ? 'clipboard clear' : 'clipboard write'});
291
316
  return jsonResult({action,length:value.length});
292
317
  }
293
318
 
@@ -310,8 +335,10 @@ export async function typeTextKeys(value) {
310
335
  }
311
336
 
312
337
  function mousePrelude() {
338
+ // dwData is a signed wheel delta. Declaring it as uint made the generated
339
+ // negative literal cast throw before the wheel event was sent.
313
340
  return String.raw`Add-Type @'
314
- using System;using System.Runtime.InteropServices;public static class RMMouse{[DllImport("user32.dll")]public static extern bool SetCursorPos(int x,int y);[DllImport("user32.dll")]public static extern void mouse_event(uint f,uint x,uint y,uint d,UIntPtr i);}
341
+ using System;using System.Runtime.InteropServices;public static class RMMouse{[DllImport("user32.dll")]public static extern bool SetCursorPos(int x,int y);[DllImport("user32.dll")]public static extern void mouse_event(uint f,uint x,uint y,int d,UIntPtr i);}
315
342
  '@`;
316
343
  }
317
344
 
@@ -337,10 +364,15 @@ export async function dragDrop(args = {}) {
337
364
  return jsonResult({from:[args.from_x,args.from_y],to:[args.to_x,args.to_y]});
338
365
  }
339
366
 
367
+ export function buildWindowsScrollPowerShell(dx, dy) {
368
+ const wheel = Math.trunc(Number(dy) || 0);
369
+ const horizontal = Math.trunc(Number(dx) || 0);
370
+ return `${mousePrelude()}\n[RMMouse]::mouse_event(0x0800,0,0,${-wheel},[UIntPtr]::Zero);[RMMouse]::mouse_event(0x01000,0,0,${horizontal},[UIntPtr]::Zero)`;
371
+ }
372
+
340
373
  export async function scroll(args = {}) {
341
374
  const dx=Number(args.delta_x||0),dy=Number(args.delta_y??args.delta??0);
342
- const script=`${mousePrelude()}\n[RMMouse]::mouse_event(0x0800,0,0,[uint32]([int]${Math.trunc(-dy)}),[UIntPtr]::Zero);[RMMouse]::mouse_event(0x01000,0,0,[uint32]([int]${Math.trunc(dx)}),[UIntPtr]::Zero)`;
343
- await runPowerShell(script,{label:'scroll'});
375
+ await runPowerShell(buildWindowsScrollPowerShell(dx,dy),{label:'scroll'});
344
376
  return jsonResult({delta_x:dx,delta_y:dy});
345
377
  }
346
378
 
@@ -25,6 +25,7 @@ import {
25
25
  spawnDetached,
26
26
  unavailable,
27
27
  } from './common.mjs';
28
+ import { parseWindowsServiceStatus } from './windows-scm-status.mjs';
28
29
 
29
30
 
30
31
  function durationSpec(value) {
@@ -52,6 +53,16 @@ export async function serviceTool(args) {
52
53
  }
53
54
  const safe = escapePowerShellSingle(name);
54
55
  if (action === 'status') {
56
+ const scm = process.env.SystemRoot
57
+ ? path.join(process.env.SystemRoot, 'System32', 'sc.exe') : 'sc.exe';
58
+ const [state, config] = await Promise.all([
59
+ runFile(scm, ['query', name], { label: 'Windows SCM state', timeout: 8_000, allowFailure: true }),
60
+ runFile(scm, ['qc', name], { label: 'Windows SCM config', timeout: 8_000, allowFailure: true }),
61
+ ]);
62
+ if (state.code === 0 && config.code === 0) {
63
+ const parsed = parseWindowsServiceStatus(state.stdout, config.stdout, name);
64
+ if (parsed) return text(JSON.stringify(parsed));
65
+ }
55
66
  const { stdout } = await runPowerShell(`Get-Service -Name '${safe}' | Select-Object Name,DisplayName,Status,StartType | ConvertTo-Json -Compress`, { label: 'service status' });
56
67
  return text(stdout.trim());
57
68
  }
@@ -0,0 +1,19 @@
1
+ // SCM is much cheaper than loading Windows PowerShell on a pressured VM.
2
+ // Keep the existing Get-Service JSON contract and fall back when output is
3
+ // localized or cannot be parsed reliably.
4
+ export function parseWindowsServiceStatus(query, config, requestedName) {
5
+ const value = (source, key) => String(source).match(new RegExp(
6
+ '^\\s*' + key + '\\s*:\\s*(.*?)\\r?$', 'mi'))?.[1]?.trim();
7
+ const queryName = value(query, 'SERVICE_NAME');
8
+ const configName = value(config, 'SERVICE_NAME');
9
+ const state = Number(value(query, 'STATE')?.match(/^(\d+)(?:\s|$)/)?.[1]);
10
+ const start = Number(value(config, 'START_TYPE')?.match(/^(\d+)(?:\s|$)/)?.[1]);
11
+ const displayName = value(config, 'DISPLAY_NAME');
12
+ if (!requestedName || !queryName || !configName ||
13
+ queryName.toLowerCase() !== requestedName.toLowerCase() ||
14
+ configName.toLowerCase() !== queryName.toLowerCase() ||
15
+ !Number.isInteger(state) || state < 1 || state > 7 ||
16
+ !Number.isInteger(start) || start < 0 || start > 4 ||
17
+ !displayName || displayName.includes('\uFFFD')) return null;
18
+ return { Name: queryName, DisplayName: displayName, Status: state, StartType: start };
19
+ }
@@ -210,9 +210,19 @@ async function copyPortalFile(source, destination) {
210
210
  try {
211
211
  sourceParent = await openDirectoryNoFollow(path.dirname(source));
212
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');
213
+ const sourcePath = path.join(sourceParent.anchor, path.basename(source));
214
+ // On Windows O_NOFOLLOW is not available: inspect the leaf explicitly.
215
+ // BigInt identifiers avoid rounding NTFS file IDs above 2^53.
216
+ const before = await lstat(sourcePath, { bigint: true });
217
+ if (!before.isFile() || before.isSymbolicLink())
218
+ throw new Error('desktop portal screenshot source cannot be a symbolic link');
219
+ input = await open(sourcePath, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
220
+ const info = await input.stat({ bigint: true });
221
+ const current = await lstat(sourcePath, { bigint: true });
222
+ if (!info.isFile() || current.isSymbolicLink() || !current.isFile() ||
223
+ info.dev !== before.dev || info.ino !== before.ino ||
224
+ current.dev !== before.dev || current.ino !== before.ino)
225
+ throw new Error('desktop portal screenshot source changed or is not regular');
216
226
  const destinationPath = path.join(destinationParent.anchor, path.basename(destination));
217
227
  const existing = await lstat(destinationPath).catch(() => null);
218
228
  if (existing?.isSymbolicLink()) throw new Error('desktop portal screenshot destination cannot be a symbolic link');
@@ -339,7 +339,10 @@ export async function setPermissionsTool(args) {
339
339
  }
340
340
  }
341
341
  }
342
- const summary = `Set mode ${raw}${uid !== null || gid !== null ? ` (uid ${uid ?? '-'} gid ${gid ?? '-'})` : ''} on ${changed} path(s) starting at ${displayPath(absolute)}.`;
342
+ const windowsNote = process.platform === 'win32'
343
+ ? '\nWindows applies only the read-only attribute; POSIX group/other bits and ownership are not changed.'
344
+ : '';
345
+ const summary = `Set mode ${raw}${uid !== null || gid !== null ? ` (uid ${uid ?? '-'} gid ${gid ?? '-'})` : ''} on ${changed} path(s) starting at ${displayPath(absolute)}.${windowsNote}`;
343
346
  if (!failures.length) return text(summary);
344
347
  return text(`${summary}\n${failures.length} path(s) could not be changed:\n${failures.slice(0, 20).join('\n')}`, true);
345
348
  }
@@ -184,7 +184,9 @@ function runRipgrep(session, { target, pattern, searchType, filePattern, ignoreC
184
184
  const searchTarget = target.anchor;
185
185
  if (ignoreCase) args.push('--ignore-case');
186
186
  if (includeHidden) args.push('--hidden');
187
- if (!includeIgnored) for (const glob of SKIP_GLOBS) args.push('-g', glob);
187
+ // includeIgnored must override both our skip globs and ripgrep's own ignore files.
188
+ if (includeIgnored) args.push('--no-ignore');
189
+ else for (const glob of SKIP_GLOBS) args.push('-g', glob);
188
190
  for (const glob of splitGlobs(filePattern)) args.push('-g', glob);
189
191
  if (searchType === 'files') {
190
192
  args.push('--files');
@@ -24,8 +24,16 @@ function shellCommand() {
24
24
  return process.env.SHELL || '/bin/bash';
25
25
  }
26
26
 
27
- function shellArgs(command) {
28
- if (process.platform === 'win32') return ['/d', '/s', '/c', command];
27
+ function usesWindowsCmd(shell) {
28
+ return process.platform === 'win32' && /(?:^|[\\/])cmd(?:\.exe)?$/i.test(shell);
29
+ }
30
+
31
+ function shellArgs(command, shell) {
32
+ // CMD parses its own quoting: wrap the full command and disable CRT escaping below.
33
+ if (usesWindowsCmd(shell)) return ['/d', '/s', '/c', '"' + command + '"'];
34
+ if (process.platform === 'win32' && /(?:^|[\\/])(?:powershell|pwsh)(?:\.exe)?$/i.test(shell)) {
35
+ return ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', command];
36
+ }
29
37
  return ['-c', command];
30
38
  }
31
39
 
@@ -90,11 +98,12 @@ export async function startProcessTool(args, extra = {}) {
90
98
  const shell = shellCommand();
91
99
  // `detached` gives the child its own process group so a session can be stopped as a
92
100
  // tree; a pipeline such as `sleep 20 | cat` otherwise survives force_terminate.
93
- const child = spawn(shell, shellArgs(command), {
101
+ const child = spawn(shell, shellArgs(command, shell), {
94
102
  cwd: process.cwd(),
95
103
  env: process.env,
96
104
  stdio: ['pipe', 'pipe', 'pipe'],
97
105
  windowsHide: true,
106
+ windowsVerbatimArguments: usesWindowsCmd(shell),
98
107
  detached: process.platform !== 'win32',
99
108
  });
100
109
  // The error listener has to exist before anything can throw: a bogus REMCP_RUNTIME_SHELL