@remcp/runtime 0.2.5 → 0.2.7
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 +19 -0
- package/package.json +1 -1
- package/src/catalog.mjs +22 -0
- package/src/index.mjs +3 -5
- package/src/patch.mjs +4 -1
- package/src/tools/files.mjs +69 -51
- package/src/util.mjs +57 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.7
|
|
4
|
+
|
|
5
|
+
Security and correctness fixes for the device runtime.
|
|
6
|
+
|
|
7
|
+
- **Symlink confinement fixed.** `read_files`, `replace_in_files` and `set_permissions` walked a
|
|
8
|
+
tree with `stat`, which follows symbolic links, and never re-checked the children they collected.
|
|
9
|
+
A directory symlink inside an allowed root could therefore be read *and rewritten* outside it.
|
|
10
|
+
Traversal now uses `lstat`, never follows a link, and re-resolves every collected path through
|
|
11
|
+
the same confinement check a single-file call uses.
|
|
12
|
+
- **Large results no longer kill the runtime.** The inline image limit is 4 MiB instead of 8 MiB:
|
|
13
|
+
base64 costs a third more bytes and the MCP stdio client drops the connection above 10 MB, which
|
|
14
|
+
used to restart the runtime in the middle of a call. The text budget is shared between `content`
|
|
15
|
+
and `structuredContent`, which carry the same string.
|
|
16
|
+
- **`apply_patch` inserts zero-context hunks at the right line**: `@@ -N,0 +M,K @@` inserts *after*
|
|
17
|
+
line N, and the previous calculation applied every such hunk one line early while reporting
|
|
18
|
+
success.
|
|
19
|
+
- **Glob patterns honour `[abc]`, `{a,b}` and `?`**: character classes and brace alternatives were
|
|
20
|
+
escaped into literal text and matched nothing, and `?` could match a directory separator.
|
|
21
|
+
|
|
3
22
|
## 0.2.3
|
|
4
23
|
|
|
5
24
|
Full surface and review-aligned annotations. (0.2.0 was published from an earlier snapshot that
|
package/package.json
CHANGED
package/src/catalog.mjs
CHANGED
|
@@ -4,6 +4,17 @@ import { terminalToolHandlers } from './tools/terminal.mjs';
|
|
|
4
4
|
import { systemToolHandlers } from './tools/system.mjs';
|
|
5
5
|
import { statsToolHandlers } from './tools/stats.mjs';
|
|
6
6
|
|
|
7
|
+
// Every tool answers with a text result (image tools add an image part as well), so the
|
|
8
|
+
// declared output schema is the same shape everywhere and clients can rely on it.
|
|
9
|
+
export const TEXT_OUTPUT_SCHEMA = {
|
|
10
|
+
type: 'object',
|
|
11
|
+
properties: {
|
|
12
|
+
text: { type: 'string', description: 'Human-readable result of the tool call.' },
|
|
13
|
+
},
|
|
14
|
+
required: ['text'],
|
|
15
|
+
additionalProperties: false,
|
|
16
|
+
};
|
|
17
|
+
|
|
7
18
|
const readOnly = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
8
19
|
const readOnlyNonIdempotent = { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false };
|
|
9
20
|
const additive = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
@@ -726,4 +737,15 @@ export const toolDefinitions = [
|
|
|
726
737
|
},
|
|
727
738
|
];
|
|
728
739
|
|
|
740
|
+
export function advertisedTools() {
|
|
741
|
+
return toolDefinitions.map(({ name, title, description, inputSchema, annotations, outputSchema }) => ({
|
|
742
|
+
name,
|
|
743
|
+
title,
|
|
744
|
+
description,
|
|
745
|
+
inputSchema,
|
|
746
|
+
annotations,
|
|
747
|
+
outputSchema: outputSchema || TEXT_OUTPUT_SCHEMA,
|
|
748
|
+
}));
|
|
749
|
+
}
|
|
750
|
+
|
|
729
751
|
export const toolHandlers = new Map(toolDefinitions.map(definition => [definition.name, definition]));
|
package/src/index.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import process from 'node:process';
|
|
3
3
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { toolDefinitions } from './catalog.mjs';
|
|
5
|
+
import { advertisedTools, toolDefinitions } from './catalog.mjs';
|
|
6
6
|
import { describeConfig, configurationError, runtimeConfigDir } from './config.mjs';
|
|
7
7
|
import { invokeTool } from './invoke.mjs';
|
|
8
8
|
import { shutdownSessions, startSessionSweeper } from './sessions.mjs';
|
|
@@ -40,7 +40,7 @@ if (args.includes('--version')) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
if (args.includes('--print-tools')) {
|
|
43
|
-
process.stdout.write(`${JSON.stringify(
|
|
43
|
+
process.stdout.write(`${JSON.stringify(advertisedTools(), null, 2)}\n`);
|
|
44
44
|
process.exit(0);
|
|
45
45
|
}
|
|
46
46
|
|
|
@@ -86,9 +86,7 @@ const server = new Server(
|
|
|
86
86
|
},
|
|
87
87
|
);
|
|
88
88
|
|
|
89
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
90
|
-
tools: toolDefinitions.map(({ name, title, description, inputSchema, annotations }) => ({ name, title, description, inputSchema, annotations })),
|
|
91
|
-
}));
|
|
89
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: advertisedTools() }));
|
|
92
90
|
|
|
93
91
|
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
94
92
|
return invokeTool(request.params.name, request.params.arguments, extra);
|
package/src/patch.mjs
CHANGED
|
@@ -52,7 +52,10 @@ function normalize(line) {
|
|
|
52
52
|
// differences, the same way patch(1) does with fuzz.
|
|
53
53
|
function locate(lines, hunk) {
|
|
54
54
|
const expected = hunk.lines.filter(entry => entry.type !== '+').map(entry => entry.text);
|
|
55
|
-
|
|
55
|
+
// A hunk with no context and no removals is a pure insertion: `@@ -N,0 +M,K @@` inserts after
|
|
56
|
+
// line N, so the zero-based insertion point is N (and the end of file when N is the last line).
|
|
57
|
+
// Using N-1 here inserted every such hunk one line too early while still reporting success.
|
|
58
|
+
if (!expected.length) return { index: Math.min(Math.max(hunk.oldStart, 0), lines.length), fuzz: 0 };
|
|
56
59
|
const candidates = [];
|
|
57
60
|
const anchor = Math.max(0, hunk.oldStart - 1);
|
|
58
61
|
for (let offset = 0; offset <= 200; offset += 1) {
|
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 } from 'node:crypto';
|
|
5
5
|
import { constants, createReadStream } from 'node:fs';
|
|
6
|
-
import { access, chmod, chown, copyFile, cp, mkdir, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { access, chmod, chown, copyFile, cp, lstat, mkdir, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
|
|
7
7
|
import { pipeline } from 'node:stream/promises';
|
|
8
8
|
import { runtimeConfig } from '../config.mjs';
|
|
9
9
|
import { diffStats, unifiedDiff } from '../diff.mjs';
|
|
@@ -12,7 +12,10 @@ import { countEvent, recordEvent } from '../telemetry.mjs';
|
|
|
12
12
|
import { clampInteger, decodeText, displayPath, fail, globToRegExp, image, looksBinary, multi, pageLines, resolveSafePath, splitLines, text } from '../util.mjs';
|
|
13
13
|
|
|
14
14
|
const MAX_INLINE_FILE_BYTES = 20 * 1024 * 1024;
|
|
15
|
-
|
|
15
|
+
// An image travels base64-encoded, which costs a third more bytes, and the MCP stdio client drops
|
|
16
|
+
// the connection above 10 MB. 4 MiB of image is ~5.4 MiB on the wire, which leaves room for the
|
|
17
|
+
// summary text and the frame overhead; anything larger goes through read_binary in chunks instead.
|
|
18
|
+
const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
|
|
16
19
|
const MAX_BINARY_CHUNK_BYTES = 1024 * 1024;
|
|
17
20
|
const IMAGE_TYPES = new Map([
|
|
18
21
|
['.png', 'image/png'], ['.jpg', 'image/jpeg'], ['.jpeg', 'image/jpeg'], ['.gif', 'image/gif'],
|
|
@@ -25,6 +28,40 @@ function detectEol(content) {
|
|
|
25
28
|
return crlf > lf ? '\r\n' : '\n';
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
// Traversal helper for every multi-file tool. A symbolic link inside an allowed root can point
|
|
32
|
+
// anywhere, so links are never followed and each collected path is resolved through
|
|
33
|
+
// resolveSafePath again before a tool reads or writes it. `stat` follows links, which is exactly
|
|
34
|
+
// how a symlinked directory inside a root used to expose files outside it.
|
|
35
|
+
async function collectTree(root, { maxFiles = 500, skip = [] } = {}) {
|
|
36
|
+
const found = [];
|
|
37
|
+
const skipName = name => skip.some(entry => (entry.endsWith('*') ? name.startsWith(entry.slice(0, -1)) : name === entry));
|
|
38
|
+
async function visit(target) {
|
|
39
|
+
if (found.length >= maxFiles) return;
|
|
40
|
+
const info = await lstat(target).catch(() => null);
|
|
41
|
+
if (!info || info.isSymbolicLink()) return;
|
|
42
|
+
if (info.isFile()) { found.push(target); return; }
|
|
43
|
+
if (!info.isDirectory()) return;
|
|
44
|
+
const entries = await readdir(target, { withFileTypes: true }).catch(() => []);
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
if (found.length >= maxFiles) return;
|
|
47
|
+
if (entry.isSymbolicLink() || skipName(entry.name)) continue;
|
|
48
|
+
await visit(path.join(target, entry.name));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
await visit(root);
|
|
52
|
+
return found;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Every path a multi-file tool is about to touch passes through the same confinement check as a
|
|
56
|
+
// single-file call, so dropping the traversal shortcut cannot widen what is reachable.
|
|
57
|
+
async function confineAll(paths) {
|
|
58
|
+
const safe = [];
|
|
59
|
+
for (const target of paths) {
|
|
60
|
+
try { safe.push(await resolveSafePath(target)); } catch { /* outside the allowed roots: skip it */ }
|
|
61
|
+
}
|
|
62
|
+
return safe;
|
|
63
|
+
}
|
|
64
|
+
|
|
28
65
|
async function readTextFile(absolute) {
|
|
29
66
|
const info = await stat(absolute).catch(() => fail(`File not found: ${displayPath(absolute)}`));
|
|
30
67
|
if (info.isDirectory()) fail(`${displayPath(absolute)} is a directory, not a file`);
|
|
@@ -366,22 +403,7 @@ export async function replaceInFilesTool(args) {
|
|
|
366
403
|
}
|
|
367
404
|
}
|
|
368
405
|
const info = await stat(root).catch(() => fail(`Path not found: ${displayPath(root)}`));
|
|
369
|
-
const files = [];
|
|
370
|
-
async function collect(target) {
|
|
371
|
-
if (files.length > maxFiles) return;
|
|
372
|
-
const entryInfo = await stat(target).catch(() => null);
|
|
373
|
-
if (!entryInfo) return;
|
|
374
|
-
if (entryInfo.isFile()) { files.push(target); return; }
|
|
375
|
-
if (!entryInfo.isDirectory()) return;
|
|
376
|
-
const entries = await readdir(target, { withFileTypes: true }).catch(() => []);
|
|
377
|
-
for (const entry of entries) {
|
|
378
|
-
if (files.length > maxFiles) return;
|
|
379
|
-
if (entry.name === '.git' || entry.name === 'node_modules' || entry.name.startsWith('.remcp-trash')) continue;
|
|
380
|
-
await collect(path.join(target, entry.name));
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
if (info.isFile()) files.push(root);
|
|
384
|
-
else await collect(root);
|
|
406
|
+
const files = await confineAll(info.isFile() ? [root] : await collectTree(root, { maxFiles, skip: ['.git', 'node_modules', '.remcp-trash*'] }));
|
|
385
407
|
const glob = filePattern ? globToRegExp(filePattern) : null;
|
|
386
408
|
const changed = [];
|
|
387
409
|
let scanned = 0;
|
|
@@ -471,30 +493,18 @@ export async function readFilesTool(args) {
|
|
|
471
493
|
const maxLinesPerFile = clampInteger(args.max_lines_per_file, runtimeConfig.maxReadLines, 1, 20000);
|
|
472
494
|
const includeIgnored = args.include_ignored === true;
|
|
473
495
|
const matcher = globToRegExp(pattern);
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
for (const entry of entries) {
|
|
487
|
-
if (files.length > maxFiles) return;
|
|
488
|
-
if (entry.name.startsWith('.remcp-trash')) continue;
|
|
489
|
-
if (!includeIgnored && (entry.name === 'node_modules' || entry.name === '.git')) continue;
|
|
490
|
-
await collect(path.join(target, entry.name));
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
if ((await stat(root).catch(() => null))?.isFile()) {
|
|
494
|
-
files.push(root);
|
|
495
|
-
} else {
|
|
496
|
-
await collect(root);
|
|
497
|
-
}
|
|
496
|
+
const rootInfo = await stat(root).catch(() => null);
|
|
497
|
+
// A file path is matched directly; a directory is walked without following links.
|
|
498
|
+
const candidates = rootInfo?.isFile()
|
|
499
|
+
? [root]
|
|
500
|
+
: await confineAll(await collectTree(root, {
|
|
501
|
+
maxFiles: maxFiles + 1,
|
|
502
|
+
skip: includeIgnored ? ['.remcp-trash*'] : ['node_modules', '.git', '.remcp-trash*'],
|
|
503
|
+
}));
|
|
504
|
+
const files = candidates.filter(target => {
|
|
505
|
+
const relative = path.relative(root, target) || path.basename(target);
|
|
506
|
+
return matcher.test(relative.split(path.sep).join('/')) || matcher.test(path.basename(target));
|
|
507
|
+
}).slice(0, maxFiles);
|
|
498
508
|
if (!files.length) return text(`No files matched ${pattern} under ${displayPath(root)}.`);
|
|
499
509
|
const sections = [];
|
|
500
510
|
let skipped = 0;
|
|
@@ -696,16 +706,24 @@ export async function setPermissionsTool(args) {
|
|
|
696
706
|
const recursive = args.recursive === true;
|
|
697
707
|
const uid = Number.isInteger(Number(args.uid)) ? Number(args.uid) : null;
|
|
698
708
|
const gid = Number.isInteger(Number(args.gid)) ? Number(args.gid) : null;
|
|
699
|
-
const targets = [];
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
709
|
+
const targets = [absolute];
|
|
710
|
+
if (recursive) {
|
|
711
|
+
// Directories are included, links are not: chmod follows a link and would change a target
|
|
712
|
+
// outside the allowed roots.
|
|
713
|
+
const walk = async target => {
|
|
714
|
+
const entries = await readdir(target, { withFileTypes: true }).catch(() => []);
|
|
715
|
+
for (const entry of entries) {
|
|
716
|
+
if (entry.isSymbolicLink()) continue;
|
|
717
|
+
const child = path.join(target, entry.name);
|
|
718
|
+
targets.push(child);
|
|
719
|
+
if (entry.isDirectory()) await walk(child);
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
await walk(absolute);
|
|
723
|
+
}
|
|
724
|
+
const confined = await confineAll(targets);
|
|
707
725
|
let changed = 0;
|
|
708
|
-
for (const target of
|
|
726
|
+
for (const target of confined) {
|
|
709
727
|
try {
|
|
710
728
|
await chmod(target, mode);
|
|
711
729
|
if (uid !== null || gid !== null) await chown(target, uid ?? -1, gid ?? -1);
|
package/src/util.mjs
CHANGED
|
@@ -112,9 +112,19 @@ export function truncate(text, maxBytes) {
|
|
|
112
112
|
return `${head}\n… output truncated (${buffer.length} bytes, limit ${limit}) …\n${tail}`;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
// structuredContent mirrors the text so a client can rely on the declared outputSchema, but the
|
|
116
|
+
// same string twice doubles the message: above this size the mirror becomes a summary and the full
|
|
117
|
+
// result stays in content, which is the primary channel every client reads.
|
|
118
|
+
const STRUCTURED_MIRROR_LIMIT_BYTES = 256 * 1024;
|
|
119
|
+
|
|
115
120
|
export function text(value, isError = false) {
|
|
116
121
|
const body = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
|
117
|
-
|
|
122
|
+
const rendered = truncate(body);
|
|
123
|
+
const size = Buffer.byteLength(rendered, 'utf8');
|
|
124
|
+
const mirror = size <= STRUCTURED_MIRROR_LIMIT_BYTES
|
|
125
|
+
? rendered
|
|
126
|
+
: `${rendered.slice(0, 512)}… (${size} bytes total; the full result is in the text content)`;
|
|
127
|
+
return { content: [{ type: 'text', text: rendered }], structuredContent: { text: mirror }, ...(isError ? { isError: true } : {}) };
|
|
118
128
|
}
|
|
119
129
|
|
|
120
130
|
export function image(data, mimeType) {
|
|
@@ -122,7 +132,8 @@ export function image(data, mimeType) {
|
|
|
122
132
|
}
|
|
123
133
|
|
|
124
134
|
export function multi(parts) {
|
|
125
|
-
|
|
135
|
+
const summary = parts.find(part => part.type === 'text')?.text ?? '';
|
|
136
|
+
return { content: parts, structuredContent: { text: summary } };
|
|
126
137
|
}
|
|
127
138
|
|
|
128
139
|
export function splitLines(value) {
|
|
@@ -166,14 +177,49 @@ export function pageLines(lines, offset, length) {
|
|
|
166
177
|
return { start, end, slice: lines.slice(start, end) };
|
|
167
178
|
}
|
|
168
179
|
|
|
180
|
+
// Glob translation for the file tools. `?` never crosses a directory separator, `[abc]` and
|
|
181
|
+
// `{a,b}` are honoured, and `**/` may match no directory at all so `**/*` also matches a file in
|
|
182
|
+
// the root. The previous version escaped class and brace syntax, so those patterns silently
|
|
183
|
+
// matched nothing.
|
|
169
184
|
export function globToRegExp(pattern) {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
185
|
+
const source = String(pattern);
|
|
186
|
+
let out = '';
|
|
187
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
188
|
+
const char = source[index];
|
|
189
|
+
if (char === '*') {
|
|
190
|
+
if (source[index + 1] === '*') {
|
|
191
|
+
if (source[index + 2] === '/') { out += '(?:.*/)?'; index += 2; }
|
|
192
|
+
else { out += '.*'; index += 1; }
|
|
193
|
+
} else out += '[^/]*';
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (char === '?') { out += '[^/]'; continue; }
|
|
197
|
+
if (char === '[') {
|
|
198
|
+
const close = source.indexOf(']', index + 1);
|
|
199
|
+
if (close > index + 1) {
|
|
200
|
+
let body = source.slice(index + 1, close);
|
|
201
|
+
const negated = body.startsWith('!') || body.startsWith('^');
|
|
202
|
+
if (negated) body = body.slice(1);
|
|
203
|
+
body = body.replace(/\\/g, '\\\\').replace(/\]/g, '\\]').replace(/\^/g, '\\^');
|
|
204
|
+
out += `[${negated ? '^/' : ''}${body}]`;
|
|
205
|
+
index = close;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
out += '\\[';
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (char === '{') {
|
|
212
|
+
const close = source.indexOf('}', index + 1);
|
|
213
|
+
if (close > index + 1) {
|
|
214
|
+
const alternatives = source.slice(index + 1, close).split(',').map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
215
|
+
out += `(?:${alternatives.join('|')})`;
|
|
216
|
+
index = close;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
out += '\\{';
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
out += /[.+^${}()|[\]\\]/.test(char) ? `\\${char}` : char;
|
|
223
|
+
}
|
|
224
|
+
return new RegExp(`^${out}$`);
|
|
179
225
|
}
|