@remcp/runtime 0.2.82 → 0.2.84

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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.84 — 2026-09-21
4
+
5
+ - `edit_document` can create a new DOCX with `create=true`, refusing to overwrite an existing destination.
6
+ - `edit_spreadsheet` can create a new XLSX workbook with an optional first-sheet name and existing cell/range/formula edits.
7
+ - Added OOXML creation, overwrite-safety, invalid-sheet-name, schema/output, and read-back regression coverage; the runtime catalog remains 83 tools.
8
+
9
+ ## 0.2.83 — 2026-09-21
10
+
11
+ - Linux X11 `record_screen` capability now requires both `ffmpeg` and a real `DISPLAY`; headless hosts no longer advertise a recorder that will fail.
12
+ - The runtime no longer invents `:0.0` for headless X11 calls and fails closed with a clear display requirement before ffmpeg runs.
13
+ - `wf-recorder` is selected only for actual Wayland sessions.
14
+ - Regression coverage locks the headless capability and handler behavior; lockstep patch release at `0.2.83`.
15
+
3
16
  ## 0.2.82 — 2026-09-21
4
17
 
5
18
  - Clarified `set_config_value` routing so local runtime preferences stay separate from hosted account-level `device_action` administration.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.82",
3
+ "version": "0.2.84",
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",
@@ -64,8 +64,8 @@ const AI_TOOL_DESCRIPTIONS = Object.freeze({
64
64
  power_action: 'Use this only when the user explicitly asks to lock, sleep, restart or shut down the computer. Prefer it over invoking those system actions through start_process; it is intentionally guarded by device command policy because restart/shutdown can interrupt work.',
65
65
  record_screen: 'Use this for a short bounded screen recording when motion/timing is necessary to diagnose a UI issue. Prefer screenshot_region for static visual verification because it is cheaper and easier to inspect.',
66
66
  read_document: 'Use this for structured document content, especially PDF, DOCX and XLSX, and for CSV/JSON/XML when document-style parsing is useful. Use read_file for ordinary source code, logs, configuration, Markdown, or other plain line-oriented text. Prefer read_document over shell extraction or launching Office when the goal is document content; PDF reading uses the built-in parser first and may fall back to local pdftotext when embedded fonts prevent direct decoding.',
67
- edit_spreadsheet: 'Use this for direct XLSX cell/range/formula edits without launching Excel. Prefer explicit cells/ranges and write to output when preserving the original matters.',
68
- edit_document: 'Use this for structured DOCX paragraph edits without launching Word. It is best for replace/insert/delete paragraph operations; use native UI automation only when document formatting/layout interaction is required.',
67
+ edit_spreadsheet: 'Use this to create a new XLSX workbook or directly edit XLSX cells/ranges/formulas without launching Excel. Set create=true only for a new path; it fails rather than overwriting an existing workbook. Prefer explicit cells/ranges and write to output when preserving an existing original matters.',
68
+ edit_document: 'Use this to create a new DOCX document or make structured DOCX paragraph edits without launching Word. Set create=true only for a new path; it fails rather than overwriting an existing document. It is best for replace/insert/delete paragraph operations; use native UI automation only when document formatting/layout interaction is required.',
69
69
  pdf_action: 'Use this for PDF metadata/annotations inspection or structural merge/split/page extraction. For reading PDF text use read_document; supply an output/output_dir for write actions when preserving originals matters.',
70
70
  });
71
71
 
@@ -217,8 +217,8 @@ const OUTPUT_FIELDS = Object.freeze({
217
217
  power_action: { text:outputString(), action:outputString() },
218
218
  record_screen: { path:outputString(), bytes:outputNumber(), duration_seconds:outputNumber(), format:outputString() },
219
219
  read_document: { text:outputString(), path:outputString(), data:{}, sheets:outputArray(outputObject()), sheet:outputString(), rows:outputArray({}), cells:outputArray({}) },
220
- edit_spreadsheet: { path:outputString(), sheet:outputString(), edited_cells:outputNumber(), bytes:outputNumber() },
221
- edit_document: { path:outputString(), operations:outputNumber(), changes:outputNumber(), bytes:outputNumber() },
220
+ edit_spreadsheet: { path:outputString(), sheet:outputString(), edited_cells:outputNumber(), bytes:outputNumber(), created:outputBoolean() },
221
+ edit_document: { path:outputString(), operations:outputNumber(), changes:outputNumber(), bytes:outputNumber(), created:outputBoolean() },
222
222
  pdf_action: { action:outputString(), path:outputString(), source:outputString(), output:outputString(), output_dir:outputString(), files:outputArray(outputString()), pages:outputString(), bytes:outputNumber(), annotations:outputArray(outputObject()), inputs:outputNumber() },
223
223
  });
224
224
 
@@ -569,8 +569,8 @@ export const extendedToolDefinitions = [
569
569
  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.') }), mutating, diagnosticHandlers.record_screen, ['record']),
570
570
 
571
571
  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),
572
- define('edit_spreadsheet', 'Edit spreadsheet', 'Edit up to 500 expanded XLSX cells directly in OOXML without launching Excel. Each edit may target one cell or a rectangular range, with a scalar fill, a values matrix, or a formula.', o({ path:s('Input .xlsx path.'), output:s('Optional output .xlsx; defaults to replacing input.'), sheet:s('Worksheet 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, ['ooxml']),
573
- define('edit_document', 'Edit DOCX document', 'Edit DOCX paragraph structure directly in OOXML: replace text, append/prepend paragraphs, insert before/after matching paragraphs, or delete matching paragraphs.', o({ path:s('Input .docx path.'), output:s('Optional output .docx path.'), operations:{ type:'array', maxItems:100, items:o({ action:e(['replace','append_paragraph','prepend_paragraph','insert_paragraph_before','insert_paragraph_after','delete_paragraph']), search:s('Paragraph text substring for replace/insert/delete operations.'), replacement:s(), all:b('Apply to all matching paragraphs; default true.'), text:s('Paragraph text to append/prepend/insert.') }, ['action']) } }, ['path','operations']), mutating, documentHandlers.edit_document, ['ooxml']),
572
+ 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, ['ooxml']),
573
+ define('edit_document', 'Edit DOCX document', 'Create a new DOCX document or edit DOCX paragraph structure directly in OOXML: replace text, append/prepend paragraphs, insert before/after matching paragraphs, or delete matching paragraphs. For creation set create=true and use path as a new destination; existing files are never overwritten by create mode.', o({ path:s('Existing .docx path, or the new document destination when create=true.'), create:b('Create a new DOCX at path. Fails if path already exists; do not combine with output.'), output:s('Optional output .docx path when editing an existing document.'), operations:{ type:'array', maxItems:100, items:o({ action:e(['replace','append_paragraph','prepend_paragraph','insert_paragraph_before','insert_paragraph_after','delete_paragraph']), search:s('Paragraph text substring for replace/insert/delete operations.'), replacement:s(), all:b('Apply to all matching paragraphs; default true.'), text:s('Paragraph text to append/prepend/insert.') }, ['action']) } }, ['path','operations']), mutating, documentHandlers.edit_document, ['ooxml']),
574
574
  define('pdf_action', 'PDF action', 'Inspect PDF annotations or metadata, merge PDFs, split a PDF into pages, or extract selected page ranges. Structural writes use qpdf/poppler/pdftk when installed.', o({ action:e(['merge','split','extract_pages','annotations','info']), path:s(), paths:{type:'array',items:{type:'string'}}, output:s(), output_dir:s(), pattern:s(), pages:s('Page list/ranges such as 1-3,5.') }, ['action']), mutating, documentHandlers.pdf_action),
575
575
  ];
576
576
 
@@ -247,11 +247,12 @@ export async function powerAction(args) {
247
247
  export function recordScreenAvailable({
248
248
  platform = process.platform,
249
249
  wayland = platform === 'linux' ? isWaylandSession() : false,
250
+ display = process.env.DISPLAY || '',
250
251
  commandExistsFn = commandExists,
251
252
  } = {}) {
252
253
  if (platform === 'linux') {
253
254
  if (wayland) return commandExistsFn('wf-recorder') && commandExistsFn('timeout');
254
- return commandExistsFn('ffmpeg');
255
+ return Boolean(String(display).trim()) && commandExistsFn('ffmpeg');
255
256
  }
256
257
  if (platform === 'darwin' || platform === 'win32') return commandExistsFn('ffmpeg');
257
258
  return false;
@@ -261,13 +262,17 @@ export async function recordScreen(args) {
261
262
  const seconds = clamp(args.duration_seconds, 5, 1, 120);
262
263
  const fps = clamp(args.fps, 15, 1, 60);
263
264
  const destination = args.destination ? await resolveSafePath(args.destination, 'destination') : path.join(os.tmpdir(), `remcp-screen-${Date.now()}.mp4`);
264
- if (process.platform === 'linux' && commandExists('wf-recorder') && commandExists('timeout')) {
265
+ const wayland = process.platform === 'linux' && isWaylandSession();
266
+ if (wayland && commandExists('wf-recorder') && commandExists('timeout')) {
265
267
  const result = await runFile('timeout', ['--signal=INT', `${seconds}s`, 'wf-recorder', '-f',destination,'-r',String(fps),'-c','libx264'], { label:'screen recording', timeout:(seconds+10)*1000, allowFailure:true });
266
268
  if (![0, 124, 130].includes(Number(result.code))) throw new Error(result.stderr.trim() || `wf-recorder exited ${result.code}`);
267
269
  } else {
268
- if (process.platform === 'linux' && isWaylandSession()) {
270
+ if (wayland) {
269
271
  unavailable('Screen recording', 'wf-recorder and timeout are required on Wayland');
270
272
  }
273
+ if (process.platform === 'linux' && !String(process.env.DISPLAY || '').trim()) {
274
+ unavailable('Screen recording', 'an active X11 DISPLAY is required on Linux X11');
275
+ }
271
276
  if (!commandExists('ffmpeg')) {
272
277
  if (process.platform === 'darwin') unavailable('Screen recording', 'ffmpeg is required on macOS');
273
278
  if (process.platform === 'win32') unavailable('Screen recording', 'ffmpeg is required on Windows');
@@ -276,7 +281,7 @@ export async function recordScreen(args) {
276
281
  let argv;
277
282
  if (process.platform === 'win32') argv=['-y','-f','gdigrab','-framerate',String(fps),'-i','desktop','-t',String(seconds),'-pix_fmt','yuv420p',destination];
278
283
  else if (process.platform === 'darwin') argv=['-y','-f','avfoundation','-framerate',String(fps),'-i','1:none','-t',String(seconds),'-pix_fmt','yuv420p',destination];
279
- else argv=['-y','-f','x11grab','-framerate',String(fps),'-i',process.env.DISPLAY || ':0.0','-t',String(seconds),'-pix_fmt','yuv420p',destination];
284
+ else argv=['-y','-f','x11grab','-framerate',String(fps),'-i',process.env.DISPLAY,'-t',String(seconds),'-pix_fmt','yuv420p',destination];
280
285
  await runFile('ffmpeg', argv, { label:'screen recording', timeout:(seconds+20)*1000, maxBuffer:8*1024*1024 });
281
286
  }
282
287
  const info = await stat(destination);
@@ -71,6 +71,47 @@ async function createZip(sourceDir, destination) {
71
71
  await runFile('zip', ['-qr',destination,'.'], { cwd:sourceDir, label:'create OOXML archive', timeout:30_000 });
72
72
  }
73
73
 
74
+ async function ensureCreateTargetIsNew(filePath) {
75
+ try {
76
+ await stat(filePath);
77
+ } catch (error) {
78
+ if (error?.code === 'ENOENT') return;
79
+ throw error;
80
+ }
81
+ throw new Error(`Cannot create document because path already exists: ${filePath}`);
82
+ }
83
+
84
+ async function writeOoxmlPart(root, relative, content) {
85
+ const target = path.join(root, ...relative.split('/'));
86
+ await mkdir(path.dirname(target), { recursive:true });
87
+ await writeFile(target, content, 'utf8');
88
+ }
89
+
90
+ function newWorksheetName(value) {
91
+ const name = optionalString(value) || 'Sheet1';
92
+ if (name.length > 31) throw new Error('Worksheet name must be 31 characters or fewer');
93
+ if (/[\\/*?:\[\]]/.test(name) || name.startsWith("'") || name.endsWith("'")) {
94
+ throw new Error('Worksheet name contains characters Excel does not allow');
95
+ }
96
+ return name;
97
+ }
98
+
99
+ async function createBlankXlsxTree(root, sheetName) {
100
+ const name = newWorksheetName(sheetName);
101
+ await writeOoxmlPart(root, '[Content_Types].xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>');
102
+ await writeOoxmlPart(root, '_rels/.rels', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>');
103
+ await writeOoxmlPart(root, 'xl/workbook.xml', `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="${xmlEscape(name)}" sheetId="1" r:id="rId1"/></sheets></workbook>`);
104
+ await writeOoxmlPart(root, 'xl/_rels/workbook.xml.rels', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>');
105
+ await writeOoxmlPart(root, 'xl/worksheets/sheet1.xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData></sheetData></worksheet>');
106
+ return name;
107
+ }
108
+
109
+ async function createBlankDocxTree(root) {
110
+ await writeOoxmlPart(root, '[Content_Types].xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>');
111
+ await writeOoxmlPart(root, '_rels/.rels', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>');
112
+ await writeOoxmlPart(root, 'word/document.xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="708" w:footer="708" w:gutter="0"/></w:sectPr></w:body></w:document>');
113
+ }
114
+
74
115
  function workbookSheetPath(workbookXml, relsXml, requestedName) {
75
116
  const sheets = [...workbookXml.matchAll(/<sheet\b[^>]*name="([^"]+)"[^>]*(?:r:id|id)="([^"]+)"[^>]*\/?>(?:<\/sheet>)?/g)].map(match => ({ name:xmlUnescape(match[1]), rid:match[2] }));
76
117
  const sheet = requestedName ? sheets.find(item => item.name.toLowerCase() === requestedName.toLowerCase()) : sheets[0];
@@ -227,14 +268,21 @@ function expandSpreadsheetEdits(edits) {
227
268
  export async function editSpreadsheet(args) {
228
269
  const filePath = await resolveSafePath(args.path,'path');
229
270
  if (!filePath.toLowerCase().endsWith('.xlsx')) throw new Error('edit_spreadsheet supports .xlsx files');
271
+ const create = args.create === true;
272
+ if (create && args.output) throw new Error('output is not used with create=true; path is the new workbook destination');
230
273
  const requestedEdits = Array.isArray(args.edits) ? args.edits : [];
231
274
  if (!requestedEdits.length || requestedEdits.length > 500) throw new Error('edits must contain 1..500 cell/range edits');
232
275
  const edits = expandSpreadsheetEdits(requestedEdits);
233
276
  const output = args.output ? await resolveSafePath(args.output,'output') : filePath;
234
- const dir = await tempDir('remcp-xlsx-edit-');
277
+ const dir = await tempDir(create ? 'remcp-xlsx-create-' : 'remcp-xlsx-edit-');
235
278
  const tempOut = path.join(path.dirname(output), `.remcp-${Date.now()}-${path.basename(output)}`);
236
279
  try {
237
- await extractZip(filePath,dir);
280
+ if (create) {
281
+ await ensureCreateTargetIsNew(filePath);
282
+ await createBlankXlsxTree(dir, optionalString(args.sheet));
283
+ } else {
284
+ await extractZip(filePath,dir);
285
+ }
238
286
  const workbook = await readFile(path.join(dir,'xl','workbook.xml'),'utf8');
239
287
  const rels = await readFile(path.join(dir,'xl','_rels','workbook.xml.rels'),'utf8');
240
288
  const sheet = workbookSheetPath(workbook,rels,optionalString(args.sheet));
@@ -242,9 +290,10 @@ export async function editSpreadsheet(args) {
242
290
  let xml = await readFile(worksheetPath,'utf8');
243
291
  for (const edit of edits) xml = setWorksheetCell(xml,String(edit.cell || '').toUpperCase(),edit.value,edit.formula);
244
292
  await writeFile(worksheetPath,xml,'utf8');
293
+ await mkdir(path.dirname(output), { recursive:true });
245
294
  await createZip(dir,tempOut);
246
295
  await copyFile(tempOut,output);
247
- return jsonResult({ path:output, sheet:sheet.name, edited_cells:edits.length, bytes:(await stat(output)).size });
296
+ return jsonResult({ path:output, sheet:sheet.name, edited_cells:edits.length, bytes:(await stat(output)).size, created:create });
248
297
  } finally {
249
298
  await rm(tempOut,{force:true}).catch(() => {});
250
299
  await removeTemp(dir);
@@ -301,14 +350,21 @@ function mutateMatchingParagraphs(xml, op) {
301
350
  export async function editDocument(args) {
302
351
  const filePath = await resolveSafePath(args.path,'path');
303
352
  if (!filePath.toLowerCase().endsWith('.docx')) throw new Error('edit_document supports .docx files');
353
+ const create = args.create === true;
354
+ if (create && args.output) throw new Error('output is not used with create=true; path is the new document destination');
304
355
  const operations = Array.isArray(args.operations) ? args.operations : [];
305
356
  if (!operations.length || operations.length > 100) throw new Error('operations must contain 1..100 edits');
306
357
  const output = args.output ? await resolveSafePath(args.output,'output') : filePath;
307
- const dir = await tempDir('remcp-docx-edit-');
358
+ const dir = await tempDir(create ? 'remcp-docx-create-' : 'remcp-docx-edit-');
308
359
  const tempOut = path.join(path.dirname(output), `.remcp-${Date.now()}-${path.basename(output)}`);
309
360
  let changes = 0;
310
361
  try {
311
- await extractZip(filePath,dir);
362
+ if (create) {
363
+ await ensureCreateTargetIsNew(filePath);
364
+ await createBlankDocxTree(dir);
365
+ } else {
366
+ await extractZip(filePath,dir);
367
+ }
312
368
  const documentPath = path.join(dir,'word','document.xml');
313
369
  let xml = await readFile(documentPath,'utf8');
314
370
  for (const op of operations) {
@@ -327,9 +383,10 @@ export async function editDocument(args) {
327
383
  }
328
384
  }
329
385
  await writeFile(documentPath,xml,'utf8');
386
+ await mkdir(path.dirname(output), { recursive:true });
330
387
  await createZip(dir,tempOut);
331
388
  await copyFile(tempOut,output);
332
- return jsonResult({ path:output, operations:operations.length, changes, bytes:(await stat(output)).size });
389
+ return jsonResult({ path:output, operations:operations.length, changes, bytes:(await stat(output)).size, created:create });
333
390
  } finally {
334
391
  await rm(tempOut,{force:true}).catch(() => {});
335
392
  await removeTemp(dir);