@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.
- package/CHANGELOG.md +23 -0
- package/README.md +96 -41
- package/package.json +3 -2
- package/src/catalog.mjs +377 -11
- package/src/config.mjs +47 -5
- package/src/diff.mjs +86 -0
- package/src/index.mjs +39 -6
- package/src/invoke.mjs +5 -2
- package/src/patch.mjs +95 -0
- package/src/policy.mjs +71 -20
- package/src/sessions.mjs +58 -10
- package/src/telemetry.mjs +16 -2
- package/src/tools/files.mjs +725 -33
- package/src/tools/search.mjs +12 -5
- package/src/tools/system.mjs +58 -1
- package/src/tools/terminal.mjs +85 -32
- package/src/util.mjs +34 -2
package/src/index.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import process from 'node:process';
|
|
|
3
3
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { toolDefinitions } from './catalog.mjs';
|
|
6
|
-
import { describeConfig, runtimeConfigDir } from './config.mjs';
|
|
6
|
+
import { describeConfig, configurationError, runtimeConfigDir } from './config.mjs';
|
|
7
7
|
import { invokeTool } from './invoke.mjs';
|
|
8
8
|
import { shutdownSessions, startSessionSweeper } from './sessions.mjs';
|
|
9
9
|
import { flush, setTelemetrySink, shutdownTelemetry, telemetryEnabled } from './telemetry.mjs';
|
|
@@ -49,6 +49,15 @@ if (args.includes('--describe')) {
|
|
|
49
49
|
process.exit(0);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// A configuration file the user cannot read must not be ignored: that is how allowedRoots
|
|
53
|
+
// and an opt-out quietly disappear. Metadata commands above still work, so an operator can
|
|
54
|
+
// inspect the device; the server itself refuses to start.
|
|
55
|
+
const configProblem = configurationError();
|
|
56
|
+
if (configProblem) {
|
|
57
|
+
console.error(`ReMCP runtime refuses to start: ${configProblem}`);
|
|
58
|
+
process.exit(2);
|
|
59
|
+
}
|
|
60
|
+
|
|
52
61
|
function announceTelemetryOnce() {
|
|
53
62
|
if (!telemetryEnabled()) return;
|
|
54
63
|
const marker = path.join(runtimeConfigDir, '.telemetry-notice');
|
|
@@ -81,8 +90,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
81
90
|
tools: toolDefinitions.map(({ name, title, description, inputSchema, annotations }) => ({ name, title, description, inputSchema, annotations })),
|
|
82
91
|
}));
|
|
83
92
|
|
|
84
|
-
server.setRequestHandler(CallToolRequestSchema, async request => {
|
|
85
|
-
return invokeTool(request.params.name, request.params.arguments);
|
|
93
|
+
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
94
|
+
return invokeTool(request.params.name, request.params.arguments, extra);
|
|
86
95
|
});
|
|
87
96
|
|
|
88
97
|
// Telemetry leaves this process only as an MCP notification to the agent that started
|
|
@@ -91,17 +100,41 @@ setTelemetrySink(async payload => {
|
|
|
91
100
|
await server.notification({ method: 'notifications/remcp/telemetry', params: payload });
|
|
92
101
|
});
|
|
93
102
|
|
|
103
|
+
const SHUTDOWN_BUDGET_MS = 1500;
|
|
104
|
+
let shutdownStarted = false;
|
|
105
|
+
|
|
94
106
|
async function shutdown(code = 0) {
|
|
107
|
+
if (shutdownStarted) return;
|
|
108
|
+
shutdownStarted = true;
|
|
109
|
+
const deadline = Date.now() + SHUTDOWN_BUDGET_MS;
|
|
95
110
|
shutdownSessions();
|
|
96
|
-
|
|
97
|
-
|
|
111
|
+
// The SDK gives a closing server about two seconds before it kills the process, so the
|
|
112
|
+
// telemetry flush is bounded and awaited instead of fire-and-forget: an unawaited
|
|
113
|
+
// notification() after close is an unhandled rejection.
|
|
114
|
+
try {
|
|
115
|
+
await Promise.race([flush(), new Promise(resolve => setTimeout(resolve, Math.max(0, deadline - Date.now())))]);
|
|
116
|
+
} catch {}
|
|
117
|
+
await shutdownTelemetry();
|
|
98
118
|
try { await server.close(); } catch {}
|
|
99
119
|
process.exit(code);
|
|
100
120
|
}
|
|
101
121
|
|
|
122
|
+
// A dead agent leaves a broken stdout pipe. Without this the process died on an
|
|
123
|
+
// uncaught EPIPE with a stack trace and left its terminal children behind.
|
|
124
|
+
process.stdout.on('error', error => {
|
|
125
|
+
if (error?.code === 'EPIPE' || error?.code === 'ERR_STREAM_DESTROYED') void shutdown(0);
|
|
126
|
+
else console.error(`ReMCP runtime stdout error: ${error instanceof Error ? error.message : String(error)}`);
|
|
127
|
+
});
|
|
128
|
+
process.on('uncaughtException', error => {
|
|
129
|
+
console.error(`ReMCP runtime uncaught exception: ${error instanceof Error ? error.stack || error.message : String(error)}`);
|
|
130
|
+
void shutdown(1);
|
|
131
|
+
});
|
|
132
|
+
process.on('unhandledRejection', reason => {
|
|
133
|
+
console.error(`ReMCP runtime unhandled rejection: ${reason instanceof Error ? reason.stack || reason.message : String(reason)}`);
|
|
134
|
+
});
|
|
102
135
|
process.on('SIGINT', () => void shutdown(0));
|
|
103
136
|
process.on('SIGTERM', () => void shutdown(0));
|
|
104
|
-
process.on('exit', () => { shutdownSessions();
|
|
137
|
+
process.on('exit', () => { shutdownSessions(); });
|
|
105
138
|
|
|
106
139
|
const transport = new StdioServerTransport();
|
|
107
140
|
await server.connect(transport);
|
package/src/invoke.mjs
CHANGED
|
@@ -12,15 +12,18 @@ function errorKind(error) {
|
|
|
12
12
|
return typeof code === 'string' && code ? code.slice(0, 32) : 'runtime_error';
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export async function invokeTool(name, args = {}) {
|
|
15
|
+
export async function invokeTool(name, args = {}, extra = {}) {
|
|
16
16
|
const started = performance.now();
|
|
17
17
|
const definition = toolHandlers.get(name);
|
|
18
18
|
if (!definition) {
|
|
19
19
|
recordEvent('tool_call', { tool: 'unknown_tool', success: false, errorKind: 'unknown_tool', durationMs: 0 });
|
|
20
20
|
return text(`Unknown tool: ${name}`, true);
|
|
21
21
|
}
|
|
22
|
+
if (extra?.signal?.aborted) {
|
|
23
|
+
return text(`Tool ${name} was cancelled by the client before it started.`, true);
|
|
24
|
+
}
|
|
22
25
|
try {
|
|
23
|
-
const result = await definition.handler(args || {});
|
|
26
|
+
const result = await definition.handler(args || {}, extra || {});
|
|
24
27
|
recordEvent('tool_call', { tool: definition.name, durationMs: performance.now() - started, success: result?.isError !== true });
|
|
25
28
|
return result;
|
|
26
29
|
} catch (error) {
|
package/src/patch.mjs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { splitLines } from './util.mjs';
|
|
2
|
+
|
|
3
|
+
// Minimal unified-diff applier. Models produce `--- a/file` / `+++ b/file` patches with
|
|
4
|
+
// `@@ -start,count +start,count @@` hunks; applying them directly is far more reliable
|
|
5
|
+
// than asking a model to re-send whole files or exact blocks.
|
|
6
|
+
|
|
7
|
+
const HUNK_HEADER = /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/;
|
|
8
|
+
|
|
9
|
+
export function parseUnifiedDiff(patch) {
|
|
10
|
+
const lines = String(patch).replace(/\r\n/g, '\n').split('\n');
|
|
11
|
+
const files = [];
|
|
12
|
+
let current = null;
|
|
13
|
+
let hunk = null;
|
|
14
|
+
for (const line of lines) {
|
|
15
|
+
if (line.startsWith('--- ')) {
|
|
16
|
+
current = { oldPath: line.slice(4).trim(), newPath: null, hunks: [] };
|
|
17
|
+
files.push(current);
|
|
18
|
+
hunk = null;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (line.startsWith('+++ ')) {
|
|
22
|
+
if (current) current.newPath = line.slice(4).trim();
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const header = line.match(HUNK_HEADER);
|
|
26
|
+
if (header) {
|
|
27
|
+
if (!current) { current = { oldPath: null, newPath: null, hunks: [] }; files.push(current); }
|
|
28
|
+
hunk = {
|
|
29
|
+
oldStart: Number(header[1]),
|
|
30
|
+
oldCount: header[2] === undefined ? 1 : Number(header[2]),
|
|
31
|
+
newStart: Number(header[3]),
|
|
32
|
+
newCount: header[4] === undefined ? 1 : Number(header[4]),
|
|
33
|
+
lines: [],
|
|
34
|
+
};
|
|
35
|
+
current.hunks.push(hunk);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (!hunk) continue;
|
|
39
|
+
if (line.startsWith('\\')) continue; // ""
|
|
40
|
+
if (line === '' && hunk.lines.length === 0) continue;
|
|
41
|
+
const marker = line[0];
|
|
42
|
+
if (marker === ' ' || marker === '+' || marker === '-') hunk.lines.push({ type: marker, text: line.slice(1) });
|
|
43
|
+
}
|
|
44
|
+
return files.filter(file => file.hunks.length);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalize(line) {
|
|
48
|
+
return String(line).replace(/[ \t]+/g, ' ').trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Find the block a hunk expects, allowing for a few lines of drift and for whitespace
|
|
52
|
+
// differences, the same way patch(1) does with fuzz.
|
|
53
|
+
function locate(lines, hunk) {
|
|
54
|
+
const expected = hunk.lines.filter(entry => entry.type !== '+').map(entry => entry.text);
|
|
55
|
+
if (!expected.length) return { index: hunk.oldStart - 1, fuzz: 0 };
|
|
56
|
+
const candidates = [];
|
|
57
|
+
const anchor = Math.max(0, hunk.oldStart - 1);
|
|
58
|
+
for (let offset = 0; offset <= 200; offset += 1) {
|
|
59
|
+
for (const index of offset === 0 ? [anchor] : [anchor - offset, anchor + offset]) {
|
|
60
|
+
if (index < 0 || index + expected.length > lines.length) continue;
|
|
61
|
+
const window = lines.slice(index, index + expected.length);
|
|
62
|
+
if (window.every((line, position) => line === expected[position])) candidates.push({ index, fuzz: offset });
|
|
63
|
+
else if (window.every((line, position) => normalize(line) === normalize(expected[position]))) candidates.push({ index, fuzz: offset + 1000 });
|
|
64
|
+
}
|
|
65
|
+
if (candidates.length) break;
|
|
66
|
+
}
|
|
67
|
+
if (!candidates.length) return null;
|
|
68
|
+
candidates.sort((a, b) => a.fuzz - b.fuzz || a.index - b.index);
|
|
69
|
+
return candidates[0];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function applyHunks(content, hunks) {
|
|
73
|
+
const eol = content.includes('\r\n') ? '\r\n' : '\n';
|
|
74
|
+
const endsWithNewline = /\n$/.test(content);
|
|
75
|
+
const lines = splitLines(content);
|
|
76
|
+
const applied = [];
|
|
77
|
+
const failed = [];
|
|
78
|
+
// Apply from the bottom of the file upwards so earlier hunks keep their line numbers.
|
|
79
|
+
const ordered = [...hunks].sort((a, b) => b.oldStart - a.oldStart);
|
|
80
|
+
for (const hunk of ordered) {
|
|
81
|
+
const found = locate(lines, hunk);
|
|
82
|
+
if (!found) { failed.push(hunk); continue; }
|
|
83
|
+
let cursor = found.index;
|
|
84
|
+
const replacement = [];
|
|
85
|
+
for (const entry of hunk.lines) {
|
|
86
|
+
if (entry.type === ' ') { replacement.push(lines[cursor]); cursor += 1; continue; }
|
|
87
|
+
if (entry.type === '-') { cursor += 1; continue; }
|
|
88
|
+
replacement.push(entry.text);
|
|
89
|
+
}
|
|
90
|
+
lines.splice(found.index, cursor - found.index, ...replacement);
|
|
91
|
+
applied.push({ hunk, fuzz: found.fuzz });
|
|
92
|
+
}
|
|
93
|
+
const updated = `${lines.join(eol)}${endsWithNewline && lines.length ? eol : ''}`;
|
|
94
|
+
return { updated, applied, failed };
|
|
95
|
+
}
|
package/src/policy.mjs
CHANGED
|
@@ -1,38 +1,84 @@
|
|
|
1
1
|
import { runtimeConfig } from './config.mjs';
|
|
2
|
+
import { recordEvent } from './telemetry.mjs';
|
|
2
3
|
import { fail } from './util.mjs';
|
|
3
4
|
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
{ id: '
|
|
16
|
-
{ id: '
|
|
17
|
-
{ id: '
|
|
18
|
-
{ id: '
|
|
19
|
-
{ id: '
|
|
20
|
-
{ id: '
|
|
5
|
+
// Optional hardening rules for catastrophic host-level commands. The default is `allow`:
|
|
6
|
+
// ReMCP is a remote-control tool for computers you own, and the agent needs to be able to
|
|
7
|
+
// do anything you could do at a shell. Deployments that want a safety net can set
|
|
8
|
+
// `dangerousCommands` to `warn` (run and report) or `block` (refuse before running).
|
|
9
|
+
//
|
|
10
|
+
// Rules are matched against the *command word* of each shell segment, so a read-only
|
|
11
|
+
// command that merely mentions a dangerous word (`grep -n format README.md`,
|
|
12
|
+
// `cat notes/shutdown.md`) is not refused.
|
|
13
|
+
const WRAPPERS = new Set(['sudo', 'doas', 'env', 'nohup', 'command', 'builtin', 'time', 'nice', 'ionice', 'stdbuf', 'timeout', 'setsid', 'exec']);
|
|
14
|
+
|
|
15
|
+
const DANGEROUS_RULES = [
|
|
16
|
+
{ id: 'filesystem-format', description: 'formats a filesystem', commands: /^mkfs(\.[a-z0-9]+)?$/i, args: () => true },
|
|
17
|
+
{ id: 'raw-disk-write', description: 'writes raw data to a block device', commands: /^dd$/i, args: segment => /\bof=\s*\/dev\//i.test(segment) || /\bof=\s*\\\\\.\\/i.test(segment) },
|
|
18
|
+
{ id: 'disk-partition', description: 'repartitions a disk', commands: /^(fdisk|sfdisk|cfdisk|parted|diskpart|gdisk)$/i, args: () => true },
|
|
19
|
+
{ id: 'redirect-to-device', description: 'redirects output into a block device', commands: null, anySegment: />>?\s*\/dev\/(sd|hd|vd|nvme|mmcblk|disk)/i },
|
|
20
|
+
{ id: 'host-power', description: 'powers off or reboots the machine', commands: /^(shutdown|reboot|halt|poweroff|restart-computer|stop-computer)$/i, args: () => true },
|
|
21
|
+
{ id: 'init-runlevel', description: 'changes the init runlevel', commands: /^init$/i, args: segment => /\binit\s+[06]\b/i.test(segment) },
|
|
22
|
+
{ id: 'fork-bomb', description: 'starts a fork bomb', commands: null, anySegment: /:\s*\(\s*\)\s*\{[^}]*\}\s*;\s*:/ },
|
|
23
|
+
{
|
|
24
|
+
id: 'recursive-root-delete',
|
|
25
|
+
description: 'recursively deletes a root or home path',
|
|
26
|
+
commands: /^rm$/i,
|
|
27
|
+
args: segment => /(^|\s)-[a-z]*r[a-z]*(\s|$)/i.test(segment)
|
|
28
|
+
&& /(^|\s)-[a-z]*f[a-z]*(\s|$)/i.test(segment)
|
|
29
|
+
&& /(\s)(\/|\/\*|~|~\/\*|\$HOME|\$\{HOME\})(\s|$)/i.test(segment),
|
|
30
|
+
},
|
|
31
|
+
{ id: 'chmod-root', description: 'recursively rewrites permissions on a root path', commands: /^chmod$/i, args: segment => /(\s)(\/|\/\*)(\s|$)/.test(segment) },
|
|
32
|
+
{ id: 'chown-root', description: 'recursively rewrites ownership on a root path', commands: /^chown$/i, args: segment => /(\s)(\/|\/\*)(\s|$)/.test(segment) },
|
|
33
|
+
{ id: 'history-rewrite', description: 'clears shell history to hide activity', commands: /^(history|shred)$/i, args: segment => /history\s+-c/.test(segment) || /\.(bash_)?history/.test(segment) },
|
|
34
|
+
{ id: 'windows-destructive', description: 'destroys Windows system state', commands: /^(format|bcdedit|diskpart)$/i, args: () => true },
|
|
35
|
+
{ id: 'windows-cipher-wipe', description: 'wipes free space on a Windows volume', commands: /^cipher$/i, args: segment => /\/w\b/i.test(segment) },
|
|
21
36
|
];
|
|
22
37
|
|
|
23
38
|
function normalize(command) {
|
|
24
39
|
return String(command).replace(/\s+/g, ' ').trim();
|
|
25
40
|
}
|
|
26
41
|
|
|
42
|
+
// Split on shell control operators so each simple command is judged on its own command
|
|
43
|
+
// word instead of on every word in the line.
|
|
44
|
+
function segments(command) {
|
|
45
|
+
return normalize(command)
|
|
46
|
+
.split(/&&|\|\||[;|\n]/)
|
|
47
|
+
.map(part => part.trim())
|
|
48
|
+
.filter(Boolean);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function commandWord(segment) {
|
|
52
|
+
const tokens = segment.split(' ').filter(Boolean);
|
|
53
|
+
while (tokens.length) {
|
|
54
|
+
const token = tokens[0];
|
|
55
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) { tokens.shift(); continue; }
|
|
56
|
+
if (WRAPPERS.has(token.toLowerCase())) {
|
|
57
|
+
tokens.shift();
|
|
58
|
+
while (tokens.length && tokens[0].startsWith('-')) tokens.shift();
|
|
59
|
+
if (token.toLowerCase() === 'timeout' && tokens.length && /^\d+[smhd]?$/.test(tokens[0])) tokens.shift();
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
const word = tokens[0] || '';
|
|
65
|
+
return word.replace(/^.*[\\/]/, '').replace(/\.(exe|cmd|bat)$/i, '');
|
|
66
|
+
}
|
|
67
|
+
|
|
27
68
|
export function checkCommand(command) {
|
|
28
69
|
const normalized = normalize(command);
|
|
70
|
+
const parts = segments(normalized);
|
|
29
71
|
const findings = [];
|
|
30
72
|
for (const blocked of runtimeConfig.blockedCommands) {
|
|
31
73
|
if (normalized.toLowerCase().includes(blocked.toLowerCase())) findings.push({ id: 'policy', description: blocked, source: 'device-policy' });
|
|
32
74
|
}
|
|
33
75
|
if (runtimeConfig.dangerousCommands !== 'allow') {
|
|
34
|
-
for (const
|
|
35
|
-
|
|
76
|
+
for (const part of parts) {
|
|
77
|
+
const word = commandWord(part);
|
|
78
|
+
for (const rule of DANGEROUS_RULES) {
|
|
79
|
+
const matches = rule.anySegment ? rule.anySegment.test(part) : Boolean(word && rule.commands?.test(word) && rule.args(part));
|
|
80
|
+
if (matches) findings.push({ id: rule.id, description: rule.description, source: 'builtin' });
|
|
81
|
+
}
|
|
36
82
|
}
|
|
37
83
|
}
|
|
38
84
|
if (!findings.length) return { warned: false, mode: runtimeConfig.dangerousCommands };
|
|
@@ -49,7 +95,12 @@ export function assertAllowedCommand(command) {
|
|
|
49
95
|
const detail = verdict.findings.map(item => item.description).join(', ');
|
|
50
96
|
fail(`Command blocked by ReMCP device policy (${detail}). Set REMCP_RUNTIME_BLOCKED_COMMANDS to adjust the device list, or REMCP_RUNTIME_DANGEROUS_COMMANDS=allow to disable the built-in catastrophic-command guardrail.`);
|
|
51
97
|
}
|
|
98
|
+
if (verdict.warned) {
|
|
99
|
+
const detail = verdict.findings.map(item => item.description).join(', ');
|
|
100
|
+
recordEvent('policy_warning', { reason: verdict.findings[0]?.id || 'builtin', success: true });
|
|
101
|
+
return { ...verdict, note: `Note: this command matches the optional destructive-command guardrail (${detail}). It was executed.` };
|
|
102
|
+
}
|
|
52
103
|
return verdict;
|
|
53
104
|
}
|
|
54
105
|
|
|
55
|
-
export const dangerousPatternIds =
|
|
106
|
+
export const dangerousPatternIds = DANGEROUS_RULES.map(entry => entry.id);
|
package/src/sessions.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import process from 'node:process';
|
|
1
2
|
import { runtimeConfig } from './config.mjs';
|
|
2
3
|
|
|
3
4
|
const processSessions = new Map();
|
|
@@ -5,12 +6,28 @@ const searchSessions = new Map();
|
|
|
5
6
|
let searchCounter = 0;
|
|
6
7
|
|
|
7
8
|
const EXITED_SESSION_TTL_MS = 30 * 60 * 1000;
|
|
9
|
+
// A stream with no newlines (a minified bundle, `yes`, a binary accidentally catted)
|
|
10
|
+
// used to grow `partial` without bound: 40 MB measured, then a RangeError inside the
|
|
11
|
+
// data handler killed the runtime. Split long partials and cap the retained characters.
|
|
12
|
+
const MAX_PARTIAL_BYTES = 64 * 1024;
|
|
13
|
+
const MAX_BUFFERED_CHARS = 8 * 1024 * 1024;
|
|
8
14
|
|
|
9
15
|
function trimBuffer(session) {
|
|
10
16
|
const overflow = session.lines.length - runtimeConfig.maxBufferedLines;
|
|
11
|
-
if (overflow
|
|
12
|
-
|
|
13
|
-
|
|
17
|
+
if (overflow > 0) {
|
|
18
|
+
const removed = session.lines.splice(0, overflow);
|
|
19
|
+
for (const line of removed) session.bufferedChars -= line.length;
|
|
20
|
+
session.droppedLines += overflow;
|
|
21
|
+
}
|
|
22
|
+
while (session.bufferedChars > MAX_BUFFERED_CHARS && session.lines.length > 0) {
|
|
23
|
+
session.bufferedChars -= session.lines.shift().length;
|
|
24
|
+
session.droppedLines += 1;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function pushLine(session, line) {
|
|
29
|
+
session.lines.push(line);
|
|
30
|
+
session.bufferedChars += line.length;
|
|
14
31
|
}
|
|
15
32
|
|
|
16
33
|
function notify(session) {
|
|
@@ -49,6 +66,7 @@ export function createProcessSession({ pid, child, command, shell }) {
|
|
|
49
66
|
partial: '',
|
|
50
67
|
lastPartialRead: null,
|
|
51
68
|
droppedLines: 0,
|
|
69
|
+
bufferedChars: 0,
|
|
52
70
|
cursor: 0,
|
|
53
71
|
exitCode: null,
|
|
54
72
|
signal: null,
|
|
@@ -60,25 +78,35 @@ export function createProcessSession({ pid, child, command, shell }) {
|
|
|
60
78
|
return session;
|
|
61
79
|
}
|
|
62
80
|
|
|
81
|
+
function drainPartial(session) { while (session.partial.length > MAX_PARTIAL_BYTES) {
|
|
82
|
+
pushLine(session, session.partial.slice(0, MAX_PARTIAL_BYTES));
|
|
83
|
+
session.partial = session.partial.slice(MAX_PARTIAL_BYTES);
|
|
84
|
+
session.partialSplit = true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
63
88
|
export function appendProcessOutput(session, chunk) {
|
|
64
89
|
const combined = session.partial + chunk;
|
|
65
90
|
const parts = combined.split('\n');
|
|
66
91
|
session.partial = parts.pop() ?? '';
|
|
67
|
-
for (const line of parts) session
|
|
92
|
+
for (const line of parts) pushLine(session, line);
|
|
93
|
+
drainPartial(session);
|
|
68
94
|
trimBuffer(session);
|
|
69
95
|
session.lastActivityAt = Date.now();
|
|
70
96
|
notify(session);
|
|
71
97
|
}
|
|
72
98
|
|
|
73
99
|
export function markProcessExited(session, code, signal) {
|
|
100
|
+
if (session.exited) return;
|
|
74
101
|
if (session.partial) {
|
|
75
|
-
|
|
102
|
+
pushLine(session, session.partial);
|
|
76
103
|
session.partial = '';
|
|
77
104
|
}
|
|
78
105
|
session.exited = true;
|
|
79
106
|
session.exitCode = code;
|
|
80
107
|
session.signal = signal;
|
|
81
108
|
session.finishedAt = Date.now();
|
|
109
|
+
trimBuffer(session);
|
|
82
110
|
notify(session);
|
|
83
111
|
}
|
|
84
112
|
|
|
@@ -100,7 +128,8 @@ export function totalLines(session) {
|
|
|
100
128
|
}
|
|
101
129
|
|
|
102
130
|
export function readNewOutput(session) {
|
|
103
|
-
const
|
|
131
|
+
const from = Math.max(0, session.cursor - session.droppedLines);
|
|
132
|
+
const complete = session.lines.slice(from);
|
|
104
133
|
session.cursor = session.droppedLines + session.lines.length;
|
|
105
134
|
const parts = [...complete];
|
|
106
135
|
if (session.partial && session.partial !== session.lastPartialRead) parts.push(session.partial);
|
|
@@ -108,6 +137,13 @@ export function readNewOutput(session) {
|
|
|
108
137
|
return parts;
|
|
109
138
|
}
|
|
110
139
|
|
|
140
|
+
export function hasNewOutput(session) {
|
|
141
|
+
if (session.cursor < session.droppedLines + session.lines.length) return true;
|
|
142
|
+
return Boolean(session.partial && session.partial !== session.lastPartialRead);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// A ranged read is a peek: it must not consume the new-output cursor, or reading a tail
|
|
146
|
+
// makes the lines before it undeliverable.
|
|
111
147
|
export function readOutputRange(session, offset, length) {
|
|
112
148
|
const snapshot = session.lines.concat(session.partial ? [session.partial] : []);
|
|
113
149
|
const total = session.droppedLines + snapshot.length;
|
|
@@ -121,7 +157,6 @@ export function readOutputRange(session, offset, length) {
|
|
|
121
157
|
start = Math.max(0, Math.min(requested - session.droppedLines, snapshot.length));
|
|
122
158
|
end = Math.min(snapshot.length, start + Math.max(1, Math.trunc(length || 200)));
|
|
123
159
|
}
|
|
124
|
-
session.cursor = session.droppedLines + session.lines.length;
|
|
125
160
|
return { slice: snapshot.slice(start, end), start: session.droppedLines + start + 1, end: session.droppedLines + end, total };
|
|
126
161
|
}
|
|
127
162
|
|
|
@@ -204,12 +239,25 @@ export function startSessionSweeper(intervalMs = 5 * 60 * 1000) {
|
|
|
204
239
|
sweepTimer.unref?.();
|
|
205
240
|
}
|
|
206
241
|
|
|
242
|
+
// The child is spawned detached on POSIX so it owns a process group; killing the group
|
|
243
|
+
// takes the whole tree with it (`sleep 20 | cat` used to survive `force_terminate`).
|
|
244
|
+
export function killSessionTree(session, signal = 'SIGKILL') {
|
|
245
|
+
const child = session.child;
|
|
246
|
+
if (!child || session.exited) return false;
|
|
247
|
+
try {
|
|
248
|
+
if (process.platform === 'win32') return child.kill(signal);
|
|
249
|
+
if (child.pid) process.kill(-child.pid, signal);
|
|
250
|
+
else return child.kill(signal);
|
|
251
|
+
return true;
|
|
252
|
+
} catch {
|
|
253
|
+
try { return child.kill(signal); } catch { return false; }
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
207
257
|
export function shutdownSessions() {
|
|
208
258
|
if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }
|
|
209
259
|
for (const session of processSessions.values()) {
|
|
210
|
-
if (!session.exited)
|
|
211
|
-
try { session.child.kill('SIGKILL'); } catch {}
|
|
212
|
-
}
|
|
260
|
+
if (!session.exited) killSessionTree(session, 'SIGKILL');
|
|
213
261
|
}
|
|
214
262
|
for (const session of searchSessions.values()) {
|
|
215
263
|
if (session.status === 'running' && session.cancel) {
|
package/src/telemetry.mjs
CHANGED
|
@@ -135,12 +135,26 @@ export async function flush() {
|
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
export function shutdownTelemetry() {
|
|
138
|
+
export async function shutdownTelemetry() {
|
|
139
139
|
if (state.timer) {
|
|
140
140
|
clearInterval(state.timer);
|
|
141
141
|
state.timer = null;
|
|
142
142
|
}
|
|
143
|
-
|
|
143
|
+
const sink = state.sink;
|
|
144
|
+
state.sink = null;
|
|
145
|
+
const events = state.buffer.splice(0, state.buffer.length);
|
|
146
|
+
if (!sink || !events.length) return;
|
|
147
|
+
try {
|
|
148
|
+
// Bounded: the SDK closes the transport about two seconds after shutdown starts, and a
|
|
149
|
+
// notification sent after that is a rejected promise nobody awaits.
|
|
150
|
+
await Promise.race([
|
|
151
|
+
sink({ runtimeVersion: VERSION, platform: process.platform, arch: process.arch, name: runtimeConfig.name, events }),
|
|
152
|
+
new Promise(resolve => setTimeout(resolve, 500)),
|
|
153
|
+
]);
|
|
154
|
+
state.sent += events.length;
|
|
155
|
+
} catch {
|
|
156
|
+
state.dropped += events.length;
|
|
157
|
+
}
|
|
144
158
|
}
|
|
145
159
|
|
|
146
160
|
export function resetTelemetryForTests() {
|