@remcp/runtime 0.2.108 → 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 +13 -0
- package/README.md +9 -4
- package/package.json +1 -1
- package/src/extended/browser.mjs +89 -22
- package/src/extended/diagnostics.mjs +88 -27
- package/src/extended/documents.mjs +11 -75
- package/src/screenshot-portal.mjs +115 -6
- package/src/sessions.mjs +4 -0
- package/src/tools/files.mjs +582 -210
- package/src/tools/search.mjs +84 -41
- package/src/util.mjs +12 -0
package/src/tools/files.mjs
CHANGED
|
@@ -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, 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
|
|
@@ -49,7 +49,158 @@ const WRITE_CREATE_NOFOLLOW = constants.O_WRONLY | constants.O_CREAT | constants
|
|
|
49
49
|
const WRITE_TRUNCATE_NOFOLLOW = constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | NOFOLLOW | NONBLOCK;
|
|
50
50
|
const DIRECTORY_READ_NOFOLLOW = constants.O_RDONLY | (constants.O_DIRECTORY || 0) | NOFOLLOW | NONBLOCK;
|
|
51
51
|
|
|
52
|
-
async function
|
|
52
|
+
async function openDirectoryFromFilesystemRoot(absolute) {
|
|
53
|
+
const root = path.parse(absolute).root;
|
|
54
|
+
const parts = absolute.slice(root.length).split(path.sep).filter(Boolean);
|
|
55
|
+
const directories = [];
|
|
56
|
+
let current = root;
|
|
57
|
+
const closeDirectories = async () => {
|
|
58
|
+
for (const handle of directories.reverse()) await handle.close().catch(() => {});
|
|
59
|
+
};
|
|
60
|
+
try {
|
|
61
|
+
const rootHandle = await open(root, DIRECTORY_READ_NOFOLLOW);
|
|
62
|
+
directories.push(rootHandle);
|
|
63
|
+
current = `/proc/self/fd/${rootHandle.fd}`;
|
|
64
|
+
for (const part of parts) {
|
|
65
|
+
const candidate = path.join(current, part);
|
|
66
|
+
const next = await open(candidate, DIRECTORY_READ_NOFOLLOW);
|
|
67
|
+
try {
|
|
68
|
+
const info = await next.stat();
|
|
69
|
+
if (!info.isDirectory()) fail('Filesystem path component is not a directory');
|
|
70
|
+
directories.push(next);
|
|
71
|
+
current = `/proc/self/fd/${next.fd}`;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
await next.close().catch(() => {});
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const handle = directories.pop();
|
|
78
|
+
return {
|
|
79
|
+
handle,
|
|
80
|
+
anchor: current,
|
|
81
|
+
close: async () => {
|
|
82
|
+
try { await handle.close(); }
|
|
83
|
+
finally { await closeDirectories(); }
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
} catch (error) {
|
|
87
|
+
await closeDirectories();
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const secureRootPromises = new Map();
|
|
93
|
+
|
|
94
|
+
async function secureAllowedRoot(root) {
|
|
95
|
+
if (!secureRootPromises.has(root)) {
|
|
96
|
+
secureRootPromises.set(root, openDirectoryFromFilesystemRoot(root));
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
return await secureRootPromises.get(root);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
secureRootPromises.delete(root);
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function cloneDirectory(directory) {
|
|
107
|
+
const handle = await open(`${directory.anchor}/.`, DIRECTORY_READ_NOFOLLOW);
|
|
108
|
+
return {
|
|
109
|
+
handle,
|
|
110
|
+
anchor: `/proc/self/fd/${handle.fd}`,
|
|
111
|
+
close: () => handle.close(),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function openConfinedDirectoryPath(absolute, create) {
|
|
116
|
+
if (!runtimeConfig.allowedRoots.length || absolute.startsWith('/proc/')) return null;
|
|
117
|
+
const root = runtimeConfig.allowedRoots.find(candidate => isInsideRoot(absolute, candidate));
|
|
118
|
+
if (!root) return null;
|
|
119
|
+
const relative = path.relative(root, absolute);
|
|
120
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) return null;
|
|
121
|
+
const rootDirectory = await secureAllowedRoot(root);
|
|
122
|
+
const directories = [];
|
|
123
|
+
let current = rootDirectory.anchor;
|
|
124
|
+
const closeDirectories = async () => {
|
|
125
|
+
for (const handle of directories.reverse()) await handle.close().catch(() => {});
|
|
126
|
+
};
|
|
127
|
+
try {
|
|
128
|
+
for (const part of relative.split(path.sep).filter(Boolean)) {
|
|
129
|
+
const candidate = path.join(current, part);
|
|
130
|
+
if (create) {
|
|
131
|
+
await mkdir(candidate, { mode:0o700 }).catch(error => {
|
|
132
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
const handle = await open(candidate, DIRECTORY_READ_NOFOLLOW);
|
|
136
|
+
const info = await handle.stat();
|
|
137
|
+
if (!info.isDirectory()) {
|
|
138
|
+
await handle.close().catch(() => {});
|
|
139
|
+
fail('Filesystem path component is not a directory');
|
|
140
|
+
}
|
|
141
|
+
directories.push(handle);
|
|
142
|
+
current = `/proc/self/fd/${handle.fd}`;
|
|
143
|
+
}
|
|
144
|
+
if (!directories.length) return cloneDirectory(rootDirectory);
|
|
145
|
+
const handle = directories.pop();
|
|
146
|
+
return {
|
|
147
|
+
handle,
|
|
148
|
+
anchor: current,
|
|
149
|
+
close: async () => {
|
|
150
|
+
try { await handle.close(); }
|
|
151
|
+
finally { await closeDirectories(); }
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
} catch (error) {
|
|
155
|
+
await closeDirectories();
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function openDescriptorDirectoryPath(absolute, create) {
|
|
161
|
+
const match = absolute.match(/^(\/proc\/(?:self|\d+)\/fd\/\d+)(?:\/(.*))?$/);
|
|
162
|
+
if (!match) return null;
|
|
163
|
+
// The proc-fd entry itself is a symlink. Make it an intermediate component so
|
|
164
|
+
// O_NOFOLLOW protects the referenced directory entry rather than rejecting procfs.
|
|
165
|
+
const base = await open(`${match[1]}/.`, DIRECTORY_READ_NOFOLLOW);
|
|
166
|
+
const directories = [base];
|
|
167
|
+
let current = `/proc/self/fd/${base.fd}`;
|
|
168
|
+
const closeDirectories = async () => {
|
|
169
|
+
for (const handle of directories.reverse()) await handle.close().catch(() => {});
|
|
170
|
+
};
|
|
171
|
+
try {
|
|
172
|
+
for (const part of (match[2] || '').split(path.sep).filter(Boolean)) {
|
|
173
|
+
const candidate = path.join(current, part);
|
|
174
|
+
if (create) {
|
|
175
|
+
await mkdir(candidate, { mode:0o700 }).catch(error => {
|
|
176
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
const handle = await open(candidate, DIRECTORY_READ_NOFOLLOW);
|
|
180
|
+
const info = await handle.stat();
|
|
181
|
+
if (!info.isDirectory()) {
|
|
182
|
+
await handle.close().catch(() => {});
|
|
183
|
+
fail('Filesystem path component is not a directory');
|
|
184
|
+
}
|
|
185
|
+
directories.push(handle);
|
|
186
|
+
current = `/proc/self/fd/${handle.fd}`;
|
|
187
|
+
}
|
|
188
|
+
const handle = directories.pop();
|
|
189
|
+
return {
|
|
190
|
+
handle,
|
|
191
|
+
anchor: current,
|
|
192
|
+
close: async () => {
|
|
193
|
+
try { await handle.close(); }
|
|
194
|
+
finally { await closeDirectories(); }
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
} catch (error) {
|
|
198
|
+
await closeDirectories();
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function openDirectoryPath(absolute, { create = false, allowOutside = false } = {}) {
|
|
53
204
|
const descriptorBound = process.platform === 'linux' && Boolean(constants.O_NOFOLLOW) && Boolean(constants.O_DIRECTORY);
|
|
54
205
|
if (!descriptorBound) {
|
|
55
206
|
if (create) await mkdir(absolute, { recursive:true, mode:0o700 });
|
|
@@ -62,10 +213,11 @@ async function openDirectoryPath(absolute, { create = false } = {}) {
|
|
|
62
213
|
};
|
|
63
214
|
return { handle, anchor:absolute, descriptorBound:false, close: handle.close };
|
|
64
215
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
216
|
+
const confinedPath = await openConfinedDirectoryPath(absolute, create);
|
|
217
|
+
if (confinedPath) return confinedPath;
|
|
218
|
+
const descriptorPath = await openDescriptorDirectoryPath(absolute, create);
|
|
219
|
+
if (descriptorPath) return descriptorPath;
|
|
220
|
+
if (runtimeConfig.allowedRoots.length && !allowOutside) fail('Path is outside the directories this device allows');
|
|
69
221
|
const root = path.parse(absolute).root;
|
|
70
222
|
const parts = absolute.slice(root.length).split(path.sep).filter(Boolean);
|
|
71
223
|
if (!parts.length) {
|
|
@@ -109,20 +261,62 @@ async function openDirectoryPath(absolute, { create = false } = {}) {
|
|
|
109
261
|
}
|
|
110
262
|
}
|
|
111
263
|
|
|
112
|
-
async function
|
|
264
|
+
async function openEntryAtPath(absolute, flags) {
|
|
265
|
+
const descriptorFile = /^\/proc\/(?:self|\d+)\/fd\/\d+$/.test(absolute);
|
|
266
|
+
if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY && !descriptorFile) {
|
|
267
|
+
const parent = await openDirectoryPath(path.dirname(absolute));
|
|
268
|
+
try {
|
|
269
|
+
const handle = await open(path.join(parent.anchor, path.basename(absolute)), flags);
|
|
270
|
+
return {
|
|
271
|
+
handle,
|
|
272
|
+
close: async () => {
|
|
273
|
+
try { await handle.close(); }
|
|
274
|
+
finally { await parent.close().catch(() => {}); }
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
} catch (error) {
|
|
278
|
+
await parent.close().catch(() => {});
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
113
282
|
const handle = await open(absolute, flags);
|
|
283
|
+
return { handle, close: () => handle.close() };
|
|
284
|
+
}
|
|
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
|
+
|
|
301
|
+
async function openRegularFileAtPath(absolute, flags, label = absolute) {
|
|
302
|
+
const opened = await openEntryAtPath(absolute, flags);
|
|
114
303
|
try {
|
|
115
|
-
const info = assertRegularFile(await handle.stat(),
|
|
116
|
-
|
|
304
|
+
const info = assertRegularFile(await opened.handle.stat(), label);
|
|
305
|
+
warnOnHardlink(label, info);
|
|
306
|
+
return { handle: opened.handle, info, close: opened.close };
|
|
117
307
|
} catch (error) {
|
|
118
|
-
await
|
|
308
|
+
await opened.close().catch(() => {});
|
|
119
309
|
throw error;
|
|
120
310
|
}
|
|
121
311
|
}
|
|
122
312
|
|
|
123
|
-
async function
|
|
313
|
+
export async function openRegularFile(absolute, flags = READ_NOFOLLOW) {
|
|
314
|
+
return openRegularFileAtPath(absolute, flags, absolute);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function openWritableParent(absolute, options = {}) {
|
|
124
318
|
if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY) {
|
|
125
|
-
return openDirectoryPath(absolute, { create:true });
|
|
319
|
+
return openDirectoryPath(absolute, { create:true, ...options });
|
|
126
320
|
}
|
|
127
321
|
await mkdir(absolute, { recursive:true });
|
|
128
322
|
const info = await lstat(absolute);
|
|
@@ -154,9 +348,27 @@ async function withWritableParent(absolute, callback) {
|
|
|
154
348
|
}
|
|
155
349
|
}
|
|
156
350
|
|
|
157
|
-
async function
|
|
351
|
+
async function openExistingParent(absolute) {
|
|
158
352
|
if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY) {
|
|
159
|
-
|
|
353
|
+
return openDirectoryPath(absolute);
|
|
354
|
+
}
|
|
355
|
+
const info = await lstat(absolute);
|
|
356
|
+
if (info.isSymbolicLink() || !info.isDirectory()) fail('Filesystem path component is not a directory');
|
|
357
|
+
return { anchor:absolute, close:async() => {} };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function withExistingParent(absolute, callback) {
|
|
361
|
+
const parent = await openExistingParent(path.dirname(absolute));
|
|
362
|
+
try {
|
|
363
|
+
return await callback(path.join(parent.anchor, path.basename(absolute)));
|
|
364
|
+
} finally {
|
|
365
|
+
await parent.close().catch(() => {});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function ensureDirectoryPath(absolute, options = {}) {
|
|
370
|
+
if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY) {
|
|
371
|
+
const directory = await openDirectoryPath(absolute, { create:true, ...options });
|
|
160
372
|
await directory.close();
|
|
161
373
|
return;
|
|
162
374
|
}
|
|
@@ -166,12 +378,13 @@ async function ensureDirectoryPath(absolute) {
|
|
|
166
378
|
}
|
|
167
379
|
|
|
168
380
|
async function readRegularBuffer(absolute, maxBytes = Infinity) {
|
|
169
|
-
const
|
|
381
|
+
const opened = await openRegularFile(absolute);
|
|
382
|
+
const { handle, info } = opened;
|
|
170
383
|
try {
|
|
171
384
|
if (info.size > maxBytes) fail(`File is too large to read inline (${info.size} bytes)`);
|
|
172
385
|
return { info, buffer: await handle.readFile() };
|
|
173
386
|
} finally {
|
|
174
|
-
await
|
|
387
|
+
await opened.close();
|
|
175
388
|
}
|
|
176
389
|
}
|
|
177
390
|
|
|
@@ -191,33 +404,72 @@ async function overwriteOpenFile(handle, content) {
|
|
|
191
404
|
// anywhere, so links are never followed and each collected path is resolved through
|
|
192
405
|
// resolveSafePath again before a tool reads or writes it. `stat` follows links, which is exactly
|
|
193
406
|
// how a symlinked directory inside a root used to expose files outside it.
|
|
194
|
-
async function collectTree(root, { maxFiles = 500, skip = [] } = {}) {
|
|
407
|
+
async function collectTree(root, { maxFiles = 500, skip = [], signal = null } = {}) {
|
|
195
408
|
const found = [];
|
|
196
409
|
const denied = [];
|
|
197
410
|
const skipName = name => skip.some(entry => (entry.endsWith('*') ? name.startsWith(entry.slice(0, -1)) : name === entry));
|
|
198
|
-
async
|
|
199
|
-
if (found.length >= maxFiles) return;
|
|
200
|
-
const info = await lstat(target).catch(() => null);
|
|
201
|
-
// A symlink is skipped on purpose (it can point outside the allowed roots); that is not a denial
|
|
202
|
-
// and must not be reported as one.
|
|
203
|
-
if (!info || info.isSymbolicLink()) return;
|
|
204
|
-
if (info.isFile()) { found.push(target); return; }
|
|
205
|
-
if (!info.isDirectory()) return;
|
|
411
|
+
const visitDirectory = async (directory, logicalBase) => {
|
|
206
412
|
let entries;
|
|
207
413
|
try {
|
|
208
|
-
entries = await readdir(
|
|
414
|
+
entries = await readdir(directory.anchor, { withFileTypes: true });
|
|
209
415
|
} catch {
|
|
210
|
-
|
|
211
|
-
denied.push(target);
|
|
416
|
+
denied.push(logicalBase);
|
|
212
417
|
return;
|
|
213
418
|
}
|
|
214
419
|
for (const entry of entries) {
|
|
420
|
+
throwIfCancelled(signal);
|
|
215
421
|
if (found.length >= maxFiles) return;
|
|
216
422
|
if (entry.isSymbolicLink() || skipName(entry.name)) continue;
|
|
217
|
-
|
|
423
|
+
const childLogical = path.join(logicalBase, entry.name);
|
|
424
|
+
const childAnchor = path.join(directory.anchor, entry.name);
|
|
425
|
+
if (entry.isDirectory()) {
|
|
426
|
+
let child;
|
|
427
|
+
try {
|
|
428
|
+
child = await openDirectoryPath(childAnchor);
|
|
429
|
+
const info = await child.handle.stat();
|
|
430
|
+
if (!info.isDirectory()) fail('Filesystem path component is not a directory');
|
|
431
|
+
await visitDirectory(child, childLogical);
|
|
432
|
+
} catch {
|
|
433
|
+
denied.push(childLogical);
|
|
434
|
+
} finally {
|
|
435
|
+
if (child) await child.close().catch(() => {});
|
|
436
|
+
}
|
|
437
|
+
} else if (entry.isFile()) {
|
|
438
|
+
let opened;
|
|
439
|
+
try {
|
|
440
|
+
opened = await openEntryAtPath(childAnchor, READ_NOFOLLOW_NONBLOCK);
|
|
441
|
+
const info = await opened.handle.stat();
|
|
442
|
+
if (info.isFile()) found.push(childLogical);
|
|
443
|
+
} catch {
|
|
444
|
+
denied.push(childLogical);
|
|
445
|
+
} finally {
|
|
446
|
+
if (opened) await opened.close().catch(() => {});
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (found.length >= maxFiles) return;
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
let directory;
|
|
453
|
+
try {
|
|
454
|
+
directory = await openDirectoryPath(root);
|
|
455
|
+
await visitDirectory(directory, root);
|
|
456
|
+
} catch {
|
|
457
|
+
// A cancelled walk must not degrade into the "root is a single file" fallback below.
|
|
458
|
+
throwIfCancelled(signal);
|
|
459
|
+
let opened;
|
|
460
|
+
try {
|
|
461
|
+
opened = await openEntryAtPath(root, READ_NOFOLLOW_NONBLOCK);
|
|
462
|
+
const info = await opened.handle.stat();
|
|
463
|
+
if (info.isFile()) found.push(root);
|
|
464
|
+
else denied.push(root);
|
|
465
|
+
} catch {
|
|
466
|
+
denied.push(root);
|
|
467
|
+
} finally {
|
|
468
|
+
if (opened) await opened.close().catch(() => {});
|
|
218
469
|
}
|
|
470
|
+
} finally {
|
|
471
|
+
if (directory) await directory.close().catch(() => {});
|
|
219
472
|
}
|
|
220
|
-
await visit(root);
|
|
221
473
|
return { files: found, denied };
|
|
222
474
|
}
|
|
223
475
|
|
|
@@ -276,11 +528,12 @@ ${page.slice.join('\n')}`);
|
|
|
276
528
|
return text(`${header}\n${slice.join('\n')}`);
|
|
277
529
|
}
|
|
278
530
|
|
|
279
|
-
export async function readMultipleFilesTool(args) {
|
|
531
|
+
export async function readMultipleFilesTool(args, extra = {}) {
|
|
280
532
|
if (!Array.isArray(args.paths) || !args.paths.length) fail('paths must be a non-empty array');
|
|
281
533
|
if (args.paths.length > 50) fail('paths accepts at most 50 entries per call');
|
|
282
534
|
const sections = [];
|
|
283
535
|
for (const entry of args.paths) {
|
|
536
|
+
throwIfCancelled(extra.signal);
|
|
284
537
|
let absolute;
|
|
285
538
|
try {
|
|
286
539
|
absolute = await resolveSafePath(entry, 'paths[]');
|
|
@@ -334,37 +587,64 @@ export async function hashFileTool(args) {
|
|
|
334
587
|
}
|
|
335
588
|
const { handle, info } = opened;
|
|
336
589
|
const algorithm = String(args.algorithm || 'sha256').toLowerCase();
|
|
337
|
-
if (!['sha256', 'sha1', 'md5'].includes(algorithm)) { await
|
|
590
|
+
if (!['sha256', 'sha1', 'md5'].includes(algorithm)) { await opened.close(); fail('algorithm must be sha256, sha1, or md5'); }
|
|
338
591
|
const hash = createHash(algorithm);
|
|
339
592
|
try {
|
|
340
593
|
await pipeline(createReadStream(absolute, { fd: handle.fd, autoClose: false }), hash);
|
|
341
594
|
return text(`${algorithm} ${hash.digest('hex')} ${displayPath(absolute)} (${info.size} bytes)`);
|
|
342
595
|
} finally {
|
|
343
|
-
await
|
|
596
|
+
await opened.close();
|
|
344
597
|
}
|
|
345
598
|
}
|
|
346
599
|
|
|
347
|
-
async function
|
|
600
|
+
async function openListEntry(base, name) {
|
|
601
|
+
const opened = await openEntryAtPath(path.join(base, name), READ_NOFOLLOW_NONBLOCK);
|
|
602
|
+
try {
|
|
603
|
+
return { handle: opened.handle, info: await opened.handle.stat(), close: opened.close };
|
|
604
|
+
} catch (error) {
|
|
605
|
+
await opened.close().catch(() => {});
|
|
606
|
+
throw error;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async function listEntry(directory, depth, maxDepth, prefix, pattern, displayBase) {
|
|
348
611
|
let entries;
|
|
349
612
|
try {
|
|
350
|
-
entries = await readdir(
|
|
613
|
+
entries = await readdir(directory.anchor, { withFileTypes: true });
|
|
351
614
|
} catch (error) {
|
|
352
|
-
|
|
353
|
-
return [`${prefix}[DENIED] ${path.basename(base)} (${error instanceof Error ? error.code || error.message : 'unreadable'})`];
|
|
615
|
+
return [`${prefix}[DENIED] ${path.basename(displayBase)} (${error instanceof Error ? error.code || error.message : 'unreadable'})`];
|
|
354
616
|
}
|
|
355
617
|
entries.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
|
|
356
618
|
const rows = [];
|
|
357
619
|
for (const entry of entries) {
|
|
358
|
-
const
|
|
620
|
+
const childDisplay = path.join(displayBase, entry.name);
|
|
359
621
|
if (entry.isDirectory()) {
|
|
360
622
|
rows.push(`${prefix}[DIR] ${entry.name}`);
|
|
361
|
-
if (depth
|
|
623
|
+
if (depth >= maxDepth) continue;
|
|
624
|
+
let child;
|
|
625
|
+
try {
|
|
626
|
+
child = await openDirectoryPath(path.join(directory.anchor, entry.name));
|
|
627
|
+
const childInfo = await child.handle.stat();
|
|
628
|
+
if (!childInfo.isDirectory()) fail('Filesystem path component is not a directory');
|
|
629
|
+
rows.push(...await listEntry(child, depth + 1, maxDepth, `${prefix} `.replace(/ {2}$/, '') + ' ', pattern, childDisplay));
|
|
630
|
+
} catch (error) {
|
|
631
|
+
rows.push(`${prefix} [DENIED] ${entry.name} (${error instanceof Error ? error.code || error.message : 'unreadable'})`);
|
|
632
|
+
} finally {
|
|
633
|
+
if (child) await child.close().catch(() => {});
|
|
634
|
+
}
|
|
362
635
|
} else if (entry.isSymbolicLink()) {
|
|
363
636
|
rows.push(`${prefix}[LINK] ${entry.name}`);
|
|
364
637
|
} else {
|
|
365
638
|
if (pattern && !pattern.test(entry.name)) continue;
|
|
366
|
-
|
|
367
|
-
|
|
639
|
+
let opened;
|
|
640
|
+
try {
|
|
641
|
+
opened = await openListEntry(directory.anchor, entry.name);
|
|
642
|
+
rows.push(`${prefix}[${opened.info.isFile() ? 'FILE' : 'SPECIAL'}] ${entry.name}${opened.info.isFile() ? ` (${opened.info.size} bytes)` : ''}`);
|
|
643
|
+
} catch (error) {
|
|
644
|
+
rows.push(`${prefix}[DENIED] ${entry.name} (${error instanceof Error ? error.code || error.message : 'unreadable'})`);
|
|
645
|
+
} finally {
|
|
646
|
+
if (opened) await opened.close().catch(() => {});
|
|
647
|
+
}
|
|
368
648
|
}
|
|
369
649
|
}
|
|
370
650
|
return rows;
|
|
@@ -372,39 +652,85 @@ async function listEntry(base, depth, maxDepth, prefix, pattern) {
|
|
|
372
652
|
|
|
373
653
|
export async function listDirectoryTool(args) {
|
|
374
654
|
const absolute = await resolveSafePath(args.path);
|
|
375
|
-
const info = await stat(absolute).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
|
|
376
|
-
if (!info.isDirectory()) fail(`${displayPath(absolute)} is not a directory`);
|
|
377
655
|
const depth = clampInteger(args.depth, 1, 1, 5);
|
|
378
656
|
const pattern = typeof args.pattern === 'string' && args.pattern.trim() ? globToRegExp(args.pattern.trim()) : null;
|
|
379
|
-
|
|
380
|
-
|
|
657
|
+
let directory;
|
|
658
|
+
try {
|
|
659
|
+
directory = await openDirectoryPath(absolute);
|
|
660
|
+
const info = await directory.handle.stat();
|
|
661
|
+
if (!info.isDirectory()) fail(`${displayPath(absolute)} is not a directory`);
|
|
662
|
+
const rows = await listEntry(directory, 1, depth, '', pattern, absolute);
|
|
663
|
+
return text(`${displayPath(absolute)}${pattern ? ` · matching ${args.pattern}` : ''}\n${rows.join('\n') || '(empty)'}`);
|
|
664
|
+
} catch (error) {
|
|
665
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') fail(`Path not found: ${displayPath(absolute)}`);
|
|
666
|
+
throw error;
|
|
667
|
+
} finally {
|
|
668
|
+
if (directory) await directory.close().catch(() => {});
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async function openPathForInfo(absolute) {
|
|
673
|
+
let directory;
|
|
674
|
+
try {
|
|
675
|
+
directory = await openDirectoryPath(absolute);
|
|
676
|
+
return { info: await directory.handle.stat(), close: directory.close };
|
|
677
|
+
} catch (directoryError) {
|
|
678
|
+
const canTryFile = directoryError?.code === 'ENOTDIR'
|
|
679
|
+
|| directoryError?.code === 'EISDIR'
|
|
680
|
+
|| (!directoryError?.code && /not a directory/i.test(directoryError?.message || ''));
|
|
681
|
+
if (!canTryFile) throw directoryError;
|
|
682
|
+
}
|
|
683
|
+
const opened = await openEntryAtPath(absolute, READ_NOFOLLOW_NONBLOCK);
|
|
684
|
+
try {
|
|
685
|
+
return { info: await opened.handle.stat(), close: opened.close };
|
|
686
|
+
} catch (error) {
|
|
687
|
+
await opened.close().catch(() => {});
|
|
688
|
+
throw error;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async function getPathInfo(absolute) {
|
|
693
|
+
const opened = await openPathForInfo(absolute);
|
|
694
|
+
try { return opened.info; }
|
|
695
|
+
finally { await opened.close().catch(() => {}); }
|
|
381
696
|
}
|
|
382
697
|
|
|
383
698
|
export async function getFileInfoTool(args) {
|
|
384
699
|
const absolute = await resolveSafePath(args.path);
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
700
|
+
let opened;
|
|
701
|
+
try {
|
|
702
|
+
opened = await openPathForInfo(absolute);
|
|
703
|
+
} catch (error) {
|
|
704
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') fail(`Path not found: ${displayPath(absolute)}`);
|
|
705
|
+
throw error;
|
|
706
|
+
}
|
|
707
|
+
const { info } = opened;
|
|
708
|
+
try {
|
|
709
|
+
const payload = {
|
|
710
|
+
path: displayPath(absolute),
|
|
711
|
+
type: info.isDirectory() ? 'directory' : info.isSymbolicLink() ? 'symlink' : info.isFile() ? 'file' : 'special',
|
|
712
|
+
size: info.size,
|
|
713
|
+
createdAt: info.birthtime.toISOString(),
|
|
714
|
+
modifiedAt: info.mtime.toISOString(),
|
|
715
|
+
permissions: `0${(info.mode & 0o777).toString(8)}`,
|
|
716
|
+
};
|
|
717
|
+
if (info.isFile() && info.size <= MAX_INLINE_FILE_BYTES) {
|
|
718
|
+
const regular = await readRegularBuffer(absolute, MAX_INLINE_FILE_BYTES).catch(() => null);
|
|
719
|
+
if (regular) {
|
|
720
|
+
const decoded = decodeText(regular.buffer);
|
|
721
|
+
if (decoded.encoding !== 'utf8') payload.encoding = decoded.encoding;
|
|
722
|
+
if (decoded.encoding !== 'utf8' || !looksBinary(regular.buffer)) {
|
|
723
|
+
const lines = splitLines(decoded.text);
|
|
724
|
+
payload.lineCount = lines.length;
|
|
725
|
+
payload.lastLine = Math.max(0, lines.length - 1);
|
|
726
|
+
payload.eol = detectEol(decoded.text) === '\r\n' ? 'CRLF' : 'LF';
|
|
727
|
+
}
|
|
404
728
|
}
|
|
405
729
|
}
|
|
730
|
+
return text(JSON.stringify(payload, null, 2));
|
|
731
|
+
} finally {
|
|
732
|
+
await opened.close().catch(() => {});
|
|
406
733
|
}
|
|
407
|
-
return text(JSON.stringify(payload, null, 2));
|
|
408
734
|
}
|
|
409
735
|
|
|
410
736
|
function assertWritableSize(content) {
|
|
@@ -471,7 +797,7 @@ export async function readBinaryTool(args) {
|
|
|
471
797
|
});
|
|
472
798
|
return text(payload);
|
|
473
799
|
} finally {
|
|
474
|
-
await
|
|
800
|
+
await opened.close();
|
|
475
801
|
}
|
|
476
802
|
}
|
|
477
803
|
|
|
@@ -589,7 +915,7 @@ export async function replaceLinesTool(args) {
|
|
|
589
915
|
return text(`${summary}.`);
|
|
590
916
|
}
|
|
591
917
|
|
|
592
|
-
export async function replaceInFilesTool(args) {
|
|
918
|
+
export async function replaceInFilesTool(args, extra = {}) {
|
|
593
919
|
const root = await resolveSafePath(args.path);
|
|
594
920
|
const pattern = typeof args.pattern === 'string' && args.pattern ? args.pattern : fail('pattern is required');
|
|
595
921
|
const replacement = typeof args.replacement === 'string' ? args.replacement : fail('replacement must be a string');
|
|
@@ -605,13 +931,14 @@ export async function replaceInFilesTool(args) {
|
|
|
605
931
|
fail(`pattern is not a valid regular expression (${error instanceof Error ? error.message : String(error)})`);
|
|
606
932
|
}
|
|
607
933
|
}
|
|
608
|
-
const info = await
|
|
609
|
-
const walk = info.isFile() ? { files: [root], denied: [] } : await collectTree(root, { maxFiles, skip: ['.git', 'node_modules', '.remcp-trash*'] });
|
|
934
|
+
const info = await getPathInfo(root).catch(() => fail(`Path not found: ${displayPath(root)}`));
|
|
935
|
+
const walk = info.isFile() ? { files: [root], denied: [] } : await collectTree(root, { maxFiles, skip: ['.git', 'node_modules', '.remcp-trash*'], signal: extra.signal });
|
|
610
936
|
const files = await confineAll(walk.files);
|
|
611
937
|
const glob = filePattern ? globToRegExp(filePattern) : null;
|
|
612
938
|
const changed = [];
|
|
613
939
|
let scanned = 0;
|
|
614
940
|
for (const file of files) {
|
|
941
|
+
throwIfCancelled(extra.signal);
|
|
615
942
|
if (changed.length >= maxFiles) break;
|
|
616
943
|
if (glob && !glob.test(path.basename(file))) continue;
|
|
617
944
|
let opened;
|
|
@@ -635,7 +962,7 @@ export async function replaceInFilesTool(args) {
|
|
|
635
962
|
const stats = diffStats(original, updated);
|
|
636
963
|
changed.push({ file: displayPath(file), replacements: count, added: stats.added, removed: stats.removed });
|
|
637
964
|
} finally {
|
|
638
|
-
await
|
|
965
|
+
await opened.close();
|
|
639
966
|
}
|
|
640
967
|
}
|
|
641
968
|
if (!changed.length) return text(`No matches for ${JSON.stringify(pattern)} in ${displayPath(root)} (${scanned} text files scanned).`);
|
|
@@ -665,7 +992,6 @@ function trashDirectoryFor() {
|
|
|
665
992
|
|
|
666
993
|
export async function moveToTrashTool(args) {
|
|
667
994
|
const source = await resolveSafePath(args.source, 'source');
|
|
668
|
-
const info = await stat(source).catch(() => fail(`Path not found: ${displayPath(source)}`));
|
|
669
995
|
const trash = trashDirectoryFor();
|
|
670
996
|
let destination = null;
|
|
671
997
|
if (trash) {
|
|
@@ -691,18 +1017,22 @@ export async function moveToTrashTool(args) {
|
|
|
691
1017
|
target = path.join(destination, `${stamp}-${counter}-${path.basename(source)}`);
|
|
692
1018
|
counter += 1;
|
|
693
1019
|
}
|
|
694
|
-
await
|
|
695
|
-
await
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
await
|
|
699
|
-
|
|
1020
|
+
await withExistingParent(source, async anchoredSource => {
|
|
1021
|
+
const sourceInfo = await lstat(anchoredSource).catch(() => fail(`Path not found: ${displayPath(source)}`));
|
|
1022
|
+
if (sourceInfo.isSymbolicLink()) fail('source cannot be a symbolic link');
|
|
1023
|
+
await withWritableParent(target, async anchoredTarget => {
|
|
1024
|
+
await rename(anchoredSource, anchoredTarget).catch(async error => {
|
|
1025
|
+
if (error?.code !== 'EXDEV') throw error;
|
|
1026
|
+
if (sourceInfo.isDirectory()) fail('Moving a directory to the trash across filesystems is not supported');
|
|
1027
|
+
await copyTreeContents(anchoredSource, anchoredTarget, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER });
|
|
1028
|
+
await rm(anchoredSource, { force:true });
|
|
1029
|
+
});
|
|
700
1030
|
});
|
|
701
1031
|
});
|
|
702
1032
|
return text(`Moved ${displayPath(source)} to ${displayPath(target)}. Restore it with move_file if this was a mistake.`);
|
|
703
1033
|
}
|
|
704
1034
|
|
|
705
|
-
export async function readFilesTool(args) {
|
|
1035
|
+
export async function readFilesTool(args, extra = {}) {
|
|
706
1036
|
// Glob-first bulk read: one call fills the model's context with every file that matters
|
|
707
1037
|
// instead of one round trip per path.
|
|
708
1038
|
const root = await resolveSafePath(args.path || '.');
|
|
@@ -711,13 +1041,14 @@ export async function readFilesTool(args) {
|
|
|
711
1041
|
const maxLinesPerFile = clampInteger(args.max_lines_per_file, liveConfig('maxReadLines'), 1, 20000);
|
|
712
1042
|
const includeIgnored = args.include_ignored === true;
|
|
713
1043
|
const matcher = globToRegExp(pattern);
|
|
714
|
-
const rootInfo = await
|
|
1044
|
+
const rootInfo = await getPathInfo(root).catch(() => null);
|
|
715
1045
|
// A file path is matched directly; a directory is walked without following links.
|
|
716
1046
|
const walk = rootInfo?.isFile()
|
|
717
1047
|
? { files: [root], denied: [] }
|
|
718
1048
|
: await collectTree(root, {
|
|
719
1049
|
maxFiles: maxFiles + 1,
|
|
720
1050
|
skip: includeIgnored ? ['.remcp-trash*'] : ['node_modules', '.git', '.remcp-trash*'],
|
|
1051
|
+
signal: extra.signal,
|
|
721
1052
|
});
|
|
722
1053
|
const candidates = await confineAll(walk.files);
|
|
723
1054
|
const matched = candidates.filter(target => {
|
|
@@ -729,6 +1060,7 @@ export async function readFilesTool(args) {
|
|
|
729
1060
|
const sections = [];
|
|
730
1061
|
let skipped = 0;
|
|
731
1062
|
for (const file of files.slice(0, maxFiles)) {
|
|
1063
|
+
throwIfCancelled(extra.signal);
|
|
732
1064
|
try {
|
|
733
1065
|
const { content, encoding } = await readTextFile(file);
|
|
734
1066
|
const lines = splitLines(content);
|
|
@@ -748,7 +1080,7 @@ export async function readFilesTool(args) {
|
|
|
748
1080
|
return text(`${header}\n\n${sections.join('\n\n')}`);
|
|
749
1081
|
}
|
|
750
1082
|
|
|
751
|
-
export async function writeFilesTool(args) {
|
|
1083
|
+
export async function writeFilesTool(args, extra = {}) {
|
|
752
1084
|
// Bulk write for scaffolding: one call creates or replaces many files.
|
|
753
1085
|
const files = Array.isArray(args.files) ? args.files : fail('files must be an array of { path, content } objects');
|
|
754
1086
|
if (!files.length) fail('files must not be empty');
|
|
@@ -756,6 +1088,7 @@ export async function writeFilesTool(args) {
|
|
|
756
1088
|
const results = [];
|
|
757
1089
|
let totalBytes = 0;
|
|
758
1090
|
for (const entry of files) {
|
|
1091
|
+
throwIfCancelled(extra.signal);
|
|
759
1092
|
const target = typeof entry?.path === 'string' ? entry.path : null;
|
|
760
1093
|
if (!target) { results.push('skipped: entry without a path'); continue; }
|
|
761
1094
|
if (typeof entry.content !== 'string') { results.push(`skipped ${target}: content must be a string`); continue; }
|
|
@@ -779,19 +1112,23 @@ export async function writeFilesTool(args) {
|
|
|
779
1112
|
|
|
780
1113
|
export async function deletePathTool(args) {
|
|
781
1114
|
const absolute = await resolveSafePath(args.path);
|
|
782
|
-
const info = await stat(absolute).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
|
|
783
1115
|
if (path.dirname(absolute) === absolute) fail(`Refusing to delete the filesystem root ${displayPath(absolute)}`);
|
|
784
1116
|
const recursive = args.recursive !== false;
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
1117
|
+
let info;
|
|
1118
|
+
let entries = [];
|
|
1119
|
+
await withExistingParent(absolute, async target => {
|
|
1120
|
+
info = await lstat(target).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
|
|
1121
|
+
if (info.isDirectory() && !recursive) {
|
|
1122
|
+
entries = await readdir(target).catch(() => []);
|
|
1123
|
+
if (entries.length) fail(`Directory is not empty: ${displayPath(absolute)}. Pass recursive: true to delete it with its contents.`);
|
|
1124
|
+
}
|
|
1125
|
+
if (info.isDirectory()) entries = await readdir(target).catch(() => []);
|
|
1126
|
+
await rm(target, { recursive:true, force:false });
|
|
1127
|
+
});
|
|
791
1128
|
return text(`Deleted ${info.isDirectory() ? 'directory' : 'file'} ${displayPath(absolute)}${info.isDirectory() ? ` and its ${entries.length} top-level entr${entries.length === 1 ? 'y' : 'ies'}` : ''}.`);
|
|
792
1129
|
}
|
|
793
1130
|
|
|
794
|
-
export async function deletePathsTool(args) {
|
|
1131
|
+
export async function deletePathsTool(args, extra = {}) {
|
|
795
1132
|
const paths = Array.isArray(args.paths) ? args.paths : fail('paths must be an array of absolute paths');
|
|
796
1133
|
if (!paths.length) fail('paths must not be empty');
|
|
797
1134
|
if (paths.length > 500) fail('paths accepts at most 500 entries per call');
|
|
@@ -799,16 +1136,19 @@ export async function deletePathsTool(args) {
|
|
|
799
1136
|
const results = [];
|
|
800
1137
|
let deleted = 0;
|
|
801
1138
|
for (const entry of paths) {
|
|
1139
|
+
throwIfCancelled(extra.signal);
|
|
802
1140
|
try {
|
|
803
1141
|
const absolute = await resolveSafePath(entry, 'paths[]');
|
|
804
1142
|
if (path.dirname(absolute) === absolute) throw new Error('refusing to delete the filesystem root');
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
1143
|
+
await withExistingParent(absolute, async target => {
|
|
1144
|
+
const info = await lstat(target).catch(() => null);
|
|
1145
|
+
if (!info) throw new Error('not found');
|
|
1146
|
+
if (info.isDirectory() && !recursive) {
|
|
1147
|
+
const children = await readdir(target).catch(() => []);
|
|
1148
|
+
if (children.length) throw new Error('directory is not empty (pass recursive: true)');
|
|
1149
|
+
}
|
|
1150
|
+
await rm(target, { recursive:true, force:false });
|
|
1151
|
+
});
|
|
812
1152
|
deleted += 1;
|
|
813
1153
|
results.push(`deleted ${displayPath(absolute)}`);
|
|
814
1154
|
} catch (error) {
|
|
@@ -820,7 +1160,7 @@ export async function deletePathsTool(args) {
|
|
|
820
1160
|
|
|
821
1161
|
// Recursive copy for files and whole directories, so a project or a backup can be
|
|
822
1162
|
// duplicated in one call.
|
|
823
|
-
export async function copyPathsTool(args) {
|
|
1163
|
+
export async function copyPathsTool(args, extra = {}) {
|
|
824
1164
|
const pairs = Array.isArray(args.paths) ? args.paths : fail('paths must be an array of { source, destination } objects');
|
|
825
1165
|
if (!pairs.length) fail('paths must not be empty');
|
|
826
1166
|
if (pairs.length > 200) fail('paths accepts at most 200 entries per call');
|
|
@@ -828,15 +1168,16 @@ export async function copyPathsTool(args) {
|
|
|
828
1168
|
const results = [];
|
|
829
1169
|
let copied = 0;
|
|
830
1170
|
for (const entry of pairs) {
|
|
1171
|
+
throwIfCancelled(extra.signal);
|
|
831
1172
|
try {
|
|
832
1173
|
const source = await resolveSafePath(entry?.source, 'paths[].source');
|
|
833
1174
|
const destination = await resolveSafePath(entry?.destination, 'paths[].destination');
|
|
834
1175
|
if (source === destination) throw new Error('source and destination are the same path');
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
1176
|
+
await withWritableParent(destination, async anchoredDestination => {
|
|
1177
|
+
const existing = await lstat(anchoredDestination).catch(() => null);
|
|
1178
|
+
if (existing && !overwrite) throw new Error('destination already exists (pass overwrite: true)');
|
|
1179
|
+
await copyTreeContents(source, anchoredDestination, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER, signal: extra.signal });
|
|
1180
|
+
});
|
|
840
1181
|
copied += 1;
|
|
841
1182
|
results.push(`copied ${displayPath(source)} → ${displayPath(destination)}`);
|
|
842
1183
|
} catch (error) {
|
|
@@ -846,7 +1187,7 @@ export async function copyPathsTool(args) {
|
|
|
846
1187
|
return text(`${copied}/${pairs.length} path(s) copied\n${results.join('\n')}`, copied !== pairs.length);
|
|
847
1188
|
}
|
|
848
1189
|
|
|
849
|
-
export async function movePathsTool(args) {
|
|
1190
|
+
export async function movePathsTool(args, extra = {}) {
|
|
850
1191
|
const pairs = Array.isArray(args.paths) ? args.paths : fail('paths must be an array of { source, destination } objects');
|
|
851
1192
|
if (!pairs.length) fail('paths must not be empty');
|
|
852
1193
|
if (pairs.length > 200) fail('paths accepts at most 200 entries per call');
|
|
@@ -854,22 +1195,26 @@ export async function movePathsTool(args) {
|
|
|
854
1195
|
const results = [];
|
|
855
1196
|
let moved = 0;
|
|
856
1197
|
for (const entry of pairs) {
|
|
1198
|
+
throwIfCancelled(extra.signal);
|
|
857
1199
|
try {
|
|
858
1200
|
const source = await resolveSafePath(entry?.source, 'paths[].source');
|
|
859
1201
|
const destination = await resolveSafePath(entry?.destination, 'paths[].destination');
|
|
860
1202
|
if (source === destination) throw new Error('source and destination are the same path');
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
1203
|
+
await withExistingParent(source, async anchoredSource => {
|
|
1204
|
+
const sourceInfo = await lstat(anchoredSource).catch(() => null);
|
|
1205
|
+
if (!sourceInfo) throw new Error('source not found');
|
|
1206
|
+
if (sourceInfo.isSymbolicLink()) throw new Error('source cannot be a symbolic link');
|
|
1207
|
+
await withWritableParent(destination, async anchoredDestination => {
|
|
1208
|
+
const existing = await lstat(anchoredDestination).catch(() => null);
|
|
1209
|
+
if (existing && !overwrite) throw new Error('destination already exists (pass overwrite: true)');
|
|
1210
|
+
try {
|
|
1211
|
+
await rename(anchoredSource, anchoredDestination);
|
|
1212
|
+
} catch (error) {
|
|
1213
|
+
if (error?.code !== 'EXDEV') throw error;
|
|
1214
|
+
await copyTreeContents(anchoredSource, anchoredDestination, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER, signal: extra.signal });
|
|
1215
|
+
await rm(anchoredSource, { recursive:true, force:true });
|
|
1216
|
+
}
|
|
1217
|
+
});
|
|
873
1218
|
});
|
|
874
1219
|
moved += 1;
|
|
875
1220
|
results.push(`moved ${displayPath(source)} → ${displayPath(destination)}`);
|
|
@@ -919,41 +1264,86 @@ export async function applyPatchTool(args) {
|
|
|
919
1264
|
return text([`${changed}/${files.length} file(s) ${dryRun ? 'would be patched' : 'patched'}`, ...results].join('\n'), failedCount > 0);
|
|
920
1265
|
}
|
|
921
1266
|
|
|
1267
|
+
async function setLinuxPermissions(target, mode, uid, gid, recursive, displayTarget, failures) {
|
|
1268
|
+
let directory = null;
|
|
1269
|
+
let file = null;
|
|
1270
|
+
try {
|
|
1271
|
+
const info = await lstat(target);
|
|
1272
|
+
if (info.isSymbolicLink()) {
|
|
1273
|
+
failures.push(`${displayTarget}: symbolic links are not changed`);
|
|
1274
|
+
return 0;
|
|
1275
|
+
}
|
|
1276
|
+
if (info.isDirectory()) {
|
|
1277
|
+
directory = await openDirectoryPath(target);
|
|
1278
|
+
let changed = 0;
|
|
1279
|
+
if (recursive) {
|
|
1280
|
+
for (const entry of await readdir(directory.anchor, { withFileTypes:true })) {
|
|
1281
|
+
if (entry.isSymbolicLink()) continue;
|
|
1282
|
+
changed += await setLinuxPermissions(path.join(directory.anchor, entry.name), mode, uid, gid, true, path.join(displayTarget, entry.name), failures);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
await directory.handle.chmod(mode);
|
|
1286
|
+
if (uid !== null || gid !== null) await directory.handle.chown(uid ?? -1, gid ?? -1);
|
|
1287
|
+
return changed + 1;
|
|
1288
|
+
}
|
|
1289
|
+
if (!info.isFile()) {
|
|
1290
|
+
failures.push(`${displayTarget}: special files are not changed`);
|
|
1291
|
+
return 0;
|
|
1292
|
+
}
|
|
1293
|
+
file = await openRegularFile(target, READ_NOFOLLOW);
|
|
1294
|
+
await file.handle.chmod(mode);
|
|
1295
|
+
if (uid !== null || gid !== null) await file.handle.chown(uid ?? -1, gid ?? -1);
|
|
1296
|
+
return 1;
|
|
1297
|
+
} catch (error) {
|
|
1298
|
+
failures.push(`${displayTarget}: ${describeFilesystemFailure(error, { path:error?.path })}`);
|
|
1299
|
+
return 0;
|
|
1300
|
+
} finally {
|
|
1301
|
+
if (file) await file.close().catch(() => {});
|
|
1302
|
+
if (directory) await directory.close().catch(() => {});
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
|
|
922
1306
|
export async function setPermissionsTool(args) {
|
|
923
1307
|
const absolute = await resolveSafePath(args.path);
|
|
924
|
-
const info = await stat(absolute).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
|
|
925
1308
|
const raw = typeof args.mode === 'string' ? args.mode.trim() : String(args.mode ?? '');
|
|
926
1309
|
if (!/^[0-7]{3,4}$/.test(raw)) fail('mode must be an octal string such as "755" or "0644"');
|
|
927
1310
|
const mode = Number.parseInt(raw, 8);
|
|
928
1311
|
const recursive = args.recursive === true;
|
|
929
1312
|
const uid = Number.isInteger(Number(args.uid)) ? Number(args.uid) : null;
|
|
930
1313
|
const gid = Number.isInteger(Number(args.gid)) ? Number(args.gid) : null;
|
|
931
|
-
const targets = [absolute];
|
|
932
|
-
if (recursive) {
|
|
933
|
-
// Directories are included, links are not: chmod follows a link and would change a target
|
|
934
|
-
// outside the allowed roots.
|
|
935
|
-
const walk = async target => {
|
|
936
|
-
const entries = await readdir(target, { withFileTypes: true }).catch(() => []);
|
|
937
|
-
for (const entry of entries) {
|
|
938
|
-
if (entry.isSymbolicLink()) continue;
|
|
939
|
-
const child = path.join(target, entry.name);
|
|
940
|
-
targets.push(child);
|
|
941
|
-
if (entry.isDirectory()) await walk(child);
|
|
942
|
-
}
|
|
943
|
-
};
|
|
944
|
-
await walk(absolute);
|
|
945
|
-
}
|
|
946
|
-
const confined = await confineAll(targets);
|
|
947
|
-
let changed = 0;
|
|
948
1314
|
const failures = [];
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
1315
|
+
let changed = 0;
|
|
1316
|
+
if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY) {
|
|
1317
|
+
changed = await setLinuxPermissions(absolute, mode, uid, gid, recursive, displayPath(absolute), failures);
|
|
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.
|
|
1324
|
+
const info = await stat(absolute).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
|
|
1325
|
+
const targets = [absolute];
|
|
1326
|
+
if (recursive && info.isDirectory()) {
|
|
1327
|
+
const walk = async target => {
|
|
1328
|
+
const entries = await readdir(target, { withFileTypes:true }).catch(() => []);
|
|
1329
|
+
for (const entry of entries) {
|
|
1330
|
+
if (entry.isSymbolicLink()) continue;
|
|
1331
|
+
const child = path.join(target, entry.name);
|
|
1332
|
+
targets.push(child);
|
|
1333
|
+
if (entry.isDirectory()) await walk(child);
|
|
1334
|
+
}
|
|
1335
|
+
};
|
|
1336
|
+
await walk(absolute);
|
|
1337
|
+
}
|
|
1338
|
+
const confined = await confineAll(targets);
|
|
1339
|
+
for (const target of confined) {
|
|
1340
|
+
try {
|
|
1341
|
+
await chmod(target, mode);
|
|
1342
|
+
if (uid !== null || gid !== null) await chown(target, uid ?? -1, gid ?? -1);
|
|
1343
|
+
changed += 1;
|
|
1344
|
+
} catch (error) {
|
|
1345
|
+
failures.push(`${displayPath(target)}: ${describeFilesystemFailure(error, { path:error?.path })}`);
|
|
1346
|
+
}
|
|
957
1347
|
}
|
|
958
1348
|
}
|
|
959
1349
|
const summary = `Set mode ${raw}${uid !== null || gid !== null ? ` (uid ${uid ?? '-'} gid ${gid ?? '-'})` : ''} on ${changed} path(s) starting at ${displayPath(absolute)}.`;
|
|
@@ -988,24 +1378,27 @@ export async function moveFileTool(args) {
|
|
|
988
1378
|
const source = await resolveSafePath(args.source, 'source');
|
|
989
1379
|
const destination = await resolveSafePath(args.destination, 'destination');
|
|
990
1380
|
if (source === destination) fail('source and destination are the same path');
|
|
991
|
-
await stat(source).catch(() => fail(`Source not found: ${displayPath(source)}`));
|
|
992
1381
|
const overwrite = args.overwrite !== false;
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1382
|
+
await withExistingParent(source, async anchoredSource => {
|
|
1383
|
+
const sourceInfo = await lstat(anchoredSource).catch(() => fail(`Source not found: ${displayPath(source)}`));
|
|
1384
|
+
if (sourceInfo.isSymbolicLink()) fail('source cannot be a symbolic link');
|
|
1385
|
+
await withWritableParent(destination, async anchoredDestination => {
|
|
1386
|
+
const existing = await lstat(anchoredDestination).catch(() => null);
|
|
1387
|
+
if (existing?.isSymbolicLink()) fail('destination cannot be a symbolic link');
|
|
1388
|
+
if (existing && !overwrite) fail(`Destination already exists: ${displayPath(destination)}. Pass overwrite: true to replace it.`);
|
|
1389
|
+
if (existing?.isDirectory()) {
|
|
1390
|
+
const entries = await readdir(anchoredDestination).catch(() => []);
|
|
1391
|
+
if (entries.length) fail(`Destination is a non-empty directory: ${displayPath(destination)}. Move it aside or pick another name.`);
|
|
1392
|
+
}
|
|
1393
|
+
try {
|
|
1394
|
+
await rename(anchoredSource, anchoredDestination);
|
|
1395
|
+
} catch (error) {
|
|
1396
|
+
if (error?.code !== 'EXDEV') throw error;
|
|
1397
|
+
if (sourceInfo.isDirectory()) fail('Moving a directory across filesystems is not supported; copy it manually or move within one volume');
|
|
1398
|
+
await copyTreeContents(anchoredSource, anchoredDestination, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER });
|
|
1399
|
+
await rm(anchoredSource, { force:true });
|
|
1400
|
+
}
|
|
1401
|
+
});
|
|
1009
1402
|
});
|
|
1010
1403
|
return text(`Moved ${displayPath(source)} to ${displayPath(destination)}.`);
|
|
1011
1404
|
}
|
|
@@ -1014,11 +1407,20 @@ export async function copyFileTool(args) {
|
|
|
1014
1407
|
const source = await resolveSafePath(args.source, 'source');
|
|
1015
1408
|
const destination = await resolveSafePath(args.destination, 'destination');
|
|
1016
1409
|
if (source === destination) fail('source and destination are the same path');
|
|
1017
|
-
const info = await stat(source).catch(() => fail(`Source not found: ${displayPath(source)}`));
|
|
1018
|
-
if (info.isDirectory()) fail('copy_file copies single files only; create the directory and copy its files individually');
|
|
1019
1410
|
const overwrite = args.overwrite !== false;
|
|
1020
|
-
|
|
1021
|
-
await
|
|
1411
|
+
let info;
|
|
1412
|
+
await withExistingParent(source, async anchoredSource => {
|
|
1413
|
+
const sourceInfo = await lstat(anchoredSource).catch(() => fail(`Source not found: ${displayPath(source)}`));
|
|
1414
|
+
if (sourceInfo.isSymbolicLink()) fail('source cannot be a symbolic link');
|
|
1415
|
+
if (sourceInfo.isDirectory()) fail('copy_file copies single files only; create the directory and copy its files individually');
|
|
1416
|
+
info = sourceInfo;
|
|
1417
|
+
await withWritableParent(destination, async anchoredDestination => {
|
|
1418
|
+
const existing = await lstat(anchoredDestination).catch(() => null);
|
|
1419
|
+
if (existing?.isSymbolicLink()) fail('destination cannot be a symbolic link');
|
|
1420
|
+
if (existing && !overwrite) fail(`Destination already exists: ${displayPath(destination)}. Pass overwrite: true to replace it.`);
|
|
1421
|
+
await copyTreeContents(anchoredSource, anchoredDestination, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER });
|
|
1422
|
+
});
|
|
1423
|
+
});
|
|
1022
1424
|
return text(`Copied ${displayPath(source)} to ${displayPath(destination)} (${info.size} bytes).`);
|
|
1023
1425
|
}
|
|
1024
1426
|
|
|
@@ -1095,43 +1497,7 @@ async function assertExtractedTree(root, state = { entries: 0, bytes: 0 }) {
|
|
|
1095
1497
|
}
|
|
1096
1498
|
|
|
1097
1499
|
async function openArchiveSource(absolute) {
|
|
1098
|
-
|
|
1099
|
-
const opened = await openRegularFile(absolute, READ_NOFOLLOW_NONBLOCK);
|
|
1100
|
-
return { ...opened, close: () => opened.handle.close() };
|
|
1101
|
-
}
|
|
1102
|
-
const root = path.parse(absolute).root;
|
|
1103
|
-
const parts = absolute.slice(root.length).split(path.sep).filter(Boolean);
|
|
1104
|
-
if (!parts.length) return openRegularFile(absolute, READ_NOFOLLOW_NONBLOCK);
|
|
1105
|
-
const directories = [];
|
|
1106
|
-
let current = root;
|
|
1107
|
-
const closeDirectories = async () => {
|
|
1108
|
-
for (const handle of directories.reverse()) await handle.close().catch(() => {});
|
|
1109
|
-
};
|
|
1110
|
-
try {
|
|
1111
|
-
for (const part of parts.slice(0, -1)) {
|
|
1112
|
-
const handle = await open(path.join(current, part), DIRECTORY_READ_NOFOLLOW);
|
|
1113
|
-
try {
|
|
1114
|
-
const info = await handle.stat();
|
|
1115
|
-
if (!info.isDirectory()) fail('Archive source is not a safe regular file');
|
|
1116
|
-
} catch (error) {
|
|
1117
|
-
await handle.close().catch(() => {});
|
|
1118
|
-
throw error;
|
|
1119
|
-
}
|
|
1120
|
-
directories.push(handle);
|
|
1121
|
-
current = `/proc/self/fd/${handle.fd}`;
|
|
1122
|
-
}
|
|
1123
|
-
const opened = await openRegularFile(path.join(current, parts.at(-1)), READ_NOFOLLOW_NONBLOCK);
|
|
1124
|
-
return {
|
|
1125
|
-
...opened,
|
|
1126
|
-
close: async () => {
|
|
1127
|
-
try { await opened.handle.close(); }
|
|
1128
|
-
finally { await closeDirectories(); }
|
|
1129
|
-
},
|
|
1130
|
-
};
|
|
1131
|
-
} catch (error) {
|
|
1132
|
-
await closeDirectories();
|
|
1133
|
-
throw error;
|
|
1134
|
-
}
|
|
1500
|
+
return openRegularFile(absolute, READ_NOFOLLOW_NONBLOCK);
|
|
1135
1501
|
}
|
|
1136
1502
|
|
|
1137
1503
|
async function snapshotRegularFile(source, destination, maxBytes) {
|
|
@@ -1214,6 +1580,7 @@ async function copyOpenRegularFile(sourceHandle, sourceInfo, destinationPath, st
|
|
|
1214
1580
|
}
|
|
1215
1581
|
|
|
1216
1582
|
async function copyTreeContents(source, destination, state = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES }) {
|
|
1583
|
+
throwIfCancelled(state.signal);
|
|
1217
1584
|
const initial = await lstat(source);
|
|
1218
1585
|
if (initial.isSymbolicLink() || (!initial.isDirectory() && !initial.isFile())) fail('Archive contains symbolic links or special files; archive destination is unsafe');
|
|
1219
1586
|
state.entries += 1;
|
|
@@ -1231,7 +1598,7 @@ async function copyTreeContents(source, destination, state = { entries: 0, bytes
|
|
|
1231
1598
|
let info = initial;
|
|
1232
1599
|
try {
|
|
1233
1600
|
try {
|
|
1234
|
-
directoryHandle = await openDirectoryPath(source);
|
|
1601
|
+
directoryHandle = await openDirectoryPath(source, { allowOutside:state.allowOutsideSource === true });
|
|
1235
1602
|
info = await directoryHandle.handle.stat();
|
|
1236
1603
|
if (!info.isDirectory()) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1237
1604
|
sourcePath = directoryHandle.anchor;
|
|
@@ -1242,13 +1609,13 @@ async function copyTreeContents(source, destination, state = { entries: 0, bytes
|
|
|
1242
1609
|
const mode = info.mode & 0o777;
|
|
1243
1610
|
const existing = await lstat(destination).catch(() => null);
|
|
1244
1611
|
if (existing && (!existing.isDirectory() || existing.isSymbolicLink())) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1245
|
-
try { await
|
|
1612
|
+
try { await ensureDirectoryPath(destination, { allowOutside:true }); }
|
|
1246
1613
|
catch (error) { if (error?.code !== 'EEXIST') throw error; }
|
|
1247
1614
|
const destinationInfo = await lstat(destination);
|
|
1248
1615
|
if (!destinationInfo.isDirectory() || destinationInfo.isSymbolicLink()) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1249
1616
|
let destinationHandle;
|
|
1250
1617
|
try {
|
|
1251
|
-
destinationHandle = await openDirectoryPath(destination);
|
|
1618
|
+
destinationHandle = await openDirectoryPath(destination, { allowOutside:true });
|
|
1252
1619
|
const openedDestination = await destinationHandle.handle.stat();
|
|
1253
1620
|
if (!openedDestination.isDirectory()) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1254
1621
|
await destinationHandle.handle.chmod(mode | 0o700);
|
|
@@ -1354,9 +1721,9 @@ async function installExtractedTree(source, destination) {
|
|
|
1354
1721
|
if (backupOwned) {
|
|
1355
1722
|
const backupInfo = await lstat(backup);
|
|
1356
1723
|
if (backupInfo.isSymbolicLink() || !backupInfo.isDirectory()) fail('Archive destination is not a safe directory');
|
|
1357
|
-
await copyTreeContents(backup, transaction);
|
|
1724
|
+
await copyTreeContents(backup, transaction, { entries:0, bytes:0, maxBytes:MAX_ARCHIVE_EXPANDED_BYTES, allowOutsideSource:true });
|
|
1358
1725
|
}
|
|
1359
|
-
await copyTreeContents(source, transaction);
|
|
1726
|
+
await copyTreeContents(source, transaction, { entries:0, bytes:0, maxBytes:MAX_ARCHIVE_EXPANDED_BYTES, allowOutsideSource:true });
|
|
1360
1727
|
await chmod(transaction, destinationMode);
|
|
1361
1728
|
await rename(transaction, destinationPath);
|
|
1362
1729
|
if (backupOwned) {
|
|
@@ -1415,12 +1782,15 @@ function archiveTool() {
|
|
|
1415
1782
|
return { tar: probe('tar'), zip: probe('zip'), unzip: probe('unzip', ['-v']) };
|
|
1416
1783
|
}
|
|
1417
1784
|
|
|
1418
|
-
export async function createArchiveTool(args) {
|
|
1785
|
+
export async function createArchiveTool(args, extra = {}) {
|
|
1419
1786
|
const tools = archiveTool();
|
|
1420
1787
|
const sources = Array.isArray(args.paths) ? args.paths : [args.paths].filter(Boolean);
|
|
1421
1788
|
if (!sources.length) fail('paths must list at least one file or directory');
|
|
1422
1789
|
const resolved = [];
|
|
1423
|
-
for (const entry of sources)
|
|
1790
|
+
for (const entry of sources) {
|
|
1791
|
+
throwIfCancelled(extra.signal);
|
|
1792
|
+
resolved.push(await resolveSafePath(entry, 'paths[]'));
|
|
1793
|
+
}
|
|
1424
1794
|
const destination = await resolveSafePath(args.destination, 'destination');
|
|
1425
1795
|
const existingDestination = await lstat(destination).catch(() => null);
|
|
1426
1796
|
if (existingDestination?.isSymbolicLink()) fail('Archive destination cannot be a symbolic link');
|
|
@@ -1438,12 +1808,13 @@ export async function createArchiveTool(args) {
|
|
|
1438
1808
|
const staging = path.join(stagingDirectory, `payload${suffix}`);
|
|
1439
1809
|
const snapshotRoot = path.join(stagingDirectory, 'source');
|
|
1440
1810
|
try {
|
|
1441
|
-
await ensureDirectoryPath(snapshotRoot);
|
|
1811
|
+
await ensureDirectoryPath(snapshotRoot, { allowOutside:true });
|
|
1442
1812
|
|
|
1443
|
-
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 };
|
|
1444
1814
|
for (let index = 0; index < resolved.length; index += 1) {
|
|
1815
|
+
throwIfCancelled(extra.signal);
|
|
1445
1816
|
const snapshotPath = path.join(snapshotRoot, names[index]);
|
|
1446
|
-
await ensureDirectoryPath(path.dirname(snapshotPath));
|
|
1817
|
+
await ensureDirectoryPath(path.dirname(snapshotPath), { allowOutside:true });
|
|
1447
1818
|
|
|
1448
1819
|
await copyTreeContents(resolved[index], snapshotPath, snapshotState);
|
|
1449
1820
|
}
|
|
@@ -1638,7 +2009,8 @@ export async function takeScreenshotTool(args) {
|
|
|
1638
2009
|
}
|
|
1639
2010
|
if (!await pathExists(file)) fail(screenshotAdvice(attempts));
|
|
1640
2011
|
|
|
1641
|
-
const
|
|
2012
|
+
const opened = await openRegularFile(file);
|
|
2013
|
+
const { handle, info } = opened;
|
|
1642
2014
|
try {
|
|
1643
2015
|
if (info.size <= MAX_IMAGE_BYTES) {
|
|
1644
2016
|
const buffer = await handle.readFile();
|
|
@@ -1651,7 +2023,7 @@ export async function takeScreenshotTool(args) {
|
|
|
1651
2023
|
preserveCapture = true;
|
|
1652
2024
|
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.`);
|
|
1653
2025
|
} finally {
|
|
1654
|
-
await
|
|
2026
|
+
await opened.close();
|
|
1655
2027
|
}
|
|
1656
2028
|
} finally {
|
|
1657
2029
|
if (!preserveCapture) {
|