@remcp/remcp 0.2.39 → 0.2.41

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.39",
3
+ "version": "0.2.41",
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",
@@ -4,7 +4,7 @@ import process from 'node:process';
4
4
  import { spawn, spawnSync } from 'node:child_process';
5
5
  import { resolveNpm } from './npm.mjs';
6
6
  import { isRuntimeSpecFor, normalizeRuntime } from './runtime.mjs';
7
- import { VERSION } from './version.mjs';
7
+ import { PACKAGE_NAME, VERSION } from './version.mjs';
8
8
 
9
9
  const npm = resolveNpm();
10
10
  // How long a handing-over agent waits for its replacement to take the device over before it keeps
@@ -61,15 +61,27 @@ function isNewer(candidate, current) {
61
61
  return false;
62
62
  }
63
63
 
64
+ export function updateInvocationArgs({ target, runtime }, trustRuntime = false) {
65
+ return [
66
+ 'update',
67
+ ...(trustRuntime ? ['--trust-runtime'] : []),
68
+ ...(target ? ['--client', target] : []),
69
+ ...(runtime ? ['--runtime', runtime] : []),
70
+ ];
71
+ }
72
+
64
73
  // What the agent should install, if anything. The client version alone is not enough: a machine
65
74
  // that already runs the newest client but an older local runtime would otherwise never catch up,
66
75
  // because its runtime is what executes the tools.
67
76
  export function updateDecision({ advertised, cliVersion, runtimeVersion, runtimePackageName, runtimeDown = false }) {
68
- const cliSpec = String(advertised?.cli || '');
77
+ const advertisedCli = String(advertised?.cli || '');
69
78
  const advertisedRuntime = String(advertised?.runtime || '');
70
- // A spec that is not a plain version of the configured runtime package is ignored rather than
71
- // installed: this is the only place a server-chosen string reaches npm.
72
- const runtimeSpec = runtimePackageName && advertisedRuntime && !isRuntimeSpecFor(runtimePackageName, advertisedRuntime) ? '' : advertisedRuntime;
79
+ // Only exact versions of the first-party client and the configured runtime package may cross
80
+ // from server metadata into the updater. Invalid specs are ignored instead of being handed to npm.
81
+ const cliSpec = advertisedCli && isRuntimeSpecFor(PACKAGE_NAME, advertisedCli) ? advertisedCli : '';
82
+ const runtimeSpec = runtimePackageName && advertisedRuntime && isRuntimeSpecFor(runtimePackageName, advertisedRuntime)
83
+ ? advertisedRuntime
84
+ : '';
73
85
  const installedRuntime = String(runtimeVersion || '');
74
86
  const runtimeKnown = Boolean(installedRuntime) && !/^unknown$/i.test(installedRuntime);
75
87
  if (isNewer(cliSpec, cliVersion)) return { needed: true, target: cliSpec, runtime: runtimeSpec, reason: 'client' };
package/src/agent.mjs CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  restartToApplyUpdate,
14
14
  supervisorRestart,
15
15
  updateDecision,
16
+ updateInvocationArgs,
16
17
  } from './agent-update.mjs';
17
18
 
18
19
  export { localRuntimeEntry, supervisorRestart, updateDecision } from './agent-update.mjs';
@@ -369,10 +370,10 @@ export async function runAgent(options) {
369
370
  lastAttemptAt = Date.now();
370
371
  updateInFlight = true;
371
372
  console.log(`Updating ReMCP to ${target}${decision.runtime ? ` with ${decision.runtime}` : ''} (${decision.reason})…`);
372
- // The trust flag is only forwarded when this machine's owner trusted the server at pairing
373
- // time; otherwise the update stops at the server's own version and asks the user.
374
- const trustFlag = options.trustRuntime === true ? ['--trust-runtime'] : [];
375
- const child = spawn(process.execPath, [cli, 'update', ...trustFlag, ...(decision.runtime ? ['--runtime', decision.runtime] : [])], {
373
+ // Keep the updater pinned to the exact release pair this trusted server advertised. That
374
+ // prevents a newer public npm tag from getting ahead of production during a public-first rollout.
375
+ const updateArgs = updateInvocationArgs(decision, options.trustRuntime === true);
376
+ const child = spawn(process.execPath, [cli, ...updateArgs], {
376
377
  detached: true,
377
378
  // The updater's own output is the only record of why an install failed, so it is piped back
378
379
  // into this agent's log instead of being discarded.
@@ -1,6 +1,6 @@
1
- // `remcp update`: install what the server advertises, then let whoever supervises the agent restart
2
- // it. Only a spec the configured package can satisfy is installed, so a pairing response from a
3
- // custom server cannot point this machine at a different package or run a git/URL spec.
1
+ // `remcp update`: install the release pair advertised by the paired server, then let whoever
2
+ // supervises the agent restart it. Only exact versions of the configured first-party packages are
3
+ // installable, so a server cannot point this machine at a different package or a git/URL/tag spec.
4
4
  import fs from 'node:fs';
5
5
  import process from 'node:process';
6
6
 
@@ -13,51 +13,192 @@ import { installedVersion } from './doctor.mjs';
13
13
  import { linuxServiceFile, macServiceFile, officialOrigin } from './env.mjs';
14
14
  import { ensureServiceIfRecorded, npmGlobalInstall, restartPersistentServiceIfInstalled } from './service.mjs';
15
15
 
16
- export async function updateCommand(flags) {
17
- const cfg = loadConfig();
18
- // The server advertises which runtime version it expects; an agent that is updating
19
- // itself passes it through so client and runtime move together.
20
- const requested = typeof flags.runtime === 'string' ? flags.runtime.trim() : '';
21
- // Whatever the server advertises is installed globally, so it is validated exactly like the
22
- // metadata from a pairing response: same package as the configured runtime, a spec that
23
- // parses, and an explicit --trust-runtime before a custom server may change it.
24
- let runtimeSpec = cfg.runtime.packageSpec;
25
- if (requested) {
16
+ const UPDATE_DISCOVERY_TIMEOUT_MS = 5000;
17
+
18
+ function runtimeTrustAllowed(cfg, flags, env = process.env) {
19
+ return cfg.trustRuntime === true
20
+ || Boolean(flags['trust-runtime'])
21
+ || env.REMCP_TRUST_RUNTIME === '1'
22
+ // A configuration written before the field existed paired with the official server, which is
23
+ // trusted by definition; refusing it would silently stop every existing device updating.
24
+ || new URL(cfg.serverUrl).origin === officialOrigin;
25
+ }
26
+
27
+ function exactClientSpec(value) {
28
+ const spec = String(value || '').trim();
29
+ return isRuntimeSpecFor(PACKAGE_NAME, spec) ? spec : '';
30
+ }
31
+
32
+ function specVersion(packageName, spec) {
33
+ const value = String(spec || '');
34
+ return isRuntimeSpecFor(packageName, value) ? value.slice(`${packageName}@`.length) : '';
35
+ }
36
+
37
+ function releasePairMatches(clientSpec, runtimePackageName, runtimeSpec) {
38
+ if (!isRuntimeSpecFor(PACKAGE_NAME, clientSpec) || !isRuntimeSpecFor(runtimePackageName, runtimeSpec)) return false;
39
+ // First-party client/runtime releases are one contract and intentionally share a version. Custom
40
+ // runtimes can version independently because their package is controlled by the operator.
41
+ if (runtimePackageName !== '@remcp/runtime') return true;
42
+ return specVersion(PACKAGE_NAME, clientSpec) === specVersion(runtimePackageName, runtimeSpec);
43
+ }
44
+
45
+ export async function resolveUpdateTargets({
46
+ cfg,
47
+ flags = {},
48
+ env = process.env,
49
+ fetchImpl = globalThis.fetch,
50
+ }) {
51
+ const requestedRuntime = typeof flags.runtime === 'string' ? flags.runtime.trim() : '';
52
+ const requestedClient = typeof flags.client === 'string' ? flags.client.trim() : '';
53
+ const latestClient = `${PACKAGE_NAME}@latest`;
54
+ const currentClient = `${PACKAGE_NAME}@${VERSION}`;
55
+ const trusted = runtimeTrustAllowed(cfg, flags, env);
56
+
57
+ let clientSpec = latestClient;
58
+ if (requestedClient) {
59
+ clientSpec = exactClientSpec(requestedClient);
60
+ if (!clientSpec) throw new Error(`--client must be ${PACKAGE_NAME}@<version>`);
61
+ }
62
+
63
+ if (requestedRuntime) {
26
64
  // Only `<configured package>@<semver>` is installable: an alias, a git/URL/file spec, a tag or
27
65
  // a range would run code the user never agreed to.
28
- if (!isRuntimeSpecFor(cfg.runtime.packageName, requested)) {
66
+ if (!isRuntimeSpecFor(cfg.runtime.packageName, requestedRuntime)) {
29
67
  throw new Error(`--runtime must be ${cfg.runtime.packageName}@<version>`);
30
68
  }
31
- const trusted = cfg.trustRuntime === true
32
- || Boolean(flags['trust-runtime'])
33
- || process.env.REMCP_TRUST_RUNTIME === '1'
34
- // A configuration written before the field existed paired with the official server, which is
35
- // trusted by definition; refusing it would silently stop every existing device updating.
36
- || new URL(cfg.serverUrl).origin === officialOrigin;
37
69
  if (!trusted) {
38
70
  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.`);
39
71
  }
40
- runtimeSpec = normalizeRuntime({ kind: 'npm', packageName: cfg.runtime.packageName, packageSpec: requested, entry: cfg.runtime.entry }).packageSpec;
72
+ const runtimeSpec = normalizeRuntime({
73
+ kind: 'npm',
74
+ packageName: cfg.runtime.packageName,
75
+ packageSpec: requestedRuntime,
76
+ entry: cfg.runtime.entry,
77
+ }).packageSpec;
78
+ if (requestedClient && !releasePairMatches(clientSpec, cfg.runtime.packageName, runtimeSpec)) {
79
+ throw new Error('The client and first-party runtime must use the same exact release version.');
80
+ }
81
+ if (!requestedClient && cfg.runtime.packageName === '@remcp/runtime' && specVersion(cfg.runtime.packageName, runtimeSpec) !== VERSION) {
82
+ throw new Error(`--runtime without --client must match the running client version ${VERSION}`);
83
+ }
84
+ return {
85
+ clientSpec: requestedClient ? clientSpec : currentClient,
86
+ runtimeSpec,
87
+ persistRuntime: runtimeSpec !== cfg.runtime.packageSpec,
88
+ installable: true,
89
+ source: requestedClient ? 'explicit-pair' : 'explicit-runtime',
90
+ warning: '',
91
+ };
41
92
  }
93
+
94
+ // A manual `remcp update` has to move the client and the first-party runtime in lockstep too.
95
+ // Older versions reused the runtime pin stored at pairing time, so a manual client update could
96
+ // install a newer client while deliberately reinstalling an older runtime. Resolve the exact
97
+ // release pair from the same public endpoint the running agent already trusts. During a public-
98
+ // first rollout this also prevents `@latest` from getting ahead of the version production
99
+ // actually advertises.
100
+ if (!trusted) {
101
+ return {
102
+ clientSpec,
103
+ runtimeSpec: cfg.runtime.packageSpec,
104
+ persistRuntime: false,
105
+ installable: true,
106
+ source: 'configured',
107
+ warning: '',
108
+ };
109
+ }
110
+
111
+ try {
112
+ const versionUrl = new URL('/api/agent/version', cfg.serverUrl).toString();
113
+ const response = await fetchImpl(versionUrl, {
114
+ headers: { accept: 'application/json' },
115
+ signal: AbortSignal.timeout(UPDATE_DISCOVERY_TIMEOUT_MS),
116
+ });
117
+ if (!response?.ok) {
118
+ return {
119
+ clientSpec: currentClient,
120
+ runtimeSpec: cfg.runtime.packageSpec,
121
+ persistRuntime: false,
122
+ installable: false,
123
+ source: 'configured',
124
+ warning: `Could not resolve the server release pair (HTTP ${response?.status ?? 'unknown'}); refusing a partial update.`,
125
+ };
126
+ }
127
+
128
+ const advertised = await response.json();
129
+ const runtimeCandidate = String(advertised?.runtime || '').trim();
130
+ const advertisedClient = exactClientSpec(advertised?.cli);
131
+ const clientCandidate = requestedClient || advertisedClient;
132
+ if (!advertisedClient
133
+ || !releasePairMatches(advertisedClient, cfg.runtime.packageName, runtimeCandidate)
134
+ || (requestedClient && requestedClient !== advertisedClient)) {
135
+ return {
136
+ clientSpec: currentClient,
137
+ runtimeSpec: cfg.runtime.packageSpec,
138
+ persistRuntime: false,
139
+ installable: false,
140
+ source: 'configured',
141
+ warning: 'Could not resolve a valid server release pair; refusing a partial update.',
142
+ };
143
+ }
144
+
145
+ const runtimeSpec = normalizeRuntime({
146
+ kind: 'npm',
147
+ packageName: cfg.runtime.packageName,
148
+ packageSpec: runtimeCandidate,
149
+ entry: cfg.runtime.entry,
150
+ }).packageSpec;
151
+ return {
152
+ clientSpec: clientCandidate,
153
+ runtimeSpec,
154
+ persistRuntime: runtimeSpec !== cfg.runtime.packageSpec,
155
+ installable: true,
156
+ source: 'server',
157
+ warning: '',
158
+ };
159
+ } catch (error) {
160
+ return {
161
+ clientSpec: currentClient,
162
+ runtimeSpec: cfg.runtime.packageSpec,
163
+ persistRuntime: false,
164
+ installable: false,
165
+ source: 'configured',
166
+ warning: `Could not resolve the server release pair (${error instanceof Error ? error.message : String(error)}); refusing a partial update.`,
167
+ };
168
+ }
169
+ }
170
+
171
+ export async function updateCommand(flags) {
172
+ const cfg = loadConfig();
173
+ const targets = await resolveUpdateTargets({ cfg, flags });
174
+ if (targets.warning) console.error(`ReMCP update: ${targets.warning}`);
175
+
42
176
  if (flags.check) {
43
177
  console.log(JSON.stringify({
44
178
  current: VERSION,
45
179
  installedRuntime: installedVersion(cfg.runtime.packageName),
46
- runtimeSpec: runtimeSpec,
47
- available: `${PACKAGE_NAME}@latest`,
180
+ clientSpec: targets.clientSpec,
181
+ runtimeSpec: targets.runtimeSpec,
182
+ updateSource: targets.source,
183
+ installable: targets.installable,
48
184
  managedService: fs.existsSync(linuxServiceFile) || fs.existsSync(macServiceFile),
49
185
  supervisor: supervisorRestart() ?? 'none',
50
186
  }, null, 2));
51
187
  return;
52
188
  }
189
+
190
+ if (!targets.installable) {
191
+ throw new Error(targets.warning || 'Could not resolve a complete client/runtime release pair.');
192
+ }
193
+
53
194
  const before = { cli: VERSION, runtime: installedVersion(cfg.runtime.packageName) };
54
- console.log(`Updating ReMCP to the latest published version (${runtimeSpec})…`);
55
- npmGlobalInstall(`${PACKAGE_NAME}@latest`, runtimeSpec);
195
+ console.log(`Updating ReMCP to ${targets.clientSpec} with ${targets.runtimeSpec}…`);
196
+ npmGlobalInstall(targets.clientSpec, targets.runtimeSpec);
56
197
  // The npm prefix can change across Node-manager upgrades. Repair an existing persistent-service
57
198
  // launcher only after the install, when globalCliPath() points at the CLI we just installed.
58
199
  ensureServiceIfRecorded(cfg);
59
200
  // Only a validated spec is persisted, so a failed update cannot leave the install unable to start.
60
- if (requested && runtimeSpec !== cfg.runtime.packageSpec) saveConfig({ ...cfg, runtime: { ...cfg.runtime, packageSpec: runtimeSpec } });
201
+ if (targets.persistRuntime) saveConfig({ ...cfg, runtime: { ...cfg.runtime, packageSpec: targets.runtimeSpec } });
61
202
  const after = { cli: installedVersion(PACKAGE_NAME), runtime: installedVersion(cfg.runtime.packageName) };
62
203
  const restarted = restartPersistentServiceIfInstalled();
63
204
  if (restarted) {