@remcp/remcp 0.2.9 → 0.2.10

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.9",
3
+ "version": "0.2.10",
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
@@ -1,12 +1,12 @@
1
1
  import os from 'node:os';
2
- import { existsSync } from 'node:fs';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import process from 'node:process';
5
5
  import { spawn, spawnSync } from 'node:child_process';
6
6
  import WebSocket from 'ws';
7
7
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
8
8
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
9
- import { normalizeRuntime } from './runtime.mjs';
9
+ import { isRuntimeSpecFor, normalizeRuntime } from './runtime.mjs';
10
10
  import { VERSION } from './version.mjs';
11
11
 
12
12
  const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
@@ -82,18 +82,74 @@ function isNewer(candidate, current) {
82
82
  // What the agent should install, if anything. The client version alone is not enough: a machine
83
83
  // that already runs the newest client but an older local runtime would otherwise never catch up,
84
84
  // because its runtime is what executes the tools.
85
- export function updateDecision({ advertised, cliVersion, runtimeVersion }) {
85
+ export function updateDecision({ advertised, cliVersion, runtimeVersion, runtimePackageName, runtimeDown = false }) {
86
86
  const cliSpec = String(advertised?.cli || '');
87
- const runtimeSpec = String(advertised?.runtime || '');
87
+ const advertisedRuntime = String(advertised?.runtime || '');
88
+ // A spec that is not a plain version of the configured runtime package is ignored rather than
89
+ // installed: this is the only place a server-chosen string reaches npm.
90
+ const runtimeSpec = runtimePackageName && advertisedRuntime && !isRuntimeSpecFor(runtimePackageName, advertisedRuntime) ? '' : advertisedRuntime;
88
91
  const installedRuntime = String(runtimeVersion || '');
89
92
  const runtimeKnown = Boolean(installedRuntime) && !/^unknown$/i.test(installedRuntime);
90
93
  if (isNewer(cliSpec, cliVersion)) return { needed: true, target: cliSpec, runtime: runtimeSpec, reason: 'client' };
91
- if (runtimeKnown && runtimeSpec && isNewer(runtimeSpec, installedRuntime)) {
94
+ if (runtimeSpec && runtimeKnown && isNewer(runtimeSpec, installedRuntime)) {
92
95
  return { needed: true, target: cliSpec || `@remcp/remcp@${cliVersion}`, runtime: runtimeSpec, reason: 'runtime' };
93
96
  }
97
+ // A runtime that never reported a version cannot be compared, so a device whose runtime is down
98
+ // (or was never installed) would never repair itself. The cooldown in checkForUpdate keeps this
99
+ // from becoming an install loop.
100
+ if (runtimeSpec && !runtimeKnown && runtimeDown) {
101
+ return { needed: true, target: cliSpec || `@remcp/remcp@${cliVersion}`, runtime: runtimeSpec, reason: 'runtime-repair' };
102
+ }
94
103
  return { needed: false, target: cliSpec, runtime: runtimeSpec, reason: 'current' };
95
104
  }
96
105
 
106
+ // Applies a freshly installed version. Exiting is what a supervisor needs; without one the new CLI is
107
+ // started in this process' place. Either way the agent stops holding a stale runtime, which is what
108
+ // makes an update actually take effect on a machine that no service manager watches.
109
+ async function restartToApplyUpdate(cli, stopAgent, markStopping) {
110
+ try {
111
+ const installed = globalInstalledVersion();
112
+ if (installed && !isNewer(installed, VERSION)) {
113
+ console.log(`ReMCP ${VERSION} is already the installed version; nothing to restart.`);
114
+ return;
115
+ }
116
+ console.log(`ReMCP ${installed || 'a newer version'} installed; restarting to apply it.`);
117
+ if (!supervisorRestart()) {
118
+ spawn(process.execPath, [cli, 'start'], { detached: true, stdio: 'ignore', env: { ...process.env } }).unref();
119
+ }
120
+ markStopping();
121
+ await stopAgent().catch(() => {});
122
+ setTimeout(() => process.exit(0), 100);
123
+ } catch (error) {
124
+ console.error(`Could not restart after the update: ${error instanceof Error ? error.message : String(error)}`);
125
+ }
126
+ }
127
+
128
+ // The version of the globally installed client, read from the package the CLI resolves to.
129
+ function globalInstalledVersion() {
130
+ try {
131
+ const cli = globalCliEntry();
132
+ if (!cli) return null;
133
+ const base = path.dirname(cli);
134
+ const candidates = [
135
+ path.join(base, '..', 'lib', 'node_modules', '@remcp', 'remcp', 'package.json'),
136
+ path.join(base, '..', 'node_modules', '@remcp', 'remcp', 'package.json'),
137
+ ];
138
+ for (const manifest of candidates) {
139
+ try { return JSON.parse(readFileSync(manifest, 'utf8')).version || null; } catch {}
140
+ }
141
+ return null;
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+
147
+ function supervisorRestart() {
148
+ if (process.env.INVOCATION_ID || process.env.JOURNAL_STREAM) return 'systemd';
149
+ try { if (existsSync('/.dockerenv')) return 'docker'; } catch {}
150
+ return null;
151
+ }
152
+
97
153
  function globalCliEntry() {
98
154
  const prefix = spawnSync(npmCommand, ['prefix', '--global'], { encoding: 'utf8' });
99
155
  if (prefix.error || prefix.status !== 0) return null;
@@ -146,7 +202,15 @@ export async function runAgent(options) {
146
202
  let runtimeDown = false;
147
203
  const telemetryQueue = [];
148
204
  let telemetryTimer = null;
149
- const runtimeEntry = localRuntimeEntry(options.runtime);
205
+ // A device whose runtime is missing or unreadable must still run the agent: the agent is what
206
+ // installs and repairs the runtime, so failing here would remove the only path back.
207
+ let runtimeEntry = '';
208
+ try {
209
+ runtimeEntry = localRuntimeEntry(options.runtime);
210
+ } catch (error) {
211
+ runtimeDown = true;
212
+ console.error(`${error instanceof Error ? error.message : String(error)} The agent keeps running and retries; remcp update reinstalls the runtime.`);
213
+ }
150
214
 
151
215
  // --- local runtime supervision ------------------------------------------------------
152
216
  // If the runtime dies (a bad shell, a broken pipe, an OOM) the agent used to stay
@@ -324,10 +388,12 @@ export async function runAgent(options) {
324
388
  return;
325
389
  }
326
390
  if (code === 1012) {
327
- // 1012 ('service restart') is what the relay sends when another agent process took over
328
- // this device. Reconnecting immediately produced two agents evicting each other in a loop,
329
- // so back off and let the surviving process keep the connection.
330
- console.error('Another ReMCP agent connected for this device; this process will stop. Run one agent per machine (systemd service or `remcp start`).');
391
+ // 1012 ('service restart') is what the relay sends when another agent process took over this
392
+ // device. Staying alive would keep a second runtime and a reconnect loop, so this process
393
+ // stops and leaves the device to the agent that owns the connection.
394
+ 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).');
395
+ stopping = true;
396
+ void stop().finally(() => setTimeout(() => process.exit(0), 100));
331
397
  return;
332
398
  }
333
399
  reconnects += 1;
@@ -358,7 +424,7 @@ export async function runAgent(options) {
358
424
  if (minimum && isNewer(minimum, VERSION)) {
359
425
  console.error(`ReMCP ${VERSION} is older than the minimum supported agent ${minimum}; update with: remcp update`);
360
426
  }
361
- const decision = updateDecision({ advertised, cliVersion: VERSION, runtimeVersion });
427
+ const decision = updateDecision({ advertised, cliVersion: VERSION, runtimeVersion, runtimePackageName: options.runtime?.packageName, runtimeDown });
362
428
  if (!decision.needed) return;
363
429
  const target = decision.target;
364
430
  queueEvent({ event: 'agent_update', at: Date.now(), reason: `${decision.reason}:${target}`.slice(0, 32), success: true });
@@ -375,13 +441,34 @@ export async function runAgent(options) {
375
441
  lastAttemptAt = Date.now();
376
442
  updateInFlight = true;
377
443
  console.log(`Updating ReMCP to ${target}${decision.runtime ? ` with ${decision.runtime}` : ''} (${decision.reason})…`);
378
- const child = spawn(process.execPath, [cli, 'update', '--trust-runtime', ...(decision.runtime ? ['--runtime', decision.runtime] : [])], {
444
+ // The trust flag is only forwarded when this machine's owner trusted the server at pairing
445
+ // time; otherwise the update stops at the server's own version and asks the user.
446
+ const trustFlag = options.trustRuntime === true ? ['--trust-runtime'] : [];
447
+ const child = spawn(process.execPath, [cli, 'update', ...trustFlag, ...(decision.runtime ? ['--runtime', decision.runtime] : [])], {
379
448
  detached: true,
380
- stdio: 'ignore',
449
+ // The updater's own output is the only record of why an install failed, so it is piped back
450
+ // into this agent's log instead of being discarded.
451
+ stdio: ['ignore', 'pipe', 'pipe'],
381
452
  env: { ...process.env },
382
453
  });
383
- child.on('exit', () => { updateInFlight = false; });
384
- child.on('error', () => { updateInFlight = false; });
454
+ const forward = chunk => {
455
+ const line = String(chunk).trim();
456
+ if (line) console.error(`remcp update: ${line}`);
457
+ };
458
+ child.stdout?.on('data', forward);
459
+ child.stderr?.on('data', forward);
460
+ child.on('exit', code => {
461
+ updateInFlight = false;
462
+ if (code !== 0) {
463
+ console.error(`remcp update exited with ${code}; keeping ${VERSION} and retrying after the cooldown.`);
464
+ return;
465
+ }
466
+ void restartToApplyUpdate(cli, stop, () => { stopping = true; });
467
+ });
468
+ child.on('error', error => {
469
+ updateInFlight = false;
470
+ console.error(`remcp update could not start: ${error.message}`);
471
+ });
385
472
  child.unref();
386
473
  } catch {
387
474
  // Offline, DNS failure, older server without the endpoint: keep running as-is.
package/src/cli.mjs CHANGED
@@ -5,7 +5,7 @@ import process from 'node:process';
5
5
  import { spawnSync } from 'node:child_process';
6
6
  import { randomUUID } from 'node:crypto';
7
7
  import { runAgent } from './agent.mjs';
8
- import { normalizeRuntime } from './runtime.mjs';
8
+ import { isRuntimeSpecFor, normalizeRuntime } from './runtime.mjs';
9
9
  import { PACKAGE_NAME, VERSION } from './version.mjs';
10
10
 
11
11
  const home = os.homedir();
@@ -200,12 +200,41 @@ function restartPersistentServiceIfInstalled() {
200
200
  if (platform === 'linux' && fs.existsSync(linuxServiceFile)) {
201
201
  run('systemctl', ['--user', 'daemon-reload']);
202
202
  run('systemctl', ['--user', 'restart', 'remcp-agent.service']);
203
- } else if (platform === 'darwin' && fs.existsSync(macServiceFile)) {
203
+ return 'remcp-agent.service';
204
+ }
205
+ if (platform === 'darwin' && fs.existsSync(macServiceFile)) {
204
206
  run('launchctl', ['kickstart', '-k', `${macLaunchDomain()}/${macServiceLabel}`]);
205
- } else if (platform === 'win32') {
207
+ return macServiceLabel;
208
+ }
209
+ if (platform === 'win32') {
206
210
  const result = spawnSync('schtasks.exe', ['/Query', '/TN', windowsTaskName], { stdio: 'ignore' });
207
- if (result.status === 0) run('schtasks.exe', ['/Run', '/TN', windowsTaskName]);
211
+ if (result.status === 0) {
212
+ run('schtasks.exe', ['/Run', '/TN', windowsTaskName]);
213
+ return windowsTaskName;
214
+ }
208
215
  }
216
+ return null;
217
+ }
218
+
219
+ // The agent the user installed with `remcp install` is the one this CLI manages. A machine can also
220
+ // be supervised by its own systemd unit, by Docker, or by a terminal, and in those cases installing
221
+ // a new version is not enough: the running process keeps the old code until something restarts it.
222
+ // systemd marks every unit process with INVOCATION_ID and Docker leaves /.dockerenv, so those two
223
+ // cases can be handed over by exiting (the supervisor starts the new build); anything else gets an
224
+ // explicit instruction instead of a silent exit that would take the device offline.
225
+ function supervisorRestart() {
226
+ if (process.env.INVOCATION_ID || process.env.JOURNAL_STREAM) return 'systemd';
227
+ try { if (fs.existsSync('/.dockerenv')) return 'docker'; } catch {}
228
+ return null;
229
+ }
230
+
231
+ // Reads the version a freshly installed global package reports, so an update that installed
232
+ // nothing (wrong prefix, npm cache, permissions) is reported instead of assumed successful.
233
+ function installedVersion(packageName) {
234
+ const prefix = spawnSync(npmCommand, ['prefix', '--global'], { encoding: 'utf8' });
235
+ if (prefix.error || prefix.status !== 0) return null;
236
+ const manifest = path.join(String(prefix.stdout || '').trim(), 'lib', 'node_modules', ...packageName.split('/'), 'package.json');
237
+ try { return JSON.parse(fs.readFileSync(manifest, 'utf8')).version || null; } catch { return null; }
209
238
  }
210
239
 
211
240
  function uninstallPersistentService() {
@@ -262,6 +291,9 @@ export async function main(argv = process.argv.slice(2)) {
262
291
  deviceName: String(flags.name || os.hostname()),
263
292
  runtime: normalizeRuntime(paired.runtime),
264
293
  machineId: ensureMachineId(),
294
+ // Remember whether this machine's owner trusted the server to name a runtime version. The
295
+ // auto-updater must not widen that decision on its own later.
296
+ trustRuntime: Boolean(flags['trust-runtime']) || new URL(server).origin === officialOrigin,
265
297
  };
266
298
  saveConfig(config);
267
299
  console.log(`Paired ${os.hostname()} with ${server}`);
@@ -275,6 +307,9 @@ export async function main(argv = process.argv.slice(2)) {
275
307
  await runAgent({
276
308
  ...cfg,
277
309
  autoUpdate: cfg.autoUpdate !== false,
310
+ trustRuntime: cfg.trustRuntime === true
311
+ || process.env.REMCP_TRUST_RUNTIME === '1'
312
+ || new URL(cfg.serverUrl).origin === officialOrigin,
278
313
  telemetryEnabled: telemetry.enabled,
279
314
  installReported: telemetry.installReported,
280
315
  installSpec: `${PACKAGE_NAME}@${VERSION}`,
@@ -338,26 +373,50 @@ export async function main(argv = process.argv.slice(2)) {
338
373
  // parses, and an explicit --trust-runtime before a custom server may change it.
339
374
  let runtimeSpec = cfg.runtime.packageSpec;
340
375
  if (requested) {
341
- const parsed = requested.match(/^(@?[a-z0-9._-]+(?:\/[a-z0-9._-]+)?)@(\S+)$/i);
342
- if (!parsed) throw new Error('--runtime must look like @scope/package@1.2.3');
343
- if (parsed[1] !== cfg.runtime.packageName) {
344
- throw new Error(`--runtime must stay on ${cfg.runtime.packageName}; refusing to install ${parsed[1]}`);
376
+ // Only `<configured package>@<semver>` is installable: an alias, a git/URL/file spec, a tag or
377
+ // a range would run code the user never agreed to.
378
+ if (!isRuntimeSpecFor(cfg.runtime.packageName, requested)) {
379
+ throw new Error(`--runtime must be ${cfg.runtime.packageName}@<version>`);
345
380
  }
346
- if (!flags['trust-runtime'] && !flags['yes']) {
347
- throw new Error('Installing a runtime version from the server requires --trust-runtime.');
381
+ const trusted = cfg.trustRuntime === true
382
+ || Boolean(flags['trust-runtime'])
383
+ || process.env.REMCP_TRUST_RUNTIME === '1'
384
+ // A configuration written before the field existed paired with the official server, which is
385
+ // trusted by definition; refusing it would silently stop every existing device updating.
386
+ || new URL(cfg.serverUrl).origin === officialOrigin;
387
+ if (!trusted) {
388
+ throw new Error(`This machine was paired without trusting ${cfg.serverUrl} to choose a runtime version. Re-run with --trust-runtime if you trust that server.`);
348
389
  }
349
- runtimeSpec = normalizeRuntime({ kind: 'npm', packageName: parsed[1], packageSpec: requested, entry: cfg.runtime.entry }).packageSpec;
390
+ runtimeSpec = normalizeRuntime({ kind: 'npm', packageName: cfg.runtime.packageName, packageSpec: requested, entry: cfg.runtime.entry }).packageSpec;
350
391
  }
351
392
  if (flags.check) {
352
- console.log(JSON.stringify({ current: VERSION, runtime: cfg.runtime.packageSpec, available: `${PACKAGE_NAME}@latest` }, null, 2));
393
+ console.log(JSON.stringify({
394
+ current: VERSION,
395
+ installedRuntime: installedVersion(cfg.runtime.packageName),
396
+ runtimeSpec: runtimeSpec,
397
+ available: `${PACKAGE_NAME}@latest`,
398
+ managedService: fs.existsSync(linuxServiceFile) || fs.existsSync(macServiceFile),
399
+ supervisor: supervisorRestart() ?? 'none',
400
+ }, null, 2));
353
401
  return;
354
402
  }
403
+ const before = { cli: VERSION, runtime: installedVersion(cfg.runtime.packageName) };
355
404
  console.log(`Updating ReMCP to the latest published version (${runtimeSpec})…`);
356
405
  npmGlobalInstall(`${PACKAGE_NAME}@latest`, runtimeSpec);
357
406
  // Only a validated spec is persisted, so a failed update cannot leave the install unable to start.
358
407
  if (requested && runtimeSpec !== cfg.runtime.packageSpec) saveConfig({ ...cfg, runtime: { ...cfg.runtime, packageSpec: runtimeSpec } });
359
- restartPersistentServiceIfInstalled();
360
- console.log('ReMCP updated. Run `remcp --version` or `remcp status` to verify.');
408
+ const after = { cli: installedVersion(PACKAGE_NAME), runtime: installedVersion(cfg.runtime.packageName) };
409
+ const restarted = restartPersistentServiceIfInstalled();
410
+ if (restarted) {
411
+ console.log(`ReMCP updated and ${restarted} restarted (client ${before.cli} → ${after.cli ?? '?'}, runtime ${before.runtime ?? '?'} → ${after.runtime ?? '?'}).`);
412
+ return;
413
+ }
414
+ // This process is the updater, not the agent: exiting here would restart nothing. The agent sees
415
+ // the exit status, verifies the installed version, and restarts itself.
416
+ console.log(`ReMCP updated (client ${before.cli} → ${after.cli ?? '?'}, runtime ${before.runtime ?? '?'} → ${after.runtime ?? '?'}). The running agent restarts itself to apply it.`);
417
+ if (after.cli === before.cli && after.runtime === before.runtime) {
418
+ console.log('Nothing changed: the installed versions already match the requested ones.');
419
+ }
361
420
  return;
362
421
  }
363
422
 
package/src/runtime.mjs CHANGED
@@ -1,7 +1,10 @@
1
1
  import path from 'node:path';
2
2
 
3
3
  const PACKAGE_NAME = /^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/i;
4
- const PACKAGE_SPEC = /^[^\s]+$/;
4
+ // A runtime spec is a plain published version of one package. Anything else — an npm alias
5
+ // (`npm:@other/pkg`), a git/https/file spec, a tag, or a version range — is a way to make a device
6
+ // install and execute code the user never agreed to, so only `name@x.y.z[-pre][+build]` is accepted.
7
+ const SEMVER = '\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?';
5
8
 
6
9
  export function normalizeRuntime(value) {
7
10
  if (!value || value.kind !== 'npm') throw new Error('Pairing server did not provide a supported local runtime');
@@ -9,7 +12,15 @@ export function normalizeRuntime(value) {
9
12
  const packageSpec = String(value.packageSpec || '');
10
13
  const entry = String(value.entry || '');
11
14
  if (!PACKAGE_NAME.test(packageName)) throw new Error('Pairing server returned an invalid runtime package name');
12
- if (!PACKAGE_SPEC.test(packageSpec) || !packageSpec.startsWith(`${packageName}@`)) throw new Error('Pairing server returned an invalid runtime package spec');
15
+ if (!isRuntimeSpecFor(packageName, packageSpec)) throw new Error(`Runtime package spec must be ${packageName}@<version>`);
13
16
  if (!entry || path.isAbsolute(entry) || entry.split(/[\\/]+/).includes('..')) throw new Error('Pairing server returned an invalid runtime entry');
14
17
  return { kind: 'npm', packageName, packageSpec, entry };
15
18
  }
19
+
20
+ // True only for `<packageName>@<semver>` of exactly that package. Exported so the CLI and the agent
21
+ // can validate a server-advertised version without repeating the rule.
22
+ export function isRuntimeSpecFor(packageName, packageSpec) {
23
+ if (!PACKAGE_NAME.test(String(packageName || ''))) return false;
24
+ const escaped = String(packageName).replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
25
+ return new RegExp(`^${escaped}@(${SEMVER})$`).test(String(packageSpec || ''));
26
+ }