@remcp/runtime 0.2.23 → 0.2.24

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.23",
3
+ "version": "0.2.24",
4
4
  "description": "First-party ReMCP local device runtime: file, search, terminal and process tools over MCP for computers paired with ReMCP.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/invoke.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { toolHandlers } from './catalog.mjs';
2
+ import { describeFilesystemFailure } from './permissions.mjs';
2
3
  import { recordEvent } from './telemetry.mjs';
3
4
  import { ToolError, text } from './util.mjs';
4
5
 
@@ -29,6 +30,7 @@ export async function invokeTool(name, args = {}, extra = {}) {
29
30
  } catch (error) {
30
31
  recordEvent('tool_call', { tool: definition.name, durationMs: performance.now() - started, success: false, errorKind: errorKind(error) });
31
32
  if (error instanceof ToolError) return text(error.message, true);
32
- return text(`Tool ${name} failed: ${error instanceof Error ? error.message : String(error)}`, true);
33
+ // A filesystem error arrives with a bare errno; the explanation is what the person can act on.
34
+ return text(`Tool ${name} failed: ${describeFilesystemFailure(error, { path: error?.path })}`, true);
33
35
  }
34
36
  }
@@ -0,0 +1,69 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+
4
+ // macOS protects Desktop, Documents, Downloads, iCloud Drive and external volumes behind TCC (the
5
+ // privacy layer that used to be callable "Full Disk Access"). A process without the grant gets a bare
6
+ // `EACCES: permission denied, mkdir '/Users/ada/Desktop/notes'` from the kernel, which tells the person
7
+ // nothing they can act on. ReMCP usually runs as a background service, and a background service cannot
8
+ // show the prompt macOS shows an app, so the grant has to be made by hand:
9
+ // System Settings → Privacy & Security → Full Disk Access → + → the binary that runs the tools
10
+ // These helpers turn those errnos into that instruction. They never widen access — they explain it.
11
+ const MACOS_HOME_FOLDERS = Object.freeze(['Desktop', 'Documents', 'Downloads']);
12
+ const PERMISSION_CODES = new Set(['EACCES', 'EPERM']);
13
+
14
+ // The protected location a path belongs to ("~/Desktop", "iCloud Drive", "an external volume"), or
15
+ // null when macOS does not treat that path as protected. Pure and injectable so it can be tested on
16
+ // any platform.
17
+ export function macosProtectedLocation(target, { platform = process.platform, home = os.homedir() } = {}) {
18
+ if (platform !== 'darwin') return null;
19
+ const value = typeof target === 'string' ? target.trim() : '';
20
+ if (!value) return null;
21
+ const resolved = path.resolve(value);
22
+ if (resolved === '/Volumes' || resolved.startsWith(`/Volumes${path.sep}`)) return 'an external or network volume';
23
+ // The kernel reports the absolute path, so a folder can be recognised even when it belongs to
24
+ // another account: every /Users/<name>/Desktop is somebody's Desktop, and it is protected for the
25
+ // process that is asking, whatever this runtime's own home directory is.
26
+ const segments = resolved.split(path.sep);
27
+ const relative = home ? path.relative(path.resolve(home), resolved) : '';
28
+ const insideHome = relative && !relative.startsWith('..') && !path.isAbsolute(relative);
29
+ const candidate = insideHome ? relative.split(path.sep) : segments[0] === '' && segments[1] === 'Users' ? segments.slice(3) : null;
30
+ if (!candidate?.length) return null;
31
+ const [first, second] = candidate;
32
+ if (MACOS_HOME_FOLDERS.includes(first)) return `~/${first}`;
33
+ if (first === 'Library' && second === 'Mobile Documents') return 'iCloud Drive';
34
+ return null;
35
+ }
36
+
37
+ // What the person should do about this failure, or null when the error is not a filesystem permission
38
+ // problem this module has better words for.
39
+ export function filesystemErrorExplanation(error, options = {}) {
40
+ const {
41
+ // Node puts the failing path on the error itself, so a caller that forgets to pass one still gets
42
+ // the right sentence; an explicit path wins because some callers catch a re-thrown error.
43
+ path: target = error?.path,
44
+ platform = process.platform,
45
+ home = os.homedir(),
46
+ execPath = process.execPath,
47
+ } = options;
48
+ const code = typeof error?.code === 'string' ? error.code : '';
49
+ const where = typeof target === 'string' && target.trim() ? target.trim() : 'that path';
50
+ if (code === 'ENOSPC') return 'The disk is full (ENOSPC). Free some space or write to another volume.';
51
+ if (code === 'EROFS') return `${where} is on a read-only file system (EROFS). Write somewhere else, or remount it read-write.`;
52
+ if (!PERMISSION_CODES.has(code)) return null;
53
+ const protectedLocation = macosProtectedLocation(target, { platform, home });
54
+ if (protectedLocation) {
55
+ return `macOS protects ${protectedLocation} (${code}), and ReMCP runs as a background service, which cannot show the permission prompt. Grant it by hand: System Settings → Privacy & Security → Full Disk Access → + → ${execPath}, then restart the agent with \`remcp start\`. A folder outside Desktop, Documents, Downloads and iCloud Drive needs no new permission.`;
56
+ }
57
+ if (platform === 'darwin') {
58
+ return `macOS denied access to ${where} (${code}). Check the ownership and permissions of that folder, or add ${execPath} to System Settings → Privacy & Security → Full Disk Access and restart the agent with \`remcp start\`.`;
59
+ }
60
+ return `The operating system denied access to ${where} (${code}). Check that this user owns the folder and its parents, or choose another path.`;
61
+ }
62
+
63
+ // One line for the tool result: the original errno first (so a log or an engineer still sees exactly
64
+ // what the kernel said), then what to do about it.
65
+ export function describeFilesystemFailure(error, options = {}) {
66
+ const message = error instanceof Error ? error.message : String(error);
67
+ const explanation = filesystemErrorExplanation(error, options);
68
+ return explanation ? `${message} ${explanation}` : message;
69
+ }
@@ -8,6 +8,7 @@ import { pipeline } from 'node:stream/promises';
8
8
  import { liveConfig, runtimeConfig } from '../config.mjs';
9
9
  import { documentKind, readDocxText, readPdfText } from '../documents.mjs';
10
10
  import { diffStats, unifiedDiff } from '../diff.mjs';
11
+ import { describeFilesystemFailure } from '../permissions.mjs';
11
12
  import { applyHunks, parseUnifiedDiff } from '../patch.mjs';
12
13
  import { countEvent, recordEvent } from '../telemetry.mjs';
13
14
  import { clampInteger, decodeText, displayPath, fail, globToRegExp, image, looksBinary, multi, pageLines, resolveSafePath, splitLines, text } from '../util.mjs';
@@ -135,7 +136,7 @@ export async function readMultipleFilesTool(args) {
135
136
  try {
136
137
  absolute = await resolveSafePath(entry, 'paths[]');
137
138
  } catch (error) {
138
- sections.push(`${String(entry)}: error - ${error instanceof Error ? error.message : String(error)}`);
139
+ sections.push(`${String(entry)}: error - ${describeFilesystemFailure(error, { path: error?.path })}`);
139
140
  continue;
140
141
  }
141
142
  try {
@@ -146,7 +147,7 @@ export async function readMultipleFilesTool(args) {
146
147
  const suffix = lines.length > limit ? `\n… ${lines.length - limit} more lines truncated` : '';
147
148
  sections.push(`${displayPath(absolute)}:\n${slice.join('\n')}${suffix}`);
148
149
  } catch (error) {
149
- sections.push(`${displayPath(absolute)}: error - ${error instanceof Error ? error.message : String(error)}`);
150
+ sections.push(`${displayPath(absolute)}: error - ${describeFilesystemFailure(error, { path: error?.path })}`);
150
151
  }
151
152
  }
152
153
  return text(sections.join('\n\n'));
@@ -555,7 +556,7 @@ export async function readFilesTool(args) {
555
556
  sections.push(`===== ${displayPath(file)} (${lines.length} lines${encoding === 'utf8' ? '' : `, ${encoding}`}) =====\n${slice.join('\n')}${suffix}`);
556
557
  } catch (error) {
557
558
  skipped += 1;
558
- sections.push(`===== ${displayPath(file)} =====\n(skipped: ${error instanceof Error ? error.message : String(error)})`);
559
+ sections.push(`===== ${displayPath(file)} =====\n(skipped: ${describeFilesystemFailure(error, { path: error?.path })})`);
559
560
  }
560
561
  }
561
562
  const notes = [];
@@ -588,7 +589,7 @@ export async function writeFilesTool(args) {
588
589
  await writeFile(absolute, content, entry.mode === 'append' ? { encoding: 'utf8', flag: 'a' } : 'utf8');
589
590
  results.push(`${entry.mode === 'append' ? 'appended' : 'wrote'} ${displayPath(absolute)} (${bytes} bytes)`);
590
591
  } catch (error) {
591
- results.push(`failed ${target}: ${error instanceof Error ? error.message : String(error)}`);
592
+ results.push(`failed ${target}: ${describeFilesystemFailure(error, { path: error?.path })}`);
592
593
  }
593
594
  }
594
595
  countEvent('bytesWritten', totalBytes);
@@ -631,7 +632,7 @@ export async function deletePathsTool(args) {
631
632
  deleted += 1;
632
633
  results.push(`deleted ${displayPath(absolute)}`);
633
634
  } catch (error) {
634
- results.push(`failed ${entry}: ${error instanceof Error ? error.message : String(error)}`);
635
+ results.push(`failed ${entry}: ${describeFilesystemFailure(error, { path: error?.path })}`);
635
636
  }
636
637
  }
637
638
  return text(`${deleted}/${paths.length} path(s) deleted\n${results.join('\n')}`, deleted !== paths.length);
@@ -660,7 +661,7 @@ export async function copyPathsTool(args) {
660
661
  copied += 1;
661
662
  results.push(`copied ${displayPath(source)} → ${displayPath(destination)}`);
662
663
  } catch (error) {
663
- results.push(`failed ${entry?.source ?? '?'}: ${error instanceof Error ? error.message : String(error)}`);
664
+ results.push(`failed ${entry?.source ?? '?'}: ${describeFilesystemFailure(error, { path: error?.path })}`);
664
665
  }
665
666
  }
666
667
  return text(`${copied}/${pairs.length} path(s) copied\n${results.join('\n')}`, copied !== pairs.length);
@@ -693,7 +694,7 @@ export async function movePathsTool(args) {
693
694
  moved += 1;
694
695
  results.push(`moved ${displayPath(source)} → ${displayPath(destination)}`);
695
696
  } catch (error) {
696
- results.push(`failed ${entry?.source ?? '?'}: ${error instanceof Error ? error.message : String(error)}`);
697
+ results.push(`failed ${entry?.source ?? '?'}: ${describeFilesystemFailure(error, { path: error?.path })}`);
697
698
  }
698
699
  }
699
700
  return text(`${moved}/${pairs.length} path(s) moved\n${results.join('\n')}`, moved !== pairs.length);
@@ -732,7 +733,7 @@ export async function applyPatchTool(args) {
732
733
  results.push(`${dryRun ? 'would patch' : 'patched'} ${displayPath(absolute)} · ${applied.length}/${file.hunks.length} hunk(s), +${stats.added}/-${stats.removed} lines${fuzzy ? `, ${fuzzy} with fuzz` : ''}${failed.length ? `, ${failed.length} hunk(s) did not match` : ''}`);
733
734
  if (dryRun) results.push(unifiedDiff(original, updated, { oldLabel: displayPath(absolute), newLabel: 'after' }));
734
735
  } catch (error) {
735
- results.push(`failed ${target}: ${error instanceof Error ? error.message : String(error)}`);
736
+ results.push(`failed ${target}: ${describeFilesystemFailure(error, { path: error?.path })}`);
736
737
  }
737
738
  }
738
739
  const failedCount = results.filter(line => line.startsWith('failed')).length;
@@ -773,7 +774,7 @@ export async function setPermissionsTool(args) {
773
774
  changed += 1;
774
775
  } catch (error) {
775
776
  // One protected file must not abort a recursive change; the caller gets the full picture.
776
- failures.push(`${displayPath(target)}: ${error instanceof Error ? error.message : String(error)}`);
777
+ failures.push(`${displayPath(target)}: ${describeFilesystemFailure(error, { path: error?.path })}`);
777
778
  }
778
779
  }
779
780
  const summary = `Set mode ${raw}${uid !== null || gid !== null ? ` (uid ${uid ?? '-'} gid ${gid ?? '-'})` : ''} on ${changed} path(s) starting at ${displayPath(absolute)}.`;
@@ -793,7 +794,7 @@ export async function createDirectoryTool(args) {
793
794
  await mkdir(absolute, { recursive: true });
794
795
  created.push(displayPath(absolute));
795
796
  } catch (error) {
796
- failed.push(`${entry}: ${error instanceof Error ? error.message : String(error)}`);
797
+ failed.push(`${entry}: ${describeFilesystemFailure(error, { path: error?.path })}`);
797
798
  }
798
799
  }
799
800
  const header = `${created.length} director${created.length === 1 ? 'y' : 'ies'} ready`;