@remcp/runtime 0.2.105 → 0.2.107
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 +10 -2
- package/package.json +1 -1
- package/src/config.mjs +12 -2
- package/src/documents.mjs +7 -0
- package/src/extended/browser.mjs +134 -14
- package/src/extended/desktop-linux.mjs +80 -15
- package/src/extended/documents.mjs +107 -21
- package/src/helpers/wayland-eis-helper.py +30 -3
- package/src/policy.mjs +42 -9
- package/src/tools/files.mjs +505 -40
- package/src/tools/terminal.mjs +4 -2
- package/src/util.mjs +7 -1
package/src/tools/files.mjs
CHANGED
|
@@ -40,8 +40,14 @@ function assertRegularFile(info, absolute) {
|
|
|
40
40
|
return info;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
const
|
|
44
|
-
const
|
|
43
|
+
const NOFOLLOW = constants.O_NOFOLLOW || 0;
|
|
44
|
+
const NONBLOCK = constants.O_NONBLOCK || 0;
|
|
45
|
+
const READ_NOFOLLOW = constants.O_RDONLY | NOFOLLOW;
|
|
46
|
+
const READ_NOFOLLOW_NONBLOCK = READ_NOFOLLOW | NONBLOCK;
|
|
47
|
+
const READWRITE_NOFOLLOW = constants.O_RDWR | NOFOLLOW;
|
|
48
|
+
const WRITE_CREATE_NOFOLLOW = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NOFOLLOW | NONBLOCK;
|
|
49
|
+
const WRITE_TRUNCATE_NOFOLLOW = constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | NOFOLLOW | NONBLOCK;
|
|
50
|
+
const DIRECTORY_READ_NOFOLLOW = constants.O_RDONLY | (constants.O_DIRECTORY || 0) | NOFOLLOW | NONBLOCK;
|
|
45
51
|
|
|
46
52
|
async function openRegularFile(absolute, flags = READ_NOFOLLOW) {
|
|
47
53
|
const handle = await open(absolute, flags);
|
|
@@ -568,11 +574,17 @@ export async function moveToTrashTool(args) {
|
|
|
568
574
|
} catch { destination = null; }
|
|
569
575
|
}
|
|
570
576
|
if (!destination) destination = path.join(path.dirname(source), '.remcp-trash');
|
|
571
|
-
await
|
|
577
|
+
const existingDestination = await lstat(destination).catch(() => null);
|
|
578
|
+
if (existingDestination?.isSymbolicLink()) fail('Refusing to use a symlink as the trash directory');
|
|
579
|
+
if (existingDestination && !existingDestination.isDirectory()) fail('Trash destination is not a directory');
|
|
580
|
+
await mkdir(destination, { recursive: true, mode: 0o700 });
|
|
581
|
+
destination = await resolveSafePath(destination, 'trash');
|
|
582
|
+
const destinationInfo = await lstat(destination);
|
|
583
|
+
if (!destinationInfo.isDirectory() || destinationInfo.isSymbolicLink()) fail('Refusing to use a symlink as the trash directory');
|
|
572
584
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
573
585
|
let target = path.join(destination, `${stamp}-${path.basename(source)}`);
|
|
574
586
|
let counter = 1;
|
|
575
|
-
while (await
|
|
587
|
+
while (await lstat(target).then(value => Boolean(value), () => false)) {
|
|
576
588
|
target = path.join(destination, `${stamp}-${counter}-${path.basename(source)}`);
|
|
577
589
|
counter += 1;
|
|
578
590
|
}
|
|
@@ -908,6 +920,384 @@ export async function copyFileTool(args) {
|
|
|
908
920
|
}
|
|
909
921
|
|
|
910
922
|
// --- 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
|
+
const MAX_ARCHIVE_EXPANDED_BYTES = Math.min(
|
|
939
|
+
4 * 1024 * 1024 * 1024,
|
|
940
|
+
Math.max(64 * 1024 * 1024, Number(process.env.REMCP_MAX_ARCHIVE_EXPANDED_BYTES) || 512 * 1024 * 1024),
|
|
941
|
+
);
|
|
942
|
+
|
|
943
|
+
function safeArchiveEntry(name) {
|
|
944
|
+
const normalized = String(name || '').replace(/\\/g, '/');
|
|
945
|
+
return Boolean(normalized) && !normalized.startsWith('/') && !/^[A-Za-z]:\//.test(normalized) && !normalized.split('/').includes('..');
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function archiveExpandedSize(lines, pattern, label, expectedEntries = null) {
|
|
949
|
+
let total = 0;
|
|
950
|
+
let matched = 0;
|
|
951
|
+
for (const line of lines) {
|
|
952
|
+
const match = line.match(pattern);
|
|
953
|
+
if (!match) continue;
|
|
954
|
+
total += Number(match[1]);
|
|
955
|
+
matched += 1;
|
|
956
|
+
if (!Number.isSafeInteger(total) || total > MAX_ARCHIVE_EXPANDED_BYTES) fail(`${label} exceeds the ${MAX_ARCHIVE_EXPANDED_BYTES}-byte extraction limit`);
|
|
957
|
+
}
|
|
958
|
+
if ((!matched && expectedEntries !== 0) || (expectedEntries !== null && matched < expectedEntries)) fail(`Could not determine ${label} size safely`);
|
|
959
|
+
return total;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function tarEntrySize(line) {
|
|
963
|
+
const tokens = String(line || '').trim().split(/\s+/);
|
|
964
|
+
for (const index of [2, 3, 4]) {
|
|
965
|
+
const size = Number(tokens[index]);
|
|
966
|
+
if (!Number.isSafeInteger(size) || size < 0) continue;
|
|
967
|
+
const tail = tokens.slice(index + 1);
|
|
968
|
+
const gnuDate = /^\d{4}-\d{2}-\d{2}$/.test(tail[0] || '');
|
|
969
|
+
const bsdDate = /^[A-Z][a-z]{2}$/.test(tail[0] || '')
|
|
970
|
+
&& /^\d{1,2}$/.test(tail[1] || '')
|
|
971
|
+
&& ((/^\d{2}:\d{2}(?::\d{2})?$/.test(tail[2] || '') && /^\d{4}$/.test(tail[3] || ''))
|
|
972
|
+
|| /^\d{4}$/.test(tail[2] || ''));
|
|
973
|
+
if (gnuDate || bsdDate) return size;
|
|
974
|
+
}
|
|
975
|
+
return null;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function archiveTarExpandedSize(lines, label, expectedEntries) {
|
|
979
|
+
let total = 0;
|
|
980
|
+
let matched = 0;
|
|
981
|
+
for (const line of lines) {
|
|
982
|
+
const size = tarEntrySize(line);
|
|
983
|
+
if (size === null) continue;
|
|
984
|
+
total += size;
|
|
985
|
+
matched += 1;
|
|
986
|
+
if (!Number.isSafeInteger(total) || total > MAX_ARCHIVE_EXPANDED_BYTES) fail(`${label} exceeds the ${MAX_ARCHIVE_EXPANDED_BYTES}-byte extraction limit`);
|
|
987
|
+
}
|
|
988
|
+
if (matched < expectedEntries) fail(`Could not determine ${label} size safely`);
|
|
989
|
+
return total;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function assertArchiveExpandedSize(bytes, label) {
|
|
993
|
+
if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > MAX_ARCHIVE_EXPANDED_BYTES) {
|
|
994
|
+
fail(`${label} exceeds the ${MAX_ARCHIVE_EXPANDED_BYTES}-byte extraction limit`);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
async function assertExtractedTree(root, state = { entries: 0, bytes: 0 }) {
|
|
999
|
+
const info = await lstat(root);
|
|
1000
|
+
if (info.isSymbolicLink()) fail('Archive extraction produced a symbolic link');
|
|
1001
|
+
if (!info.isDirectory() && !info.isFile()) fail('Archive extraction produced a special file');
|
|
1002
|
+
state.entries += 1;
|
|
1003
|
+
state.bytes += info.isFile() ? info.size : 0;
|
|
1004
|
+
if (state.entries > 100_000 || state.bytes > MAX_ARCHIVE_EXPANDED_BYTES) fail('Archive extraction exceeded the safety limit');
|
|
1005
|
+
if (!info.isDirectory()) return;
|
|
1006
|
+
for (const entry of await readdir(root, { withFileTypes: true })) await assertExtractedTree(path.join(root, entry.name), state);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
async function openArchiveSource(absolute) {
|
|
1010
|
+
if (process.platform !== 'linux') {
|
|
1011
|
+
const opened = await openRegularFile(absolute, READ_NOFOLLOW_NONBLOCK);
|
|
1012
|
+
return { ...opened, close: () => opened.handle.close() };
|
|
1013
|
+
}
|
|
1014
|
+
const root = path.parse(absolute).root;
|
|
1015
|
+
const parts = absolute.slice(root.length).split(path.sep).filter(Boolean);
|
|
1016
|
+
if (!parts.length) return openRegularFile(absolute, READ_NOFOLLOW_NONBLOCK);
|
|
1017
|
+
const directories = [];
|
|
1018
|
+
let current = root;
|
|
1019
|
+
const closeDirectories = async () => {
|
|
1020
|
+
for (const handle of directories.reverse()) await handle.close().catch(() => {});
|
|
1021
|
+
};
|
|
1022
|
+
try {
|
|
1023
|
+
for (const part of parts.slice(0, -1)) {
|
|
1024
|
+
const handle = await open(path.join(current, part), DIRECTORY_READ_NOFOLLOW);
|
|
1025
|
+
try {
|
|
1026
|
+
const info = await handle.stat();
|
|
1027
|
+
if (!info.isDirectory()) fail('Archive source is not a safe regular file');
|
|
1028
|
+
} catch (error) {
|
|
1029
|
+
await handle.close().catch(() => {});
|
|
1030
|
+
throw error;
|
|
1031
|
+
}
|
|
1032
|
+
directories.push(handle);
|
|
1033
|
+
current = `/proc/self/fd/${handle.fd}`;
|
|
1034
|
+
}
|
|
1035
|
+
const opened = await openRegularFile(path.join(current, parts.at(-1)), READ_NOFOLLOW_NONBLOCK);
|
|
1036
|
+
return {
|
|
1037
|
+
...opened,
|
|
1038
|
+
close: async () => {
|
|
1039
|
+
try { await opened.handle.close(); }
|
|
1040
|
+
finally { await closeDirectories(); }
|
|
1041
|
+
},
|
|
1042
|
+
};
|
|
1043
|
+
} catch (error) {
|
|
1044
|
+
await closeDirectories();
|
|
1045
|
+
throw error;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
async function snapshotRegularFile(source, destination, maxBytes) {
|
|
1050
|
+
const sourceInfo = await lstat(source);
|
|
1051
|
+
if (sourceInfo.isSymbolicLink()) fail(`Archive source is a symbolic link: ${displayPath(source)}`);
|
|
1052
|
+
if (!sourceInfo.isFile()) fail(`Archive source is not a regular file: ${displayPath(source)}`);
|
|
1053
|
+
const opened = await openArchiveSource(source);
|
|
1054
|
+
const { handle, info } = opened;
|
|
1055
|
+
let output;
|
|
1056
|
+
try {
|
|
1057
|
+
if (info.size > maxBytes) fail(`Archive is ${info.size} bytes, above the ${maxBytes}-byte snapshot limit`);
|
|
1058
|
+
output = await open(destination, WRITE_CREATE_NOFOLLOW, 0o400);
|
|
1059
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
1060
|
+
let offset = 0;
|
|
1061
|
+
for (;;) {
|
|
1062
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
|
|
1063
|
+
if (!bytesRead) break;
|
|
1064
|
+
if (!Number.isSafeInteger(offset + bytesRead) || offset + bytesRead > maxBytes) {
|
|
1065
|
+
fail(`Archive grew beyond the ${maxBytes}-byte snapshot limit`);
|
|
1066
|
+
}
|
|
1067
|
+
let written = 0;
|
|
1068
|
+
while (written < bytesRead) {
|
|
1069
|
+
const result = await output.write(buffer, written, bytesRead - written, offset + written);
|
|
1070
|
+
if (!result.bytesWritten) throw new Error('Could not finish writing the archive snapshot');
|
|
1071
|
+
written += result.bytesWritten;
|
|
1072
|
+
}
|
|
1073
|
+
offset += bytesRead;
|
|
1074
|
+
}
|
|
1075
|
+
await output.truncate(offset);
|
|
1076
|
+
await output.sync();
|
|
1077
|
+
await output.chmod(0o400);
|
|
1078
|
+
return offset;
|
|
1079
|
+
} finally {
|
|
1080
|
+
if (output) await output.close().catch(() => {});
|
|
1081
|
+
await opened.close().catch(() => {});
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
async function copyOpenRegularFile(sourceHandle, sourceInfo, destinationPath, state) {
|
|
1086
|
+
const mode = sourceInfo.mode & 0o777;
|
|
1087
|
+
const currentBytes = state.bytes || 0;
|
|
1088
|
+
if (state.maxBytes !== undefined && sourceInfo.size > state.maxBytes - currentBytes) {
|
|
1089
|
+
fail(`Archive contents exceed the ${state.maxBytes}-byte extraction limit`);
|
|
1090
|
+
}
|
|
1091
|
+
const existing = await lstat(destinationPath).catch(() => null);
|
|
1092
|
+
if (existing && (!existing.isFile() || existing.isSymbolicLink())) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1093
|
+
let output;
|
|
1094
|
+
try {
|
|
1095
|
+
try {
|
|
1096
|
+
output = await open(destinationPath, WRITE_TRUNCATE_NOFOLLOW, mode);
|
|
1097
|
+
} catch (error) {
|
|
1098
|
+
if (['ELOOP', 'ENOTDIR', 'EISDIR'].includes(error?.code)) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1099
|
+
throw error;
|
|
1100
|
+
}
|
|
1101
|
+
const outputInfo = await output.stat();
|
|
1102
|
+
if (!outputInfo.isFile()) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1103
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
1104
|
+
let offset = 0;
|
|
1105
|
+
for (;;) {
|
|
1106
|
+
const { bytesRead } = await sourceHandle.read(buffer, 0, buffer.length, offset);
|
|
1107
|
+
if (!bytesRead) break;
|
|
1108
|
+
if (!Number.isSafeInteger(offset + bytesRead) || (state.maxBytes !== undefined && currentBytes + offset + bytesRead > state.maxBytes)) {
|
|
1109
|
+
fail(`Archive contents exceed the ${state.maxBytes}-byte extraction limit`);
|
|
1110
|
+
}
|
|
1111
|
+
let written = 0;
|
|
1112
|
+
while (written < bytesRead) {
|
|
1113
|
+
const result = await output.write(buffer, written, bytesRead - written, offset + written);
|
|
1114
|
+
if (!result.bytesWritten) throw new Error('Could not finish copying archive contents');
|
|
1115
|
+
written += result.bytesWritten;
|
|
1116
|
+
}
|
|
1117
|
+
offset += bytesRead;
|
|
1118
|
+
}
|
|
1119
|
+
await output.truncate(offset);
|
|
1120
|
+
await output.sync();
|
|
1121
|
+
await output.chmod(mode);
|
|
1122
|
+
state.bytes = currentBytes + offset;
|
|
1123
|
+
} finally {
|
|
1124
|
+
if (output) await output.close().catch(() => {});
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
async function copyTreeContents(source, destination, state = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES }) {
|
|
1129
|
+
const initial = await lstat(source);
|
|
1130
|
+
if (initial.isSymbolicLink() || (!initial.isDirectory() && !initial.isFile())) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1131
|
+
state.entries += 1;
|
|
1132
|
+
if (state.entries > 100_000) fail('Archive contains too many entries');
|
|
1133
|
+
if (initial.isFile()) {
|
|
1134
|
+
const opened = await openRegularFile(source, READ_NOFOLLOW_NONBLOCK);
|
|
1135
|
+
try { await copyOpenRegularFile(opened.handle, opened.info, destination, state); }
|
|
1136
|
+
finally { await opened.handle.close(); }
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
let directoryHandle = null;
|
|
1140
|
+
let sourcePath = source;
|
|
1141
|
+
let info = initial;
|
|
1142
|
+
try {
|
|
1143
|
+
if (process.platform === 'linux') {
|
|
1144
|
+
try {
|
|
1145
|
+
directoryHandle = await open(source, DIRECTORY_READ_NOFOLLOW);
|
|
1146
|
+
info = await directoryHandle.stat();
|
|
1147
|
+
if (!info.isDirectory()) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1148
|
+
sourcePath = `/proc/self/fd/${directoryHandle.fd}`;
|
|
1149
|
+
} catch (error) {
|
|
1150
|
+
if (['ELOOP', 'ENOTDIR'].includes(error?.code)) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1151
|
+
throw error;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
const mode = info.mode & 0o777;
|
|
1155
|
+
const existing = await lstat(destination).catch(() => null);
|
|
1156
|
+
if (existing && (!existing.isDirectory() || existing.isSymbolicLink())) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1157
|
+
try { await mkdir(destination, { mode: mode | 0o700 }); }
|
|
1158
|
+
catch (error) { if (error?.code !== 'EEXIST') throw error; }
|
|
1159
|
+
const destinationInfo = await lstat(destination);
|
|
1160
|
+
if (!destinationInfo.isDirectory() || destinationInfo.isSymbolicLink()) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1161
|
+
let destinationHandle;
|
|
1162
|
+
try {
|
|
1163
|
+
destinationHandle = await open(destination, DIRECTORY_READ_NOFOLLOW);
|
|
1164
|
+
const openedDestination = await destinationHandle.stat();
|
|
1165
|
+
if (!openedDestination.isDirectory()) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1166
|
+
await destinationHandle.chmod(mode | 0o700);
|
|
1167
|
+
for (const name of await readdir(sourcePath)) {
|
|
1168
|
+
await copyTreeContents(path.join(sourcePath, name), path.join(destination, name), state);
|
|
1169
|
+
}
|
|
1170
|
+
await destinationHandle.chmod(mode);
|
|
1171
|
+
} finally {
|
|
1172
|
+
if (destinationHandle) await destinationHandle.close().catch(() => {});
|
|
1173
|
+
}
|
|
1174
|
+
} finally {
|
|
1175
|
+
if (directoryHandle) await directoryHandle.close().catch(() => {});
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
async function removeExtractionArtifact(target) {
|
|
1180
|
+
const info = await lstat(target).catch(() => null);
|
|
1181
|
+
if (!info) return;
|
|
1182
|
+
if (info.isDirectory() && !info.isSymbolicLink()) await rm(target, { recursive: true, force: true });
|
|
1183
|
+
else await unlink(target).catch(error => { if (error?.code !== 'ENOENT') throw error; });
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
async function recoverExtractionArtifacts(parent, destination, prefix) {
|
|
1187
|
+
const entries = await readdir(parent, { withFileTypes: true });
|
|
1188
|
+
const staleBefore = Date.now() - 60 * 60 * 1000;
|
|
1189
|
+
for (const entry of entries) {
|
|
1190
|
+
if (!entry.name.startsWith('.remcp-extract-txn-')) continue;
|
|
1191
|
+
const candidate = path.join(parent, entry.name);
|
|
1192
|
+
const info = await lstat(candidate).catch(() => null);
|
|
1193
|
+
if (info && info.mtimeMs < staleBefore) await removeExtractionArtifact(candidate);
|
|
1194
|
+
}
|
|
1195
|
+
const backups = entries.filter(entry => entry.name.startsWith(prefix)).map(entry => path.join(parent, entry.name));
|
|
1196
|
+
for (const backup of backups) {
|
|
1197
|
+
const destinationInfo = await lstat(destination).catch(() => null);
|
|
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;
|
|
1207
|
+
}
|
|
1208
|
+
try {
|
|
1209
|
+
await rename(backup, destination);
|
|
1210
|
+
} catch {
|
|
1211
|
+
continue;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
async function restoreExtractionBackup(backup, destination) {
|
|
1217
|
+
if (await lstat(destination).catch(() => null)) return false;
|
|
1218
|
+
const info = await lstat(backup).catch(() => null);
|
|
1219
|
+
if (!info) return false;
|
|
1220
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
1221
|
+
await removeExtractionArtifact(backup);
|
|
1222
|
+
return false;
|
|
1223
|
+
}
|
|
1224
|
+
try {
|
|
1225
|
+
await rename(backup, destination);
|
|
1226
|
+
return true;
|
|
1227
|
+
} catch {
|
|
1228
|
+
return false;
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
async function installExtractedTree(source, destination) {
|
|
1233
|
+
const parent = path.dirname(destination);
|
|
1234
|
+
if (destination === path.parse(destination).root) fail('Archive destination cannot be a filesystem root');
|
|
1235
|
+
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
1236
|
+
const backupPrefix = `.remcp-extract-backup-${createHash('sha256').update(destination).digest('hex').slice(0, 16)}-`;
|
|
1237
|
+
await recoverExtractionArtifacts(parent, destination, backupPrefix);
|
|
1238
|
+
const existing = await lstat(destination).catch(() => null);
|
|
1239
|
+
const destinationMode = existing?.isDirectory() ? existing.mode & 0o777 : 0o700;
|
|
1240
|
+
const transaction = await mkdtemp(path.join(parent, '.remcp-extract-txn-'));
|
|
1241
|
+
let backup = '';
|
|
1242
|
+
let backupOwned = false;
|
|
1243
|
+
let preserveBackup = false;
|
|
1244
|
+
try {
|
|
1245
|
+
if (existing) {
|
|
1246
|
+
backup = path.join(parent, `${backupPrefix}${randomUUID()}`);
|
|
1247
|
+
try {
|
|
1248
|
+
await rename(destination, backup);
|
|
1249
|
+
backupOwned = true;
|
|
1250
|
+
} catch (error) {
|
|
1251
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
1252
|
+
backup = '';
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
if (backupOwned) {
|
|
1256
|
+
const backupInfo = await lstat(backup);
|
|
1257
|
+
if (backupInfo.isSymbolicLink() || !backupInfo.isDirectory()) fail('Archive destination is not a safe directory');
|
|
1258
|
+
await copyTreeContents(backup, transaction);
|
|
1259
|
+
}
|
|
1260
|
+
await copyTreeContents(source, transaction);
|
|
1261
|
+
await chmod(transaction, destinationMode);
|
|
1262
|
+
await rename(transaction, destination);
|
|
1263
|
+
if (backupOwned) {
|
|
1264
|
+
await removeExtractionArtifact(backup);
|
|
1265
|
+
backupOwned = false;
|
|
1266
|
+
}
|
|
1267
|
+
} catch (error) {
|
|
1268
|
+
if (backupOwned) {
|
|
1269
|
+
try {
|
|
1270
|
+
await restoreExtractionBackup(backup, destination);
|
|
1271
|
+
if (!await lstat(backup).catch(() => null)) backupOwned = false;
|
|
1272
|
+
else preserveBackup = true;
|
|
1273
|
+
} catch (restoreError) {
|
|
1274
|
+
preserveBackup = true;
|
|
1275
|
+
throw new AggregateError([error, restoreError], 'Could not restore archive destination after extraction failure');
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
throw error;
|
|
1279
|
+
} finally {
|
|
1280
|
+
await rm(transaction, { recursive: true, force: true });
|
|
1281
|
+
if (backupOwned && !preserveBackup) await removeExtractionArtifact(backup);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
async function moveArchiveFile(source, destination) {
|
|
1286
|
+
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
|
+
try {
|
|
1292
|
+
await copyFile(source, temporary, constants.COPYFILE_EXCL);
|
|
1293
|
+
await rename(temporary, destination);
|
|
1294
|
+
} finally {
|
|
1295
|
+
await unlink(temporary).catch(() => {});
|
|
1296
|
+
}
|
|
1297
|
+
await unlink(source);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
911
1301
|
function archiveTool() {
|
|
912
1302
|
const probe = (name, versionArgs = ['--version']) => {
|
|
913
1303
|
const result = spawnSync(name, versionArgs, { encoding: 'utf8' });
|
|
@@ -924,57 +1314,132 @@ export async function createArchiveTool(args) {
|
|
|
924
1314
|
if (!sources.length) fail('paths must list at least one file or directory');
|
|
925
1315
|
const resolved = [];
|
|
926
1316
|
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);
|
|
927
1319
|
const destination = await resolveSafePath(args.destination, 'destination');
|
|
1320
|
+
const existingDestination = await lstat(destination).catch(() => null);
|
|
1321
|
+
if (existingDestination?.isSymbolicLink()) fail('Archive destination cannot be a symbolic link');
|
|
928
1322
|
const format = String(args.format || (destination.endsWith('.zip') ? 'zip' : 'tar.gz')).toLowerCase();
|
|
929
1323
|
await mkdir(path.dirname(destination), { recursive: true });
|
|
930
1324
|
const baseDir = path.dirname(resolved[0]);
|
|
931
1325
|
const names = resolved.map(entry => path.relative(baseDir, entry));
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
// tree first and move it into place afterwards.
|
|
935
|
-
const destinationRelative = path.relative(baseDir, destination);
|
|
936
|
-
const selfInside = !destinationRelative.startsWith('..') && !path.isAbsolute(destinationRelative);
|
|
1326
|
+
if (names.some(name => !name || name.startsWith('..') || path.isAbsolute(name))) fail('Archive sources must share one parent directory');
|
|
1327
|
+
const archiveNames = names.map(name => name.startsWith('-') ? `./${name}` : name);
|
|
937
1328
|
const suffix = format === 'zip' ? '.zip' : format === 'tar' ? '.tar' : '.tar.gz';
|
|
938
|
-
const
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
const result = spawnSync(tools.zip, ['-r', '-q', output, ...names], { cwd: baseDir, encoding: 'utf8' });
|
|
943
|
-
if (result.status !== 0) fail(`zip failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
|
|
944
|
-
} else if (format === 'tar' || format === 'tar.gz' || format === 'tgz') {
|
|
945
|
-
if (!tools.tar) fail('tar is not installed on this device');
|
|
946
|
-
const flags = format === 'tar' ? '-cf' : '-czf';
|
|
947
|
-
const result = spawnSync(tools.tar, [flags, output, ...names], { cwd: baseDir, encoding: 'utf8' });
|
|
948
|
-
if (result.status !== 0) fail(`tar failed: ${(result.stderr || '').trim() || `exit ${result.status}`}`);
|
|
949
|
-
} else {
|
|
950
|
-
fail('format must be tar, tar.gz, or zip');
|
|
1329
|
+
const stagingDirectory = await mkdtemp(path.join(os.tmpdir(), 'remcp-archive-'));
|
|
1330
|
+
if (resolved.some(entry => !path.relative(entry, stagingDirectory).startsWith('..') && !path.isAbsolute(path.relative(entry, stagingDirectory)))) {
|
|
1331
|
+
await rm(stagingDirectory, { recursive: true, force: true });
|
|
1332
|
+
fail('Archive source must not contain the system temporary directory');
|
|
951
1333
|
}
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1334
|
+
const staging = path.join(stagingDirectory, `payload${suffix}`);
|
|
1335
|
+
const snapshotRoot = path.join(stagingDirectory, 'source');
|
|
1336
|
+
try {
|
|
1337
|
+
await mkdir(snapshotRoot, { mode: 0o700 });
|
|
1338
|
+
const snapshotState = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES };
|
|
1339
|
+
for (let index = 0; index < resolved.length; index += 1) {
|
|
1340
|
+
const snapshotPath = path.join(snapshotRoot, names[index]);
|
|
1341
|
+
await mkdir(path.dirname(snapshotPath), { recursive: true, mode: 0o700 });
|
|
1342
|
+
await copyTreeContents(resolved[index], snapshotPath, snapshotState);
|
|
1343
|
+
}
|
|
1344
|
+
if (format === 'zip') {
|
|
1345
|
+
if (!tools.zip) fail('zip is not installed on this device; use format "tar.gz"');
|
|
1346
|
+
const result = spawnSync(tools.zip, ['-r', '-q', '-y', staging, '--', ...archiveNames], { cwd: snapshotRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
|
|
1347
|
+
if (result.status !== 0) fail(`zip failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
|
|
1348
|
+
} else if (format === 'tar' || format === 'tar.gz' || format === 'tgz') {
|
|
1349
|
+
if (!tools.tar) fail('tar is not installed on this device');
|
|
1350
|
+
const flags = format === 'tar' ? '-cf' : '-czf';
|
|
1351
|
+
const result = spawnSync(tools.tar, [flags, staging, '--', ...archiveNames], { cwd: snapshotRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
|
|
1352
|
+
if (result.status !== 0) fail(`tar failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
|
|
1353
|
+
} else {
|
|
1354
|
+
fail('format must be tar, tar.gz, or zip');
|
|
1355
|
+
}
|
|
1356
|
+
const stagedInfo = await stat(staging);
|
|
1357
|
+
assertArchiveExpandedSize(stagedInfo.size, 'archive output');
|
|
1358
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
1359
|
+
|
|
1360
|
+
await moveArchiveFile(staging, destination);
|
|
1361
|
+
const info = await stat(destination).catch(() => null);
|
|
1362
|
+
const note = ' (built outside the tree in temporary staging and committed atomically)';
|
|
1363
|
+
|
|
1364
|
+
return text(`Created ${displayPath(destination)} (${format}, ${info?.size ?? 0} bytes) from ${resolved.length} path(s)${note}.`);
|
|
1365
|
+
} finally {
|
|
1366
|
+
if (stagingDirectory) await rm(stagingDirectory, { recursive: true, force: true });
|
|
955
1367
|
}
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
function archiveFormatForPath(value) {
|
|
1371
|
+
if (/\.zip$/i.test(value)) return 'zip';
|
|
1372
|
+
if (/\.(tar\.gz|tgz)$/i.test(value)) return 'tar.gz';
|
|
1373
|
+
if (/\.(tar\.bz2|tbz2?)$/i.test(value)) return 'tar.bz2';
|
|
1374
|
+
if (/\.tar\.xz$/i.test(value)) return 'tar.xz';
|
|
1375
|
+
return 'tar';
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
function archiveSnapshotSuffix(format) {
|
|
1379
|
+
if (format === 'zip') return '.zip';
|
|
1380
|
+
if (format === 'tar.bz2') return '.tar.bz2';
|
|
1381
|
+
if (format === 'tar.xz') return '.tar.xz';
|
|
1382
|
+
if (format === 'tar.gz') return '.tar.gz';
|
|
1383
|
+
return '.tar';
|
|
959
1384
|
}
|
|
960
1385
|
|
|
961
1386
|
export async function extractArchiveTool(args) {
|
|
962
1387
|
const tools = archiveTool();
|
|
963
1388
|
const archive = await resolveSafePath(args.archive, 'archive');
|
|
964
1389
|
const destination = await resolveSafePath(args.destination || path.dirname(archive), 'destination');
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
1390
|
+
if (destination === path.parse(destination).root) fail('Archive destination cannot be a filesystem root');
|
|
1391
|
+
const parent = path.dirname(destination);
|
|
1392
|
+
const backupPrefix = `.remcp-extract-backup-${createHash('sha256').update(destination).digest('hex').slice(0, 16)}-`;
|
|
1393
|
+
const format = archiveFormatForPath(archive);
|
|
1394
|
+
const stagingDirectory = await mkdtemp(path.join(os.tmpdir(), 'remcp-extract-'));
|
|
1395
|
+
const archiveSnapshot = path.join(stagingDirectory, `source${archiveSnapshotSuffix(format)}`);
|
|
1396
|
+
const staging = path.join(stagingDirectory, 'tree');
|
|
1397
|
+
try {
|
|
1398
|
+
await mkdir(staging, { mode: 0o700 });
|
|
1399
|
+
await snapshotRegularFile(archive, archiveSnapshot, MAX_ARCHIVE_EXPANDED_BYTES);
|
|
1400
|
+
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
1401
|
+
await recoverExtractionArtifacts(parent, destination, backupPrefix);
|
|
1402
|
+
const existingDestination = await lstat(destination).catch(() => null);
|
|
1403
|
+
if (existingDestination?.isSymbolicLink() || (existingDestination && !existingDestination.isDirectory())) fail('Archive destination is not a safe directory');
|
|
1404
|
+
if (format === 'zip') {
|
|
1405
|
+
if (!tools.unzip) fail('unzip is not installed on this device');
|
|
1406
|
+
const listing = spawnSync(tools.unzip, ['-Z', '-1', archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
|
|
1407
|
+
if (listing.error || listing.status !== 0) fail(`Could not inspect ZIP archive: ${(listing.stderr || listing.error?.message || `exit ${listing.status}`).trim()}`);
|
|
1408
|
+
const names = String(listing.stdout || '').split(/\r?\n/).filter(Boolean);
|
|
1409
|
+
if (names.length > 100_000 || names.some(name => !safeArchiveEntry(name))) fail('ZIP archive contains an unsafe path or too many entries');
|
|
1410
|
+
const details = spawnSync(tools.unzip, ['-Z', '-v', archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
|
|
1411
|
+
if (details.error || details.status !== 0) fail(`Could not inspect ZIP sizes: ${(details.stderr || details.error?.message || `exit ${details.status}`).trim()}`);
|
|
1412
|
+
const detailText = String(details.stdout || '');
|
|
1413
|
+
if (/symbolic link|Unix file attributes \([^)]*\b12\d{4}/i.test(detailText)) fail('ZIP archive contains a symbolic link');
|
|
1414
|
+
const expandedBytes = archiveExpandedSize(detailText.split(/\r?\n/), /uncompressed size:\s*(\d+)\s*bytes/i, 'ZIP archive', names.length);
|
|
1415
|
+
assertArchiveExpandedSize(expandedBytes, 'ZIP archive');
|
|
1416
|
+
const result = spawnSync(tools.unzip, ['-o', '-q', archiveSnapshot, '-d', staging], { encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
|
|
1417
|
+
if (result.status !== 0) fail(`unzip failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
|
|
1418
|
+
} else {
|
|
1419
|
+
if (!tools.tar) fail('tar is not installed on this device');
|
|
1420
|
+
const listFlags = format === 'tar.gz' ? '-tzf' : format === 'tar.bz2' ? '-tjf' : format === 'tar.xz' ? '-tJf' : '-tf';
|
|
1421
|
+
const listing = spawnSync(tools.tar, [listFlags, archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
|
|
1422
|
+
if (listing.error || listing.status !== 0) fail(`Could not inspect archive: ${(listing.stderr || listing.error?.message || `exit ${listing.status}`).trim()}`);
|
|
1423
|
+
const names = String(listing.stdout || '').split(/\r?\n/).filter(Boolean);
|
|
1424
|
+
if (names.length > 100_000 || names.some(name => !safeArchiveEntry(name))) fail('Archive contains an unsafe path or too many entries');
|
|
1425
|
+
const verboseFlags = format === 'tar.gz' ? '-tvzf' : format === 'tar.bz2' ? '-tvjf' : format === 'tar.xz' ? '-tvJf' : '-tvf';
|
|
1426
|
+
const details = spawnSync(tools.tar, [verboseFlags, archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
|
|
1427
|
+
if (details.error || details.status !== 0) fail(`Could not inspect archive sizes: ${(details.stderr || details.error?.message || `exit ${details.status}`).trim()}`);
|
|
1428
|
+
const detailLines = String(details.stdout || '').split(/\r?\n/);
|
|
1429
|
+
if (detailLines.some(line => /^[bclph]/.test(line))) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1430
|
+
const expandedBytes = archiveTarExpandedSize(detailLines, 'archive', names.length);
|
|
1431
|
+
assertArchiveExpandedSize(expandedBytes, 'archive');
|
|
1432
|
+
const flags = format === 'tar.gz' ? '-xzf' : format === 'tar.bz2' ? '-xjf' : format === 'tar.xz' ? '-xJf' : '-xf';
|
|
1433
|
+
const result = spawnSync(tools.tar, [flags, archiveSnapshot, '-C', staging], { encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
|
|
1434
|
+
if (result.status !== 0) fail(`tar failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
|
|
1435
|
+
}
|
|
1436
|
+
await assertExtractedTree(staging);
|
|
1437
|
+
await installExtractedTree(staging, destination);
|
|
1438
|
+
const entries = await readdir(destination).catch(() => []);
|
|
1439
|
+
return text(`Extracted ${displayPath(archive)} into ${displayPath(destination)} (${entries.length} top-level entries).`);
|
|
1440
|
+
} finally {
|
|
1441
|
+
await rm(stagingDirectory, { recursive: true, force: true });
|
|
975
1442
|
}
|
|
976
|
-
const entries = await readdir(destination).catch(() => []);
|
|
977
|
-
return text(`Extracted ${displayPath(archive)} into ${displayPath(destination)} (${entries.length} top-level entries).`);
|
|
978
1443
|
}
|
|
979
1444
|
|
|
980
1445
|
// --- screenshots ----------------------------------------------------------------------
|
package/src/tools/terminal.mjs
CHANGED
|
@@ -221,8 +221,10 @@ export async function interactWithProcessTool(args, extra = {}) {
|
|
|
221
221
|
const session = getProcessSession(pid);
|
|
222
222
|
if (!session) fail(`No ReMCP session with pid ${pid}`);
|
|
223
223
|
if (session.exited) fail(`Process ${pid} already exited`);
|
|
224
|
-
|
|
225
|
-
|
|
224
|
+
const input = typeof args.input === 'string' ? args.input : fail('input must be a string');
|
|
225
|
+
assertAllowedCommand(input);
|
|
226
|
+
const timeoutMs = clampInteger(args.timeout_ms, 1000, 0, 120000);
|
|
227
|
+
|
|
226
228
|
session.cursor = session.droppedLines + session.lines.length;
|
|
227
229
|
session.waitCursor = session.cursor;
|
|
228
230
|
session.lastPartialRead = null;
|
package/src/util.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { realpath } from 'node:fs/promises';
|
|
3
|
+
import { lstat, realpath } from 'node:fs/promises';
|
|
4
4
|
import { liveConfig, runtimeConfig } from './config.mjs';
|
|
5
5
|
|
|
6
6
|
export class ToolError extends Error {}
|
|
@@ -93,6 +93,12 @@ export async function resolveSafePath(value, field = 'path') {
|
|
|
93
93
|
const absolute = path.resolve(raw);
|
|
94
94
|
if (!runtimeConfig.allowedRoots.length) return absolute;
|
|
95
95
|
|
|
96
|
+
const leaf = await lstat(absolute).catch(error => {
|
|
97
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return null;
|
|
98
|
+
throw error;
|
|
99
|
+
});
|
|
100
|
+
if (leaf?.isSymbolicLink()) fail(`Path is a symbolic link: ${absolute}`);
|
|
101
|
+
|
|
96
102
|
// Canonicalize both sides before enforcing confinement. This matters on macOS where
|
|
97
103
|
// /var is a symlink to /private/var: a safe path returned by an earlier canonicalization
|
|
98
104
|
// must not be rejected merely because its lexical prefix differs from the configured root.
|