@remcp/runtime 0.2.109 → 0.2.110

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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.110 — 2026-09-25
4
+
5
+ - Bulk handlers (`read_multiple_files`, `read_files`, `replace_in_files`, `write_files`, `copy_paths`, `move_paths`, `delete_paths`, `create_archive`) and the directory walk now honour client cancellation between items, so an aborted call stops at the next item boundary instead of finishing on the device; single-file and external-tool (`tar`/`unzip`) operations remain atomic and are not interrupted mid-step.
6
+ - A file with more than one hard link now produces one warning per path: path-based confinement cannot see a shared inode, and refusing would break backup and dotfile layouts.
7
+ - Runtime tool names, input schemas and output schemas are unchanged.
8
+
3
9
  ## 0.2.109 — 2026-09-25
4
10
 
5
11
  - Linux file reads and recursive permission changes retain descriptor-bound parent traversal for the full operation; proc-fd anchored captures remain readable without reopening the path lexically.
package/README.md CHANGED
@@ -99,10 +99,10 @@ UI actions and the XDG Screenshot portal remain separate from that low-level inp
99
99
  - file writes replace by default (`mode: "append"` to add), moves and copies replace the destination
100
100
  (`overwrite: false` refuses instead), `replace_in_files` applies immediately (`dry_run: true`
101
101
  previews), and `move_to_trash` is there when you want an undo;
102
- - the destructive-command guardrail defaults to `warn`: the command runs and the result carries a note
103
- when it matches the catastrophic list (`mkfs`, raw device writes, repartitioning, host power
104
- control, fork bombs, recursive root deletion). `allow` removes even the note, `block` refuses
105
- before running, and `blockedCommands` adds your own deny list;
102
+ - the destructive-command guardrail defaults to `block`: a catastrophic command is refused before it
103
+ runs when it matches the list (`mkfs`, raw device writes, repartitioning, host power control, fork
104
+ bombs, recursive root deletion). `warn` downgrades that to a note on the result, `allow` removes
105
+ even the note, and `blockedCommands` adds your own deny list;
106
106
  - `allowedRoots` is empty, so the device reaches everything the agent's account can reach. Set it to
107
107
  confine a device to specific directories, enforced against the resolved real path.
108
108
 
@@ -169,6 +169,11 @@ in `~/.config/remcp/runtime.json`. `--describe` always reports the current state
169
169
  descriptor-bound directory traversal on Linux. macOS and Windows preserve the same bounded snapshot,
170
170
  link/special-file rejection, canonical root confinement and atomic staging semantics, while using their
171
171
  portable path APIs where Node does not expose directory-fd-relative open/rename/unlink primitives.
172
+ Two consequences are worth knowing: on macOS and Windows `set_permissions` re-resolves each path
173
+ and then chmods it, so a component swap between those two steps is not descriptor-bound, and a file
174
+ with more than one hard link (`st_nlink > 1`) is reachable from outside the allowed roots no matter
175
+ which path was used. The runtime warns once per hard-linked path instead of refusing it, because
176
+ backups and dotfile managers create those layouts legitimately.
172
177
  - **Crash-resistant sessions.** A bogus shell, a closed stdin, or a dead parent cannot take the
173
178
  runtime down; sessions run in their own process group so `force_terminate` stops a whole pipeline;
174
179
  the agent restarts the runtime if it ever exits, so a device recovers instead of going silently
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.109",
3
+ "version": "0.2.110",
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",
@@ -12,7 +12,7 @@ import { describeFilesystemFailure } from '../permissions.mjs';
12
12
  import { capturePortalScreenshot, isWaylandSession } from '../screenshot-portal.mjs';
13
13
  import { applyHunks, parseUnifiedDiff } from '../patch.mjs';
14
14
  import { countEvent, recordEvent } from '../telemetry.mjs';
15
- import { clampInteger, decodeText, displayPath, fail, globToRegExp, image, isInsideRoot, looksBinary, multi, pageLines, resolveSafePath, splitLines, text } from '../util.mjs';
15
+ import { clampInteger, decodeText, displayPath, fail, globToRegExp, image, isInsideRoot, looksBinary, multi, pageLines, resolveSafePath, splitLines, text, throwIfCancelled } from '../util.mjs';
16
16
 
17
17
  const MAX_INLINE_FILE_BYTES = 20 * 1024 * 1024;
18
18
  // An image travels base64-encoded, which costs a third more bytes. The agent's stdio transport holds
@@ -283,10 +283,26 @@ async function openEntryAtPath(absolute, flags) {
283
283
  return { handle, close: () => handle.close() };
284
284
  }
285
285
 
286
+ // A hard link inside an allowed root is the one escape path-based confinement cannot see: the very
287
+ // same inode is reachable from outside the root, so a read or write here is visible there (round 2
288
+ // audit R2-14). The allowlist is a guardrail, not an inode boundary, so this warns instead of
289
+ // refusing — refusing would break legitimate layouts (backups, dotfile managers, deduplicated
290
+ // stores). One warning per path keeps a hot loop from flooding the log.
291
+ const hardlinkWarned = new Set();
292
+
293
+ function warnOnHardlink(label, info) {
294
+ if (!Number.isFinite(info?.nlink) || info.nlink <= 1 || hardlinkWarned.has(label)) return;
295
+ if (hardlinkWarned.size > 1000) hardlinkWarned.clear();
296
+ hardlinkWarned.add(label);
297
+ countEvent('hardlinkWarnings');
298
+ console.warn(`ReMCP runtime: ${label} has ${info.nlink} hard links, so the same file is reachable from outside the allowed roots`);
299
+ }
300
+
286
301
  async function openRegularFileAtPath(absolute, flags, label = absolute) {
287
302
  const opened = await openEntryAtPath(absolute, flags);
288
303
  try {
289
304
  const info = assertRegularFile(await opened.handle.stat(), label);
305
+ warnOnHardlink(label, info);
290
306
  return { handle: opened.handle, info, close: opened.close };
291
307
  } catch (error) {
292
308
  await opened.close().catch(() => {});
@@ -388,7 +404,7 @@ async function overwriteOpenFile(handle, content) {
388
404
  // anywhere, so links are never followed and each collected path is resolved through
389
405
  // resolveSafePath again before a tool reads or writes it. `stat` follows links, which is exactly
390
406
  // how a symlinked directory inside a root used to expose files outside it.
391
- async function collectTree(root, { maxFiles = 500, skip = [] } = {}) {
407
+ async function collectTree(root, { maxFiles = 500, skip = [], signal = null } = {}) {
392
408
  const found = [];
393
409
  const denied = [];
394
410
  const skipName = name => skip.some(entry => (entry.endsWith('*') ? name.startsWith(entry.slice(0, -1)) : name === entry));
@@ -401,6 +417,7 @@ async function collectTree(root, { maxFiles = 500, skip = [] } = {}) {
401
417
  return;
402
418
  }
403
419
  for (const entry of entries) {
420
+ throwIfCancelled(signal);
404
421
  if (found.length >= maxFiles) return;
405
422
  if (entry.isSymbolicLink() || skipName(entry.name)) continue;
406
423
  const childLogical = path.join(logicalBase, entry.name);
@@ -437,6 +454,8 @@ async function collectTree(root, { maxFiles = 500, skip = [] } = {}) {
437
454
  directory = await openDirectoryPath(root);
438
455
  await visitDirectory(directory, root);
439
456
  } catch {
457
+ // A cancelled walk must not degrade into the "root is a single file" fallback below.
458
+ throwIfCancelled(signal);
440
459
  let opened;
441
460
  try {
442
461
  opened = await openEntryAtPath(root, READ_NOFOLLOW_NONBLOCK);
@@ -509,11 +528,12 @@ ${page.slice.join('\n')}`);
509
528
  return text(`${header}\n${slice.join('\n')}`);
510
529
  }
511
530
 
512
- export async function readMultipleFilesTool(args) {
531
+ export async function readMultipleFilesTool(args, extra = {}) {
513
532
  if (!Array.isArray(args.paths) || !args.paths.length) fail('paths must be a non-empty array');
514
533
  if (args.paths.length > 50) fail('paths accepts at most 50 entries per call');
515
534
  const sections = [];
516
535
  for (const entry of args.paths) {
536
+ throwIfCancelled(extra.signal);
517
537
  let absolute;
518
538
  try {
519
539
  absolute = await resolveSafePath(entry, 'paths[]');
@@ -895,7 +915,7 @@ export async function replaceLinesTool(args) {
895
915
  return text(`${summary}.`);
896
916
  }
897
917
 
898
- export async function replaceInFilesTool(args) {
918
+ export async function replaceInFilesTool(args, extra = {}) {
899
919
  const root = await resolveSafePath(args.path);
900
920
  const pattern = typeof args.pattern === 'string' && args.pattern ? args.pattern : fail('pattern is required');
901
921
  const replacement = typeof args.replacement === 'string' ? args.replacement : fail('replacement must be a string');
@@ -912,12 +932,13 @@ export async function replaceInFilesTool(args) {
912
932
  }
913
933
  }
914
934
  const info = await getPathInfo(root).catch(() => fail(`Path not found: ${displayPath(root)}`));
915
- const walk = info.isFile() ? { files: [root], denied: [] } : await collectTree(root, { maxFiles, skip: ['.git', 'node_modules', '.remcp-trash*'] });
935
+ const walk = info.isFile() ? { files: [root], denied: [] } : await collectTree(root, { maxFiles, skip: ['.git', 'node_modules', '.remcp-trash*'], signal: extra.signal });
916
936
  const files = await confineAll(walk.files);
917
937
  const glob = filePattern ? globToRegExp(filePattern) : null;
918
938
  const changed = [];
919
939
  let scanned = 0;
920
940
  for (const file of files) {
941
+ throwIfCancelled(extra.signal);
921
942
  if (changed.length >= maxFiles) break;
922
943
  if (glob && !glob.test(path.basename(file))) continue;
923
944
  let opened;
@@ -1011,7 +1032,7 @@ export async function moveToTrashTool(args) {
1011
1032
  return text(`Moved ${displayPath(source)} to ${displayPath(target)}. Restore it with move_file if this was a mistake.`);
1012
1033
  }
1013
1034
 
1014
- export async function readFilesTool(args) {
1035
+ export async function readFilesTool(args, extra = {}) {
1015
1036
  // Glob-first bulk read: one call fills the model's context with every file that matters
1016
1037
  // instead of one round trip per path.
1017
1038
  const root = await resolveSafePath(args.path || '.');
@@ -1027,6 +1048,7 @@ export async function readFilesTool(args) {
1027
1048
  : await collectTree(root, {
1028
1049
  maxFiles: maxFiles + 1,
1029
1050
  skip: includeIgnored ? ['.remcp-trash*'] : ['node_modules', '.git', '.remcp-trash*'],
1051
+ signal: extra.signal,
1030
1052
  });
1031
1053
  const candidates = await confineAll(walk.files);
1032
1054
  const matched = candidates.filter(target => {
@@ -1038,6 +1060,7 @@ export async function readFilesTool(args) {
1038
1060
  const sections = [];
1039
1061
  let skipped = 0;
1040
1062
  for (const file of files.slice(0, maxFiles)) {
1063
+ throwIfCancelled(extra.signal);
1041
1064
  try {
1042
1065
  const { content, encoding } = await readTextFile(file);
1043
1066
  const lines = splitLines(content);
@@ -1057,7 +1080,7 @@ export async function readFilesTool(args) {
1057
1080
  return text(`${header}\n\n${sections.join('\n\n')}`);
1058
1081
  }
1059
1082
 
1060
- export async function writeFilesTool(args) {
1083
+ export async function writeFilesTool(args, extra = {}) {
1061
1084
  // Bulk write for scaffolding: one call creates or replaces many files.
1062
1085
  const files = Array.isArray(args.files) ? args.files : fail('files must be an array of { path, content } objects');
1063
1086
  if (!files.length) fail('files must not be empty');
@@ -1065,6 +1088,7 @@ export async function writeFilesTool(args) {
1065
1088
  const results = [];
1066
1089
  let totalBytes = 0;
1067
1090
  for (const entry of files) {
1091
+ throwIfCancelled(extra.signal);
1068
1092
  const target = typeof entry?.path === 'string' ? entry.path : null;
1069
1093
  if (!target) { results.push('skipped: entry without a path'); continue; }
1070
1094
  if (typeof entry.content !== 'string') { results.push(`skipped ${target}: content must be a string`); continue; }
@@ -1104,7 +1128,7 @@ export async function deletePathTool(args) {
1104
1128
  return text(`Deleted ${info.isDirectory() ? 'directory' : 'file'} ${displayPath(absolute)}${info.isDirectory() ? ` and its ${entries.length} top-level entr${entries.length === 1 ? 'y' : 'ies'}` : ''}.`);
1105
1129
  }
1106
1130
 
1107
- export async function deletePathsTool(args) {
1131
+ export async function deletePathsTool(args, extra = {}) {
1108
1132
  const paths = Array.isArray(args.paths) ? args.paths : fail('paths must be an array of absolute paths');
1109
1133
  if (!paths.length) fail('paths must not be empty');
1110
1134
  if (paths.length > 500) fail('paths accepts at most 500 entries per call');
@@ -1112,6 +1136,7 @@ export async function deletePathsTool(args) {
1112
1136
  const results = [];
1113
1137
  let deleted = 0;
1114
1138
  for (const entry of paths) {
1139
+ throwIfCancelled(extra.signal);
1115
1140
  try {
1116
1141
  const absolute = await resolveSafePath(entry, 'paths[]');
1117
1142
  if (path.dirname(absolute) === absolute) throw new Error('refusing to delete the filesystem root');
@@ -1135,7 +1160,7 @@ export async function deletePathsTool(args) {
1135
1160
 
1136
1161
  // Recursive copy for files and whole directories, so a project or a backup can be
1137
1162
  // duplicated in one call.
1138
- export async function copyPathsTool(args) {
1163
+ export async function copyPathsTool(args, extra = {}) {
1139
1164
  const pairs = Array.isArray(args.paths) ? args.paths : fail('paths must be an array of { source, destination } objects');
1140
1165
  if (!pairs.length) fail('paths must not be empty');
1141
1166
  if (pairs.length > 200) fail('paths accepts at most 200 entries per call');
@@ -1143,6 +1168,7 @@ export async function copyPathsTool(args) {
1143
1168
  const results = [];
1144
1169
  let copied = 0;
1145
1170
  for (const entry of pairs) {
1171
+ throwIfCancelled(extra.signal);
1146
1172
  try {
1147
1173
  const source = await resolveSafePath(entry?.source, 'paths[].source');
1148
1174
  const destination = await resolveSafePath(entry?.destination, 'paths[].destination');
@@ -1150,7 +1176,7 @@ export async function copyPathsTool(args) {
1150
1176
  await withWritableParent(destination, async anchoredDestination => {
1151
1177
  const existing = await lstat(anchoredDestination).catch(() => null);
1152
1178
  if (existing && !overwrite) throw new Error('destination already exists (pass overwrite: true)');
1153
- await copyTreeContents(source, anchoredDestination, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER });
1179
+ await copyTreeContents(source, anchoredDestination, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER, signal: extra.signal });
1154
1180
  });
1155
1181
  copied += 1;
1156
1182
  results.push(`copied ${displayPath(source)} → ${displayPath(destination)}`);
@@ -1161,7 +1187,7 @@ export async function copyPathsTool(args) {
1161
1187
  return text(`${copied}/${pairs.length} path(s) copied\n${results.join('\n')}`, copied !== pairs.length);
1162
1188
  }
1163
1189
 
1164
- export async function movePathsTool(args) {
1190
+ export async function movePathsTool(args, extra = {}) {
1165
1191
  const pairs = Array.isArray(args.paths) ? args.paths : fail('paths must be an array of { source, destination } objects');
1166
1192
  if (!pairs.length) fail('paths must not be empty');
1167
1193
  if (pairs.length > 200) fail('paths accepts at most 200 entries per call');
@@ -1169,6 +1195,7 @@ export async function movePathsTool(args) {
1169
1195
  const results = [];
1170
1196
  let moved = 0;
1171
1197
  for (const entry of pairs) {
1198
+ throwIfCancelled(extra.signal);
1172
1199
  try {
1173
1200
  const source = await resolveSafePath(entry?.source, 'paths[].source');
1174
1201
  const destination = await resolveSafePath(entry?.destination, 'paths[].destination');
@@ -1184,7 +1211,7 @@ export async function movePathsTool(args) {
1184
1211
  await rename(anchoredSource, anchoredDestination);
1185
1212
  } catch (error) {
1186
1213
  if (error?.code !== 'EXDEV') throw error;
1187
- await copyTreeContents(anchoredSource, anchoredDestination, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER });
1214
+ await copyTreeContents(anchoredSource, anchoredDestination, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER, signal: extra.signal });
1188
1215
  await rm(anchoredSource, { recursive:true, force:true });
1189
1216
  }
1190
1217
  });
@@ -1289,6 +1316,11 @@ export async function setPermissionsTool(args) {
1289
1316
  if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY) {
1290
1317
  changed = await setLinuxPermissions(absolute, mode, uid, gid, recursive, displayPath(absolute), failures);
1291
1318
  } else {
1319
+ // macOS/Windows fallback: Node exposes no directory-fd-relative chmod/chown there, so this
1320
+ // branch re-resolves each path lexically (confineAll) and then acts on the path. Between those
1321
+ // two steps another process can swap a component, so confinement is a guardrail on this
1322
+ // platform rather than a descriptor-bound guarantee — the same residual the archive/document
1323
+ // staging path documents. Linux uses the descriptor-bound path above.
1292
1324
  const info = await stat(absolute).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
1293
1325
  const targets = [absolute];
1294
1326
  if (recursive && info.isDirectory()) {
@@ -1548,6 +1580,7 @@ async function copyOpenRegularFile(sourceHandle, sourceInfo, destinationPath, st
1548
1580
  }
1549
1581
 
1550
1582
  async function copyTreeContents(source, destination, state = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES }) {
1583
+ throwIfCancelled(state.signal);
1551
1584
  const initial = await lstat(source);
1552
1585
  if (initial.isSymbolicLink() || (!initial.isDirectory() && !initial.isFile())) fail('Archive contains symbolic links or special files; archive destination is unsafe');
1553
1586
  state.entries += 1;
@@ -1749,12 +1782,15 @@ function archiveTool() {
1749
1782
  return { tar: probe('tar'), zip: probe('zip'), unzip: probe('unzip', ['-v']) };
1750
1783
  }
1751
1784
 
1752
- export async function createArchiveTool(args) {
1785
+ export async function createArchiveTool(args, extra = {}) {
1753
1786
  const tools = archiveTool();
1754
1787
  const sources = Array.isArray(args.paths) ? args.paths : [args.paths].filter(Boolean);
1755
1788
  if (!sources.length) fail('paths must list at least one file or directory');
1756
1789
  const resolved = [];
1757
- for (const entry of sources) resolved.push(await resolveSafePath(entry, 'paths[]'));
1790
+ for (const entry of sources) {
1791
+ throwIfCancelled(extra.signal);
1792
+ resolved.push(await resolveSafePath(entry, 'paths[]'));
1793
+ }
1758
1794
  const destination = await resolveSafePath(args.destination, 'destination');
1759
1795
  const existingDestination = await lstat(destination).catch(() => null);
1760
1796
  if (existingDestination?.isSymbolicLink()) fail('Archive destination cannot be a symbolic link');
@@ -1774,8 +1810,9 @@ export async function createArchiveTool(args) {
1774
1810
  try {
1775
1811
  await ensureDirectoryPath(snapshotRoot, { allowOutside:true });
1776
1812
 
1777
- const snapshotState = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES };
1813
+ const snapshotState = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES, signal: extra.signal };
1778
1814
  for (let index = 0; index < resolved.length; index += 1) {
1815
+ throwIfCancelled(extra.signal);
1779
1816
  const snapshotPath = path.join(snapshotRoot, names[index]);
1780
1817
  await ensureDirectoryPath(path.dirname(snapshotPath), { allowOutside:true });
1781
1818
 
package/src/util.mjs CHANGED
@@ -9,6 +9,14 @@ export function fail(message) {
9
9
  throw new ToolError(String(message));
10
10
  }
11
11
 
12
+ // Cooperative cancellation for handlers that loop over many items. The MCP layer already rejects
13
+ // the call to the client on abort, so the job here is to stop the device-side side effects: a bulk
14
+ // read/write/archive that ignored the signal kept mutating files long after the client gave up.
15
+ // Long handlers call this between items, so a cancel lands at the next item boundary.
16
+ export function throwIfCancelled(signal) {
17
+ if (signal?.aborted) fail('Cancelled by the client; stopped before the next item.');
18
+ }
19
+
12
20
  export function expandHome(value) {
13
21
  const text = String(value ?? '');
14
22
  if (text === '~') return os.homedir();