@remcp/runtime 0.2.107 → 0.2.108
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 +6 -0
- package/README.md +14 -2
- package/package.json +2 -2
- package/src/catalog.mjs +8 -0
- package/src/compact-catalog.mjs +2 -2
- package/src/extended/browser.mjs +316 -39
- package/src/extended/catalog.mjs +11 -8
- package/src/extended/desktop.mjs +14 -10
- package/src/extended/documents.mjs +316 -82
- package/src/index.mjs +2 -2
- package/src/mcp-v2.mjs +7 -3
- package/src/tools/files.mjs +245 -130
package/src/tools/files.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import os from 'node:os';
|
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
4
|
import { createHash, randomUUID } from 'node:crypto';
|
|
5
5
|
import { constants, createReadStream } from 'node:fs';
|
|
6
|
-
import { access, chmod, chown, copyFile, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, unlink
|
|
6
|
+
import { access, chmod, chown, copyFile, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, unlink } from 'node:fs/promises';
|
|
7
7
|
import { pipeline } from 'node:stream/promises';
|
|
8
8
|
import { liveConfig, runtimeConfig } from '../config.mjs';
|
|
9
9
|
import { documentKind, readDocxText, readPdfText } from '../documents.mjs';
|
|
@@ -49,6 +49,66 @@ 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 openDirectoryPath(absolute, { create = false } = {}) {
|
|
53
|
+
const descriptorBound = process.platform === 'linux' && Boolean(constants.O_NOFOLLOW) && Boolean(constants.O_DIRECTORY);
|
|
54
|
+
if (!descriptorBound) {
|
|
55
|
+
if (create) await mkdir(absolute, { recursive:true, mode:0o700 });
|
|
56
|
+
const info = await lstat(absolute);
|
|
57
|
+
if (info.isSymbolicLink() || !info.isDirectory()) fail('Filesystem path component is not a safe directory');
|
|
58
|
+
const handle = {
|
|
59
|
+
stat: () => stat(absolute),
|
|
60
|
+
chmod: mode => chmod(absolute, mode),
|
|
61
|
+
close: async () => {},
|
|
62
|
+
};
|
|
63
|
+
return { handle, anchor:absolute, descriptorBound:false, close: handle.close };
|
|
64
|
+
}
|
|
65
|
+
if (absolute.startsWith('/proc/self/fd/')) {
|
|
66
|
+
const handle = await open(absolute, DIRECTORY_READ_NOFOLLOW);
|
|
67
|
+
return { handle, anchor: `/proc/self/fd/${handle.fd}`, close: () => handle.close() };
|
|
68
|
+
}
|
|
69
|
+
const root = path.parse(absolute).root;
|
|
70
|
+
const parts = absolute.slice(root.length).split(path.sep).filter(Boolean);
|
|
71
|
+
if (!parts.length) {
|
|
72
|
+
const handle = await open(root, DIRECTORY_READ_NOFOLLOW);
|
|
73
|
+
return { handle, anchor: `/proc/self/fd/${handle.fd}`, close: () => handle.close() };
|
|
74
|
+
}
|
|
75
|
+
const directories = [];
|
|
76
|
+
let current = root;
|
|
77
|
+
const closeDirectories = async () => {
|
|
78
|
+
for (const handle of directories.reverse()) await handle.close().catch(() => {});
|
|
79
|
+
};
|
|
80
|
+
try {
|
|
81
|
+
for (const part of parts) {
|
|
82
|
+
const candidate = path.join(current, part);
|
|
83
|
+
if (create) {
|
|
84
|
+
await mkdir(candidate, { mode:0o700 }).catch(error => {
|
|
85
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const handle = await open(candidate, DIRECTORY_READ_NOFOLLOW);
|
|
89
|
+
const info = await handle.stat();
|
|
90
|
+
if (!info.isDirectory()) {
|
|
91
|
+
await handle.close().catch(() => {});
|
|
92
|
+
fail('Filesystem path component is not a directory');
|
|
93
|
+
}
|
|
94
|
+
directories.push(handle);
|
|
95
|
+
current = `/proc/self/fd/${handle.fd}`;
|
|
96
|
+
}
|
|
97
|
+
const handle = directories.pop();
|
|
98
|
+
return {
|
|
99
|
+
handle,
|
|
100
|
+
anchor: current,
|
|
101
|
+
close: async () => {
|
|
102
|
+
try { await handle.close(); }
|
|
103
|
+
finally { await closeDirectories(); }
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
} catch (error) {
|
|
107
|
+
await closeDirectories();
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
52
112
|
async function openRegularFile(absolute, flags = READ_NOFOLLOW) {
|
|
53
113
|
const handle = await open(absolute, flags);
|
|
54
114
|
try {
|
|
@@ -60,6 +120,51 @@ async function openRegularFile(absolute, flags = READ_NOFOLLOW) {
|
|
|
60
120
|
}
|
|
61
121
|
}
|
|
62
122
|
|
|
123
|
+
async function openWritableParent(absolute) {
|
|
124
|
+
if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY) {
|
|
125
|
+
return openDirectoryPath(absolute, { create:true });
|
|
126
|
+
}
|
|
127
|
+
await mkdir(absolute, { recursive:true });
|
|
128
|
+
const info = await lstat(absolute);
|
|
129
|
+
if (info.isSymbolicLink() || !info.isDirectory()) fail('Filesystem path component is not a directory');
|
|
130
|
+
return { anchor:absolute, close:async() => {} };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function writeResolvedFile(absolute, content, { append = false } = {}) {
|
|
134
|
+
const parent = await openWritableParent(path.dirname(absolute));
|
|
135
|
+
let handle;
|
|
136
|
+
try {
|
|
137
|
+
const flags = append
|
|
138
|
+
? constants.O_WRONLY | constants.O_CREAT | (constants.O_APPEND || 0) | NOFOLLOW | NONBLOCK
|
|
139
|
+
: WRITE_TRUNCATE_NOFOLLOW;
|
|
140
|
+
handle = await open(path.join(parent.anchor, path.basename(absolute)), flags, 0o600);
|
|
141
|
+
await handle.writeFile(content);
|
|
142
|
+
} finally {
|
|
143
|
+
if (handle) await handle.close().catch(() => {});
|
|
144
|
+
await parent.close().catch(() => {});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function withWritableParent(absolute, callback) {
|
|
149
|
+
const parent = await openWritableParent(path.dirname(absolute));
|
|
150
|
+
try {
|
|
151
|
+
return await callback(path.join(parent.anchor, path.basename(absolute)));
|
|
152
|
+
} finally {
|
|
153
|
+
await parent.close().catch(() => {});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function ensureDirectoryPath(absolute) {
|
|
158
|
+
if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY) {
|
|
159
|
+
const directory = await openDirectoryPath(absolute, { create:true });
|
|
160
|
+
await directory.close();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
await mkdir(absolute, { recursive:true });
|
|
164
|
+
const info = await lstat(absolute);
|
|
165
|
+
if (info.isSymbolicLink() || !info.isDirectory()) fail('Filesystem path component is not a directory');
|
|
166
|
+
}
|
|
167
|
+
|
|
63
168
|
async function readRegularBuffer(absolute, maxBytes = Infinity) {
|
|
64
169
|
const { handle, info } = await openRegularFile(absolute);
|
|
65
170
|
try {
|
|
@@ -331,8 +436,7 @@ export async function writeFileTool(args) {
|
|
|
331
436
|
fail(`Appending ${bytes} bytes would grow ${displayPath(absolute)} to ${existingSize + bytes} bytes, above the ${runtimeConfig.maxWriteBytes}-byte write limit for this device`);
|
|
332
437
|
}
|
|
333
438
|
}
|
|
334
|
-
await
|
|
335
|
-
await writeFile(absolute, content, mode === 'append' ? { encoding: 'utf8', flag: 'a' } : 'utf8');
|
|
439
|
+
await writeResolvedFile(absolute, content, { append: mode === 'append' });
|
|
336
440
|
countEvent('bytesWritten', bytes);
|
|
337
441
|
return text(`${mode === 'append' ? 'Appended' : 'Wrote'} ${bytes} bytes to ${displayPath(absolute)}.`);
|
|
338
442
|
}
|
|
@@ -387,8 +491,7 @@ export async function writeBinaryTool(args) {
|
|
|
387
491
|
recordEvent('write_denied', { reason: 'size_limit' });
|
|
388
492
|
fail(`Decoded content is ${buffer.length} bytes, above the ${runtimeConfig.maxWriteBytes}-byte write limit for this device`);
|
|
389
493
|
}
|
|
390
|
-
await
|
|
391
|
-
await writeFile(absolute, buffer, mode === 'append' ? { flag: 'a' } : undefined);
|
|
494
|
+
await writeResolvedFile(absolute, buffer, { append: mode === 'append' });
|
|
392
495
|
countEvent('bytesWritten', buffer.length);
|
|
393
496
|
return text(`${mode === 'append' ? 'Appended' : 'Wrote'} ${buffer.length} bytes to ${displayPath(absolute)}.`);
|
|
394
497
|
}
|
|
@@ -435,7 +538,7 @@ export async function editBlockTool(args) {
|
|
|
435
538
|
if (occurrences === expected) {
|
|
436
539
|
const updated = content.split(oldString).join(newString);
|
|
437
540
|
assertWritableSize(updated);
|
|
438
|
-
if (!dryRun) await
|
|
541
|
+
if (!dryRun) await writeResolvedFile(absolute, updated);
|
|
439
542
|
return text(present(updated, `Replaced ${occurrences} occurrence(s)`));
|
|
440
543
|
}
|
|
441
544
|
if (occurrences > 0) {
|
|
@@ -457,7 +560,7 @@ export async function editBlockTool(args) {
|
|
|
457
560
|
// file to LF and turned a one-line change into a whole-file diff on Windows.
|
|
458
561
|
const updated = `${lines.join(eol)}${endsWithNewline && lines.length ? eol : ''}`;
|
|
459
562
|
assertWritableSize(updated);
|
|
460
|
-
if (!dryRun) await
|
|
563
|
+
if (!dryRun) await writeResolvedFile(absolute, updated);
|
|
461
564
|
return text(present(updated, `Replaced ${starts.length} occurrence(s) using whitespace-tolerant matching (line endings kept as ${eol === '\r\n' ? 'CRLF' : 'LF'})`));
|
|
462
565
|
}
|
|
463
566
|
|
|
@@ -482,7 +585,7 @@ export async function replaceLinesTool(args) {
|
|
|
482
585
|
if (dryRun) {
|
|
483
586
|
return text(`${summary}\n(dry run: nothing was written)\n${unifiedDiff(original, updatedText, { oldLabel: displayPath(absolute), newLabel: 'after' })}`);
|
|
484
587
|
}
|
|
485
|
-
await
|
|
588
|
+
await writeResolvedFile(absolute, updatedText);
|
|
486
589
|
return text(`${summary}.`);
|
|
487
590
|
}
|
|
488
591
|
|
|
@@ -577,7 +680,7 @@ export async function moveToTrashTool(args) {
|
|
|
577
680
|
const existingDestination = await lstat(destination).catch(() => null);
|
|
578
681
|
if (existingDestination?.isSymbolicLink()) fail('Refusing to use a symlink as the trash directory');
|
|
579
682
|
if (existingDestination && !existingDestination.isDirectory()) fail('Trash destination is not a directory');
|
|
580
|
-
await
|
|
683
|
+
await ensureDirectoryPath(destination);
|
|
581
684
|
destination = await resolveSafePath(destination, 'trash');
|
|
582
685
|
const destinationInfo = await lstat(destination);
|
|
583
686
|
if (!destinationInfo.isDirectory() || destinationInfo.isSymbolicLink()) fail('Refusing to use a symlink as the trash directory');
|
|
@@ -588,11 +691,13 @@ export async function moveToTrashTool(args) {
|
|
|
588
691
|
target = path.join(destination, `${stamp}-${counter}-${path.basename(source)}`);
|
|
589
692
|
counter += 1;
|
|
590
693
|
}
|
|
591
|
-
await
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
694
|
+
await withWritableParent(target, async anchoredTarget => {
|
|
695
|
+
await rename(source, anchoredTarget).catch(async error => {
|
|
696
|
+
if (error?.code !== 'EXDEV') throw error;
|
|
697
|
+
if (info.isDirectory()) fail('Moving a directory to the trash across filesystems is not supported');
|
|
698
|
+
await copyFile(source, anchoredTarget);
|
|
699
|
+
await unlink(source);
|
|
700
|
+
});
|
|
596
701
|
});
|
|
597
702
|
return text(`Moved ${displayPath(source)} to ${displayPath(target)}. Restore it with move_file if this was a mistake.`);
|
|
598
703
|
}
|
|
@@ -661,8 +766,7 @@ export async function writeFilesTool(args) {
|
|
|
661
766
|
const bytes = assertWritableSize(content);
|
|
662
767
|
totalBytes += bytes;
|
|
663
768
|
if (totalBytes > runtimeConfig.maxWriteBytes * 4) fail(`This call would write ${totalBytes} bytes, above the ${runtimeConfig.maxWriteBytes * 4}-byte batch limit`);
|
|
664
|
-
await
|
|
665
|
-
await writeFile(absolute, content, entry.mode === 'append' ? { encoding: 'utf8', flag: 'a' } : 'utf8');
|
|
769
|
+
await writeResolvedFile(absolute, content, { append: entry.mode === 'append' });
|
|
666
770
|
results.push(`${entry.mode === 'append' ? 'appended' : 'wrote'} ${displayPath(absolute)} (${bytes} bytes)`);
|
|
667
771
|
} catch (error) {
|
|
668
772
|
results.push(`failed ${target}: ${describeFilesystemFailure(error, { path: error?.path })}`);
|
|
@@ -732,8 +836,7 @@ export async function copyPathsTool(args) {
|
|
|
732
836
|
if (!info) throw new Error('source not found');
|
|
733
837
|
const existing = await stat(destination).catch(() => null);
|
|
734
838
|
if (existing && !overwrite) throw new Error('destination already exists (pass overwrite: true)');
|
|
735
|
-
await
|
|
736
|
-
await cp(source, destination, { recursive: true, force: overwrite, errorOnExist: !overwrite });
|
|
839
|
+
await withWritableParent(destination, anchoredDestination => cp(source, anchoredDestination, { recursive: true, force: overwrite, errorOnExist: !overwrite }));
|
|
737
840
|
copied += 1;
|
|
738
841
|
results.push(`copied ${displayPath(source)} → ${displayPath(destination)}`);
|
|
739
842
|
} catch (error) {
|
|
@@ -759,14 +862,15 @@ export async function movePathsTool(args) {
|
|
|
759
862
|
if (!info) throw new Error('source not found');
|
|
760
863
|
const existing = await stat(destination).catch(() => null);
|
|
761
864
|
if (existing && !overwrite) throw new Error('destination already exists (pass overwrite: true)');
|
|
762
|
-
await
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
865
|
+
await withWritableParent(destination, async anchoredDestination => {
|
|
866
|
+
try {
|
|
867
|
+
await rename(source, anchoredDestination);
|
|
868
|
+
} catch (error) {
|
|
869
|
+
if (error?.code !== 'EXDEV') throw error;
|
|
870
|
+
await cp(source, anchoredDestination, { recursive: true, force: overwrite, errorOnExist: !overwrite });
|
|
871
|
+
await rm(source, { recursive: true, force: true });
|
|
872
|
+
}
|
|
873
|
+
});
|
|
770
874
|
moved += 1;
|
|
771
875
|
results.push(`moved ${displayPath(source)} → ${displayPath(destination)}`);
|
|
772
876
|
} catch (error) {
|
|
@@ -801,8 +905,7 @@ export async function applyPatchTool(args) {
|
|
|
801
905
|
const stats = diffStats(original, updated);
|
|
802
906
|
if (!dryRun) {
|
|
803
907
|
assertWritableSize(updated);
|
|
804
|
-
await
|
|
805
|
-
await writeFile(absolute, updated, 'utf8');
|
|
908
|
+
await writeResolvedFile(absolute, updated);
|
|
806
909
|
}
|
|
807
910
|
changed += 1;
|
|
808
911
|
const fuzzy = applied.filter(entry => entry.fuzz > 0).length;
|
|
@@ -867,7 +970,7 @@ export async function createDirectoryTool(args) {
|
|
|
867
970
|
for (const entry of list) {
|
|
868
971
|
try {
|
|
869
972
|
const absolute = await resolveSafePath(entry);
|
|
870
|
-
await
|
|
973
|
+
await ensureDirectoryPath(absolute);
|
|
871
974
|
created.push(displayPath(absolute));
|
|
872
975
|
} catch (error) {
|
|
873
976
|
failed.push(`${entry}: ${describeFilesystemFailure(error, { path: error?.path })}`);
|
|
@@ -893,16 +996,17 @@ export async function moveFileTool(args) {
|
|
|
893
996
|
const entries = await readdir(destination).catch(() => []);
|
|
894
997
|
if (entries.length) fail(`Destination is a non-empty directory: ${displayPath(destination)}. Move it aside or pick another name.`);
|
|
895
998
|
}
|
|
896
|
-
await
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
999
|
+
await withWritableParent(destination, async anchoredDestination => {
|
|
1000
|
+
try {
|
|
1001
|
+
await rename(source, anchoredDestination);
|
|
1002
|
+
} catch (error) {
|
|
1003
|
+
if (error?.code !== 'EXDEV') throw error;
|
|
1004
|
+
const info = await stat(source);
|
|
1005
|
+
if (info.isDirectory()) fail('Moving a directory across filesystems is not supported; copy it manually or move within one volume');
|
|
1006
|
+
await copyFile(source, anchoredDestination);
|
|
1007
|
+
await unlink(source);
|
|
1008
|
+
}
|
|
1009
|
+
});
|
|
906
1010
|
return text(`Moved ${displayPath(source)} to ${displayPath(destination)}.`);
|
|
907
1011
|
}
|
|
908
1012
|
|
|
@@ -914,27 +1018,11 @@ export async function copyFileTool(args) {
|
|
|
914
1018
|
if (info.isDirectory()) fail('copy_file copies single files only; create the directory and copy its files individually');
|
|
915
1019
|
const overwrite = args.overwrite !== false;
|
|
916
1020
|
if (!overwrite && await pathExists(destination)) fail(`Destination already exists: ${displayPath(destination)}. Pass overwrite: true to replace it.`);
|
|
917
|
-
await
|
|
918
|
-
await copyFile(source, destination, overwrite ? 0 : constants.COPYFILE_EXCL);
|
|
1021
|
+
await withWritableParent(destination, anchoredDestination => copyFile(source, anchoredDestination, overwrite ? 0 : constants.COPYFILE_EXCL));
|
|
919
1022
|
return text(`Copied ${displayPath(source)} to ${displayPath(destination)} (${info.size} bytes).`);
|
|
920
1023
|
}
|
|
921
1024
|
|
|
922
1025
|
// --- archives -------------------------------------------------------------------------
|
|
923
|
-
async function assertNoSymlinkTree(root, state = { entries: 0 }) {
|
|
924
|
-
const info = await lstat(root);
|
|
925
|
-
if (info.isSymbolicLink()) fail(`Archive sources cannot contain symbolic links: ${displayPath(root)}`);
|
|
926
|
-
if (!info.isDirectory() && !info.isFile()) fail(`Archive sources cannot contain special files: ${displayPath(root)}`);
|
|
927
|
-
state.entries += 1;
|
|
928
|
-
if (state.entries > 100_000) fail('Archive source contains too many entries');
|
|
929
|
-
if (state.maxBytes !== undefined && info.isFile()) {
|
|
930
|
-
state.bytes = (state.bytes || 0) + info.size;
|
|
931
|
-
if (state.bytes > state.maxBytes) fail('Archive source exceeds the safety limit');
|
|
932
|
-
}
|
|
933
|
-
if (!info.isDirectory()) return;
|
|
934
|
-
const entries = await readdir(root, { withFileTypes: true });
|
|
935
|
-
for (const entry of entries) await assertNoSymlinkTree(path.join(root, entry.name), state);
|
|
936
|
-
}
|
|
937
|
-
|
|
938
1026
|
const MAX_ARCHIVE_EXPANDED_BYTES = Math.min(
|
|
939
1027
|
4 * 1024 * 1024 * 1024,
|
|
940
1028
|
Math.max(64 * 1024 * 1024, Number(process.env.REMCP_MAX_ARCHIVE_EXPANDED_BYTES) || 512 * 1024 * 1024),
|
|
@@ -1007,7 +1095,7 @@ async function assertExtractedTree(root, state = { entries: 0, bytes: 0 }) {
|
|
|
1007
1095
|
}
|
|
1008
1096
|
|
|
1009
1097
|
async function openArchiveSource(absolute) {
|
|
1010
|
-
if (process.platform !== 'linux') {
|
|
1098
|
+
if (process.platform !== 'linux' || !constants.O_NOFOLLOW || !constants.O_DIRECTORY) {
|
|
1011
1099
|
const opened = await openRegularFile(absolute, READ_NOFOLLOW_NONBLOCK);
|
|
1012
1100
|
return { ...opened, close: () => opened.handle.close() };
|
|
1013
1101
|
}
|
|
@@ -1127,29 +1215,29 @@ async function copyOpenRegularFile(sourceHandle, sourceInfo, destinationPath, st
|
|
|
1127
1215
|
|
|
1128
1216
|
async function copyTreeContents(source, destination, state = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES }) {
|
|
1129
1217
|
const initial = await lstat(source);
|
|
1130
|
-
if (initial.isSymbolicLink() || (!initial.isDirectory() && !initial.isFile())) fail('Archive contains
|
|
1218
|
+
if (initial.isSymbolicLink() || (!initial.isDirectory() && !initial.isFile())) fail('Archive contains symbolic links or special files; archive destination is unsafe');
|
|
1131
1219
|
state.entries += 1;
|
|
1132
1220
|
if (state.entries > 100_000) fail('Archive contains too many entries');
|
|
1133
1221
|
if (initial.isFile()) {
|
|
1134
|
-
const opened =
|
|
1222
|
+
const opened = source.startsWith('/proc/self/fd/')
|
|
1223
|
+
? await openRegularFile(source, READ_NOFOLLOW_NONBLOCK)
|
|
1224
|
+
: await openArchiveSource(source);
|
|
1135
1225
|
try { await copyOpenRegularFile(opened.handle, opened.info, destination, state); }
|
|
1136
|
-
finally { await opened.handle.close(); }
|
|
1226
|
+
finally { if (opened.close) await opened.close(); else await opened.handle.close(); }
|
|
1137
1227
|
return;
|
|
1138
1228
|
}
|
|
1139
1229
|
let directoryHandle = null;
|
|
1140
1230
|
let sourcePath = source;
|
|
1141
1231
|
let info = initial;
|
|
1142
1232
|
try {
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
throw error;
|
|
1152
|
-
}
|
|
1233
|
+
try {
|
|
1234
|
+
directoryHandle = await openDirectoryPath(source);
|
|
1235
|
+
info = await directoryHandle.handle.stat();
|
|
1236
|
+
if (!info.isDirectory()) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1237
|
+
sourcePath = directoryHandle.anchor;
|
|
1238
|
+
} catch (error) {
|
|
1239
|
+
if (['ELOOP', 'ENOTDIR'].includes(error?.code)) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1240
|
+
throw error;
|
|
1153
1241
|
}
|
|
1154
1242
|
const mode = info.mode & 0o777;
|
|
1155
1243
|
const existing = await lstat(destination).catch(() => null);
|
|
@@ -1160,14 +1248,15 @@ async function copyTreeContents(source, destination, state = { entries: 0, bytes
|
|
|
1160
1248
|
if (!destinationInfo.isDirectory() || destinationInfo.isSymbolicLink()) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1161
1249
|
let destinationHandle;
|
|
1162
1250
|
try {
|
|
1163
|
-
destinationHandle = await
|
|
1164
|
-
const openedDestination = await destinationHandle.stat();
|
|
1251
|
+
destinationHandle = await openDirectoryPath(destination);
|
|
1252
|
+
const openedDestination = await destinationHandle.handle.stat();
|
|
1165
1253
|
if (!openedDestination.isDirectory()) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1166
|
-
await destinationHandle.chmod(mode | 0o700);
|
|
1254
|
+
await destinationHandle.handle.chmod(mode | 0o700);
|
|
1255
|
+
const destinationAnchor = destinationHandle.anchor;
|
|
1167
1256
|
for (const name of await readdir(sourcePath)) {
|
|
1168
|
-
await copyTreeContents(path.join(sourcePath, name), path.join(
|
|
1257
|
+
await copyTreeContents(path.join(sourcePath, name), path.join(destinationAnchor, name), state);
|
|
1169
1258
|
}
|
|
1170
|
-
await destinationHandle.chmod(mode);
|
|
1259
|
+
await destinationHandle.handle.chmod(mode);
|
|
1171
1260
|
} finally {
|
|
1172
1261
|
if (destinationHandle) await destinationHandle.close().catch(() => {});
|
|
1173
1262
|
}
|
|
@@ -1184,32 +1273,39 @@ async function removeExtractionArtifact(target) {
|
|
|
1184
1273
|
}
|
|
1185
1274
|
|
|
1186
1275
|
async function recoverExtractionArtifacts(parent, destination, prefix) {
|
|
1187
|
-
const
|
|
1188
|
-
const
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
const
|
|
1192
|
-
const
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
if (destinationInfo) {
|
|
1199
|
-
await removeExtractionArtifact(backup);
|
|
1200
|
-
continue;
|
|
1201
|
-
}
|
|
1202
|
-
const backupInfo = await lstat(backup).catch(() => null);
|
|
1203
|
-
if (!backupInfo) continue;
|
|
1204
|
-
if (backupInfo.isSymbolicLink() || !backupInfo.isDirectory()) {
|
|
1205
|
-
await removeExtractionArtifact(backup);
|
|
1206
|
-
continue;
|
|
1276
|
+
const parentDirectory = await openDirectoryPath(parent, { create:true });
|
|
1277
|
+
const anchor = parentDirectory.anchor;
|
|
1278
|
+
const destinationPath = path.join(anchor, path.basename(destination));
|
|
1279
|
+
try {
|
|
1280
|
+
const entries = await readdir(anchor, { withFileTypes: true });
|
|
1281
|
+
const staleBefore = Date.now() - 60 * 60 * 1000;
|
|
1282
|
+
for (const entry of entries) {
|
|
1283
|
+
if (!entry.name.startsWith('.remcp-extract-txn-')) continue;
|
|
1284
|
+
const candidate = path.join(anchor, entry.name);
|
|
1285
|
+
const info = await lstat(candidate).catch(() => null);
|
|
1286
|
+
if (info && info.mtimeMs < staleBefore) await removeExtractionArtifact(candidate);
|
|
1207
1287
|
}
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1288
|
+
const backups = entries.filter(entry => entry.name.startsWith(prefix)).map(entry => path.join(anchor, entry.name));
|
|
1289
|
+
for (const backup of backups) {
|
|
1290
|
+
const destinationInfo = await lstat(destinationPath).catch(() => null);
|
|
1291
|
+
if (destinationInfo) {
|
|
1292
|
+
await removeExtractionArtifact(backup);
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
const backupInfo = await lstat(backup).catch(() => null);
|
|
1296
|
+
if (!backupInfo) continue;
|
|
1297
|
+
if (backupInfo.isSymbolicLink() || !backupInfo.isDirectory()) {
|
|
1298
|
+
await removeExtractionArtifact(backup);
|
|
1299
|
+
continue;
|
|
1300
|
+
}
|
|
1301
|
+
try {
|
|
1302
|
+
await rename(backup, destinationPath);
|
|
1303
|
+
} catch {
|
|
1304
|
+
continue;
|
|
1305
|
+
}
|
|
1212
1306
|
}
|
|
1307
|
+
} finally {
|
|
1308
|
+
await parentDirectory.close().catch(() => {});
|
|
1213
1309
|
}
|
|
1214
1310
|
}
|
|
1215
1311
|
|
|
@@ -1232,20 +1328,23 @@ async function restoreExtractionBackup(backup, destination) {
|
|
|
1232
1328
|
async function installExtractedTree(source, destination) {
|
|
1233
1329
|
const parent = path.dirname(destination);
|
|
1234
1330
|
if (destination === path.parse(destination).root) fail('Archive destination cannot be a filesystem root');
|
|
1235
|
-
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
1236
1331
|
const backupPrefix = `.remcp-extract-backup-${createHash('sha256').update(destination).digest('hex').slice(0, 16)}-`;
|
|
1237
1332
|
await recoverExtractionArtifacts(parent, destination, backupPrefix);
|
|
1238
|
-
const
|
|
1333
|
+
const parentDirectory = await openDirectoryPath(parent, { create:true });
|
|
1334
|
+
const parentAnchor = parentDirectory.anchor;
|
|
1335
|
+
const destinationPath = path.join(parentAnchor, path.basename(destination));
|
|
1336
|
+
const existing = await lstat(destinationPath).catch(() => null);
|
|
1239
1337
|
const destinationMode = existing?.isDirectory() ? existing.mode & 0o777 : 0o700;
|
|
1240
|
-
|
|
1338
|
+
let transaction = '';
|
|
1241
1339
|
let backup = '';
|
|
1242
1340
|
let backupOwned = false;
|
|
1243
1341
|
let preserveBackup = false;
|
|
1244
1342
|
try {
|
|
1343
|
+
transaction = await mkdtemp(path.join(parentAnchor, '.remcp-extract-txn-'));
|
|
1245
1344
|
if (existing) {
|
|
1246
|
-
backup = path.join(
|
|
1345
|
+
backup = path.join(parentAnchor, `${backupPrefix}${randomUUID()}`);
|
|
1247
1346
|
try {
|
|
1248
|
-
await rename(
|
|
1347
|
+
await rename(destinationPath, backup);
|
|
1249
1348
|
backupOwned = true;
|
|
1250
1349
|
} catch (error) {
|
|
1251
1350
|
if (error?.code !== 'ENOENT') throw error;
|
|
@@ -1259,7 +1358,7 @@ async function installExtractedTree(source, destination) {
|
|
|
1259
1358
|
}
|
|
1260
1359
|
await copyTreeContents(source, transaction);
|
|
1261
1360
|
await chmod(transaction, destinationMode);
|
|
1262
|
-
await rename(transaction,
|
|
1361
|
+
await rename(transaction, destinationPath);
|
|
1263
1362
|
if (backupOwned) {
|
|
1264
1363
|
await removeExtractionArtifact(backup);
|
|
1265
1364
|
backupOwned = false;
|
|
@@ -1267,7 +1366,7 @@ async function installExtractedTree(source, destination) {
|
|
|
1267
1366
|
} catch (error) {
|
|
1268
1367
|
if (backupOwned) {
|
|
1269
1368
|
try {
|
|
1270
|
-
await restoreExtractionBackup(backup,
|
|
1369
|
+
await restoreExtractionBackup(backup, destinationPath);
|
|
1271
1370
|
if (!await lstat(backup).catch(() => null)) backupOwned = false;
|
|
1272
1371
|
else preserveBackup = true;
|
|
1273
1372
|
} catch (restoreError) {
|
|
@@ -1277,24 +1376,32 @@ async function installExtractedTree(source, destination) {
|
|
|
1277
1376
|
}
|
|
1278
1377
|
throw error;
|
|
1279
1378
|
} finally {
|
|
1280
|
-
await rm(transaction, { recursive: true, force: true });
|
|
1379
|
+
if (transaction) await rm(transaction, { recursive: true, force: true });
|
|
1281
1380
|
if (backupOwned && !preserveBackup) await removeExtractionArtifact(backup);
|
|
1381
|
+
await parentDirectory.close().catch(() => {});
|
|
1282
1382
|
}
|
|
1283
1383
|
}
|
|
1284
1384
|
|
|
1285
1385
|
async function moveArchiveFile(source, destination) {
|
|
1386
|
+
const parent = path.dirname(destination);
|
|
1387
|
+
const parentDirectory = await openDirectoryPath(parent, { create:true });
|
|
1388
|
+
const destinationPath = path.join(parentDirectory.anchor, path.basename(destination));
|
|
1286
1389
|
try {
|
|
1287
|
-
await rename(source, destination);
|
|
1288
|
-
} catch (error) {
|
|
1289
|
-
if (!['EXDEV', 'EPERM'].includes(error?.code)) throw error;
|
|
1290
|
-
const temporary = path.join(path.dirname(destination), `.${path.basename(destination)}.remcp-${randomUUID()}.tmp`);
|
|
1291
1390
|
try {
|
|
1292
|
-
await
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1391
|
+
await rename(source, destinationPath);
|
|
1392
|
+
} catch (error) {
|
|
1393
|
+
if (!['EXDEV', 'EPERM'].includes(error?.code)) throw error;
|
|
1394
|
+
const temporary = path.join(parentDirectory.anchor, `.${path.basename(destination)}.remcp-${randomUUID()}.tmp`);
|
|
1395
|
+
try {
|
|
1396
|
+
await copyFile(source, temporary, constants.COPYFILE_EXCL);
|
|
1397
|
+
await rename(temporary, destinationPath);
|
|
1398
|
+
} finally {
|
|
1399
|
+
await unlink(temporary).catch(() => {});
|
|
1400
|
+
}
|
|
1401
|
+
await unlink(source);
|
|
1296
1402
|
}
|
|
1297
|
-
|
|
1403
|
+
} finally {
|
|
1404
|
+
await parentDirectory.close().catch(() => {});
|
|
1298
1405
|
}
|
|
1299
1406
|
}
|
|
1300
1407
|
|
|
@@ -1314,13 +1421,10 @@ export async function createArchiveTool(args) {
|
|
|
1314
1421
|
if (!sources.length) fail('paths must list at least one file or directory');
|
|
1315
1422
|
const resolved = [];
|
|
1316
1423
|
for (const entry of sources) resolved.push(await resolveSafePath(entry, 'paths[]'));
|
|
1317
|
-
const sourceState = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES };
|
|
1318
|
-
for (const entry of resolved) await assertNoSymlinkTree(entry, sourceState);
|
|
1319
1424
|
const destination = await resolveSafePath(args.destination, 'destination');
|
|
1320
1425
|
const existingDestination = await lstat(destination).catch(() => null);
|
|
1321
1426
|
if (existingDestination?.isSymbolicLink()) fail('Archive destination cannot be a symbolic link');
|
|
1322
1427
|
const format = String(args.format || (destination.endsWith('.zip') ? 'zip' : 'tar.gz')).toLowerCase();
|
|
1323
|
-
await mkdir(path.dirname(destination), { recursive: true });
|
|
1324
1428
|
const baseDir = path.dirname(resolved[0]);
|
|
1325
1429
|
const names = resolved.map(entry => path.relative(baseDir, entry));
|
|
1326
1430
|
if (names.some(name => !name || name.startsWith('..') || path.isAbsolute(name))) fail('Archive sources must share one parent directory');
|
|
@@ -1334,11 +1438,13 @@ export async function createArchiveTool(args) {
|
|
|
1334
1438
|
const staging = path.join(stagingDirectory, `payload${suffix}`);
|
|
1335
1439
|
const snapshotRoot = path.join(stagingDirectory, 'source');
|
|
1336
1440
|
try {
|
|
1337
|
-
|
|
1441
|
+
await ensureDirectoryPath(snapshotRoot);
|
|
1442
|
+
|
|
1338
1443
|
const snapshotState = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES };
|
|
1339
1444
|
for (let index = 0; index < resolved.length; index += 1) {
|
|
1340
1445
|
const snapshotPath = path.join(snapshotRoot, names[index]);
|
|
1341
|
-
|
|
1446
|
+
await ensureDirectoryPath(path.dirname(snapshotPath));
|
|
1447
|
+
|
|
1342
1448
|
await copyTreeContents(resolved[index], snapshotPath, snapshotState);
|
|
1343
1449
|
}
|
|
1344
1450
|
if (format === 'zip') {
|
|
@@ -1353,11 +1459,11 @@ export async function createArchiveTool(args) {
|
|
|
1353
1459
|
} else {
|
|
1354
1460
|
fail('format must be tar, tar.gz, or zip');
|
|
1355
1461
|
}
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1462
|
+
const stagedInfo = await stat(staging);
|
|
1463
|
+
assertArchiveExpandedSize(stagedInfo.size, 'archive output');
|
|
1464
|
+
|
|
1465
|
+
await moveArchiveFile(staging, destination);
|
|
1359
1466
|
|
|
1360
|
-
await moveArchiveFile(staging, destination);
|
|
1361
1467
|
const info = await stat(destination).catch(() => null);
|
|
1362
1468
|
const note = ' (built outside the tree in temporary staging and committed atomically)';
|
|
1363
1469
|
|
|
@@ -1397,7 +1503,6 @@ export async function extractArchiveTool(args) {
|
|
|
1397
1503
|
try {
|
|
1398
1504
|
await mkdir(staging, { mode: 0o700 });
|
|
1399
1505
|
await snapshotRegularFile(archive, archiveSnapshot, MAX_ARCHIVE_EXPANDED_BYTES);
|
|
1400
|
-
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
1401
1506
|
await recoverExtractionArtifacts(parent, destination, backupPrefix);
|
|
1402
1507
|
const existingDestination = await lstat(destination).catch(() => null);
|
|
1403
1508
|
if (existingDestination?.isSymbolicLink() || (existingDestination && !existingDestination.isDirectory())) fail('Archive destination is not a safe directory');
|
|
@@ -1493,8 +1598,17 @@ export async function takeScreenshotTool(args) {
|
|
|
1493
1598
|
const requestedDirectory = args.directory ? await resolveSafePath(args.directory, 'directory') : '';
|
|
1494
1599
|
const temporaryDirectory = requestedDirectory ? '' : await mkdtemp(path.join(os.tmpdir(), 'remcp-screenshot-'));
|
|
1495
1600
|
const directory = requestedDirectory || temporaryDirectory;
|
|
1496
|
-
|
|
1497
|
-
|
|
1601
|
+
let outputDirectory = null;
|
|
1602
|
+
if (requestedDirectory) {
|
|
1603
|
+
await ensureDirectoryPath(directory);
|
|
1604
|
+
outputDirectory = await openWritableParent(directory);
|
|
1605
|
+
}
|
|
1606
|
+
const outputAnchor = outputDirectory
|
|
1607
|
+
? outputDirectory.anchor.replace('/proc/self/fd/', `/proc/${process.pid}/fd/`)
|
|
1608
|
+
: directory;
|
|
1609
|
+
const fileName = `capture-${randomUUID()}.png`;
|
|
1610
|
+
const file = path.join(outputAnchor, fileName);
|
|
1611
|
+
const displayFile = path.join(directory, fileName);
|
|
1498
1612
|
const attempts = [];
|
|
1499
1613
|
let preserveCapture = false;
|
|
1500
1614
|
try {
|
|
@@ -1530,12 +1644,12 @@ export async function takeScreenshotTool(args) {
|
|
|
1530
1644
|
const buffer = await handle.readFile();
|
|
1531
1645
|
preserveCapture = args.keep === true;
|
|
1532
1646
|
return multi([
|
|
1533
|
-
{ type: 'text', text: `Screenshot of ${os.hostname()} (${info.size} bytes)${preserveCapture ? (' saved at ' + displayPath(
|
|
1647
|
+
{ type: 'text', text: `Screenshot of ${os.hostname()} (${info.size} bytes)${preserveCapture ? (' saved at ' + displayPath(displayFile)) : ''}` },
|
|
1534
1648
|
image(buffer.toString('base64'), 'image/png'),
|
|
1535
1649
|
]);
|
|
1536
1650
|
}
|
|
1537
1651
|
preserveCapture = true;
|
|
1538
|
-
return text(`Screenshot of ${os.hostname()} captured: ${info.size} bytes, above the ${MAX_IMAGE_BYTES}-byte inline limit, so it is saved at ${displayPath(
|
|
1652
|
+
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.`);
|
|
1539
1653
|
} finally {
|
|
1540
1654
|
await handle.close();
|
|
1541
1655
|
}
|
|
@@ -1544,6 +1658,7 @@ export async function takeScreenshotTool(args) {
|
|
|
1544
1658
|
if (temporaryDirectory) await rm(temporaryDirectory, { recursive: true, force: true }).catch(() => {});
|
|
1545
1659
|
else await rm(file, { force: true }).catch(() => {});
|
|
1546
1660
|
}
|
|
1661
|
+
if (outputDirectory) await outputDirectory.close().catch(() => {});
|
|
1547
1662
|
}
|
|
1548
1663
|
}
|
|
1549
1664
|
export const fileToolHandlers = {
|