@remcp/runtime 0.2.0 → 0.2.5

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.
@@ -21,6 +21,9 @@ const MAX_PATTERN_LENGTH = 400;
21
21
  let ripgrepPath;
22
22
 
23
23
  function ripgrep() {
24
+ // REMCP_RUNTIME_FORCE_FALLBACK=1 exercises the dependency-free scanner even where
25
+ // ripgrep is installed, which is how CI covers both code paths.
26
+ if (process.env.REMCP_RUNTIME_FORCE_FALLBACK === '1') return null;
24
27
  if (ripgrepPath !== undefined) return ripgrepPath;
25
28
  try {
26
29
  const result = spawnSync('rg', ['--version'], { encoding: 'utf8' });
@@ -29,11 +32,13 @@ function ripgrep() {
29
32
  return ripgrepPath;
30
33
  }
31
34
 
32
- function normalizePattern(value, literal) {
35
+ function normalizePattern(value, literal, ignoreCase) {
33
36
  const pattern = requireString(value, 'pattern');
34
37
  if (pattern.length > MAX_PATTERN_LENGTH) fail(`pattern must be at most ${MAX_PATTERN_LENGTH} characters`);
35
38
  if (literal) return { regex: null, literal: pattern, patternIsLiteral: true };
36
- try { return { regex: new RegExp(pattern, 'g'), literal: null, patternIsLiteral: false }; } catch (error) {
39
+ // The flag has to be baked into the expression: the fallback scanner has no separate
40
+ // case-folding step, so `ignoreCase` used to be silently ignored without ripgrep.
41
+ try { return { regex: new RegExp(pattern, ignoreCase ? 'gi' : 'g'), literal: null, patternIsLiteral: false }; } catch (error) {
37
42
  // Silently downgrading an invalid regular expression to a substring search changes the
38
43
  // meaning of the call without telling anyone.
39
44
  fail(`pattern is not a valid regular expression (${error instanceof Error ? error.message : String(error)}). Pass literalSearch: true to search for this text literally.`);
@@ -92,7 +97,9 @@ function runRipgrep(session, { path: target, pattern, searchType, filePattern, i
92
97
  for (const glob of splitGlobs(filePattern)) args.push('-g', glob);
93
98
  if (searchType === 'files') {
94
99
  args.push('--files');
95
- args.push('-g', fileNameGlob(pattern));
100
+ // Globs are case-sensitive even under --ignore-case, so a case-insensitive file
101
+ // search needs --iglob.
102
+ args.push(ignoreCase ? '--iglob' : '-g', fileNameGlob(pattern));
96
103
  args.push('--', target);
97
104
  } else {
98
105
  args.push('--line-number', '--with-filename');
@@ -180,7 +187,7 @@ export async function startSearchTool(args) {
180
187
  const includeIgnored = args.includeIgnored === true;
181
188
  const contextLines = clampInteger(args.contextLines, 0, 0, 10);
182
189
  const maxResults = clampInteger(args.maxResults, 200, 1, 5000);
183
- const matcher = searchType === 'content' ? normalizePattern(pattern, args.literalSearch === true) : { regex: null, literal: pattern, patternIsLiteral: false };
190
+ const matcher = searchType === 'content' ? normalizePattern(pattern, args.literalSearch === true, ignoreCase) : { regex: null, literal: pattern, patternIsLiteral: false };
184
191
  const session = createSearchSession({ type: searchType, pattern, path: target, filePattern });
185
192
  countEvent('searchesStarted');
186
193
  recordEvent('session_started', { sessionKind: 'search', success: true });
@@ -189,7 +196,7 @@ export async function startSearchTool(args) {
189
196
  // only runs when ripgrep is unavailable.
190
197
  if (ripgrep()) runRipgrep(session, options);
191
198
  else void runFallback(session, options).catch(error => finishSearchSession(session, 'failed', error instanceof Error ? error.message : String(error)));
192
- await waitForSearchResults(session, 1, 1500);
199
+ await waitForSearchResults(session, 1, 800);
193
200
  const initial = session.results.slice(0, 50);
194
201
  const status = session.error ? `failed: ${session.error}` : session.status;
195
202
  return text([
@@ -1,6 +1,8 @@
1
+ import os from 'node:os';
1
2
  import process from 'node:process';
2
3
  import { execFile } from 'node:child_process';
3
4
  import { promisify } from 'node:util';
5
+ import { runtimeConfig } from '../config.mjs';
4
6
  import { clampInteger, fail, requireInteger, text } from '../util.mjs';
5
7
 
6
8
  const run = promisify(execFile);
@@ -12,7 +14,61 @@ export function redactSecrets(command) {
12
14
  return String(command).replace(SECRET_FLAG, '$1$2***');
13
15
  }
14
16
 
15
- export async function listProcessesTool(args = {}) {
17
+ async function diskUsage() {
18
+ if (process.platform === 'win32') return null;
19
+ try {
20
+ const { stdout } = await run('df', ['-kP', process.cwd()], { maxBuffer: 1024 * 1024 });
21
+ const line = stdout.trim().split('\n')[1];
22
+ if (!line) return null;
23
+ const parts = line.split(/\s+/);
24
+ const sizeKb = Number(parts[1]);
25
+ const usedKb = Number(parts[2]);
26
+ const availableKb = Number(parts[3]);
27
+ if (![sizeKb, usedKb, availableKb].every(Number.isFinite)) return null;
28
+ return {
29
+ mount: parts[5] || null,
30
+ totalBytes: sizeKb * 1024,
31
+ usedBytes: usedKb * 1024,
32
+ availableBytes: availableKb * 1024,
33
+ usedRatio: sizeKb ? Number((usedKb / sizeKb).toFixed(3)) : 0,
34
+ };
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ export async function getSystemInfoTool() {
41
+ const cpus = os.cpus();
42
+ const totalMem = os.totalmem();
43
+ const freeMem = os.freemem();
44
+ const load = os.loadavg();
45
+ return text(JSON.stringify({
46
+ hostname: os.hostname(),
47
+ platform: process.platform,
48
+ release: os.release(),
49
+ arch: process.arch,
50
+ uptimeSeconds: Math.round(os.uptime()),
51
+ node: process.versions.node,
52
+ shell: runtimeConfig.defaultShell || (process.platform === 'win32' ? process.env.ComSpec : process.env.SHELL) || null,
53
+ cpu: {
54
+ model: cpus[0]?.model?.trim() || 'unknown',
55
+ count: cpus.length,
56
+ loadAverage: process.platform === 'win32' ? null : load.map(value => Number(value.toFixed(2))),
57
+ loadPerCore: process.platform === 'win32' || !cpus.length ? null : Number((load[0] / cpus.length).toFixed(2)),
58
+ },
59
+ memory: {
60
+ totalBytes: totalMem,
61
+ freeBytes: freeMem,
62
+ usedRatio: totalMem ? Number(((totalMem - freeMem) / totalMem).toFixed(3)) : 0,
63
+ },
64
+ disk: await diskUsage(),
65
+ home: os.homedir(),
66
+ tempDir: os.tmpdir(),
67
+ runtimeName: runtimeConfig.name,
68
+ }, null, 2));
69
+ }
70
+
71
+ export async function listProcessesTool(args) {
16
72
  const limit = clampInteger(args.limit, 100, 1, 1000);
17
73
  const rows = [];
18
74
  const parsed = [];
@@ -59,6 +115,7 @@ export async function killProcessTool(args) {
59
115
  }
60
116
 
61
117
  export const systemToolHandlers = {
118
+ get_system_info: getSystemInfoTool,
62
119
  list_processes: listProcessesTool,
63
120
  kill_process: killProcessTool,
64
121
  };
@@ -2,11 +2,13 @@ import process from 'node:process';
2
2
  import { spawn } from 'node:child_process';
3
3
  import { runtimeConfig } from '../config.mjs';
4
4
  import { assertAllowedCommand } from '../policy.mjs';
5
- import { countEvent, recordEvent } from '../telemetry.mjs';
5
+ import { recordEvent } from '../telemetry.mjs';
6
6
  import {
7
7
  appendProcessOutput,
8
8
  createProcessSession,
9
9
  getProcessSession,
10
+ hasNewOutput,
11
+ killSessionTree,
10
12
  listProcessSessions,
11
13
  markProcessExited,
12
14
  readNewOutput,
@@ -33,41 +35,66 @@ function describeSession(session) {
33
35
  return { pid: session.pid, status, runtimeMs: (session.finishedAt || Date.now()) - session.startedAt, lines: totalLines(session) };
34
36
  }
35
37
 
36
- export async function startProcessTool(args) {
38
+ function abortError() {
39
+ return new Error('Tool call was cancelled by the client');
40
+ }
41
+
42
+ function throwIfAborted(signal) {
43
+ if (signal?.aborted) throw abortError();
44
+ }
45
+
46
+ export async function startProcessTool(args, extra = {}) {
37
47
  const command = requireString(args.command, 'command');
38
48
  const verdict = assertAllowedCommand(command);
39
- if (verdict.warned) {
40
- countEvent('policyBlocks');
41
- recordEvent('policy_block', { reason: verdict.findings?.[0]?.id || 'builtin', success: false });
42
- }
43
- const timeoutMs = clampInteger(args.timeout_ms, 1000, 0, 120000);
49
+ const timeoutMs = clampInteger(args.timeout_ms, 500, 0, 120000);
44
50
  const shell = shellCommand();
51
+ // `detached` gives the child its own process group so a session can be stopped as a
52
+ // tree; a pipeline such as `sleep 20 | cat` otherwise survives force_terminate.
45
53
  const child = spawn(shell, shellArgs(command), {
46
54
  cwd: process.cwd(),
47
55
  env: process.env,
48
56
  stdio: ['pipe', 'pipe', 'pipe'],
49
57
  windowsHide: true,
58
+ detached: process.platform !== 'win32',
59
+ });
60
+ // The error listener has to exist before anything can throw: a bogus REMCP_RUNTIME_SHELL
61
+ // used to surface as an unhandled ENOENT that took the whole runtime down.
62
+ child.on('error', error => {
63
+ const session = child.remcpSession;
64
+ if (session) {
65
+ appendProcessOutput(session, `${error.message}\n`);
66
+ markProcessExited(session, null, null);
67
+ }
50
68
  });
51
- if (!child.pid) fail('Could not start the command');
69
+ if (!child.pid) fail(`Could not start the command with ${shell}`);
52
70
  const session = createProcessSession({ pid: child.pid, child, command, shell });
71
+ child.remcpSession = session;
53
72
  recordEvent('session_started', { sessionKind: 'process', success: true });
73
+
74
+ if (child.stdin) {
75
+ child.stdin.on('error', () => {});
76
+ child.stdin.on('close', () => { session.stdinClosed = true; });
77
+ }
54
78
  child.stdout?.on('data', chunk => appendProcessOutput(session, chunk.toString('utf8')));
55
79
  child.stderr?.on('data', chunk => appendProcessOutput(session, chunk.toString('utf8')));
56
- child.on('error', error => { appendProcessOutput(session, `${error.message}\n`); markProcessExited(session, null, null); });
80
+ // `exit` carries the real status; `close` only follows once every stdio stream is done,
81
+ // which for a backgrounded child can be seconds later.
82
+ child.on('exit', (code, signal) => markProcessExited(session, code, signal));
57
83
  child.on('close', (code, signal) => markProcessExited(session, code, signal));
84
+
58
85
  await waitForProcessExit(session, timeoutMs);
59
86
  const headline = session.exited
60
- ? `Process ${session.pid} finished${session.exitCode === null ? '' : ` with code ${session.exitCode}`}.`
87
+ ? `Process ${session.pid} finished${session.exitCode === null ? '' : ` with code ${session.exitCode}`}${session.signal ? ` (${session.signal})` : ''}.`
61
88
  : `Process ${session.pid} is running.`;
62
89
  const output = session.lines.slice(-200).join('\n');
63
90
  const partial = session.partial;
64
91
  session.cursor = session.droppedLines + session.lines.length;
65
92
  session.lastPartialRead = session.partial || null;
66
- const warning = verdict.warned ? `Warning: this command matches the built-in dangerous-command guardrail (${verdict.findings.map(item => item.description).join(', ')}).\n` : '';
93
+ const warning = verdict.note ? `${verdict.note}\n` : '';
67
94
  return text(`${warning}${[headline, output, partial].filter(Boolean).join('\n')}`);
68
95
  }
69
96
 
70
- export async function readProcessOutputTool(args) {
97
+ export async function readProcessOutputTool(args, extra = {}) {
71
98
  const pid = requireInteger(args.pid, 'pid');
72
99
  const session = getProcessSession(pid);
73
100
  if (!session) fail(`No ReMCP session with pid ${pid}`);
@@ -76,16 +103,21 @@ export async function readProcessOutputTool(args) {
76
103
  let slice;
77
104
  let range;
78
105
  if (hasOffset) {
79
- // An explicit offset always means a line range: zero-based from the first line the
80
- // session produced, or a negative value for the last N lines. Omit the argument to get
81
- // only the output produced since the previous read. Lines evicted by the buffer cap are
82
- // gone, so the earliest readable line is droppedLines.
83
- const requested = Number(args.offset);
84
- const page = readOutputRange(session, requested, clampInteger(args.length, 200, 1, 5000));
106
+ // An explicit offset is a peek at a line range: it does not consume the new-output
107
+ // cursor, so a caller can look at the tail and still read everything afterwards.
108
+ // Zero-based from the first line the session produced; negative reads the last N.
109
+ const page = readOutputRange(session, Number(args.offset), clampInteger(args.length, 200, 1, 5000));
85
110
  slice = page.slice;
86
111
  range = `${page.start}-${page.end} of ${page.total}`;
87
112
  } else {
88
- if (timeoutMs) await waitForProcessActivity(session, timeoutMs);
113
+ // Never sleep over output that is already buffered.
114
+ if (timeoutMs && !hasNewOutput(session)) {
115
+ const deadline = Date.now() + timeoutMs;
116
+ while (!hasNewOutput(session) && !session.exited && Date.now() < deadline) {
117
+ throwIfAborted(extra.signal);
118
+ await waitForProcessActivity(session, Math.min(200, Math.max(20, deadline - Date.now())));
119
+ }
120
+ }
89
121
  slice = readNewOutput(session);
90
122
  const first = Math.max(1, session.cursor - slice.length + 1);
91
123
  range = slice.length ? `${first}-${session.cursor} of ${totalLines(session)}` : `no new output (${totalLines(session)} lines total)`;
@@ -105,31 +137,37 @@ function waiterMatches(lines, matcher) {
105
137
  return lines.some(line => matcher.regex.test(line));
106
138
  }
107
139
 
108
- export async function waitForProcessOutputTool(args) {
140
+ export async function waitForProcessOutputTool(args, extra = {}) {
109
141
  const pid = requireInteger(args.pid, 'pid');
110
142
  const session = getProcessSession(pid);
111
143
  if (!session) fail(`No ReMCP session with pid ${pid}`);
112
144
  const pattern = requireString(args.pattern, 'pattern');
113
145
  const matcher = compileWaiter(pattern);
114
146
  const timeoutMs = clampInteger(args.timeout_ms, 10000, 0, 120000);
115
- const startLine = Math.max(0, session.cursor - session.droppedLines);
147
+ const cursorStart = Math.max(0, session.cursor - session.droppedLines);
148
+ // Output that was already buffered before the call still counts: a pattern printed
149
+ // earlier should answer immediately instead of spinning for the whole timeout.
150
+ let slice = session.lines.slice(cursorStart);
151
+ const bufferedMatch = waiterMatches(slice, matcher);
116
152
  const deadline = Date.now() + timeoutMs;
117
- let slice = session.lines.slice(startLine);
118
- while (!waiterMatches(slice, matcher) && Date.now() < deadline && !session.exited) {
119
- await waitForProcessActivity(session, Math.min(250, Math.max(20, deadline - Date.now())));
120
- slice = session.lines.slice(startLine);
153
+ if (!bufferedMatch) {
154
+ while (!waiterMatches(session.lines.slice(cursorStart), matcher) && Date.now() < deadline && !session.exited) {
155
+ throwIfAborted(extra.signal);
156
+ await waitForProcessActivity(session, Math.min(250, Math.max(20, deadline - Date.now())));
157
+ }
158
+ slice = session.lines.slice(cursorStart);
121
159
  }
122
160
  const matched = waiterMatches(slice, matcher);
123
161
  session.cursor = session.droppedLines + session.lines.length;
124
162
  session.lastPartialRead = session.partial || null;
125
163
  const status = describeSession(session);
126
164
  const headline = matched
127
- ? `pid ${pid} ${status.status} · pattern matched`
165
+ ? `pid ${pid} ${status.status} · pattern matched${bufferedMatch ? ' (already buffered)' : ''}`
128
166
  : `pid ${pid} ${status.status} · pattern not matched within ${timeoutMs}ms`;
129
167
  return text([headline, slice.join('\n')].filter(Boolean).join('\n'));
130
168
  }
131
169
 
132
- export async function interactWithProcessTool(args) {
170
+ export async function interactWithProcessTool(args, extra = {}) {
133
171
  const pid = requireInteger(args.pid, 'pid');
134
172
  const session = getProcessSession(pid);
135
173
  if (!session) fail(`No ReMCP session with pid ${pid}`);
@@ -138,8 +176,23 @@ export async function interactWithProcessTool(args) {
138
176
  const timeoutMs = clampInteger(args.timeout_ms, 1000, 0, 120000);
139
177
  session.cursor = session.droppedLines + session.lines.length;
140
178
  session.lastPartialRead = null;
141
- session.child.stdin?.write(`${input}\n`);
142
- await waitForProcessActivity(session, timeoutMs);
179
+ throwIfAborted(extra.signal);
180
+ const stdin = session.child?.stdin;
181
+ if (!stdin || stdin.destroyed || session.stdinClosed) {
182
+ fail(`Process ${pid} no longer accepts input`);
183
+ }
184
+ try {
185
+ stdin.write(`${input}\n`);
186
+ } catch (error) {
187
+ // A closed read end used to raise an async EPIPE that killed the runtime.
188
+ session.stdinClosed = true;
189
+ fail(`Could not write to process ${pid}: ${error instanceof Error ? error.message : String(error)}`);
190
+ }
191
+ const deadline = Date.now() + timeoutMs;
192
+ while (!hasNewOutput(session) && !session.exited && Date.now() < deadline) {
193
+ throwIfAborted(extra.signal);
194
+ await waitForProcessActivity(session, Math.min(200, Math.max(20, deadline - Date.now())));
195
+ }
143
196
  const slice = readNewOutput(session);
144
197
  const status = describeSession(session);
145
198
  return text([`pid ${pid} ${status.status}`, slice.join('\n')].filter(Boolean).join('\n'));
@@ -150,15 +203,15 @@ export async function forceTerminateTool(args) {
150
203
  const session = getProcessSession(pid);
151
204
  if (!session) fail(`No ReMCP session with pid ${pid}`);
152
205
  if (session.exited) return text(`Process ${pid} already exited.`);
153
- try { session.child.kill('SIGTERM'); } catch {}
206
+ killSessionTree(session, 'SIGTERM');
154
207
  const deadline = Date.now() + 2000;
155
208
  while (!session.exited && Date.now() < deadline) await waitForProcessActivity(session, 100);
156
209
  if (!session.exited) {
157
- try { session.child.kill('SIGKILL'); } catch {}
210
+ killSessionTree(session, 'SIGKILL');
158
211
  await waitForProcessActivity(session, 1000);
159
212
  }
160
213
  const status = describeSession(session);
161
- return text(`Terminated session ${pid}. Status: ${status.status}.`);
214
+ return text(`Terminated session ${pid}${status.status.startsWith('exited') ? '' : ' (still running)'}. Status: ${status.status}.`);
162
215
  }
163
216
 
164
217
  export async function listSessionsTool() {
package/src/util.mjs CHANGED
@@ -44,7 +44,11 @@ export function resolveInputPath(value, field = 'path') {
44
44
  }
45
45
 
46
46
  export function isInsideRoot(candidate, root) {
47
- return candidate === root || candidate.startsWith(root + path.sep);
47
+ if (candidate === root) return true;
48
+ // `allowedRoots: ["/"]` is a legitimate way to say "the whole filesystem"; a naive
49
+ // `root + sep` check turns it into "//" and rejects every path.
50
+ const prefix = root.endsWith(path.sep) ? root : root + path.sep;
51
+ return candidate.startsWith(prefix);
48
52
  }
49
53
 
50
54
  function isInsideAnyRoot(candidate) {
@@ -113,6 +117,14 @@ export function text(value, isError = false) {
113
117
  return { content: [{ type: 'text', text: truncate(body) }], ...(isError ? { isError: true } : {}) };
114
118
  }
115
119
 
120
+ export function image(data, mimeType) {
121
+ return { type: 'image', data, mimeType };
122
+ }
123
+
124
+ export function multi(parts) {
125
+ return { content: parts };
126
+ }
127
+
116
128
  export function splitLines(value) {
117
129
  const normalized = String(value).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
118
130
  if (normalized === '') return [];
@@ -125,6 +137,23 @@ export function looksBinary(buffer) {
125
137
  return buffer.subarray(0, 8000).includes(0);
126
138
  }
127
139
 
140
+ // Text decoding that keeps Windows-authored files readable: UTF-16LE/BE with a BOM, and
141
+ // UTF-8 with a BOM, are decoded rather than reported as binary.
142
+ export function decodeText(buffer) {
143
+ if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
144
+ return { text: buffer.subarray(2).toString('utf16le'), encoding: 'utf16le' };
145
+ }
146
+ if (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) {
147
+ const swapped = Buffer.from(buffer.subarray(2));
148
+ swapped.swap16();
149
+ return { text: swapped.toString('utf16le'), encoding: 'utf16be' };
150
+ }
151
+ if (buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) {
152
+ return { text: buffer.subarray(3).toString('utf8'), encoding: 'utf8bom' };
153
+ }
154
+ return { text: buffer.toString('utf8'), encoding: 'utf8' };
155
+ }
156
+
128
157
  export function pageLines(lines, offset, length) {
129
158
  const total = lines.length;
130
159
  const requested = Math.trunc(offset || 0);
@@ -138,10 +167,13 @@ export function pageLines(lines, offset, length) {
138
167
  }
139
168
 
140
169
  export function globToRegExp(pattern) {
170
+ // `**/` may match no directory at all, so `**/*` also matches a file in the root.
141
171
  const escaped = String(pattern).replace(/[.+^${}()|[\]\\]/g, '\\$&')
172
+ .replace(/\*\*\//g, '\u0001')
142
173
  .replace(/\*\*/g, '\u0000')
143
174
  .replace(/\*/g, '[^/]*')
144
175
  .replace(/\?/g, '.')
145
- .replace(/\u0000/g, '.*');
176
+ .replace(/\u0000/g, '.*')
177
+ .replace(/\u0001/g, '(?:.*/)?');
146
178
  return new RegExp(`^${escaped}$`);
147
179
  }