@remcp/remcp 0.2.42 → 0.2.44
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 +1 -1
- package/src/agent.mjs +44 -13
- package/src/cli/config.mjs +45 -2
- package/src/cli/service.mjs +30 -3
- package/src/cli/update.mjs +1 -1
- package/src/cli.mjs +7 -4
package/package.json
CHANGED
package/src/agent.mjs
CHANGED
|
@@ -77,6 +77,7 @@ export async function runAgent(options) {
|
|
|
77
77
|
const inFlight = new Map();
|
|
78
78
|
let activeSocket;
|
|
79
79
|
let reconnects = 0;
|
|
80
|
+
let reconnectTimer = null;
|
|
80
81
|
// Set when a replica asks this agent to move before it is replaced; the close handler reads it.
|
|
81
82
|
let askedToReconnect = false;
|
|
82
83
|
let pendingRequests = 0;
|
|
@@ -219,6 +220,26 @@ export async function runAgent(options) {
|
|
|
219
220
|
}
|
|
220
221
|
}
|
|
221
222
|
|
|
223
|
+
function scheduleReconnect(delay) {
|
|
224
|
+
if (stopping || revoked || reconnectTimer) return;
|
|
225
|
+
reconnectTimer = setTimeout(() => {
|
|
226
|
+
reconnectTimer = null;
|
|
227
|
+
connect();
|
|
228
|
+
}, delay);
|
|
229
|
+
reconnectTimer.unref?.();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function scheduleBackoffReconnect() {
|
|
233
|
+
if (stopping || revoked || reconnectTimer) return;
|
|
234
|
+
reconnects += 1;
|
|
235
|
+
// The first retry after an unexpected drop is quick on purpose: a deploy blip, a proxy restart or
|
|
236
|
+
// a dropped packet should cost a fraction of a second, not the two seconds the backoff starts at.
|
|
237
|
+
const delay = reconnects === 1
|
|
238
|
+
? jitter(RECONNECT_FIRST_MS)
|
|
239
|
+
: jitter(Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** Math.min(reconnects, 5)));
|
|
240
|
+
scheduleReconnect(delay);
|
|
241
|
+
}
|
|
242
|
+
|
|
222
243
|
async function respond(ws, message) {
|
|
223
244
|
pendingRequests += 1;
|
|
224
245
|
// The relay forwards a cancel when the MCP client goes away. Without it a cancelled tool call
|
|
@@ -250,15 +271,29 @@ export async function runAgent(options) {
|
|
|
250
271
|
function connect() {
|
|
251
272
|
if (stopping) return;
|
|
252
273
|
const ws = new WebSocket(agentUrl, { headers: { Authorization: `Bearer ${deviceToken}` } });
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
274
|
+
// The ws client leaves cleanup/retry to the caller when an unexpected-response listener exists.
|
|
275
|
+
// A temporary workspace pause therefore needs an explicit retry; otherwise the first 423 leaves
|
|
276
|
+
// this WebSocket stuck in CONNECTING forever and turning the device back on cannot recover it.
|
|
277
|
+
ws.on('unexpected-response', (request, response) => {
|
|
278
|
+
const handshakeRevoked = String(response.headers['x-remcp-revoked'] || '') === '1';
|
|
279
|
+
const handshakeDisabled = String(response.headers['x-remcp-disabled'] || '') === '1';
|
|
280
|
+
if (handshakeRevoked) revoked = true;
|
|
281
|
+
console.error(`ReMCP relay refused the connection (HTTP ${response.statusCode})${handshakeRevoked ? ': this device was revoked' : handshakeDisabled ? ': this device is temporarily disabled' : ''}.`);
|
|
258
282
|
response.resume();
|
|
283
|
+
request.destroy();
|
|
284
|
+
if (handshakeDisabled && !revoked) {
|
|
285
|
+
reconnects += 1;
|
|
286
|
+
// Service access is a user-controlled pause, not an outage. Poll slowly enough not to hammer
|
|
287
|
+
// the relay, but cap recovery so an enable action becomes effective within seconds.
|
|
288
|
+
scheduleReconnect(jitter(RECONNECT_BASE_MS));
|
|
289
|
+
}
|
|
259
290
|
});
|
|
260
291
|
activeSocket = ws;
|
|
261
292
|
ws.on('open', () => {
|
|
293
|
+
if (reconnectTimer) {
|
|
294
|
+
clearTimeout(reconnectTimer);
|
|
295
|
+
reconnectTimer = null;
|
|
296
|
+
}
|
|
262
297
|
reconnects = 0;
|
|
263
298
|
ws.send(JSON.stringify({
|
|
264
299
|
type: 'hello',
|
|
@@ -303,7 +338,7 @@ export async function runAgent(options) {
|
|
|
303
338
|
askedToReconnect = false;
|
|
304
339
|
reconnects = 0;
|
|
305
340
|
console.log('ReMCP relay is being redeployed; reconnecting now.');
|
|
306
|
-
|
|
341
|
+
scheduleReconnect(150);
|
|
307
342
|
return;
|
|
308
343
|
}
|
|
309
344
|
if (code === 1008 || revoked) {
|
|
@@ -321,13 +356,7 @@ export async function runAgent(options) {
|
|
|
321
356
|
void stop().finally(() => setTimeout(() => process.exit(0), 100));
|
|
322
357
|
return;
|
|
323
358
|
}
|
|
324
|
-
|
|
325
|
-
// The first retry after an unexpected drop is quick on purpose: a deploy blip, a proxy restart or
|
|
326
|
-
// a dropped packet should cost a fraction of a second, not the two seconds the backoff starts at.
|
|
327
|
-
const delay = reconnects === 1
|
|
328
|
-
? jitter(RECONNECT_FIRST_MS)
|
|
329
|
-
: jitter(Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** Math.min(reconnects, 5)));
|
|
330
|
-
setTimeout(connect, delay);
|
|
359
|
+
scheduleBackoffReconnect();
|
|
331
360
|
});
|
|
332
361
|
ws.on('error', error => console.error(`ReMCP relay: ${error.message}`));
|
|
333
362
|
}
|
|
@@ -443,6 +472,8 @@ export async function runAgent(options) {
|
|
|
443
472
|
if (stopPromise) return stopPromise;
|
|
444
473
|
stopping = true;
|
|
445
474
|
clearTimeout(runtimeRestartTimer);
|
|
475
|
+
clearTimeout(reconnectTimer);
|
|
476
|
+
reconnectTimer = null;
|
|
446
477
|
if (telemetryTimer) clearInterval(telemetryTimer);
|
|
447
478
|
clearInterval(telemetryFlushTimer);
|
|
448
479
|
clearInterval(updateTimer);
|
package/src/cli/config.mjs
CHANGED
|
@@ -7,21 +7,64 @@ import { normalizeRuntime } from '../runtime.mjs';
|
|
|
7
7
|
|
|
8
8
|
import { configDir, configFile, machineIdFile, officialOrigin, runtimeConfigFile } from './env.mjs';
|
|
9
9
|
|
|
10
|
+
export const CONFIG_SCHEMA_VERSION = 1;
|
|
11
|
+
const legacyOfficialOrigins = new Set(['https://remcp.delio24.com']);
|
|
12
|
+
|
|
13
|
+
function migrateConfig(value) {
|
|
14
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('ReMCP config must be a JSON object');
|
|
15
|
+
const declared = Number(value.configSchemaVersion || 0);
|
|
16
|
+
// A newer client may have added fields this client does not understand. Preserve that document
|
|
17
|
+
// byte-for-meaning and only use the known fields below; never downgrade its schema marker.
|
|
18
|
+
if (Number.isSafeInteger(declared) && declared > CONFIG_SCHEMA_VERSION) return { value: { ...value }, changed: false };
|
|
19
|
+
|
|
20
|
+
let next = { ...value };
|
|
21
|
+
let changed = false;
|
|
22
|
+
if (declared < 1) {
|
|
23
|
+
try {
|
|
24
|
+
const server = new URL(String(next.serverUrl || ''));
|
|
25
|
+
if (legacyOfficialOrigins.has(server.origin)) {
|
|
26
|
+
next.serverUrl = officialOrigin;
|
|
27
|
+
if (next.trustRuntime === undefined) next.trustRuntime = true;
|
|
28
|
+
changed = true;
|
|
29
|
+
}
|
|
30
|
+
} catch {}
|
|
31
|
+
if (next.configSchemaVersion !== CONFIG_SCHEMA_VERSION) {
|
|
32
|
+
next.configSchemaVersion = CONFIG_SCHEMA_VERSION;
|
|
33
|
+
changed = true;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { value: next, changed };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function configForWrite(value) {
|
|
40
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('ReMCP config must be a JSON object');
|
|
41
|
+
const declared = Number(value.configSchemaVersion || 0);
|
|
42
|
+
return {
|
|
43
|
+
...value,
|
|
44
|
+
configSchemaVersion: Number.isSafeInteger(declared) && declared > CONFIG_SCHEMA_VERSION
|
|
45
|
+
? declared
|
|
46
|
+
: CONFIG_SCHEMA_VERSION,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
10
50
|
export function loadConfig(required = true) {
|
|
11
51
|
if (!fs.existsSync(configFile)) {
|
|
12
52
|
if (!required) return undefined;
|
|
13
53
|
throw new Error(`ReMCP is not paired. Generate a pairing command at ${officialOrigin}/app/connect`);
|
|
14
54
|
}
|
|
15
|
-
const
|
|
55
|
+
const migrated = migrateConfig(JSON.parse(fs.readFileSync(configFile, 'utf8')));
|
|
56
|
+
const value = migrated.value;
|
|
16
57
|
// A configuration that only carries preferences (for example after `remcp auto-update off`
|
|
17
58
|
// before pairing) has no runtime yet; it must not fail as if it were corrupt.
|
|
18
59
|
if (value.runtime !== undefined) value.runtime = normalizeRuntime(value.runtime);
|
|
60
|
+
if (migrated.changed) saveConfig(value);
|
|
19
61
|
return value;
|
|
20
62
|
}
|
|
21
63
|
|
|
22
64
|
export function saveConfig(value) {
|
|
65
|
+
const persisted = configForWrite(value);
|
|
23
66
|
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
24
|
-
fs.writeFileSync(configFile, JSON.stringify(
|
|
67
|
+
fs.writeFileSync(configFile, JSON.stringify(persisted, null, 2) + '\n', { mode: 0o600 });
|
|
25
68
|
fs.chmodSync(configFile, 0o600);
|
|
26
69
|
}
|
|
27
70
|
|
package/src/cli/service.mjs
CHANGED
|
@@ -72,6 +72,22 @@ export function installWindowsService(cliPath = globalCliPath()) {
|
|
|
72
72
|
run('schtasks.exe', ['/Run', '/TN', windowsTaskName]);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// `serviceInstalled` was added after background services already existed in the wild. Treat an
|
|
76
|
+
// explicit false as the user's opt-out, an explicit true as the current marker, and only infer the
|
|
77
|
+
// old intent from an OS service artifact when the marker is absent. This keeps legacy installs
|
|
78
|
+
// repairable without resurrecting a service that a newer client explicitly disabled.
|
|
79
|
+
export function persistentServiceExpected(config) {
|
|
80
|
+
if (config?.serviceInstalled === false) return false;
|
|
81
|
+
if (config?.serviceInstalled === true) return true;
|
|
82
|
+
const platform = servicePlatform();
|
|
83
|
+
if (platform === 'linux') return fs.existsSync(linuxServiceFile);
|
|
84
|
+
if (platform === 'darwin') return fs.existsSync(macServiceFile);
|
|
85
|
+
if (platform === 'win32') {
|
|
86
|
+
return spawnSync('schtasks.exe', ['/Query', '/TN', windowsTaskName], { stdio: 'ignore' }).status === 0;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
75
91
|
export function configurePostInstallAccess() {
|
|
76
92
|
const platform = servicePlatform();
|
|
77
93
|
try {
|
|
@@ -136,7 +152,7 @@ export function installPersistentAgent(config) {
|
|
|
136
152
|
// (a failed install, a cleaned LaunchAgents directory, a re-imaged user), the next update recreates
|
|
137
153
|
// it instead of leaving a hand-over to a process nobody supervises.
|
|
138
154
|
export function ensureServiceIfRecorded(config) {
|
|
139
|
-
if (config
|
|
155
|
+
if (!persistentServiceExpected(config)) return false;
|
|
140
156
|
try {
|
|
141
157
|
const cliPath = globalCliPath();
|
|
142
158
|
const platform = servicePlatform();
|
|
@@ -169,6 +185,9 @@ export function ensureServiceIfRecorded(config) {
|
|
|
169
185
|
// kickstart) and is what the Windows task already does on every update.
|
|
170
186
|
installMacService(cliPath);
|
|
171
187
|
} else if (platform === 'win32') installWindowsService(cliPath);
|
|
188
|
+
// Upgrade the legacy inferred state only after the supervisor repair succeeded. A failed repair
|
|
189
|
+
// must not turn a stale artifact into a permanent "managed service" declaration.
|
|
190
|
+
if (config?.serviceInstalled !== true) saveConfig({ ...config, serviceInstalled: true });
|
|
172
191
|
return true;
|
|
173
192
|
} catch (error) {
|
|
174
193
|
console.error(`Could not ensure the background service: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -176,7 +195,8 @@ export function ensureServiceIfRecorded(config) {
|
|
|
176
195
|
}
|
|
177
196
|
}
|
|
178
197
|
|
|
179
|
-
export function restartPersistentServiceIfInstalled() {
|
|
198
|
+
export function restartPersistentServiceIfInstalled(config) {
|
|
199
|
+
if (!persistentServiceExpected(config)) return null;
|
|
180
200
|
const platform = servicePlatform();
|
|
181
201
|
if (platform === 'linux' && fs.existsSync(linuxServiceFile)) {
|
|
182
202
|
run('systemctl', ['--user', 'daemon-reload']);
|
|
@@ -184,7 +204,14 @@ export function restartPersistentServiceIfInstalled() {
|
|
|
184
204
|
return 'remcp-agent.service';
|
|
185
205
|
}
|
|
186
206
|
if (platform === 'darwin' && fs.existsSync(macServiceFile)) {
|
|
187
|
-
|
|
207
|
+
try {
|
|
208
|
+
run('launchctl', ['kickstart', '-k', `${macLaunchDomain()}/${macServiceLabel}`]);
|
|
209
|
+
} catch {
|
|
210
|
+
// A plist can survive while launchd has no loaded job (older installs, logout/login cleanup,
|
|
211
|
+
// manual bootout, or a failed previous update). Re-register the job instead of surfacing the
|
|
212
|
+
// opaque launchctl 113 error. installMacService is idempotent and rewrites stale Node paths too.
|
|
213
|
+
installMacService(globalCliPath());
|
|
214
|
+
}
|
|
188
215
|
return macServiceLabel;
|
|
189
216
|
}
|
|
190
217
|
if (platform === 'win32') {
|
package/src/cli/update.mjs
CHANGED
|
@@ -200,7 +200,7 @@ export async function updateCommand(flags) {
|
|
|
200
200
|
// Only a validated spec is persisted, so a failed update cannot leave the install unable to start.
|
|
201
201
|
if (targets.persistRuntime) saveConfig({ ...cfg, runtime: { ...cfg.runtime, packageSpec: targets.runtimeSpec } });
|
|
202
202
|
const after = { cli: installedVersion(PACKAGE_NAME), runtime: installedVersion(cfg.runtime.packageName) };
|
|
203
|
-
const restarted = restartPersistentServiceIfInstalled();
|
|
203
|
+
const restarted = restartPersistentServiceIfInstalled(cfg);
|
|
204
204
|
if (restarted) {
|
|
205
205
|
console.log(`ReMCP updated and ${restarted} restarted (client ${before.cli} → ${after.cli ?? '?'}, runtime ${before.runtime ?? '?'} → ${after.runtime ?? '?'}).`);
|
|
206
206
|
return;
|
package/src/cli.mjs
CHANGED
|
@@ -128,13 +128,13 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
128
128
|
}
|
|
129
129
|
if (action === 'on' || action === 'enable') {
|
|
130
130
|
console.log(JSON.stringify(setTelemetry(true), null, 2));
|
|
131
|
-
restartPersistentServiceIfInstalled();
|
|
131
|
+
restartPersistentServiceIfInstalled(loadConfig(false));
|
|
132
132
|
console.log('Usage metrics enabled and the agent restarted to apply it.');
|
|
133
133
|
return;
|
|
134
134
|
}
|
|
135
135
|
if (action === 'off' || action === 'disable') {
|
|
136
136
|
console.log(JSON.stringify(setTelemetry(false), null, 2));
|
|
137
|
-
restartPersistentServiceIfInstalled();
|
|
137
|
+
restartPersistentServiceIfInstalled(loadConfig(false));
|
|
138
138
|
console.log('Usage metrics disabled and the agent restarted to apply it.');
|
|
139
139
|
return;
|
|
140
140
|
}
|
|
@@ -162,7 +162,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
162
162
|
if (!['on', 'off', 'enable', 'disable'].includes(action)) throw new Error('Usage: remcp godmode [status|on|off]');
|
|
163
163
|
const enabled = action === 'on' || action === 'enable';
|
|
164
164
|
writeJsonFile(runtimeConfigFile, { ...readJsonFile(runtimeConfigFile), unrestricted: enabled });
|
|
165
|
-
const restarted = restartPersistentServiceIfInstalled();
|
|
165
|
+
const restarted = restartPersistentServiceIfInstalled(loadConfig(false));
|
|
166
166
|
if (enabled) {
|
|
167
167
|
console.error('Unrestricted mode is ON for this computer: every path and every command is allowed, including sudo. Anything the model is asked to do -- and anything a prompt injection asks it to do -- can now change this machine. Turn it off with `remcp godmode off` when you are done.');
|
|
168
168
|
} else {
|
|
@@ -213,9 +213,12 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
213
213
|
}
|
|
214
214
|
|
|
215
215
|
if (command === 'uninstall') {
|
|
216
|
+
const cfg = loadConfig(false);
|
|
216
217
|
uninstallPersistentService();
|
|
218
|
+
// Keep the pairing unless --purge was requested, but remember that the owner explicitly removed
|
|
219
|
+
// the supervisor. Future updates must not infer an old service artifact and recreate it.
|
|
220
|
+
if (cfg) saveConfig({ ...cfg, serviceInstalled: false });
|
|
217
221
|
if (flags.purge) {
|
|
218
|
-
const cfg = loadConfig(false);
|
|
219
222
|
const specs = [PACKAGE_NAME, ...(cfg?.runtime?.packageName ? [cfg.runtime.packageName] : [])];
|
|
220
223
|
run(npm.command, [...npm.args, 'uninstall', '--global', ...specs, '--no-audit', '--no-fund', '--loglevel=error']);
|
|
221
224
|
}
|