@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/remcp",
3
- "version": "0.2.42",
3
+ "version": "0.2.44",
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",
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
- // A revoked device is refused during the handshake with a 401 and this header, because a bare
254
- // rejection looked like a network problem (close 1006) and the agent retried it forever.
255
- ws.on('unexpected-response', (_request, response) => {
256
- if (String(response.headers['x-remcp-revoked'] || '') === '1') revoked = true;
257
- console.error(`ReMCP relay refused the connection (HTTP ${response.statusCode})${revoked ? ': this device was revoked' : ''}.`);
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
- setTimeout(connect, 150);
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
- reconnects += 1;
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);
@@ -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 value = JSON.parse(fs.readFileSync(configFile, 'utf8'));
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(value, null, 2) + '\n', { mode: 0o600 });
67
+ fs.writeFileSync(configFile, JSON.stringify(persisted, null, 2) + '\n', { mode: 0o600 });
25
68
  fs.chmodSync(configFile, 0o600);
26
69
  }
27
70
 
@@ -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?.serviceInstalled !== true) return false;
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
- run('launchctl', ['kickstart', '-k', `${macLaunchDomain()}/${macServiceLabel}`]);
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') {
@@ -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
  }