@remcp/remcp 0.2.9 → 0.2.11

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.11",
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,77 @@ 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, onRuntimeRepaired) {
110
+ try {
111
+ const installed = globalInstalledVersion();
112
+ if (installed && !isNewer(installed, VERSION)) {
113
+ // The client is current, so the update was a runtime repair: restart the runtime rather than
114
+ // the whole agent, or a device with no usable runtime would stay broken.
115
+ console.log(`ReMCP ${VERSION} is already the installed version; restarting the local runtime.`);
116
+ await onRuntimeRepaired?.();
117
+ return;
118
+ }
119
+ console.log(`ReMCP ${installed || 'a newer version'} installed; restarting to apply it.`);
120
+ if (!supervisorRestart()) {
121
+ spawn(process.execPath, [cli, 'start'], { detached: true, stdio: 'ignore', env: { ...process.env } }).unref();
122
+ }
123
+ markStopping();
124
+ await stopAgent().catch(() => {});
125
+ setTimeout(() => process.exit(0), 100);
126
+ } catch (error) {
127
+ console.error(`Could not restart after the update: ${error instanceof Error ? error.message : String(error)}`);
128
+ }
129
+ }
130
+
131
+ // The version of the globally installed client, read from the package the CLI resolves to.
132
+ function globalInstalledVersion() {
133
+ try {
134
+ const cli = globalCliEntry();
135
+ if (!cli) return null;
136
+ const base = path.dirname(cli);
137
+ const candidates = [
138
+ path.join(base, '..', 'lib', 'node_modules', '@remcp', 'remcp', 'package.json'),
139
+ path.join(base, '..', 'node_modules', '@remcp', 'remcp', 'package.json'),
140
+ ];
141
+ for (const manifest of candidates) {
142
+ try { return JSON.parse(readFileSync(manifest, 'utf8')).version || null; } catch {}
143
+ }
144
+ return null;
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ function supervisorRestart() {
151
+ if (process.env.INVOCATION_ID || process.env.JOURNAL_STREAM) return 'systemd';
152
+ try { if (existsSync('/.dockerenv')) return 'docker'; } catch {}
153
+ return null;
154
+ }
155
+
97
156
  function globalCliEntry() {
98
157
  const prefix = spawnSync(npmCommand, ['prefix', '--global'], { encoding: 'utf8' });
99
158
  if (prefix.error || prefix.status !== 0) return null;
@@ -144,9 +203,21 @@ export async function runAgent(options) {
144
203
  let runtimeVersion = 'unknown';
145
204
  let runtimeRestarts = 0;
146
205
  let runtimeDown = false;
206
+ // The reason the runtime is not running, sent to the server so the workspace can show something
207
+ // actionable instead of a machine that merely looks connected.
208
+ let runtimeError = '';
147
209
  const telemetryQueue = [];
148
210
  let telemetryTimer = null;
149
- const runtimeEntry = localRuntimeEntry(options.runtime);
211
+ // A device whose runtime is missing or unreadable must still run the agent: the agent is what
212
+ // installs and repairs the runtime, so failing here would remove the only path back.
213
+ let runtimeEntry = '';
214
+ try {
215
+ runtimeEntry = localRuntimeEntry(options.runtime);
216
+ } catch (error) {
217
+ runtimeDown = true;
218
+ runtimeError = error instanceof Error ? error.message : String(error);
219
+ console.error(`${runtimeError} The agent keeps running and retries; remcp update reinstalls the runtime.`);
220
+ }
150
221
 
151
222
  // --- local runtime supervision ------------------------------------------------------
152
223
  // If the runtime dies (a bad shell, a broken pipe, an OOM) the agent used to stay
@@ -190,11 +261,15 @@ export async function runAgent(options) {
190
261
  runtimeVersion = client.getServerVersion()?.version || runtimeVersion;
191
262
  runtimeRestarts += 1;
192
263
  runtimeRestartDelay = RUNTIME_RESTART_BASE_MS;
264
+ runtimeDown = false;
265
+ runtimeError = '';
193
266
  console.log(`ReMCP local runtime ready (${runtimeVersion})`);
194
267
  send({ type: 'metrics', metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown: false }) });
195
268
  if (runtimeRestarts > 1) queueEvent({ event: 'runtime_restart', at: Date.now(), count: runtimeRestarts, success: true });
196
269
  } catch (error) {
197
- console.error(`ReMCP local runtime failed to start: ${error instanceof Error ? error.message : String(error)}`);
270
+ runtimeDown = true;
271
+ runtimeError = error instanceof Error ? error.message : String(error);
272
+ console.error(`ReMCP local runtime failed to start: ${runtimeError}`);
198
273
  handleRuntimeExit('failed');
199
274
  }
200
275
  }
@@ -241,6 +316,7 @@ export async function runAgent(options) {
241
316
  platform: process.platform,
242
317
  arch: process.arch,
243
318
  installSpec: String(options.installSpec || ''),
319
+ runtimeState: runtimeDown ? 'down' : 'ready',
244
320
  })) {
245
321
  persistState({ installReported: true });
246
322
  console.log('ReMCP reported this installation to your own workspace (disable with `remcp telemetry off`).');
@@ -297,11 +373,13 @@ export async function runAgent(options) {
297
373
  arch: process.arch,
298
374
  agentVersion: VERSION,
299
375
  runtimeVersion,
376
+ runtimeState: runtimeDown ? 'down' : 'ready',
377
+ runtimeError,
300
378
  telemetryEnabled,
301
379
  reconnects,
302
380
  }));
303
381
  console.log(`Connected to ${serverUrl} as ${deviceName}`);
304
- send({ type: 'metrics', metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown }) });
382
+ send({ type: 'metrics', metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown }), runtimeState: runtimeDown ? 'down' : 'ready', runtimeError });
305
383
  reportInstallOnce();
306
384
  flushTelemetry();
307
385
  void checkForUpdate();
@@ -324,10 +402,12 @@ export async function runAgent(options) {
324
402
  return;
325
403
  }
326
404
  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`).');
405
+ // 1012 ('service restart') is what the relay sends when another agent process took over this
406
+ // device. Staying alive would keep a second runtime and a reconnect loop, so this process
407
+ // stops and leaves the device to the agent that owns the connection.
408
+ 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).');
409
+ stopping = true;
410
+ void stop().finally(() => setTimeout(() => process.exit(0), 100));
331
411
  return;
332
412
  }
333
413
  reconnects += 1;
@@ -358,7 +438,7 @@ export async function runAgent(options) {
358
438
  if (minimum && isNewer(minimum, VERSION)) {
359
439
  console.error(`ReMCP ${VERSION} is older than the minimum supported agent ${minimum}; update with: remcp update`);
360
440
  }
361
- const decision = updateDecision({ advertised, cliVersion: VERSION, runtimeVersion });
441
+ const decision = updateDecision({ advertised, cliVersion: VERSION, runtimeVersion, runtimePackageName: options.runtime?.packageName, runtimeDown });
362
442
  if (!decision.needed) return;
363
443
  const target = decision.target;
364
444
  queueEvent({ event: 'agent_update', at: Date.now(), reason: `${decision.reason}:${target}`.slice(0, 32), success: true });
@@ -375,13 +455,46 @@ export async function runAgent(options) {
375
455
  lastAttemptAt = Date.now();
376
456
  updateInFlight = true;
377
457
  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] : [])], {
458
+ // The trust flag is only forwarded when this machine's owner trusted the server at pairing
459
+ // time; otherwise the update stops at the server's own version and asks the user.
460
+ const trustFlag = options.trustRuntime === true ? ['--trust-runtime'] : [];
461
+ const child = spawn(process.execPath, [cli, 'update', ...trustFlag, ...(decision.runtime ? ['--runtime', decision.runtime] : [])], {
379
462
  detached: true,
380
- stdio: 'ignore',
463
+ // The updater's own output is the only record of why an install failed, so it is piped back
464
+ // into this agent's log instead of being discarded.
465
+ stdio: ['ignore', 'pipe', 'pipe'],
381
466
  env: { ...process.env },
382
467
  });
383
- child.on('exit', () => { updateInFlight = false; });
384
- child.on('error', () => { updateInFlight = false; });
468
+ const forward = chunk => {
469
+ const line = String(chunk).trim();
470
+ if (line) console.error(`remcp update: ${line}`);
471
+ };
472
+ child.stdout?.on('data', forward);
473
+ child.stderr?.on('data', forward);
474
+ child.on('exit', code => {
475
+ updateInFlight = false;
476
+ if (code !== 0) {
477
+ console.error(`remcp update exited with ${code}; keeping ${VERSION} and retrying after the cooldown.`);
478
+ return;
479
+ }
480
+ void restartToApplyUpdate(cli, stop, () => { stopping = true; }, async () => {
481
+ // The packages are installed now: clear the failure, reset the backoff and start again.
482
+ runtimeRestartDelay = RUNTIME_RESTART_BASE_MS;
483
+ runtimeDown = false;
484
+ runtimeError = '';
485
+ try {
486
+ runtimeEntry = localRuntimeEntry(options.runtime);
487
+ } catch (error) {
488
+ runtimeDown = true;
489
+ runtimeError = error instanceof Error ? error.message : String(error);
490
+ }
491
+ if (!runtimeDown && !stopping) await startRuntime();
492
+ });
493
+ });
494
+ child.on('error', error => {
495
+ updateInFlight = false;
496
+ console.error(`remcp update could not start: ${error.message}`);
497
+ });
385
498
  child.unref();
386
499
  } catch {
387
500
  // 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
+ }