@remcp/remcp 0.2.202 → 0.2.204

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.202",
3
+ "version": "0.2.204",
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",
@@ -2,6 +2,7 @@
2
2
  // the server uses to recognise this computer across pairings.
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
+ import process from 'node:process';
5
6
  import { randomUUID } from 'node:crypto';
6
7
  import { normalizeRuntime } from '../runtime.mjs';
7
8
 
@@ -78,6 +79,15 @@ export function writeJsonFile(file, value) {
78
79
  fs.chmodSync(file, 0o600);
79
80
  }
80
81
 
82
+ export function autoUpdateEnabled(config, platform = process.platform) {
83
+ // Linux often runs unrelated applications through the same global npm prefix. Never
84
+ // mutate that prefix implicitly: require `remcp auto-update on` for Linux agents.
85
+ // Keep the historical preference for existing explicit opt-ins and other platforms.
86
+ if (config?.autoUpdate === true) return true;
87
+ if (config?.autoUpdate === false) return false;
88
+ return platform !== 'linux';
89
+ }
90
+
81
91
  export function flagEnabled(value) {
82
92
  return value === undefined || value === null ? true : value !== false;
83
93
  }
@@ -11,7 +11,26 @@ export function servicePlatform() {
11
11
  return process.env.NODE_ENV === 'test' && process.env.REMCP_TEST_PLATFORM ? process.env.REMCP_TEST_PLATFORM : process.platform;
12
12
  }
13
13
 
14
+ export function assertSafeReMcpNpmPrefix(value) {
15
+ const prefix = String(value || '').trim();
16
+ if (!path.isAbsolute(prefix)) throw new Error('ReMCP update refused: npm global prefix must be an absolute directory.');
17
+ const normalized = path.resolve(prefix);
18
+ const parts = normalized.split(path.sep).filter(Boolean);
19
+ // An inherited NPM_CONFIG_PREFIX aimed inside an already-installed package creates a
20
+ // second copy in its lib/node_modules. npm then reports success but systemd still starts
21
+ // the old agent, causing endless replacement attempts and offline device flapping.
22
+ for (let index = 0; index < parts.length - 2; index += 1) {
23
+ if (parts[index] === 'node_modules' && parts[index + 1] === '@remcp'
24
+ && (parts[index + 2] === 'remcp' || parts[index + 2] === 'runtime')) {
25
+ throw new Error('ReMCP update refused: npm prefix points inside a ReMCP package. Repair the service launcher before retrying.');
26
+ }
27
+ }
28
+ return normalized;
29
+ }
30
+
14
31
  export function globalPrefix() {
32
+ // Read-only diagnostics must work even when a legacy launcher exports a broken prefix.
33
+ // Validate only at installation/update write boundaries, not while discovering paths.
15
34
  return output(npm.command, [...npm.args, 'prefix', '--global']);
16
35
  }
17
36
 
@@ -34,6 +53,7 @@ function npmGlobalInstallArgs(specs, { preferOnline = false } = {}) {
34
53
  }
35
54
 
36
55
  export function npmGlobalInstall(...specs) {
56
+ assertSafeReMcpNpmPrefix(globalPrefix()); // Reject an unsafe prefix before npm writes anything.
37
57
  run(npm.command, [...npm.args, ...npmGlobalInstallArgs(specs)]);
38
58
  if (servicePlatform() === 'darwin' && specs.some(spec => /^@remcp\/runtime@[0-9]/.test(spec))) {
39
59
  try {
@@ -198,7 +218,7 @@ function transactionalWindowsGlobalInstall({ resolved, prefix, packageNames, spe
198
218
  // version, so updates revalidate metadata while retaining npm's cache for package bytes.
199
219
  export function npmGlobalUpdate(packageNames, ...specs) {
200
220
  const platform = servicePlatform();
201
- const prefix = globalPrefix();
221
+ const prefix = assertSafeReMcpNpmPrefix(globalPrefix());
202
222
  if (platform === 'darwin') {
203
223
  transactionalMacGlobalInstall({ resolved:npm, prefix, packageNames, specs, preferOnline:true });
204
224
  return;
@@ -213,7 +233,7 @@ export function npmGlobalUpdate(packageNames, ...specs) {
213
233
  export function npmGlobalUpdateForNode(nodePath, packageNames, ...specs) {
214
234
  const platform = servicePlatform();
215
235
  const resolved = resolveNpm({ nodePath, home, platform });
216
- const prefix = output(resolved.command, [...resolved.args, 'prefix', '--global']);
236
+ const prefix = assertSafeReMcpNpmPrefix(output(resolved.command, [...resolved.args, 'prefix', '--global']));
217
237
  if (platform === 'darwin') {
218
238
  transactionalMacGlobalInstall({ resolved, prefix, packageNames, specs, preferOnline:true });
219
239
  return;
@@ -189,10 +189,11 @@ export function rememberCurrentInstallation(config, { service = false } = {}) {
189
189
  export function syncKnownInstallations(config, ...specs) {
190
190
  const serviceInfo = serviceInstallationInfo(config);
191
191
  const current = currentInstallationInfo(config);
192
- const candidates = [
193
- ...(Array.isArray(config?.installations) ? config.installations : []),
194
- ...(serviceInfo.nodePath ? [{ nodePath:serviceInfo.nodePath, cliPath:serviceInfo.cliPath, service:true }] : []),
195
- ];
192
+ // Only the active service installation is mandatory. Other recorded Node manager prefixes
193
+ // can host unrelated user scripts and must not be mutated by an automatic agent update.
194
+ const candidates = serviceInfo.nodePath
195
+ ? [{ nodePath:serviceInfo.nodePath, cliPath:serviceInfo.cliPath, service:true }]
196
+ : [];
196
197
  const currentNode = (() => { try { return fs.realpathSync(current.nodePath); } catch { return current.nodePath; } })();
197
198
  const seen = new Set();
198
199
  const results = [];
@@ -29,8 +29,14 @@ export function linuxServiceLauncherText(cliPath, nodePath) {
29
29
  if (!path.isAbsolute(cliPath) || !path.isAbsolute(nodePath) || path.basename(path.dirname(cliPath)) !== 'bin') {
30
30
  throw new Error('Linux service needs absolute CLI and Node paths in a global bin directory');
31
31
  }
32
- const prefix = path.dirname(path.dirname(cliPath));
33
32
  const cliScript = resolvedCliScript(cliPath);
33
+ // A saved service CLI is often the resolved package entry rather than the global bin shim.
34
+ // Using dirname(dirname(cliPath)) for that case targets the package itself as npm prefix,
35
+ // nesting each update under @remcp/remcp/lib/node_modules while leaving the agent stale.
36
+ const packageEntrySuffix = path.join('lib', 'node_modules', ...PACKAGE_NAME.split('/'), 'bin', 'remcp.mjs');
37
+ const prefix = cliScript.endsWith(`${path.sep}${packageEntrySuffix}`)
38
+ ? cliScript.slice(0, -(packageEntrySuffix.length + 1)) || path.sep
39
+ : path.dirname(path.dirname(cliPath));
34
40
  const lines = [
35
41
  '#!/bin/sh',
36
42
  'NPM_CONFIG_PREFIX=' + shellQuote(prefix),
@@ -12,7 +12,7 @@ import { loadConfig, saveConfig } from './config.mjs';
12
12
  import { assertSecureServerUrl } from './connect.mjs';
13
13
  import { installedVersion } from './doctor.mjs';
14
14
  import { linuxServiceFile, macServiceFile, officialOrigin } from './env.mjs';
15
- import { currentInstallationInfo, ensureMacCliCommand, ensureServiceIfRecorded, installationVersionsAtCliPath, npmGlobalUpdate, persistentServiceExpected, rememberCurrentInstallation, restartPersistentServiceIfInstalled, serviceInstallationInfo, syncKnownInstallations } from './service.mjs';
15
+ import { currentInstallationInfo, ensureMacCliCommand, ensureServiceIfRecorded, npmGlobalUpdate, persistentServiceExpected, rememberCurrentInstallation, restartPersistentServiceIfInstalled, serviceInstallationInfo, syncKnownInstallations } from './service.mjs';
16
16
 
17
17
  const UPDATE_DISCOVERY_TIMEOUT_MS = 5000;
18
18
 
@@ -188,19 +188,19 @@ export function updateAlreadyCurrent({ cfg, targets, currentInstall, serviceInst
188
188
  if (String(serviceInstall?.runtimeVersion || '').trim() !== targetRuntimeVersion) return false;
189
189
  }
190
190
 
191
- // A machine can have more than one Node manager. Do not claim "already current" while a known,
192
- // still-existing ReMCP installation is recorded on an older release; the normal update path will
193
- // converge it through syncKnownInstallations().
194
- for (const item of Array.isArray(cfg?.installations) ? cfg.installations : []) {
195
- const cliPath = String(item?.cliPath || '').trim();
196
- if (!cliPath || !fs.existsSync(cliPath)) continue;
197
- const actual = installationVersionsAtCliPath(cliPath, cfg.runtime.packageName);
198
- if (String(actual.cliVersion || '').trim() !== targetClientVersion) return false;
199
- if (String(actual.runtimeVersion || '').trim() !== targetRuntimeVersion) return false;
200
- }
191
+ // Historic secondary installations may belong to other Node managers and applications.
192
+ // Never let one stale record force an automatic update of the active, healthy service.
201
193
  return true;
202
194
  }
203
195
 
196
+ export function verifyManagedServiceReleasePair(serviceInstall, clientSpec, runtimePackageName, runtimeSpec) {
197
+ const expectedClient = specVersion(PACKAGE_NAME, clientSpec);
198
+ const expectedRuntime = specVersion(runtimePackageName, runtimeSpec);
199
+ if (!expectedClient || !expectedRuntime || serviceInstall?.cliVersion !== expectedClient || serviceInstall?.runtimeVersion !== expectedRuntime) {
200
+ throw new Error(`ReMCP update refused to restart: managed service still runs client ${serviceInstall?.cliVersion || 'missing'} / runtime ${serviceInstall?.runtimeVersion || 'missing'}; expected ${expectedClient || 'invalid'} / ${expectedRuntime || 'invalid'}. Repair npm prefix and retry.`);
201
+ }
202
+ }
203
+
204
204
  export async function updateCommand(flags) {
205
205
  let cfg = rememberCurrentInstallation(loadConfig());
206
206
  const targets = await resolveUpdateTargets({ cfg, flags });
@@ -253,14 +253,25 @@ export async function updateCommand(flags) {
253
253
  console.log(`Updating ReMCP to ${targets.clientSpec} with ${targets.runtimeSpec}…`);
254
254
  npmGlobalUpdate([PACKAGE_NAME, cfg.runtime.packageName], targets.clientSpec, targets.runtimeSpec);
255
255
 
256
- // Converge every ReMCP installation this account has actually used on this machine. The canonical
257
- // service installation is mandatory; secondary nvm/Hermes/Homebrew copies are best-effort and
258
- // remain visible in status if they cannot be updated.
256
+ // Converge only the canonical service installation. Never update secondary nvm/Hermes/
257
+ // Homebrew installations implicitly: they may be managed by other applications.
259
258
  const synced = syncKnownInstallations(cfg, targets.clientSpec, targets.runtimeSpec);
260
259
  for (const item of synced) {
261
260
  if (!item.ok) console.error(`ReMCP update: could not sync secondary installation at ${item.nodePath}: ${item.error}`);
262
261
  }
263
262
 
263
+ // npm can report a successful install into a wrong global prefix. Before touching
264
+ // systemd/launchd, verify the exact release pair at the canonical service CLI path.
265
+ // Otherwise a stale launcher repeatedly restarts the old agent and loses device RPCs.
266
+ // Exact server-advertised or explicit release pairs must match the service on disk.
267
+ // A legacy manual `remcp update` against an untrusted/custom server may use @latest,
268
+ // for which no exact version is known until npm resolves it. Keep that manual path
269
+ // compatible; the automatic agent always supplies an exact version.
270
+ if (serviceExpected && specVersion(PACKAGE_NAME, targets.clientSpec)
271
+ && specVersion(cfg.runtime.packageName, targets.runtimeSpec)) {
272
+ verifyManagedServiceReleasePair(serviceInstallationInfo(cfg), targets.clientSpec, cfg.runtime.packageName, targets.runtimeSpec);
273
+ }
274
+
264
275
  // Persist the validated runtime pair before repairing the supervisor so a repair cannot be
265
276
  // overwritten by an older in-memory config object.
266
277
  if (targets.persistRuntime) {
package/src/cli.mjs CHANGED
@@ -7,7 +7,7 @@ import { normalizeRuntime } from './runtime.mjs';
7
7
  import { PACKAGE_NAME, VERSION } from './version.mjs';
8
8
  import { probeFilesystemAccess } from './fs-access.mjs';
9
9
 
10
- import { ensureMachineId, loadConfig, readJsonFile, saveConfig, setTelemetry, telemetryState, writeJsonFile } from './cli/config.mjs';
10
+ import { autoUpdateEnabled, ensureMachineId, loadConfig, readJsonFile, saveConfig, setTelemetry, telemetryState, writeJsonFile } from './cli/config.mjs';
11
11
  import { assertRuntimeTrust, assertSecureServerUrl, pairWithDeviceCode } from './cli/connect.mjs';
12
12
  import { diagnoseLocalRuntime, installedVersion, runtimeAllowedRoots } from './cli/doctor.mjs';
13
13
  import { configFile, npm, officialOrigin, runtimeConfigFile } from './cli/env.mjs';
@@ -40,6 +40,13 @@ function printHelp() {
40
40
  export async function main(argv = process.argv.slice(2)) {
41
41
  const { command, flags, positional } = parse(argv);
42
42
 
43
+ // Help must be side-effect free even for mutating subcommands. Older agents
44
+ // incorrectly treated `remcp update --help` as an update and ran npm.
45
+ if (command === '--help' || command === '-h' || flags.help === true || flags.h === true) {
46
+ printHelp();
47
+ return;
48
+ }
49
+
43
50
  if (command === '--version' || command === '-v' || command === 'version') {
44
51
  console.log(VERSION);
45
52
  return;
@@ -144,7 +151,7 @@ export async function main(argv = process.argv.slice(2)) {
144
151
  const telemetry = telemetryState();
145
152
  await runAgent({
146
153
  ...cfg,
147
- autoUpdate: cfg.autoUpdate !== false,
154
+ autoUpdate: autoUpdateEnabled(cfg),
148
155
  trustRuntime: cfg.trustRuntime === true
149
156
  || process.env.REMCP_TRUST_RUNTIME === '1'
150
157
  || new URL(cfg.serverUrl).origin === officialOrigin,
@@ -160,7 +167,7 @@ export async function main(argv = process.argv.slice(2)) {
160
167
  const action = String(positional[0] || 'status').toLowerCase();
161
168
  const cfg = loadConfig(false) || {};
162
169
  if (action === 'status') {
163
- console.log(JSON.stringify({ autoUpdate: cfg.autoUpdate !== false, checkIntervalHours: 6 }, null, 2));
170
+ console.log(JSON.stringify({ autoUpdate: autoUpdateEnabled(cfg), checkIntervalHours: 6 }, null, 2));
164
171
  return;
165
172
  }
166
173
  if (action === 'on' || action === 'enable') { saveConfig({ ...cfg, autoUpdate: true }); console.log('Auto-update enabled.'); return; }
@@ -301,7 +308,7 @@ export async function main(argv = process.argv.slice(2)) {
301
308
  ...(health.error ? { error:health.error } : {}),
302
309
  },
303
310
  update:{
304
- auto:cfg.autoUpdate !== false,
311
+ auto:autoUpdateEnabled(cfg),
305
312
  targetClient,
306
313
  targetRuntime,
307
314
  multipleInstallations,