@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.
- package/CHANGELOG.md +5 -0
- package/package.json +1 -1
- package/src/extended/browser-actions.mjs +311 -0
- package/src/extended/browser-endpoint.mjs +469 -0
- package/src/extended/browser-read.mjs +288 -0
- package/src/extended/browser-session.mjs +413 -0
- package/src/extended/browser.mjs +4 -1440
- package/src/extended/desktop-computer-actions.mjs +346 -0
- package/src/extended/desktop-computer-snapshot.mjs +245 -0
- package/src/extended/desktop-core.mjs +449 -0
- package/src/extended/desktop-linux-displays.mjs +366 -0
- package/src/extended/desktop-linux-input.mjs +485 -0
- package/src/extended/desktop-linux-screen.mjs +105 -0
- package/src/extended/desktop-linux-ui.mjs +407 -0
- package/src/extended/desktop-linux-window-actions.mjs +237 -0
- package/src/extended/desktop-linux-windows.mjs +321 -0
- package/src/extended/desktop-linux.mjs +6 -1791
- package/src/extended/desktop-ui-actions.mjs +402 -0
- package/src/extended/desktop-vision.mjs +209 -0
- package/src/extended/desktop.mjs +6 -1583
- package/src/tools/files-archive-actions.mjs +166 -0
- package/src/tools/files-archive-safety.mjs +395 -0
- package/src/tools/files-bulk-operations.mjs +415 -0
- package/src/tools/files-read-traversal.mjs +347 -0
- package/src/tools/files-screenshots.mjs +167 -0
- package/src/tools/files-secure-paths.mjs +408 -0
- package/src/tools/files-write-edit.mjs +336 -0
- package/src/tools/files.mjs +8 -2111
|
@@ -0,0 +1,347 @@
|
|
|
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 { IMAGE_TYPES, MAX_IMAGE_BYTES, MAX_INLINE_FILE_BYTES, READ_NOFOLLOW_NONBLOCK, detectEol, openDirectoryPath, openEntryAtPath, openRegularFile, readRegularBuffer } from './files-secure-paths.mjs';
|
|
19
|
+
|
|
20
|
+
export async function collectTree(root, { maxFiles = 500, skip = [], signal = null } = {}) {
|
|
21
|
+
const found = [];
|
|
22
|
+
const denied = [];
|
|
23
|
+
const skipName = name => skip.some(entry => (entry.endsWith('*') ? name.startsWith(entry.slice(0, -1)) : name === entry));
|
|
24
|
+
const visitDirectory = async (directory, logicalBase) => {
|
|
25
|
+
let entries;
|
|
26
|
+
try {
|
|
27
|
+
entries = await readdir(directory.anchor, { withFileTypes: true });
|
|
28
|
+
} catch {
|
|
29
|
+
denied.push(logicalBase);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
throwIfCancelled(signal);
|
|
34
|
+
if (found.length >= maxFiles) return;
|
|
35
|
+
if (entry.isSymbolicLink() || skipName(entry.name)) continue;
|
|
36
|
+
const childLogical = path.join(logicalBase, entry.name);
|
|
37
|
+
const childAnchor = path.join(directory.anchor, entry.name);
|
|
38
|
+
if (entry.isDirectory()) {
|
|
39
|
+
let child;
|
|
40
|
+
try {
|
|
41
|
+
child = await openDirectoryPath(childAnchor);
|
|
42
|
+
const info = await child.handle.stat();
|
|
43
|
+
if (!info.isDirectory()) fail('Filesystem path component is not a directory');
|
|
44
|
+
await visitDirectory(child, childLogical);
|
|
45
|
+
} catch {
|
|
46
|
+
denied.push(childLogical);
|
|
47
|
+
} finally {
|
|
48
|
+
if (child) await child.close().catch(() => {});
|
|
49
|
+
}
|
|
50
|
+
} else if (entry.isFile()) {
|
|
51
|
+
let opened;
|
|
52
|
+
try {
|
|
53
|
+
opened = await openEntryAtPath(childAnchor, READ_NOFOLLOW_NONBLOCK);
|
|
54
|
+
const info = await opened.handle.stat();
|
|
55
|
+
if (info.isFile()) found.push(childLogical);
|
|
56
|
+
} catch {
|
|
57
|
+
denied.push(childLogical);
|
|
58
|
+
} finally {
|
|
59
|
+
if (opened) await opened.close().catch(() => {});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (found.length >= maxFiles) return;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
let directory;
|
|
66
|
+
try {
|
|
67
|
+
directory = await openDirectoryPath(root);
|
|
68
|
+
await visitDirectory(directory, root);
|
|
69
|
+
} catch {
|
|
70
|
+
// A cancelled walk must not degrade into the "root is a single file" fallback below.
|
|
71
|
+
throwIfCancelled(signal);
|
|
72
|
+
let opened;
|
|
73
|
+
try {
|
|
74
|
+
opened = await openEntryAtPath(root, READ_NOFOLLOW_NONBLOCK);
|
|
75
|
+
const info = await opened.handle.stat();
|
|
76
|
+
if (info.isFile()) found.push(root);
|
|
77
|
+
else denied.push(root);
|
|
78
|
+
} catch {
|
|
79
|
+
denied.push(root);
|
|
80
|
+
} finally {
|
|
81
|
+
if (opened) await opened.close().catch(() => {});
|
|
82
|
+
}
|
|
83
|
+
} finally {
|
|
84
|
+
if (directory) await directory.close().catch(() => {});
|
|
85
|
+
}
|
|
86
|
+
return { files: found, denied };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Every path a multi-file tool is about to touch passes through the same confinement check as a
|
|
90
|
+
// single-file call, so dropping the traversal shortcut cannot widen what is reachable.
|
|
91
|
+
export async function confineAll(paths) {
|
|
92
|
+
const safe = [];
|
|
93
|
+
for (const target of paths) {
|
|
94
|
+
try { safe.push(await resolveSafePath(target)); } catch { /* outside the allowed roots: skip it */ }
|
|
95
|
+
}
|
|
96
|
+
return safe;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function readTextFile(absolute) {
|
|
100
|
+
let opened;
|
|
101
|
+
try { opened = await readRegularBuffer(absolute, MAX_INLINE_FILE_BYTES); }
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (error?.code === 'ENOENT') fail(`File not found: ${displayPath(absolute)}`);
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
const { info, buffer } = opened;
|
|
107
|
+
const decoded = decodeText(buffer);
|
|
108
|
+
if (decoded.encoding === 'utf8' && looksBinary(buffer)) {
|
|
109
|
+
fail(`${displayPath(absolute)} looks like a binary file and cannot be read as text. Use read_image for images, or get_file_info and hash_file for other binaries.`);
|
|
110
|
+
}
|
|
111
|
+
return { info, content: decoded.text, encoding: decoded.encoding, eol: detectEol(decoded.text) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function readFileTool(args) {
|
|
115
|
+
const absolute = await resolveSafePath(args.path);
|
|
116
|
+
// Documents first: a .docx or .pdf is not text, and the binary guard below would refuse it.
|
|
117
|
+
const kind = documentKind(absolute);
|
|
118
|
+
if (kind) {
|
|
119
|
+
const { buffer } = await readRegularBuffer(absolute, MAX_INLINE_FILE_BYTES);
|
|
120
|
+
const extracted = kind === 'docx' ? readDocxText(buffer) : readPdfText(buffer);
|
|
121
|
+
const documentLines = splitLines(extracted);
|
|
122
|
+
const offset = Number.isFinite(Number(args.offset)) ? Math.trunc(Number(args.offset)) : 0;
|
|
123
|
+
const length = clampInteger(args.length, liveConfig('maxReadLines'), 1, 10000);
|
|
124
|
+
const page = pageLines(documentLines, offset, length);
|
|
125
|
+
const label = kind === 'docx' ? 'Word document' : 'PDF text';
|
|
126
|
+
const header = documentLines.length
|
|
127
|
+
? `${displayPath(absolute)} (${label}, lines ${page.start + 1}-${page.end} of ${documentLines.length})`
|
|
128
|
+
: `${displayPath(absolute)} (${label}, no text)`;
|
|
129
|
+
return text(`${header}
|
|
130
|
+
${page.slice.join('\n')}`);
|
|
131
|
+
}
|
|
132
|
+
const { content, encoding, eol } = await readTextFile(absolute);
|
|
133
|
+
const lines = splitLines(content);
|
|
134
|
+
const offset = Number.isFinite(Number(args.offset)) ? Math.trunc(Number(args.offset)) : 0;
|
|
135
|
+
const length = clampInteger(args.length, liveConfig('maxReadLines'), 1, 10000);
|
|
136
|
+
const { start, end, slice } = pageLines(lines, offset, length);
|
|
137
|
+
const notes = `${encoding === 'utf8' ? '' : ` ${encoding}`}${eol === '\r\n' ? ' CRLF' : ''}`;
|
|
138
|
+
const header = lines.length
|
|
139
|
+
? `${displayPath(absolute)} (lines ${start + 1}-${end} of ${lines.length}${notes})`
|
|
140
|
+
: `${displayPath(absolute)} (empty file)`;
|
|
141
|
+
return text(`${header}\n${slice.join('\n')}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function readMultipleFilesTool(args, extra = {}) {
|
|
145
|
+
if (!Array.isArray(args.paths) || !args.paths.length) fail('paths must be a non-empty array');
|
|
146
|
+
if (args.paths.length > 50) fail('paths accepts at most 50 entries per call');
|
|
147
|
+
const sections = [];
|
|
148
|
+
for (const entry of args.paths) {
|
|
149
|
+
throwIfCancelled(extra.signal);
|
|
150
|
+
let absolute;
|
|
151
|
+
try {
|
|
152
|
+
absolute = await resolveSafePath(entry, 'paths[]');
|
|
153
|
+
} catch (error) {
|
|
154
|
+
sections.push(`${String(entry)}: error - ${describeFilesystemFailure(error, { path: error?.path })}`);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
const { content } = await readTextFile(absolute);
|
|
159
|
+
const lines = splitLines(content);
|
|
160
|
+
const limit = liveConfig('maxReadLines');
|
|
161
|
+
const slice = lines.slice(0, limit);
|
|
162
|
+
const suffix = lines.length > limit ? `\n… ${lines.length - limit} more lines truncated` : '';
|
|
163
|
+
sections.push(`${displayPath(absolute)}:\n${slice.join('\n')}${suffix}`);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
sections.push(`${displayPath(absolute)}: error - ${describeFilesystemFailure(error, { path: error?.path })}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return text(sections.join('\n\n'));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function readImageTool(args) {
|
|
172
|
+
const absolute = await resolveSafePath(args.path);
|
|
173
|
+
let opened;
|
|
174
|
+
try { opened = await readRegularBuffer(absolute, MAX_IMAGE_BYTES); }
|
|
175
|
+
catch (error) {
|
|
176
|
+
if (error?.code === 'ENOENT') fail(`File not found: ${displayPath(absolute)}`);
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
const { info, buffer } = opened;
|
|
180
|
+
const mimeType = IMAGE_TYPES.get(path.extname(absolute).toLowerCase());
|
|
181
|
+
if (!mimeType) fail(`${displayPath(absolute)} is not a supported image type (${[...IMAGE_TYPES.keys()].join(', ')})`);
|
|
182
|
+
if (mimeType === 'image/svg+xml') {
|
|
183
|
+
const decoded = decodeText(buffer);
|
|
184
|
+
if (decoded.encoding === 'utf8' && looksBinary(buffer)) fail(`${displayPath(absolute)} looks like a binary file`);
|
|
185
|
+
return text(`SVG image ${displayPath(absolute)} (${info.size} bytes):\n${decoded.text}`);
|
|
186
|
+
}
|
|
187
|
+
return multi([
|
|
188
|
+
{ type: 'text', text: `${displayPath(absolute)} — ${mimeType}, ${info.size} bytes` },
|
|
189
|
+
image(buffer.toString('base64'), mimeType),
|
|
190
|
+
]);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function hashFileTool(args) {
|
|
194
|
+
const absolute = await resolveSafePath(args.path);
|
|
195
|
+
let opened;
|
|
196
|
+
try { opened = await openRegularFile(absolute); }
|
|
197
|
+
catch (error) {
|
|
198
|
+
if (error?.code === 'ENOENT') fail(`File not found: ${displayPath(absolute)}`);
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
const { handle, info } = opened;
|
|
202
|
+
const algorithm = String(args.algorithm || 'sha256').toLowerCase();
|
|
203
|
+
if (!['sha256', 'sha1', 'md5'].includes(algorithm)) { await opened.close(); fail('algorithm must be sha256, sha1, or md5'); }
|
|
204
|
+
const hash = createHash(algorithm);
|
|
205
|
+
try {
|
|
206
|
+
await pipeline(createReadStream(absolute, { fd: handle.fd, autoClose: false }), hash);
|
|
207
|
+
return text(`${algorithm} ${hash.digest('hex')} ${displayPath(absolute)} (${info.size} bytes)`);
|
|
208
|
+
} finally {
|
|
209
|
+
await opened.close();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function openListEntry(base, name) {
|
|
214
|
+
const opened = await openEntryAtPath(path.join(base, name), READ_NOFOLLOW_NONBLOCK);
|
|
215
|
+
try {
|
|
216
|
+
return { handle: opened.handle, info: await opened.handle.stat(), close: opened.close };
|
|
217
|
+
} catch (error) {
|
|
218
|
+
await opened.close().catch(() => {});
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function listEntry(directory, depth, maxDepth, prefix, pattern, displayBase) {
|
|
224
|
+
let entries;
|
|
225
|
+
try {
|
|
226
|
+
entries = await readdir(directory.anchor, { withFileTypes: true });
|
|
227
|
+
} catch (error) {
|
|
228
|
+
return [`${prefix}[DENIED] ${path.basename(displayBase)} (${error instanceof Error ? error.code || error.message : 'unreadable'})`];
|
|
229
|
+
}
|
|
230
|
+
entries.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
|
|
231
|
+
const rows = [];
|
|
232
|
+
for (const entry of entries) {
|
|
233
|
+
const childDisplay = path.join(displayBase, entry.name);
|
|
234
|
+
if (entry.isDirectory()) {
|
|
235
|
+
rows.push(`${prefix}[DIR] ${entry.name}`);
|
|
236
|
+
if (depth >= maxDepth) continue;
|
|
237
|
+
let child;
|
|
238
|
+
try {
|
|
239
|
+
child = await openDirectoryPath(path.join(directory.anchor, entry.name));
|
|
240
|
+
const childInfo = await child.handle.stat();
|
|
241
|
+
if (!childInfo.isDirectory()) fail('Filesystem path component is not a directory');
|
|
242
|
+
rows.push(...await listEntry(child, depth + 1, maxDepth, `${prefix} `.replace(/ {2}$/, '') + ' ', pattern, childDisplay));
|
|
243
|
+
} catch (error) {
|
|
244
|
+
rows.push(`${prefix} [DENIED] ${entry.name} (${error instanceof Error ? error.code || error.message : 'unreadable'})`);
|
|
245
|
+
} finally {
|
|
246
|
+
if (child) await child.close().catch(() => {});
|
|
247
|
+
}
|
|
248
|
+
} else if (entry.isSymbolicLink()) {
|
|
249
|
+
rows.push(`${prefix}[LINK] ${entry.name}`);
|
|
250
|
+
} else {
|
|
251
|
+
if (pattern && !pattern.test(entry.name)) continue;
|
|
252
|
+
let opened;
|
|
253
|
+
try {
|
|
254
|
+
opened = await openListEntry(directory.anchor, entry.name);
|
|
255
|
+
rows.push(`${prefix}[${opened.info.isFile() ? 'FILE' : 'SPECIAL'}] ${entry.name}${opened.info.isFile() ? ` (${opened.info.size} bytes)` : ''}`);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
rows.push(`${prefix}[DENIED] ${entry.name} (${error instanceof Error ? error.code || error.message : 'unreadable'})`);
|
|
258
|
+
} finally {
|
|
259
|
+
if (opened) await opened.close().catch(() => {});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return rows;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export async function listDirectoryTool(args) {
|
|
267
|
+
const absolute = await resolveSafePath(args.path);
|
|
268
|
+
const depth = clampInteger(args.depth, 1, 1, 5);
|
|
269
|
+
const pattern = typeof args.pattern === 'string' && args.pattern.trim() ? globToRegExp(args.pattern.trim()) : null;
|
|
270
|
+
let directory;
|
|
271
|
+
try {
|
|
272
|
+
directory = await openDirectoryPath(absolute);
|
|
273
|
+
const info = await directory.handle.stat();
|
|
274
|
+
if (!info.isDirectory()) fail(`${displayPath(absolute)} is not a directory`);
|
|
275
|
+
const rows = await listEntry(directory, 1, depth, '', pattern, absolute);
|
|
276
|
+
return text(`${displayPath(absolute)}${pattern ? ` · matching ${args.pattern}` : ''}\n${rows.join('\n') || '(empty)'}`);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') fail(`Path not found: ${displayPath(absolute)}`);
|
|
279
|
+
throw error;
|
|
280
|
+
} finally {
|
|
281
|
+
if (directory) await directory.close().catch(() => {});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function openPathForInfo(absolute) {
|
|
286
|
+
let directory;
|
|
287
|
+
try {
|
|
288
|
+
directory = await openDirectoryPath(absolute);
|
|
289
|
+
return { info: await directory.handle.stat(), close: directory.close };
|
|
290
|
+
} catch (directoryError) {
|
|
291
|
+
const canTryFile = directoryError?.code === 'ENOTDIR'
|
|
292
|
+
|| directoryError?.code === 'EISDIR'
|
|
293
|
+
|| (!directoryError?.code && /not a directory/i.test(directoryError?.message || ''));
|
|
294
|
+
if (!canTryFile) throw directoryError;
|
|
295
|
+
}
|
|
296
|
+
const opened = await openEntryAtPath(absolute, READ_NOFOLLOW_NONBLOCK);
|
|
297
|
+
try {
|
|
298
|
+
return { info: await opened.handle.stat(), close: opened.close };
|
|
299
|
+
} catch (error) {
|
|
300
|
+
await opened.close().catch(() => {});
|
|
301
|
+
throw error;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export async function getPathInfo(absolute) {
|
|
306
|
+
const opened = await openPathForInfo(absolute);
|
|
307
|
+
try { return opened.info; }
|
|
308
|
+
finally { await opened.close().catch(() => {}); }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export async function getFileInfoTool(args) {
|
|
312
|
+
const absolute = await resolveSafePath(args.path);
|
|
313
|
+
let opened;
|
|
314
|
+
try {
|
|
315
|
+
opened = await openPathForInfo(absolute);
|
|
316
|
+
} catch (error) {
|
|
317
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') fail(`Path not found: ${displayPath(absolute)}`);
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
const { info } = opened;
|
|
321
|
+
try {
|
|
322
|
+
const payload = {
|
|
323
|
+
path: displayPath(absolute),
|
|
324
|
+
type: info.isDirectory() ? 'directory' : info.isSymbolicLink() ? 'symlink' : info.isFile() ? 'file' : 'special',
|
|
325
|
+
size: info.size,
|
|
326
|
+
createdAt: info.birthtime.toISOString(),
|
|
327
|
+
modifiedAt: info.mtime.toISOString(),
|
|
328
|
+
permissions: `0${(info.mode & 0o777).toString(8)}`,
|
|
329
|
+
};
|
|
330
|
+
if (info.isFile() && info.size <= MAX_INLINE_FILE_BYTES) {
|
|
331
|
+
const regular = await readRegularBuffer(absolute, MAX_INLINE_FILE_BYTES).catch(() => null);
|
|
332
|
+
if (regular) {
|
|
333
|
+
const decoded = decodeText(regular.buffer);
|
|
334
|
+
if (decoded.encoding !== 'utf8') payload.encoding = decoded.encoding;
|
|
335
|
+
if (decoded.encoding !== 'utf8' || !looksBinary(regular.buffer)) {
|
|
336
|
+
const lines = splitLines(decoded.text);
|
|
337
|
+
payload.lineCount = lines.length;
|
|
338
|
+
payload.lastLine = Math.max(0, lines.length - 1);
|
|
339
|
+
payload.eol = detectEol(decoded.text) === '\r\n' ? 'CRLF' : 'LF';
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return text(JSON.stringify(payload, null, 2));
|
|
344
|
+
} finally {
|
|
345
|
+
await opened.close().catch(() => {});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
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_IMAGE_BYTES, ensureDirectoryPath, openRegularFile, openWritableParent } from './files-secure-paths.mjs';
|
|
19
|
+
import { getFileInfoTool, hashFileTool, listDirectoryTool, readFileTool, readImageTool, readMultipleFilesTool } from './files-read-traversal.mjs';
|
|
20
|
+
import { diffFilesTool, editBlockTool, moveToTrashTool, readBinaryTool, replaceInFilesTool, replaceLinesTool, writeBinaryTool, writeFileTool } from './files-write-edit.mjs';
|
|
21
|
+
import { applyPatchTool, copyFileTool, copyPathsTool, createDirectoryTool, deletePathTool, deletePathsTool, moveFileTool, movePathsTool, pathExists, readFilesTool, setPermissionsTool, writeFilesTool } from './files-bulk-operations.mjs';
|
|
22
|
+
import { createArchiveTool, extractArchiveTool } from './files-archive-actions.mjs';
|
|
23
|
+
|
|
24
|
+
export const SCREENSHOT_COMMANDS = [
|
|
25
|
+
{ command: 'grim', args: file => [file] },
|
|
26
|
+
{ command: 'gnome-screenshot', args: file => ['-f', file] },
|
|
27
|
+
{ command: 'spectacle', args: file => ['-b', '-n', '-o', file] },
|
|
28
|
+
{ command: 'scrot', args: file => ['-o', file] },
|
|
29
|
+
{ command: 'import', args: file => ['-window', 'root', file] },
|
|
30
|
+
{ command: 'screencapture', args: file => ['-x', file] },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
// What to tell the person when no capture worked. The general "install a screenshot tool" line was
|
|
34
|
+
// wrong on a Mac, where `screencapture` ships with the system and fails only because TCC has not
|
|
35
|
+
// granted Screen Recording — the exact failure behind "the screenshot returned an error" reports.
|
|
36
|
+
export function screenshotAdvice(attempts, { platform = process.platform, env = process.env, execPath = process.execPath } = {}) {
|
|
37
|
+
if (platform === 'darwin') {
|
|
38
|
+
return `macOS refused the screen capture (${attempts.join('; ') || 'no capture command ran'}). Grant Screen Recording to the binary that runs the tools — System Settings → Privacy & Security → Screen Recording → + → ${execPath} — then restart the agent with \`remcp start\`. macOS requires it for screencapture even when the file itself is writable.`;
|
|
39
|
+
}
|
|
40
|
+
if (platform === 'win32') {
|
|
41
|
+
return `Windows refused the screen capture (${attempts.join('; ') || 'no capture command ran'}). Screen capture needs an interactive desktop session: a machine where nobody is signed in, or a locked session, cannot be captured. Sign in on that computer and try again.`;
|
|
42
|
+
}
|
|
43
|
+
const wayland = /wayland/i.test(String(env.XDG_SESSION_TYPE || '')) || Boolean(env.WAYLAND_DISPLAY);
|
|
44
|
+
if (!env.DISPLAY && !wayland) {
|
|
45
|
+
return `This computer has no graphical session (no DISPLAY and no Wayland display), so there is nothing to capture — servers and containers usually have none.`;
|
|
46
|
+
}
|
|
47
|
+
// GNOME on Wayland is its own case: Mutter does not expose wlr-screencopy, so grim cannot
|
|
48
|
+
// capture it. ReMCP tries the compositor-supported XDG Desktop Portal first; local screenshot
|
|
49
|
+
// commands remain fallbacks for environments where the portal is unavailable.
|
|
50
|
+
if (wayland && /gnome/i.test(String(env.XDG_CURRENT_DESKTOP || ''))) {
|
|
51
|
+
return `Could not capture the screen on GNOME Wayland (${attempts.join('; ') || 'no capture backend ran'}). ReMCP tried the XDG Desktop Portal first, which is GNOME's supported screenshot API. Make sure xdg-desktop-portal and xdg-desktop-portal-gnome are installed and the agent is running inside the signed-in user's graphical session; command-line capture helpers are only fallbacks.`;
|
|
52
|
+
}
|
|
53
|
+
if (wayland) {
|
|
54
|
+
return `Could not capture the screen on Wayland (${attempts.join('; ') || 'no capture backend ran'}). ReMCP tried the XDG Desktop Portal first. Verify xdg-desktop-portal is running; on wlroots compositors, \`grim\` is also supported as a fallback.`;
|
|
55
|
+
}
|
|
56
|
+
return `Could not capture the screen. Install one of grim, gnome-screenshot, spectacle, scrot, or ImageMagick import (tried: ${attempts.join('; ') || 'none available'}).`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function windowsScreenshotScript(file) {
|
|
60
|
+
return [
|
|
61
|
+
'Add-Type -AssemblyName System.Windows.Forms,System.Drawing',
|
|
62
|
+
'$b = [System.Windows.Forms.SystemInformation]::VirtualScreen',
|
|
63
|
+
'$bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height',
|
|
64
|
+
'$g = [System.Drawing.Graphics]::FromImage($bmp)',
|
|
65
|
+
'$g.CopyFromScreen($b.Left, $b.Top, 0, 0, $bmp.Size)',
|
|
66
|
+
`$bmp.Save('${file.replace(/'/g, "''")}', [System.Drawing.Imaging.ImageFormat]::Png)`,
|
|
67
|
+
].join('; ');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function takeScreenshotTool(args) {
|
|
71
|
+
const requestedDirectory = args.directory ? await resolveSafePath(args.directory, 'directory') : '';
|
|
72
|
+
const temporaryDirectory = requestedDirectory ? '' : await mkdtemp(path.join(os.tmpdir(), 'remcp-screenshot-'));
|
|
73
|
+
const directory = requestedDirectory || temporaryDirectory;
|
|
74
|
+
let outputDirectory = null;
|
|
75
|
+
if (requestedDirectory) {
|
|
76
|
+
await ensureDirectoryPath(directory);
|
|
77
|
+
outputDirectory = await openWritableParent(directory);
|
|
78
|
+
}
|
|
79
|
+
const outputAnchor = outputDirectory
|
|
80
|
+
? outputDirectory.anchor.replace('/proc/self/fd/', `/proc/${process.pid}/fd/`)
|
|
81
|
+
: directory;
|
|
82
|
+
const fileName = `capture-${randomUUID()}.png`;
|
|
83
|
+
const file = path.join(outputAnchor, fileName);
|
|
84
|
+
const displayFile = path.join(directory, fileName);
|
|
85
|
+
const attempts = [];
|
|
86
|
+
let preserveCapture = false;
|
|
87
|
+
try {
|
|
88
|
+
if (process.platform === 'win32') {
|
|
89
|
+
const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', windowsScreenshotScript(file)], { encoding: 'utf8', timeout: 30000 });
|
|
90
|
+
attempts.push(`powershell: ${(result.stderr || '').trim() || ('exit ' + result.status)}`);
|
|
91
|
+
} else {
|
|
92
|
+
if (process.platform === 'linux' && isWaylandSession()) {
|
|
93
|
+
try {
|
|
94
|
+
await capturePortalScreenshot(file);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
const message = error instanceof Error ? error.message : `xdg-desktop-portal: ${String(error)}`;
|
|
97
|
+
attempts.push(message);
|
|
98
|
+
if (error && typeof error === 'object' && error.code === 'PORTAL_CANCELLED') {
|
|
99
|
+
fail(`Screen capture was cancelled in the desktop permission dialog (${message}).`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (!await pathExists(file)) {
|
|
104
|
+
for (const candidate of SCREENSHOT_COMMANDS) {
|
|
105
|
+
if (spawnSync('which', [candidate.command], { encoding: 'utf8' }).status !== 0) continue;
|
|
106
|
+
const result = spawnSync(candidate.command, candidate.args(file), { encoding: 'utf8', timeout: 30000 });
|
|
107
|
+
if (result.status === 0 && await pathExists(file)) break;
|
|
108
|
+
attempts.push(`${candidate.command}: ${(result.stderr || '').trim() || ('exit ' + result.status)}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (!await pathExists(file)) fail(screenshotAdvice(attempts));
|
|
113
|
+
|
|
114
|
+
const opened = await openRegularFile(file);
|
|
115
|
+
const { handle, info } = opened;
|
|
116
|
+
try {
|
|
117
|
+
if (info.size <= MAX_IMAGE_BYTES) {
|
|
118
|
+
const buffer = await handle.readFile();
|
|
119
|
+
preserveCapture = args.keep === true;
|
|
120
|
+
return multi([
|
|
121
|
+
{ type: 'text', text: `Screenshot of ${os.hostname()} (${info.size} bytes)${preserveCapture ? (' saved at ' + displayPath(displayFile)) : ''}` },
|
|
122
|
+
image(buffer.toString('base64'), 'image/png'),
|
|
123
|
+
]);
|
|
124
|
+
}
|
|
125
|
+
preserveCapture = true;
|
|
126
|
+
return text(`Screenshot of ${os.hostname()} captured: ${info.size} bytes, above the ${MAX_IMAGE_BYTES}-byte inline limit, so it is saved at ${displayPath(displayFile)} instead of being returned as an image. Fetch it with read_binary using chunks of up to ${MAX_BINARY_CHUNK_BYTES} bytes (offset_bytes and length_bytes), or ask for a smaller region.`);
|
|
127
|
+
} finally {
|
|
128
|
+
await opened.close();
|
|
129
|
+
}
|
|
130
|
+
} finally {
|
|
131
|
+
if (!preserveCapture) {
|
|
132
|
+
if (temporaryDirectory) await rm(temporaryDirectory, { recursive: true, force: true }).catch(() => {});
|
|
133
|
+
else await rm(file, { force: true }).catch(() => {});
|
|
134
|
+
}
|
|
135
|
+
if (outputDirectory) await outputDirectory.close().catch(() => {});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
export const fileToolHandlers = {
|
|
139
|
+
read_file: readFileTool,
|
|
140
|
+
read_files: readFilesTool,
|
|
141
|
+
read_multiple_files: readMultipleFilesTool,
|
|
142
|
+
read_image: readImageTool,
|
|
143
|
+
read_binary: readBinaryTool,
|
|
144
|
+
hash_file: hashFileTool,
|
|
145
|
+
list_directory: listDirectoryTool,
|
|
146
|
+
get_file_info: getFileInfoTool,
|
|
147
|
+
write_file: writeFileTool,
|
|
148
|
+
write_files: writeFilesTool,
|
|
149
|
+
write_binary: writeBinaryTool,
|
|
150
|
+
edit_block: editBlockTool,
|
|
151
|
+
replace_lines: replaceLinesTool,
|
|
152
|
+
replace_in_files: replaceInFilesTool,
|
|
153
|
+
diff_files: diffFilesTool,
|
|
154
|
+
create_directory: createDirectoryTool,
|
|
155
|
+
apply_patch: applyPatchTool,
|
|
156
|
+
set_permissions: setPermissionsTool,
|
|
157
|
+
delete_path: deletePathTool,
|
|
158
|
+
delete_paths: deletePathsTool,
|
|
159
|
+
move_file: moveFileTool,
|
|
160
|
+
move_paths: movePathsTool,
|
|
161
|
+
copy_file: copyFileTool,
|
|
162
|
+
copy_paths: copyPathsTool,
|
|
163
|
+
move_to_trash: moveToTrashTool,
|
|
164
|
+
create_archive: createArchiveTool,
|
|
165
|
+
extract_archive: extractArchiveTool,
|
|
166
|
+
take_screenshot: takeScreenshotTool,
|
|
167
|
+
};
|