@remcp/runtime 0.2.22 → 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 +1 -1
- package/src/invoke.mjs +3 -1
- package/src/permissions.mjs +69 -0
- package/src/tools/files.mjs +11 -10
package/package.json
CHANGED
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
|
-
|
|
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
|
+
}
|
package/src/tools/files.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`;
|