@remcp/runtime 0.2.227 → 0.2.228

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.227",
3
+ "version": "0.2.228",
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",
@@ -177,7 +177,7 @@ export const extendedToolDefinitions = [
177
177
  define('environment', 'Runtime environment', 'Report platform, architecture, Node version, working/home/temp paths, shell, PATH, and optionally environment variables. Environment variables are opt-in and secret-looking or credential-bearing values are masked.', o({ include_env:b('Include sanitized environment variables; default false.') }), readOnly, diagnosticHandlers.environment),
178
178
  define('audio', 'Audio control', 'Read or change default output volume or mute state using native operating-system audio controls when available.', o({ action:e(['status','set_volume','mute','unmute']), volume:n('0..100 for set_volume.') }, ['action']), mutatingNonDestructive, diagnosticHandlers.audio, ['audio']),
179
179
  define('power_action', 'Power action', 'Lock, sleep, restart, or shut down the paired computer using native operating-system power facilities. The action passes through ReMCP command policy. Delayed actions are cancelled if the client stops waiting.', o({ action:e(['lock','sleep','restart','shutdown']), delay_seconds:{ type:'number', minimum:0, maximum:90, description:'Optional delay in seconds, maximum 90. Longer delays are rejected because hosted calls expire before they can complete safely.' } }, ['action']), mutating, diagnosticHandlers.power_action, ['power']),
180
- define('record_screen', 'Record screen', 'Record a short bounded desktop video to a permitted local file for debugging and return its path and size. Uses native/available recording helpers.', o({ duration_seconds:n('1..120 seconds.'), fps:n('1..60.'), destination:s('Permitted output path. If an extension is supplied, use .webm for native GNOME Wayland recording and .mp4 for the other recorder backends.') }), mutating, diagnosticHandlers.record_screen, ['record']),
180
+ define('record_screen', 'Record screen', 'Record a short bounded desktop video to a permitted local file for debugging and return its path and size. Uses native/available recording helpers.', o({ duration_seconds:n('1..120 seconds.'), fps:n('1..60.'), destination:s('Permitted output path. Omit the extension to accept the native format. GNOME may produce .webm or .mp4; other backends produce .mp4. An explicit extension must match the recorded format.') }), mutating, diagnosticHandlers.record_screen, ['record']),
181
181
 
182
182
  define('read_document', 'Read document', 'Read PDF, DOCX, XLSX, TXT, Markdown, CSV, JSON, or XML. PDF/DOCX extraction is built in and XLSX is parsed from its OOXML worksheet cells.', o({ path:s('Document path.'), sheet:s('Worksheet name for XLSX.'), max_cells:n() }, ['path']), readOnly, documentHandlers.read_document, ['documents']),
183
183
  define('edit_spreadsheet', 'Edit spreadsheet', 'Create a new XLSX workbook or edit up to 500 expanded XLSX cells directly in OOXML without launching Excel. For creation set create=true and use path as a new destination; existing files are never overwritten by create mode. Each edit may target one cell or a rectangular range, with a scalar fill, a values matrix, or a formula.', o({ path:s('Existing .xlsx path, or the new workbook destination when create=true.'), create:b('Create a new XLSX at path. Fails if path already exists; do not combine with output.'), output:s('Optional output .xlsx when editing an existing workbook; defaults to replacing input.'), sheet:s('Worksheet name. For create=true, defaults to Sheet1 and becomes the new workbook sheet name.'), edits:{ type:'array', maxItems:500, items:o({ cell:s('Single A1 cell reference.'), range:s('Rectangular range such as A1:C3.'), value:{ description:'Scalar value for one cell or to fill a range.' }, values:{ type:'array', items:{ type:'array', items:{} }, description:'2D matrix matching the range dimensions.' }, formula:s('Optional formula; for a range it is written to each expanded cell.') }) } }, ['path','edits']), mutating, documentHandlers.edit_spreadsheet, ['documents','ooxml']),
@@ -113,7 +113,9 @@ export async function windowAction(args) {
113
113
  return text(`Window action ${action} completed.`);
114
114
  }
115
115
 
116
- export function pyAtSpiPrelude() {
116
+ export function pyAtSpiPrelude({ pid = 0, app = '' } = {}) {
117
+ const scopedPid = Number.isInteger(Number(pid)) ? Number(pid) : 0;
118
+ const scopedApp = JSON.stringify(optionalString(app) || '');
117
119
  return `import json,sys,math
118
120
  try:
119
121
  import pyatspi
@@ -188,10 +190,13 @@ def _proc(e):
188
190
  except: pass
189
191
  return 0
190
192
  q=[]
193
+ scope_pid=${scopedPid};scope_app=${scopedApp}
191
194
  for app in desktop:
192
195
  try:
193
- app_name=app.name or ""
194
196
  pid=_proc(app)
197
+ if scope_pid and pid!=scope_pid: continue
198
+ app_name=app.name or ""
199
+ if scope_app and scope_app.lower() not in app_name.lower(): continue
195
200
  active=False
196
201
  try:
197
202
  active=bool(app.getState().contains(pyatspi.STATE_ACTIVE))
@@ -216,7 +221,7 @@ export async function uiSnapshot(args = {}) {
216
221
  const wantedPid = Number.isInteger(Number(args.pid)) ? Number(args.pid) : 0;
217
222
  const wantedWindow = JSON.stringify(optionalString(args.window_title || args.windowTitle) || '');
218
223
  const activeOnly = args.active_only === true;
219
- const script = pyAtSpiPrelude() + `
224
+ const script = pyAtSpiPrelude({ pid:wantedPid, app:args.app }) + `
220
225
  want_app=${wantedApp};want_pid=${wantedPid};want_window=${wantedWindow};want_active=${activeOnly ? 'True' : 'False'}
221
226
  roots=[]
222
227
  for root,app_name,pid,active in q:
@@ -288,7 +293,7 @@ export async function uiAction(args = {}) {
288
293
  const wantedName = JSON.stringify(optionalString(args.name) || '');
289
294
  const wantedRole = JSON.stringify(optionalString(args.role) || '');
290
295
  const value = JSON.stringify(String(args.value ?? ''));
291
- const script = pyAtSpiPrelude() + `
296
+ const script = pyAtSpiPrelude({ pid:wantedPid, app:args.app }) + `
292
297
  want_index=${legacyIndex};want_pid=${wantedPid};want_path=${JSON.stringify(stablePath)}
293
298
  want_app=${wantedApp};want_window=${wantedWindow};want_name=${wantedName};want_role=${wantedRole};action=${JSON.stringify(action)};value=${value}
294
299
  queue=[];seen=0;target=None;target_meta={}
@@ -67,7 +67,7 @@ export async function nativeWaylandAccessibilityAction(row, actionNames) {
67
67
  if (!Number.isInteger(pid) || pid <= 0 || !Array.isArray(actionNames) || !actionNames.length) {
68
68
  return { applied:false, available:[] };
69
69
  }
70
- const script = pyAtSpiPrelude() + `
70
+ const script = pyAtSpiPrelude({ pid }) + `
71
71
  want_pid=${pid};want_window=${JSON.stringify(String(row.title || ''))};wanted_actions=${JSON.stringify(actionNames)}
72
72
  target=None
73
73
  for root,app_name,pid,active in q:
@@ -253,11 +253,26 @@ print(JSON.stringify({ destination:started[1], stopped:stopped[0] }));
253
253
  async function recordGnomeShellScreen({ staged, stagingDirectory, seconds, fps }) {
254
254
  const helper = path.join(stagingDirectory, 'gnome-screencast.js');
255
255
  await writeFile(helper, GNOME_SCREENCAST_HELPER, { mode:0o600 });
256
- await runFile('gjs', [helper, staged, String(seconds), String(fps)], {
256
+ const result = await runFile('gjs', [helper, staged, String(seconds), String(fps)], {
257
257
  label:'GNOME Shell screen recording',
258
258
  timeout:(seconds + 20) * 1000,
259
259
  maxBuffer:2 * 1024 * 1024,
260
260
  });
261
+ let report;
262
+ try { report = JSON.parse(result.stdout.trim()); } catch {}
263
+ const output = report?.destination;
264
+ // GNOME selects the container and may replace the template extension. Accept
265
+ // only the exact recording stem in our private staging directory, never an
266
+ // arbitrary path returned by the desktop service.
267
+ if (report?.stopped !== true || typeof output !== 'string' || !path.isAbsolute(output)
268
+ || path.dirname(output) !== stagingDirectory
269
+ || path.parse(output).name !== path.parse(staged).name
270
+ || !['.mp4','.webm'].includes(path.extname(output))) {
271
+ throw new Error('GNOME returned an invalid screen recording staging path or format');
272
+ }
273
+ const info = await lstat(output);
274
+ if (!info.isFile() || !info.size) throw new Error('GNOME screen recording is not a non-empty regular file');
275
+ return { path:output, format:path.extname(output).slice(1) };
261
276
  }
262
277
 
263
278
  export function validateRecordingDestinationFormat(destination, format) {
@@ -328,21 +343,27 @@ export async function recordScreen(args) {
328
343
  unavailable('Screen recording', 'ffmpeg is required on X11');
329
344
  }
330
345
 
331
- const format = backend === 'gnome-shell' ? 'webm' : 'mp4';
346
+ let format = backend === 'gnome-shell' ? 'webm' : 'mp4';
332
347
  const requestedDestination = args.destination ? await resolveSafePath(args.destination, 'destination') : '';
333
348
  if (requestedDestination) {
334
- validateRecordingDestinationFormat(requestedDestination, format);
349
+ if (backend !== 'gnome-shell') validateRecordingDestinationFormat(requestedDestination, format);
350
+ else if (path.extname(requestedDestination) && !['.mp4','.webm'].includes(path.extname(requestedDestination).toLowerCase())) {
351
+ throw new Error('GNOME screen recording destination must use .mp4, .webm or omit the extension');
352
+ }
335
353
  const existing = await lstat(requestedDestination).catch(() => null);
336
354
  if (existing?.isSymbolicLink()) throw new Error('Screen recording destination cannot be a symbolic link');
337
355
  if (existing && !existing.isFile()) throw new Error('Screen recording destination is not a regular file');
338
356
  }
339
357
 
340
358
  const stagingDirectory = await mkdtemp(path.join(os.tmpdir(), 'remcp-screen-'));
341
- const staged = path.join(stagingDirectory, `capture-${randomUUID()}.${format}`);
359
+ let staged = path.join(stagingDirectory, `capture-${randomUUID()}.${format}`);
342
360
  let keepStaging = false;
343
361
  try {
344
362
  if (backend === 'gnome-shell') {
345
- await recordGnomeShellScreen({ staged, stagingDirectory, seconds, fps });
363
+ const recorded = await recordGnomeShellScreen({ staged, stagingDirectory, seconds, fps });
364
+ staged = recorded.path;
365
+ format = recorded.format;
366
+ validateRecordingDestinationFormat(requestedDestination, format);
346
367
  } else if (backend === 'wf-recorder') {
347
368
  const result = await runFile('timeout', ['--signal=INT', `${seconds}s`, 'wf-recorder', '-f',staged,'-r',String(fps),'-c','libx264'], { label:'screen recording', timeout:(seconds+10)*1000, allowFailure:true });
348
369
  if (![0, 124, 130].includes(Number(result.code))) throw new Error(result.stderr.trim() || `wf-recorder exited ${result.code}`);