@remcp/runtime 0.2.0

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.
@@ -0,0 +1,182 @@
1
+ import process from 'node:process';
2
+ import { spawn } from 'node:child_process';
3
+ import { runtimeConfig } from '../config.mjs';
4
+ import { assertAllowedCommand } from '../policy.mjs';
5
+ import { countEvent, recordEvent } from '../telemetry.mjs';
6
+ import {
7
+ appendProcessOutput,
8
+ createProcessSession,
9
+ getProcessSession,
10
+ listProcessSessions,
11
+ markProcessExited,
12
+ readNewOutput,
13
+ readOutputRange,
14
+ totalLines,
15
+ waitForProcessExit,
16
+ waitForProcessActivity,
17
+ } from '../sessions.mjs';
18
+ import { clampInteger, fail, requireInteger, requireString, text } from '../util.mjs';
19
+
20
+ function shellCommand() {
21
+ if (runtimeConfig.defaultShell) return runtimeConfig.defaultShell;
22
+ if (process.platform === 'win32') return process.env.ComSpec || 'cmd.exe';
23
+ return process.env.SHELL || '/bin/bash';
24
+ }
25
+
26
+ function shellArgs(command) {
27
+ if (process.platform === 'win32') return ['/d', '/s', '/c', command];
28
+ return ['-c', command];
29
+ }
30
+
31
+ function describeSession(session) {
32
+ const status = session.exited ? `exited${session.signal ? ` (${session.signal})` : session.exitCode === null ? '' : ` (code ${session.exitCode})`}` : 'running';
33
+ return { pid: session.pid, status, runtimeMs: (session.finishedAt || Date.now()) - session.startedAt, lines: totalLines(session) };
34
+ }
35
+
36
+ export async function startProcessTool(args) {
37
+ const command = requireString(args.command, 'command');
38
+ 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);
44
+ const shell = shellCommand();
45
+ const child = spawn(shell, shellArgs(command), {
46
+ cwd: process.cwd(),
47
+ env: process.env,
48
+ stdio: ['pipe', 'pipe', 'pipe'],
49
+ windowsHide: true,
50
+ });
51
+ if (!child.pid) fail('Could not start the command');
52
+ const session = createProcessSession({ pid: child.pid, child, command, shell });
53
+ recordEvent('session_started', { sessionKind: 'process', success: true });
54
+ child.stdout?.on('data', chunk => appendProcessOutput(session, chunk.toString('utf8')));
55
+ child.stderr?.on('data', chunk => appendProcessOutput(session, chunk.toString('utf8')));
56
+ child.on('error', error => { appendProcessOutput(session, `${error.message}\n`); markProcessExited(session, null, null); });
57
+ child.on('close', (code, signal) => markProcessExited(session, code, signal));
58
+ await waitForProcessExit(session, timeoutMs);
59
+ const headline = session.exited
60
+ ? `Process ${session.pid} finished${session.exitCode === null ? '' : ` with code ${session.exitCode}`}.`
61
+ : `Process ${session.pid} is running.`;
62
+ const output = session.lines.slice(-200).join('\n');
63
+ const partial = session.partial;
64
+ session.cursor = session.droppedLines + session.lines.length;
65
+ 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` : '';
67
+ return text(`${warning}${[headline, output, partial].filter(Boolean).join('\n')}`);
68
+ }
69
+
70
+ export async function readProcessOutputTool(args) {
71
+ const pid = requireInteger(args.pid, 'pid');
72
+ const session = getProcessSession(pid);
73
+ if (!session) fail(`No ReMCP session with pid ${pid}`);
74
+ const timeoutMs = clampInteger(args.timeout_ms, 0, 0, 120000);
75
+ const hasOffset = args.offset !== undefined && args.offset !== null;
76
+ let slice;
77
+ let range;
78
+ 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));
85
+ slice = page.slice;
86
+ range = `${page.start}-${page.end} of ${page.total}`;
87
+ } else {
88
+ if (timeoutMs) await waitForProcessActivity(session, timeoutMs);
89
+ slice = readNewOutput(session);
90
+ const first = Math.max(1, session.cursor - slice.length + 1);
91
+ range = slice.length ? `${first}-${session.cursor} of ${totalLines(session)}` : `no new output (${totalLines(session)} lines total)`;
92
+ }
93
+ const status = describeSession(session);
94
+ const header = `pid ${pid} ${status.status} · lines ${range}`;
95
+ return text(`${header}\n${slice.join('\n')}`);
96
+ }
97
+
98
+ function compileWaiter(pattern) {
99
+ try { return { regex: new RegExp(pattern) }; } catch { return { literal: pattern }; }
100
+ }
101
+
102
+ function waiterMatches(lines, matcher) {
103
+ if (matcher.literal) return lines.some(line => line.includes(matcher.literal));
104
+ matcher.regex.lastIndex = 0;
105
+ return lines.some(line => matcher.regex.test(line));
106
+ }
107
+
108
+ export async function waitForProcessOutputTool(args) {
109
+ const pid = requireInteger(args.pid, 'pid');
110
+ const session = getProcessSession(pid);
111
+ if (!session) fail(`No ReMCP session with pid ${pid}`);
112
+ const pattern = requireString(args.pattern, 'pattern');
113
+ const matcher = compileWaiter(pattern);
114
+ const timeoutMs = clampInteger(args.timeout_ms, 10000, 0, 120000);
115
+ const startLine = Math.max(0, session.cursor - session.droppedLines);
116
+ 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);
121
+ }
122
+ const matched = waiterMatches(slice, matcher);
123
+ session.cursor = session.droppedLines + session.lines.length;
124
+ session.lastPartialRead = session.partial || null;
125
+ const status = describeSession(session);
126
+ const headline = matched
127
+ ? `pid ${pid} ${status.status} · pattern matched`
128
+ : `pid ${pid} ${status.status} · pattern not matched within ${timeoutMs}ms`;
129
+ return text([headline, slice.join('\n')].filter(Boolean).join('\n'));
130
+ }
131
+
132
+ export async function interactWithProcessTool(args) {
133
+ const pid = requireInteger(args.pid, 'pid');
134
+ const session = getProcessSession(pid);
135
+ if (!session) fail(`No ReMCP session with pid ${pid}`);
136
+ if (session.exited) fail(`Process ${pid} already exited`);
137
+ const input = typeof args.input === 'string' ? args.input : fail('input must be a string');
138
+ const timeoutMs = clampInteger(args.timeout_ms, 1000, 0, 120000);
139
+ session.cursor = session.droppedLines + session.lines.length;
140
+ session.lastPartialRead = null;
141
+ session.child.stdin?.write(`${input}\n`);
142
+ await waitForProcessActivity(session, timeoutMs);
143
+ const slice = readNewOutput(session);
144
+ const status = describeSession(session);
145
+ return text([`pid ${pid} ${status.status}`, slice.join('\n')].filter(Boolean).join('\n'));
146
+ }
147
+
148
+ export async function forceTerminateTool(args) {
149
+ const pid = requireInteger(args.pid, 'pid');
150
+ const session = getProcessSession(pid);
151
+ if (!session) fail(`No ReMCP session with pid ${pid}`);
152
+ if (session.exited) return text(`Process ${pid} already exited.`);
153
+ try { session.child.kill('SIGTERM'); } catch {}
154
+ const deadline = Date.now() + 2000;
155
+ while (!session.exited && Date.now() < deadline) await waitForProcessActivity(session, 100);
156
+ if (!session.exited) {
157
+ try { session.child.kill('SIGKILL'); } catch {}
158
+ await waitForProcessActivity(session, 1000);
159
+ }
160
+ const status = describeSession(session);
161
+ return text(`Terminated session ${pid}. Status: ${status.status}.`);
162
+ }
163
+
164
+ export async function listSessionsTool() {
165
+ const sessions = listProcessSessions();
166
+ if (!sessions.length) return text('No active terminal sessions.');
167
+ const rows = sessions.map(session => {
168
+ const status = describeSession(session);
169
+ const blocked = session.exited ? '' : session.partial ? 'blocked-possibly' : 'idle-or-running';
170
+ return `pid ${status.pid} · ${status.status} · ${Math.round(status.runtimeMs / 1000)}s · ${blocked} · ${session.command.slice(0, 120)}`;
171
+ });
172
+ return text(rows.join('\n'));
173
+ }
174
+
175
+ export const terminalToolHandlers = {
176
+ start_process: startProcessTool,
177
+ read_process_output: readProcessOutputTool,
178
+ wait_for_process_output: waitForProcessOutputTool,
179
+ interact_with_process: interactWithProcessTool,
180
+ force_terminate: forceTerminateTool,
181
+ list_sessions: listSessionsTool,
182
+ };
package/src/util.mjs ADDED
@@ -0,0 +1,147 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { realpath } from 'node:fs/promises';
4
+ import { runtimeConfig } from './config.mjs';
5
+
6
+ export class ToolError extends Error {}
7
+
8
+ export function fail(message) {
9
+ throw new ToolError(String(message));
10
+ }
11
+
12
+ export function expandHome(value) {
13
+ const text = String(value ?? '');
14
+ if (text === '~') return os.homedir();
15
+ if (text.startsWith('~/') || text.startsWith('~\\')) return path.join(os.homedir(), text.slice(2));
16
+ return text;
17
+ }
18
+
19
+ export function requireString(value, field) {
20
+ if (typeof value !== 'string' || !value.trim()) fail(`${field} is required`);
21
+ return value.trim();
22
+ }
23
+
24
+ export function requireInteger(value, field) {
25
+ const parsed = Number(value);
26
+ if (!Number.isInteger(parsed) || parsed <= 0) fail(`${field} must be a positive integer`);
27
+ return parsed;
28
+ }
29
+
30
+ export function clampInteger(value, fallback, min, max) {
31
+ const parsed = Number(value);
32
+ if (!Number.isFinite(parsed)) return fallback;
33
+ return Math.min(max, Math.max(min, Math.trunc(parsed)));
34
+ }
35
+
36
+ export function resolveInputPath(value, field = 'path') {
37
+ const raw = expandHome(requireString(value, field));
38
+ if (raw.includes('\0')) fail(`${field} contains an invalid character`);
39
+ const absolute = path.resolve(raw);
40
+ if (runtimeConfig.allowedRoots.length && !isInsideAnyRoot(absolute)) {
41
+ fail(`Path is outside the directories this device allows: ${runtimeConfig.allowedRoots.join(', ')}`);
42
+ }
43
+ return absolute;
44
+ }
45
+
46
+ export function isInsideRoot(candidate, root) {
47
+ return candidate === root || candidate.startsWith(root + path.sep);
48
+ }
49
+
50
+ function isInsideAnyRoot(candidate) {
51
+ return runtimeConfig.allowedRoots.some(root => isInsideRoot(candidate, root));
52
+ }
53
+
54
+ // Resolve symlinks for the deepest path segment that exists, then re-append the
55
+ // segments that do not exist yet. A lexical prefix check alone is not enough:
56
+ // `<allowed>/link -> /etc` would otherwise pass the allowlist and read /etc.
57
+ export async function canonicalizePath(target) {
58
+ let current = path.resolve(target);
59
+ const missing = [];
60
+ for (;;) {
61
+ try {
62
+ const resolved = await realpath(current);
63
+ return missing.length ? path.join(resolved, ...missing) : resolved;
64
+ } catch (error) {
65
+ if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') return missing.length ? path.join(current, ...missing) : current;
66
+ const parent = path.dirname(current);
67
+ if (parent === current) return missing.length ? path.join(current, ...missing) : current;
68
+ missing.unshift(path.basename(current));
69
+ current = parent;
70
+ }
71
+ }
72
+ }
73
+
74
+ let resolvedRootsPromise;
75
+ function resolvedRoots() {
76
+ if (!resolvedRootsPromise) {
77
+ resolvedRootsPromise = Promise.all(runtimeConfig.allowedRoots.map(async root => {
78
+ try { return await realpath(root); } catch { return root; }
79
+ }));
80
+ }
81
+ return resolvedRootsPromise;
82
+ }
83
+
84
+ // Canonical, allowlist-checked path for every file tool. The canonical path is what
85
+ // callers must use, so a symlink cannot be swapped between the check and the access.
86
+ export async function resolveSafePath(value, field = 'path') {
87
+ const absolute = resolveInputPath(value, field);
88
+ if (!runtimeConfig.allowedRoots.length) return absolute;
89
+ const canonical = await canonicalizePath(absolute);
90
+ const roots = await resolvedRoots();
91
+ if (!roots.some(root => isInsideRoot(canonical, root))) {
92
+ fail(`Path resolves outside the directories this device allows: ${runtimeConfig.allowedRoots.join(', ')}`);
93
+ }
94
+ return canonical;
95
+ }
96
+
97
+ export function displayPath(absolute) {
98
+ const home = os.homedir();
99
+ return absolute.startsWith(home + path.sep) ? `~/${absolute.slice(home.length + 1)}` : absolute;
100
+ }
101
+
102
+ export function truncate(text, maxBytes) {
103
+ const limit = maxBytes || runtimeConfig.maxOutputBytes;
104
+ const buffer = Buffer.from(text, 'utf8');
105
+ if (buffer.length <= limit) return text;
106
+ const head = buffer.subarray(0, Math.floor(limit * 0.7)).toString('utf8');
107
+ const tail = buffer.subarray(buffer.length - Math.floor(limit * 0.2)).toString('utf8');
108
+ return `${head}\n… output truncated (${buffer.length} bytes, limit ${limit}) …\n${tail}`;
109
+ }
110
+
111
+ export function text(value, isError = false) {
112
+ const body = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
113
+ return { content: [{ type: 'text', text: truncate(body) }], ...(isError ? { isError: true } : {}) };
114
+ }
115
+
116
+ export function splitLines(value) {
117
+ const normalized = String(value).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
118
+ if (normalized === '') return [];
119
+ const lines = normalized.split('\n');
120
+ if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
121
+ return lines;
122
+ }
123
+
124
+ export function looksBinary(buffer) {
125
+ return buffer.subarray(0, 8000).includes(0);
126
+ }
127
+
128
+ export function pageLines(lines, offset, length) {
129
+ const total = lines.length;
130
+ const requested = Math.trunc(offset || 0);
131
+ if (requested < 0) {
132
+ const count = Math.min(Math.abs(requested), total);
133
+ return { start: total - count, end: total, slice: lines.slice(total - count) };
134
+ }
135
+ const start = Math.min(requested, total);
136
+ const end = Math.min(start + Math.max(1, Math.trunc(length || runtimeConfig.maxReadLines)), total);
137
+ return { start, end, slice: lines.slice(start, end) };
138
+ }
139
+
140
+ export function globToRegExp(pattern) {
141
+ const escaped = String(pattern).replace(/[.+^${}()|[\]\\]/g, '\\$&')
142
+ .replace(/\*\*/g, '\u0000')
143
+ .replace(/\*/g, '[^/]*')
144
+ .replace(/\?/g, '.')
145
+ .replace(/\u0000/g, '.*');
146
+ return new RegExp(`^${escaped}$`);
147
+ }
@@ -0,0 +1,6 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
4
+
5
+ export const PACKAGE_NAME = manifest.name;
6
+ export const VERSION = manifest.version;