@remcp/runtime 0.2.153 → 0.2.155

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.
@@ -0,0 +1,336 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import { constants, createReadStream } from 'node:fs';
6
+ import { access, chmod, chown, copyFile, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, unlink } from 'node:fs/promises';
7
+ import { pipeline } from 'node:stream/promises';
8
+ import { liveConfig, runtimeConfig } from '../config.mjs';
9
+ import { documentKind, readDocxText, readPdfText } from '../documents.mjs';
10
+ import { diffStats, unifiedDiff } from '../diff.mjs';
11
+ import { describeFilesystemFailure } from '../permissions.mjs';
12
+ import { capturePortalScreenshot, isWaylandSession } from '../screenshot-portal.mjs';
13
+ import { applyHunks, parseUnifiedDiff } from '../patch.mjs';
14
+ import { countEvent, recordEvent } from '../telemetry.mjs';
15
+ import { clampInteger, decodeText, displayPath, fail, fitsOutput, globToRegExp, image, isInsideRoot, looksBinary, multi, pageLines, resolveSafePath, splitLines, text, throwIfCancelled } from '../util.mjs';
16
+
17
+
18
+ import { MAX_BINARY_CHUNK_BYTES, MAX_INLINE_FILE_BYTES, READWRITE_NOFOLLOW, READ_NOFOLLOW, ensureDirectoryPath, openRegularFile, overwriteOpenFile, withExistingParent, withWritableParent, writeResolvedFile } from './files-secure-paths.mjs';
19
+ import { collectTree, confineAll, getPathInfo, readTextFile } from './files-read-traversal.mjs';
20
+ import { copyTreeContents } from './files-archive-safety.mjs';
21
+
22
+ export function assertWritableSize(content) {
23
+ const bytes = Buffer.byteLength(content, 'utf8');
24
+ if (bytes > runtimeConfig.maxWriteBytes) {
25
+ countEvent('writeDenials');
26
+ recordEvent('write_denied', { reason: 'size_limit' });
27
+ fail(`Content is ${bytes} bytes, above the ${runtimeConfig.maxWriteBytes}-byte write limit for this device`);
28
+ }
29
+ return bytes;
30
+ }
31
+
32
+ export async function writeFileTool(args) {
33
+ const absolute = await resolveSafePath(args.path);
34
+ const content = typeof args.content === 'string' ? args.content : fail('content must be a string');
35
+ const providedMode = typeof args.mode === 'string' && args.mode.trim() ? args.mode.trim().toLowerCase() : '';
36
+ if (providedMode && !['rewrite', 'append'].includes(providedMode)) fail('mode must be rewrite or append');
37
+ const mode = providedMode || 'rewrite';
38
+ const bytes = Buffer.byteLength(content, 'utf8');
39
+ if (mode === 'rewrite') {
40
+ assertWritableSize(content);
41
+ if (content.includes('\0')) fail('content contains NUL bytes. For binary data pass encoding: "base64" (or use write_binary) so the file is written byte for byte.');
42
+ } else {
43
+ const existing = await stat(absolute).catch(() => null);
44
+ const existingSize = existing?.isFile() ? existing.size : 0;
45
+ if (existingSize + bytes > runtimeConfig.maxWriteBytes) {
46
+ countEvent('writeDenials');
47
+ recordEvent('write_denied', { reason: 'size_limit' });
48
+ fail(`Appending ${bytes} bytes would grow ${displayPath(absolute)} to ${existingSize + bytes} bytes, above the ${runtimeConfig.maxWriteBytes}-byte write limit for this device`);
49
+ }
50
+ }
51
+ await writeResolvedFile(absolute, content, { append: mode === 'append' });
52
+ countEvent('bytesWritten', bytes);
53
+ return text(`${mode === 'append' ? 'Appended' : 'Wrote'} ${bytes} bytes to ${displayPath(absolute)}.`);
54
+ }
55
+
56
+ // Binary transfer in both directions, in chunks: the relay carries MCP results, so a
57
+ // large file is read as a sequence of base64 slices and written back the same way.
58
+ export async function readBinaryTool(args) {
59
+ const absolute = await resolveSafePath(args.path);
60
+ let opened;
61
+ try { opened = await openRegularFile(absolute); }
62
+ catch (error) {
63
+ if (error?.code === 'ENOENT') fail(`File not found: ${displayPath(absolute)}`);
64
+ throw error;
65
+ }
66
+ const { handle, info } = opened;
67
+ const offset = Math.max(0, Number.isFinite(Number(args.offset_bytes)) ? Math.trunc(Number(args.offset_bytes)) : 0);
68
+ const length = clampInteger(args.length_bytes, MAX_BINARY_CHUNK_BYTES, 1, MAX_BINARY_CHUNK_BYTES);
69
+ const start = Math.min(offset, info.size);
70
+ const end = Math.min(info.size, start + length);
71
+ try {
72
+ const buffer = Buffer.alloc(end - start);
73
+ if (buffer.length) await handle.read(buffer, 0, buffer.length, start);
74
+ const renderPayload = lengthBytes => {
75
+ const actualEnd = start + lengthBytes;
76
+ return JSON.stringify({
77
+ path: displayPath(absolute),
78
+ size: info.size,
79
+ offsetBytes: start,
80
+ lengthBytes,
81
+ nextOffsetBytes: actualEnd < info.size ? actualEnd : null,
82
+ complete: actualEnd >= info.size,
83
+ encoding: 'base64',
84
+ data: buffer.subarray(0, lengthBytes).toString('base64'),
85
+ });
86
+ };
87
+ let returnedLength = buffer.length;
88
+ let payload = renderPayload(returnedLength);
89
+ if (!fitsOutput(payload)) {
90
+ let low = 0;
91
+ let high = returnedLength;
92
+ while (low < high) {
93
+ const candidate = Math.ceil((low + high) / 2);
94
+ if (fitsOutput(renderPayload(candidate))) low = candidate;
95
+ else high = candidate - 1;
96
+ }
97
+ returnedLength = low;
98
+ payload = renderPayload(returnedLength);
99
+ if (!fitsOutput(payload)) fail('Runtime output limit is too small to return read_binary metadata');
100
+ }
101
+ return text(payload);
102
+ } finally {
103
+ await opened.close();
104
+ }
105
+ }
106
+
107
+ export async function writeBinaryTool(args) {
108
+ const absolute = await resolveSafePath(args.path);
109
+ const data = typeof args.data === 'string' ? args.data : fail('data must be a base64 string');
110
+ const mode = String(args.mode || 'rewrite').toLowerCase();
111
+ if (!['rewrite', 'append'].includes(mode)) fail('mode must be rewrite or append');
112
+ let buffer;
113
+ try {
114
+ buffer = Buffer.from(data.replace(/\s+/g, ''), 'base64');
115
+ } catch {
116
+ fail('data must be valid base64');
117
+ }
118
+ if (buffer.length > runtimeConfig.maxWriteBytes) {
119
+ countEvent('writeDenials');
120
+ recordEvent('write_denied', { reason: 'size_limit' });
121
+ fail(`Decoded content is ${buffer.length} bytes, above the ${runtimeConfig.maxWriteBytes}-byte write limit for this device`);
122
+ }
123
+ await writeResolvedFile(absolute, buffer, { append: mode === 'append' });
124
+ countEvent('bytesWritten', buffer.length);
125
+ return text(`${mode === 'append' ? 'Appended' : 'Wrote'} ${buffer.length} bytes to ${displayPath(absolute)}.`);
126
+ }
127
+
128
+ export function normalizeForFuzzy(value) {
129
+ return splitLines(value).map(line => line.replace(/[ \t]+/g, ' ').trim());
130
+ }
131
+
132
+ // Whitespace-tolerant fallback: models frequently re-indent an exact block they just
133
+ // read. Every candidate window is compared with collapsed whitespace, and the edit is
134
+ // applied only when the number of candidate windows matches expected_replacements.
135
+ export function fuzzyMatchStarts(lines, target) {
136
+ const normalized = lines.map(line => line.replace(/[ \t]+/g, ' ').trim());
137
+ const starts = [];
138
+ for (let start = 0; start + target.length <= normalized.length; start += 1) {
139
+ let equal = true;
140
+ for (let index = 0; index < target.length; index += 1) {
141
+ if (normalized[start + index] !== target[index]) { equal = false; break; }
142
+ }
143
+ if (equal) starts.push(start);
144
+ }
145
+ return starts;
146
+ }
147
+
148
+ export async function editBlockTool(args) {
149
+ const absolute = await resolveSafePath(args.file_path, 'file_path');
150
+ const oldString = typeof args.old_string === 'string' ? args.old_string : fail('old_string must be a string');
151
+ const newString = typeof args.new_string === 'string' ? args.new_string : fail('new_string must be a string');
152
+ if (!oldString) fail('old_string must not be empty');
153
+ if (oldString === newString) fail('old_string and new_string are identical');
154
+ const allowFuzzy = args.allow_fuzzy !== false;
155
+ const dryRun = args.dry_run === true;
156
+ const expected = Number.isInteger(Number(args.expected_replacements)) ? Math.max(1, Math.trunc(Number(args.expected_replacements))) : 1;
157
+ const { content, eol } = await readTextFile(absolute);
158
+ const occurrences = content.split(oldString).length - 1;
159
+
160
+ const present = (updated, how) => {
161
+ const stats = diffStats(content, updated);
162
+ const summary = `${how} in ${displayPath(absolute)} (+${stats.added}/-${stats.removed} lines)`;
163
+ if (!dryRun) return `${summary}.`;
164
+ return `${summary}\n(dry run: nothing was written)\n${unifiedDiff(content, updated, { oldLabel: displayPath(absolute), newLabel: 'after' })}`;
165
+ };
166
+
167
+ if (occurrences === expected) {
168
+ const updated = content.split(oldString).join(newString);
169
+ assertWritableSize(updated);
170
+ if (!dryRun) await writeResolvedFile(absolute, updated);
171
+ return text(present(updated, `Replaced ${occurrences} occurrence(s)`));
172
+ }
173
+ if (occurrences > 0) {
174
+ fail(`Expected ${expected} occurrence(s) of old_string but found ${occurrences}. Add more surrounding context.`);
175
+ }
176
+ if (!allowFuzzy) fail('old_string was not found in the file');
177
+ const target = normalizeForFuzzy(oldString);
178
+ const lines = splitLines(content);
179
+ const starts = target.length ? fuzzyMatchStarts(lines, target) : [];
180
+ if (starts.length !== expected) {
181
+ fail(starts.length
182
+ ? `old_string matched ${starts.length} block(s) after whitespace normalization, expected ${expected}. Add more surrounding context.`
183
+ : 'old_string was not found in the file, even after whitespace normalization');
184
+ }
185
+ const replacement = splitLines(newString);
186
+ const endsWithNewline = /\n$/.test(content);
187
+ for (const start of [...starts].reverse()) lines.splice(start, target.length, ...replacement);
188
+ // Rebuild with the file's own line ending: hard-coding \n silently rewrote every CRLF
189
+ // file to LF and turned a one-line change into a whole-file diff on Windows.
190
+ const updated = `${lines.join(eol)}${endsWithNewline && lines.length ? eol : ''}`;
191
+ assertWritableSize(updated);
192
+ if (!dryRun) await writeResolvedFile(absolute, updated);
193
+ return text(present(updated, `Replaced ${starts.length} occurrence(s) using whitespace-tolerant matching (line endings kept as ${eol === '\r\n' ? 'CRLF' : 'LF'})`));
194
+ }
195
+
196
+ export async function replaceLinesTool(args) {
197
+ const absolute = await resolveSafePath(args.path);
198
+ const startLine = Number(args.start_line);
199
+ const endLine = Number(args.end_line);
200
+ if (!Number.isInteger(startLine) || startLine < 1) fail('start_line must be a positive integer (1-based)');
201
+ if (!Number.isInteger(endLine) || endLine < startLine) fail('end_line must be an integer greater than or equal to start_line');
202
+ const content = typeof args.content === 'string' ? args.content : fail('content must be a string');
203
+ const dryRun = args.dry_run === true;
204
+ const { content: original, eol } = await readTextFile(absolute);
205
+ const lines = splitLines(original);
206
+ if (startLine > lines.length) fail(`${displayPath(absolute)} has ${lines.length} lines; start_line ${startLine} is past the end`);
207
+ const endsWithNewline = /\n$/.test(original);
208
+ const replacement = splitLines(content);
209
+ const updated = [...lines.slice(0, startLine - 1), ...replacement, ...lines.slice(Math.min(endLine, lines.length))];
210
+ const updatedText = `${updated.join(eol)}${endsWithNewline && updated.length ? eol : ''}`;
211
+ assertWritableSize(updatedText);
212
+ const stats = diffStats(original, updatedText);
213
+ const summary = `Replaced lines ${startLine}-${Math.min(endLine, lines.length)} of ${displayPath(absolute)} (+${stats.added}/-${stats.removed} lines)`;
214
+ if (dryRun) {
215
+ return text(`${summary}\n(dry run: nothing was written)\n${unifiedDiff(original, updatedText, { oldLabel: displayPath(absolute), newLabel: 'after' })}`);
216
+ }
217
+ await writeResolvedFile(absolute, updatedText);
218
+ return text(`${summary}.`);
219
+ }
220
+
221
+ export async function replaceInFilesTool(args, extra = {}) {
222
+ const root = await resolveSafePath(args.path);
223
+ const pattern = typeof args.pattern === 'string' && args.pattern ? args.pattern : fail('pattern is required');
224
+ const replacement = typeof args.replacement === 'string' ? args.replacement : fail('replacement must be a string');
225
+ const filePattern = typeof args.filePattern === 'string' && args.filePattern.trim() ? args.filePattern.trim() : null;
226
+ const isRegex = args.regex === true;
227
+ // Applying is the default: the agent is expected to act, and a dry run is available
228
+ // when a caller explicitly wants a preview.
229
+ const dryRun = args.dry_run === true;
230
+ const maxFiles = clampInteger(args.maxFiles, 100, 1, 500);
231
+ let matcher = null;
232
+ if (isRegex) {
233
+ try { matcher = new RegExp(pattern, 'g'); } catch (error) {
234
+ fail(`pattern is not a valid regular expression (${error instanceof Error ? error.message : String(error)})`);
235
+ }
236
+ }
237
+ const info = await getPathInfo(root).catch(() => fail(`Path not found: ${displayPath(root)}`));
238
+ const walk = info.isFile() ? { files: [root], denied: [] } : await collectTree(root, { maxFiles, skip: ['.git', 'node_modules', '.remcp-trash*'], signal: extra.signal });
239
+ const files = await confineAll(walk.files);
240
+ const glob = filePattern ? globToRegExp(filePattern) : null;
241
+ const changed = [];
242
+ let scanned = 0;
243
+ for (const file of files) {
244
+ throwIfCancelled(extra.signal);
245
+ if (changed.length >= maxFiles) break;
246
+ if (glob && !glob.test(path.basename(file))) continue;
247
+ let opened;
248
+ try { opened = await openRegularFile(file, dryRun ? READ_NOFOLLOW : READWRITE_NOFOLLOW); }
249
+ catch { continue; }
250
+ const { handle, info: fileInfo } = opened;
251
+ try {
252
+ if (fileInfo.size > MAX_INLINE_FILE_BYTES) continue;
253
+ const buffer = await handle.readFile();
254
+ const decoded = decodeText(buffer);
255
+ if (decoded.encoding === 'utf8' && looksBinary(buffer)) continue;
256
+ scanned += 1;
257
+ const original = decoded.text;
258
+ const count = isRegex ? (original.match(matcher) || []).length : original.split(pattern).length - 1;
259
+ if (!count) continue;
260
+ if (isRegex) matcher.lastIndex = 0;
261
+ const updated = isRegex ? original.replace(matcher, replacement) : original.split(pattern).join(replacement);
262
+ if (updated === original) continue;
263
+ assertWritableSize(updated);
264
+ if (!dryRun) await overwriteOpenFile(handle, updated);
265
+ const stats = diffStats(original, updated);
266
+ changed.push({ file: displayPath(file), replacements: count, added: stats.added, removed: stats.removed });
267
+ } finally {
268
+ await opened.close();
269
+ }
270
+ }
271
+ if (!changed.length) return text(`No matches for ${JSON.stringify(pattern)} in ${displayPath(root)} (${scanned} text files scanned).`);
272
+ const rows = changed.map(entry => `${dryRun ? 'would change' : 'changed'} ${entry.file} · ${entry.replacements} replacement(s) · +${entry.added}/-${entry.removed} lines`);
273
+ const header = `${dryRun ? 'Dry run' : 'Applied'}: ${changed.length} file(s), ${changed.reduce((sum, entry) => sum + entry.replacements, 0)} replacement(s)`;
274
+ const hint = dryRun ? '\nNothing was written. Call again with dry_run: false to apply.' : '';
275
+ return text(`${header}\n${rows.join('\n')}${hint}`);
276
+ }
277
+
278
+ export async function diffFilesTool(args) {
279
+ const left = await resolveSafePath(args.left, 'left');
280
+ const right = await resolveSafePath(args.right, 'right');
281
+ const context = clampInteger(args.context_lines, 3, 0, 20);
282
+ const a = await readTextFile(left);
283
+ const b = await readTextFile(right);
284
+ const diff = unifiedDiff(a.content, b.content, { oldLabel: displayPath(left), newLabel: displayPath(right), context });
285
+ if (!diff) return text(`${displayPath(left)} and ${displayPath(right)} are identical (${a.content.length} bytes).`);
286
+ const stats = diffStats(a.content, b.content);
287
+ return text(`${displayPath(left)} → ${displayPath(right)} (+${stats.added}/-${stats.removed} lines)\n${diff}`);
288
+ }
289
+
290
+ export function trashDirectoryFor() {
291
+ if (process.platform === 'darwin') return path.join(os.homedir(), '.Trash');
292
+ if (process.platform === 'win32') return null;
293
+ return path.join(os.homedir(), '.local', 'share', 'Trash', 'files');
294
+ }
295
+
296
+ export async function moveToTrashTool(args) {
297
+ const source = await resolveSafePath(args.source, 'source');
298
+ const trash = trashDirectoryFor();
299
+ let destination = null;
300
+ if (trash) {
301
+ // The trash lives outside the allowed roots, so only use it when confinement permits
302
+ // it; otherwise fall back to a trash folder beside the file.
303
+ try {
304
+ await resolveSafePath(trash, 'trash');
305
+ destination = trash;
306
+ } catch { destination = null; }
307
+ }
308
+ if (!destination) destination = path.join(path.dirname(source), '.remcp-trash');
309
+ const existingDestination = await lstat(destination).catch(() => null);
310
+ if (existingDestination?.isSymbolicLink()) fail('Refusing to use a symlink as the trash directory');
311
+ if (existingDestination && !existingDestination.isDirectory()) fail('Trash destination is not a directory');
312
+ await ensureDirectoryPath(destination);
313
+ destination = await resolveSafePath(destination, 'trash');
314
+ const destinationInfo = await lstat(destination);
315
+ if (!destinationInfo.isDirectory() || destinationInfo.isSymbolicLink()) fail('Refusing to use a symlink as the trash directory');
316
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
317
+ let target = path.join(destination, `${stamp}-${path.basename(source)}`);
318
+ let counter = 1;
319
+ while (await lstat(target).then(value => Boolean(value), () => false)) {
320
+ target = path.join(destination, `${stamp}-${counter}-${path.basename(source)}`);
321
+ counter += 1;
322
+ }
323
+ await withExistingParent(source, async anchoredSource => {
324
+ const sourceInfo = await lstat(anchoredSource).catch(() => fail(`Path not found: ${displayPath(source)}`));
325
+ if (sourceInfo.isSymbolicLink()) fail('source cannot be a symbolic link');
326
+ await withWritableParent(target, async anchoredTarget => {
327
+ await rename(anchoredSource, anchoredTarget).catch(async error => {
328
+ if (error?.code !== 'EXDEV') throw error;
329
+ if (sourceInfo.isDirectory()) fail('Moving a directory to the trash across filesystems is not supported');
330
+ await copyTreeContents(anchoredSource, anchoredTarget, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER });
331
+ await rm(anchoredSource, { force:true });
332
+ });
333
+ });
334
+ });
335
+ return text(`Moved ${displayPath(source)} to ${displayPath(target)}. Restore it with move_file if this was a mistake.`);
336
+ }