@remcp/remcp 0.2.196 → 0.2.198
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/package.json +2 -2
- package/src/agent-deploy-reconnect.mjs +26 -0
- package/src/agent-relay-connection.mjs +26 -18
- package/src/agent-update-monitor.mjs +20 -6
- package/src/cli/doctor.mjs +33 -10
- package/src/cli/service.mjs +21 -2
- package/src/fetch-update-manifest.mjs +45 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remcp/remcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.198",
|
|
4
4
|
"description": "ReMCP device client: pair a computer with ReMCP and run the outbound-only agent that hosts the local MCP runtime.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"README.md"
|
|
19
19
|
],
|
|
20
20
|
"scripts": {
|
|
21
|
-
"check": "node --check bin/remcp.mjs && node --check src/agent-network-probe-client.mjs && node --check src/agent-relay-connection.mjs && node --check src/agent-responder.mjs && node --check src/agent-runtime-capabilities.mjs && node --check src/agent-update-monitor.mjs && node --check src/agent-update.mjs && node --check src/agent-utilities.mjs && node --check src/agent.mjs && node --check src/cli.mjs && node --check src/cli/config.mjs && node --check src/cli/connect.mjs && node --check src/cli/doctor.mjs && node --check src/cli/env.mjs && node --check src/cli/npm-global.mjs && node --check src/cli/pairing-qr.mjs && node --check src/cli/service-inventory.mjs && node --check src/cli/service-macos.mjs && node --check src/cli/service-paths.mjs && node --check src/cli/service.mjs && node --check src/cli/shell.mjs && node --check src/cli/update.mjs && node --check src/cli/windows-supervisor.mjs && node --check src/fs-access.mjs && node --check src/npm.mjs && node --check src/runtime.mjs && node --check src/version.mjs",
|
|
21
|
+
"check": "node --check bin/remcp.mjs && node --check src/agent-deploy-reconnect.mjs && node --check src/agent-network-probe-client.mjs && node --check src/agent-relay-connection.mjs && node --check src/agent-responder.mjs && node --check src/agent-runtime-capabilities.mjs && node --check src/agent-update-monitor.mjs && node --check src/agent-update.mjs && node --check src/agent-utilities.mjs && node --check src/agent.mjs && node --check src/cli.mjs && node --check src/cli/config.mjs && node --check src/cli/connect.mjs && node --check src/cli/doctor.mjs && node --check src/cli/env.mjs && node --check src/cli/npm-global.mjs && node --check src/cli/pairing-qr.mjs && node --check src/cli/service-inventory.mjs && node --check src/cli/service-macos.mjs && node --check src/cli/service-paths.mjs && node --check src/cli/service.mjs && node --check src/cli/shell.mjs && node --check src/cli/update.mjs && node --check src/cli/windows-supervisor.mjs && node --check src/fetch-update-manifest.mjs && node --check src/fs-access.mjs && node --check src/npm.mjs && node --check src/runtime.mjs && node --check src/version.mjs",
|
|
22
22
|
"test": "node --test test/*.test.mjs"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bound repeated relay-deployment reconnects while preserving fast recovery from
|
|
3
|
+
* a single short interruption. State is per agent connection, never global.
|
|
4
|
+
*
|
|
5
|
+
* The gateway's requested minimum is honored; a burst of sub-10s sessions
|
|
6
|
+
* progressively backs off so dozens of agents cannot spin on a deploy loop.
|
|
7
|
+
*/
|
|
8
|
+
export function nextDeployReconnect({
|
|
9
|
+
requestedDelayMs = 150,
|
|
10
|
+
rapidDisconnects = 0,
|
|
11
|
+
connectionUptimeMs = 0,
|
|
12
|
+
} = {}) {
|
|
13
|
+
const input = Number(requestedDelayMs);
|
|
14
|
+
const requested = Number.isFinite(input)
|
|
15
|
+
? Math.max(100, Math.min(60_000, Math.floor(input)))
|
|
16
|
+
: 150;
|
|
17
|
+
const stable = Number(connectionUptimeMs) >= 10_000;
|
|
18
|
+
const prior = Number.isSafeInteger(rapidDisconnects) && rapidDisconnects >= 0
|
|
19
|
+
? rapidDisconnects : 0;
|
|
20
|
+
const count = stable ? 0 : Math.min(8, prior + 1);
|
|
21
|
+
const adaptive = count <= 1 ? 0 : Math.min(8_000, 250 * 2 ** Math.min(6, count - 2));
|
|
22
|
+
return {
|
|
23
|
+
delayMs: Math.max(requested, adaptive),
|
|
24
|
+
rapidDisconnects: count,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -4,11 +4,11 @@ import process from 'node:process';
|
|
|
4
4
|
import WebSocket from 'ws';
|
|
5
5
|
import { VERSION } from './version.mjs';
|
|
6
6
|
import { boundedReconnectDelay, jitter, sendRelayMessage } from './agent-utilities.mjs';
|
|
7
|
+
import { nextDeployReconnect } from './agent-deploy-reconnect.mjs';
|
|
7
8
|
|
|
8
9
|
const RECONNECT_BASE_MS = 2_000;
|
|
9
10
|
const RECONNECT_FIRST_MS = 500;
|
|
10
11
|
const RECONNECT_MAX_MS = 60_000;
|
|
11
|
-
|
|
12
12
|
// Higher-level agent owns runtime supervision/telemetry; this controller owns only relay transport.
|
|
13
13
|
export function createAgentRelayConnection({
|
|
14
14
|
agentUrl, serverUrl, deviceToken, deviceId, deviceName,
|
|
@@ -21,6 +21,9 @@ export function createAgentRelayConnection({
|
|
|
21
21
|
let reconnectTimer = null;
|
|
22
22
|
let askedToReconnect = false;
|
|
23
23
|
let requestedReconnectDelayMs = 0;
|
|
24
|
+
let requestedReconnectReason = '';
|
|
25
|
+
let connectedAt = 0;
|
|
26
|
+
let rapidDeployDisconnects = 0;
|
|
24
27
|
function scheduleReconnect(delay) {
|
|
25
28
|
if (isStopping() || revoked || reconnectTimer) return;
|
|
26
29
|
// Keep the upper-bound guard in control flow immediately before the timer sink. CodeQL's
|
|
@@ -88,6 +91,7 @@ export function createAgentRelayConnection({
|
|
|
88
91
|
});
|
|
89
92
|
setSocket(ws);
|
|
90
93
|
ws.on('open', () => {
|
|
94
|
+
connectedAt = Date.now();
|
|
91
95
|
if (reconnectTimer) {
|
|
92
96
|
clearTimeout(reconnectTimer);
|
|
93
97
|
reconnectTimer = null;
|
|
@@ -117,6 +121,8 @@ export function createAgentRelayConnection({
|
|
|
117
121
|
if (message?.type === 'reconnect') {
|
|
118
122
|
askedToReconnect = true;
|
|
119
123
|
requestedReconnectDelayMs = boundedReconnectDelay(message.delayMs, 150);
|
|
124
|
+
requestedReconnectReason = ['deploy', 'credentials', 'capacity'].includes(message.reason)
|
|
125
|
+
? message.reason : 'transient';
|
|
120
126
|
return;
|
|
121
127
|
}
|
|
122
128
|
if (message?.type === 'request') void respond(ws, message);
|
|
@@ -129,33 +135,35 @@ export function createAgentRelayConnection({
|
|
|
129
135
|
clearPostConnectUpdateChecks();
|
|
130
136
|
for (const controller of inFlight.values()) controller.abort();
|
|
131
137
|
if (isStopping()) return;
|
|
132
|
-
//
|
|
133
|
-
// is not down, it is moving: reconnect at once and forget the backoff, so the person and the
|
|
134
|
-
// model see nothing at all.
|
|
135
|
-
if (code === 1013 || askedToReconnect) {
|
|
136
|
-
const delayMs = requestedReconnectDelayMs || 150;
|
|
137
|
-
askedToReconnect = false;
|
|
138
|
-
requestedReconnectDelayMs = 0;
|
|
139
|
-
setReconnects(0);
|
|
140
|
-
console.log(`ReMCP relay is being redeployed; reconnecting in ${delayMs}ms.`);
|
|
141
|
-
scheduleReconnect(delayMs);
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
138
|
+
// A replacement owner or revoked device takes precedence over a reconnect hint.
|
|
144
139
|
if (code === 1008 || revoked) {
|
|
145
|
-
// The relay closes with 1008 when the device was revoked. Retrying forever would
|
|
146
|
-
// hide that from the person at the computer.
|
|
147
140
|
console.error('ReMCP access for this device was revoked. Pair the machine again from the ReMCP workspace: remcp connect --server <url> --code <code> --install');
|
|
148
141
|
return;
|
|
149
142
|
}
|
|
150
143
|
if (code === 1012) {
|
|
151
|
-
// 1012 ('service restart') is what the relay sends when another agent process took over this
|
|
152
|
-
// device. Staying alive would keep a second runtime and a reconnect loop, so this process
|
|
153
|
-
// stops and leaves the device to the agent that owns the connection.
|
|
154
144
|
console.error('Another ReMCP agent connected for this device; this process will stop. Run one agent per machine (a service manager or the remcp start command).');
|
|
155
145
|
markStopping();
|
|
156
146
|
void stop().finally(() => setTimeout(() => process.exit(0), 100));
|
|
157
147
|
return;
|
|
158
148
|
}
|
|
149
|
+
if (code === 1013 || askedToReconnect) {
|
|
150
|
+
// 1013 covers transient overload and relay-store failures, not just deployment.
|
|
151
|
+
// Preserve a fast first handover while bounding repeated short-lived sessions.
|
|
152
|
+
const reconnect = nextDeployReconnect({
|
|
153
|
+
requestedDelayMs: requestedReconnectDelayMs || 150,
|
|
154
|
+
rapidDisconnects: rapidDeployDisconnects,
|
|
155
|
+
connectionUptimeMs: connectedAt ? Date.now() - connectedAt : 0,
|
|
156
|
+
});
|
|
157
|
+
rapidDeployDisconnects = reconnect.rapidDisconnects;
|
|
158
|
+
const reason = requestedReconnectReason || 'transient';
|
|
159
|
+
askedToReconnect = false;
|
|
160
|
+
requestedReconnectReason = '';
|
|
161
|
+
requestedReconnectDelayMs = 0;
|
|
162
|
+
setReconnects(0);
|
|
163
|
+
console.log(`ReMCP relay reconnect (${reason}); retrying in ${reconnect.delayMs}ms.`);
|
|
164
|
+
scheduleReconnect(reconnect.delayMs);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
159
167
|
scheduleBackoffReconnect();
|
|
160
168
|
});
|
|
161
169
|
ws.on('error', error => console.error(`ReMCP relay: ${error.message}`));
|
|
@@ -2,10 +2,13 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import process from 'node:process';
|
|
4
4
|
import { VERSION } from './version.mjs';
|
|
5
|
+
import { fetchUpdateManifest } from './fetch-update-manifest.mjs';
|
|
5
6
|
import { globalCliEntry, globalInstalledVersion, isNewer, restartToApplyUpdate, updateDecision, updateInvocationArgs } from './agent-update.mjs';
|
|
6
7
|
|
|
7
8
|
export const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
8
|
-
const UPDATE_CHECK_TIMEOUT_MS =
|
|
9
|
+
const UPDATE_CHECK_TIMEOUT_MS = 12000;
|
|
10
|
+
// A flapping relay must not trigger update discovery on every successful reconnect.
|
|
11
|
+
const UPDATE_CHECK_RECONNECT_GAP_MS = 30000;
|
|
9
12
|
const POST_CONNECT_UPDATE_RECHECK_DELAYS_MS = Object.freeze([60_000, 5 * 60_000, 15 * 60_000]);
|
|
10
13
|
const POST_CONNECT_UPDATE_RECHECK_JITTER_MS = 30_000;
|
|
11
14
|
const UPDATE_RETRY_COOLDOWN_MS = 30 * 60 * 1000;
|
|
@@ -31,6 +34,7 @@ export function createAgentUpdateMonitor({ options, serverUrl, queueEvent, getRu
|
|
|
31
34
|
let lastAttemptAt = 0;
|
|
32
35
|
let lastUpdateCheckError = '';
|
|
33
36
|
let lastUpdateCheckErrorAt = 0;
|
|
37
|
+
let lastDiscoveryAt = 0;
|
|
34
38
|
|
|
35
39
|
function clearPostConnectUpdateChecks() {
|
|
36
40
|
for (const timer of postConnectUpdateTimers) clearTimeout(timer);
|
|
@@ -46,22 +50,32 @@ export function createAgentUpdateMonitor({ options, serverUrl, queueEvent, getRu
|
|
|
46
50
|
: 0;
|
|
47
51
|
const timer = setTimeout(() => {
|
|
48
52
|
postConnectUpdateTimers = postConnectUpdateTimers.filter(candidate => candidate !== timer);
|
|
49
|
-
void checkForUpdate();
|
|
53
|
+
void checkForUpdate({ scheduled: true });
|
|
50
54
|
}, delay + jitter);
|
|
51
55
|
timer.unref?.();
|
|
52
56
|
postConnectUpdateTimers.push(timer);
|
|
53
57
|
}
|
|
54
58
|
}
|
|
55
59
|
|
|
56
|
-
async function checkForUpdate() {
|
|
60
|
+
async function checkForUpdate({ scheduled = false } = {}) {
|
|
57
61
|
if (options.autoUpdate === false) return;
|
|
58
62
|
if (updateInFlight) return;
|
|
63
|
+
const minimumGap = Number.isFinite(Number(options.updateCheckMinGapMs))
|
|
64
|
+
? Math.max(0, Math.min(UPDATE_CHECK_RECONNECT_GAP_MS, Number(options.updateCheckMinGapMs)))
|
|
65
|
+
: UPDATE_CHECK_RECONNECT_GAP_MS;
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
// Scheduled bounded convergence polls are distinct from reconnect-triggered discovery.
|
|
68
|
+
// Do not starve release-pause/Redis-recovery checks behind the reconnect storm cooldown.
|
|
69
|
+
if (!scheduled && lastDiscoveryAt && now - lastDiscoveryAt < minimumGap) return;
|
|
70
|
+
lastDiscoveryAt = now;
|
|
59
71
|
try {
|
|
60
72
|
// This request contains no local file payload; the config-derived URL is the explicitly paired relay.
|
|
61
73
|
// codeql[js/file-access-to-http]
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
74
|
+
// Bound both connection and JSON body parsing even if platform fetch ignores AbortSignal.
|
|
75
|
+
const advertised = await fetchUpdateManifest(`${serverUrl}/api/agent/version`, {
|
|
76
|
+
timeoutMs: UPDATE_CHECK_TIMEOUT_MS,
|
|
77
|
+
});
|
|
78
|
+
if (!advertised) return;
|
|
65
79
|
const minimum = advertised.minimum;
|
|
66
80
|
if (minimum && isNewer(minimum, VERSION)) {
|
|
67
81
|
console.error(`ReMCP ${VERSION} is older than the minimum supported agent ${minimum}; update with: remcp update`);
|
package/src/cli/doctor.mjs
CHANGED
|
@@ -15,6 +15,21 @@ import { npm, runtimeConfigFile } from './env.mjs';
|
|
|
15
15
|
// that would take the device offline.
|
|
16
16
|
// One real handshake with the local runtime, plus everything needed to explain a failure: where the
|
|
17
17
|
// entry resolved, whether the package is installed, the node that would run it, and the exact error.
|
|
18
|
+
// A stuck local MCP transport must not hold remcp doctor indefinitely.
|
|
19
|
+
export async function doctorDeadline(promise, timeoutMs) {
|
|
20
|
+
let timer;
|
|
21
|
+
try {
|
|
22
|
+
return await Promise.race([
|
|
23
|
+
promise,
|
|
24
|
+
new Promise((_, reject) => {
|
|
25
|
+
timer = setTimeout(() => reject(new Error('Runtime diagnostic timed out after ' + timeoutMs + 'ms')), timeoutMs);
|
|
26
|
+
}),
|
|
27
|
+
]);
|
|
28
|
+
} finally {
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
18
33
|
export async function diagnoseLocalRuntime(cfg) {
|
|
19
34
|
const packageName = cfg.runtime?.packageName || '';
|
|
20
35
|
const diagnosis = {
|
|
@@ -54,16 +69,24 @@ export async function diagnoseLocalRuntime(cfg) {
|
|
|
54
69
|
{ name: 'remcp-doctor', version: VERSION },
|
|
55
70
|
{ versionNegotiation:{ mode:'auto' } },
|
|
56
71
|
);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
stdio
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
72
|
+
// Keep runtime stderr off the inherited CLI pipe: it must not block a
|
|
73
|
+
// parent test runner when a diagnostic child remains active.
|
|
74
|
+
const stdio = new StdioClientTransport({
|
|
75
|
+
command: process.execPath, args: [entry], env: { ...process.env },
|
|
76
|
+
stderr: 'pipe', maxBufferSize: 4 * 1024 * 1024,
|
|
77
|
+
});
|
|
78
|
+
stdio.stderr?.on('data', () => {}); // drain; do not log potentially sensitive output
|
|
79
|
+
try {
|
|
80
|
+
await doctorDeadline(client.connect(stdio), 20_000);
|
|
81
|
+
diagnosis.runtimeVersion = client.getServerVersion()?.version || 'unknown';
|
|
82
|
+
const tools = await doctorDeadline(client.listTools(undefined, { timeout: 20_000 }), 25_000);
|
|
83
|
+
diagnosis.tools = tools.tools.length;
|
|
84
|
+
diagnosis.verdict = 'ok';
|
|
85
|
+
return diagnosis;
|
|
86
|
+
} finally {
|
|
87
|
+
// Clean up even on a failed handshake. Never let close hang the doctor.
|
|
88
|
+
await doctorDeadline(client.close(), 6_000).catch(() => {});
|
|
89
|
+
}
|
|
67
90
|
} catch (error) {
|
|
68
91
|
diagnosis.verdict = 'runtime-handshake-failed';
|
|
69
92
|
diagnosis.error = error instanceof Error ? error.message : String(error);
|
package/src/cli/service.mjs
CHANGED
|
@@ -23,9 +23,28 @@ export function quoteSystemd(value) {
|
|
|
23
23
|
return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
// A systemd user's PATH may resolve system npm even when the CLI lives under nvm.
|
|
27
|
+
// Pin the npm installation target to the CLI's own existing global prefix.
|
|
28
|
+
export function linuxServiceLauncherText(cliPath, nodePath) {
|
|
29
|
+
if (!path.isAbsolute(cliPath) || !path.isAbsolute(nodePath) || path.basename(path.dirname(cliPath)) !== 'bin') {
|
|
30
|
+
throw new Error('Linux service needs absolute CLI and Node paths in a global bin directory');
|
|
31
|
+
}
|
|
32
|
+
const prefix = path.dirname(path.dirname(cliPath));
|
|
27
33
|
const cliScript = resolvedCliScript(cliPath);
|
|
28
|
-
const
|
|
34
|
+
const lines = [
|
|
35
|
+
'#!/bin/sh',
|
|
36
|
+
'NPM_CONFIG_PREFIX=' + shellQuote(prefix),
|
|
37
|
+
'export NPM_CONFIG_PREFIX',
|
|
38
|
+
'PATH=' + shellQuote(path.dirname(nodePath)) + ':"$PATH"',
|
|
39
|
+
'export PATH',
|
|
40
|
+
'exec ' + shellQuote(nodePath) + ' ' + shellQuote(cliScript) + ' "$@"',
|
|
41
|
+
'',
|
|
42
|
+
];
|
|
43
|
+
return lines.join('\n');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function writeLinuxServiceLauncher(cliPath = globalCliPath(), nodePath = process.execPath) {
|
|
47
|
+
const launcher = linuxServiceLauncherText(cliPath, nodePath);
|
|
29
48
|
fs.mkdirSync(path.dirname(linuxServiceLauncherFile), { recursive: true, mode: 0o700 });
|
|
30
49
|
const temporary = `${linuxServiceLauncherFile}.${process.pid}.${Date.now()}.tmp`;
|
|
31
50
|
fs.writeFileSync(temporary, launcher, { mode: 0o700 });
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded ReMCP release discovery. The deadline covers both the HTTP response
|
|
3
|
+
* and its JSON body. AbortSignal alone does not reliably cancel stalled TCP
|
|
4
|
+
* connects on every supported Node/undici + operating-system combination.
|
|
5
|
+
*
|
|
6
|
+
* No credentials or local computer data are transmitted here: the caller
|
|
7
|
+
* supplies the trusted ReMCP relay's public version endpoint.
|
|
8
|
+
*/
|
|
9
|
+
export async function fetchUpdateManifest(url, {
|
|
10
|
+
fetchImpl = globalThis.fetch,
|
|
11
|
+
timeoutMs = 5_000,
|
|
12
|
+
} = {}) {
|
|
13
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000) {
|
|
14
|
+
throw new RangeError('Update manifest timeout must be 1–60000 milliseconds');
|
|
15
|
+
}
|
|
16
|
+
const controller = new AbortController();
|
|
17
|
+
let timer;
|
|
18
|
+
const deadline = new Promise((_, reject) => {
|
|
19
|
+
timer = setTimeout(() => {
|
|
20
|
+
controller.abort();
|
|
21
|
+
const error = new Error('ReMCP update manifest check exceeded its deadline');
|
|
22
|
+
error.name = 'TimeoutError';
|
|
23
|
+
reject(error);
|
|
24
|
+
}, timeoutMs);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
return await Promise.race([
|
|
29
|
+
(async () => {
|
|
30
|
+
const response = await fetchImpl(url, {
|
|
31
|
+
redirect: 'error',
|
|
32
|
+
signal: controller.signal,
|
|
33
|
+
});
|
|
34
|
+
if (!response.ok) return null;
|
|
35
|
+
return response.json();
|
|
36
|
+
})(),
|
|
37
|
+
deadline,
|
|
38
|
+
]);
|
|
39
|
+
} finally {
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
// Release a response connection if a late completion beats the deadline
|
|
42
|
+
// after Promise.race has already chosen the timed-out branch.
|
|
43
|
+
controller.abort();
|
|
44
|
+
}
|
|
45
|
+
}
|