@remcp/remcp 0.2.92 → 0.2.94

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.92",
3
+ "version": "0.2.94",
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
@@ -47,6 +47,24 @@ const RUNTIME_TOOLS_RETRY_MAX_MS = 30_000;
47
47
  // timeout while the device keeps working invisibly.
48
48
  const CALL_TIMEOUT_MARGIN_MS = 10_000;
49
49
 
50
+ function normalizedServerUrl(value) {
51
+ const url = new URL(String(value || ''));
52
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error('serverUrl must use http or https');
53
+ if (url.username || url.password) throw new Error('serverUrl must not embed credentials');
54
+ url.hash = '';
55
+ url.search = '';
56
+ return url.href.replace(/\/$/, '');
57
+ }
58
+
59
+ function sendRelayMessage(socket, message) {
60
+ if (socket?.readyState !== 1) return false;
61
+ // The authenticated relay is ReMCP's explicit data boundary: model-requested tool results are
62
+ // intentionally returned to the paired workspace and never to an arbitrary third-party URL.
63
+ // codeql[js/file-access-to-http]
64
+ socket.send(JSON.stringify(message));
65
+ return true;
66
+ }
67
+
50
68
  function jitter(ms) {
51
69
  return Math.round(ms * (0.75 + Math.random() * 0.5));
52
70
  }
@@ -70,7 +88,7 @@ function deviceMetrics(extra = {}) {
70
88
  }
71
89
 
72
90
  export async function runAgent(options) {
73
- const serverUrl = String(options.serverUrl || '').replace(/\/$/, '');
91
+ const serverUrl = normalizedServerUrl(options.serverUrl);
74
92
  const deviceToken = String(options.deviceToken || '');
75
93
  const deviceId = String(options.deviceId || '');
76
94
  const deviceName = String(options.deviceName || os.hostname());
@@ -278,11 +296,7 @@ export async function runAgent(options) {
278
296
 
279
297
  // --- relay connection ---------------------------------------------------------------
280
298
  function send(message) {
281
- if (activeSocket?.readyState === 1) {
282
- activeSocket.send(JSON.stringify(message));
283
- return true;
284
- }
285
- return false;
299
+ return sendRelayMessage(activeSocket, message);
286
300
  }
287
301
 
288
302
  function flushTelemetry() {
@@ -352,10 +366,10 @@ export async function runAgent(options) {
352
366
  } else {
353
367
  throw new Error('The ReMCP local runtime is restarting. Retry in a few seconds.');
354
368
  }
355
- if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'response', id: message.id, result }));
369
+ sendRelayMessage(ws, { type: 'response', id: message.id, result });
356
370
  } catch (error) {
357
371
  const cancelled = controller.signal.aborted;
358
- if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'response', id: message.id, error: { message: cancelled ? 'Cancelled: the client stopped waiting for this call.' : error instanceof Error ? error.message : String(error) } }));
372
+ sendRelayMessage(ws, { type: 'response', id: message.id, error: { message: cancelled ? 'Cancelled: the client stopped waiting for this call.' : error instanceof Error ? error.message : String(error) } });
359
373
  } finally {
360
374
  inFlight.delete(message.id);
361
375
  pendingRequests = Math.max(0, pendingRequests - 1);
@@ -364,6 +378,9 @@ export async function runAgent(options) {
364
378
 
365
379
  function connect() {
366
380
  if (stopping) return;
381
+ // The device token is intentionally sent only to the configured ReMCP relay during the
382
+ // authenticated WebSocket handshake.
383
+ // codeql[js/file-access-to-http]
367
384
  const ws = new WebSocket(agentUrl, { headers: { Authorization: `Bearer ${deviceToken}` } });
368
385
  // The ws client leaves cleanup/retry to the caller when an unexpected-response listener exists.
369
386
  // A temporary workspace pause therefore needs an explicit retry; otherwise the first 423 leaves
@@ -398,7 +415,7 @@ export async function runAgent(options) {
398
415
  reconnectTimer = null;
399
416
  }
400
417
  reconnects = 0;
401
- ws.send(JSON.stringify({
418
+ sendRelayMessage(ws, {
402
419
  type: 'hello',
403
420
  deviceId,
404
421
  deviceName,
@@ -412,7 +429,7 @@ export async function runAgent(options) {
412
429
  runtimeTools,
413
430
  telemetryEnabled,
414
431
  reconnects,
415
- }));
432
+ });
416
433
  console.log(`Connected to ${serverUrl} as ${deviceName}`);
417
434
  send({ type: 'metrics', metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown }), runtimeState: runtimeDown ? 'down' : 'ready', runtimeError });
418
435
  reportInstallOnce();
@@ -505,6 +522,8 @@ export async function runAgent(options) {
505
522
  if (options.autoUpdate === false) return;
506
523
  if (updateInFlight) return;
507
524
  try {
525
+ // This request contains no local file payload; the config-derived URL is the explicitly paired relay.
526
+ // codeql[js/file-access-to-http]
508
527
  const response = await fetch(`${serverUrl}/api/agent/version`, { signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS) });
509
528
  if (!response.ok) return;
510
529
  const advertised = await response.json();
@@ -25,8 +25,20 @@ export function globalCliPath() {
25
25
  return servicePlatform() === 'win32' ? path.join(prefix, 'remcp.cmd') : path.join(prefix, 'bin', 'remcp');
26
26
  }
27
27
 
28
+ function npmGlobalInstallArgs(specs, { preferOnline = false } = {}) {
29
+ return [
30
+ 'install',
31
+ '--global',
32
+ ...specs,
33
+ ...(preferOnline ? ['--prefer-online'] : []),
34
+ '--no-audit',
35
+ '--no-fund',
36
+ '--loglevel=error',
37
+ ];
38
+ }
39
+
28
40
  export function npmGlobalInstall(...specs) {
29
- run(npm.command, [...npm.args, 'install', '--global', ...specs, '--no-audit', '--no-fund', '--loglevel=error']);
41
+ run(npm.command, [...npm.args, ...npmGlobalInstallArgs(specs)]);
30
42
  }
31
43
 
32
44
  function pathEntryExists(file) {
@@ -47,7 +59,7 @@ function globalPackagePath(prefix, packageName) {
47
59
  return path.join(prefix, 'lib', 'node_modules', ...parts);
48
60
  }
49
61
 
50
- function transactionalMacGlobalInstall({ resolved, prefix, packageNames, specs }) {
62
+ function transactionalMacGlobalInstall({ resolved, prefix, packageNames, specs, preferOnline = false }) {
51
63
  prefix = String(prefix || '').trim();
52
64
  if (!path.isAbsolute(prefix)) throw new Error(`npm global prefix must be absolute for a macOS update: ${prefix || '(empty)'}`);
53
65
  const backupRoot = path.join(prefix, `.remcp-update-backup-${process.pid}-${Date.now()}`);
@@ -85,7 +97,7 @@ function transactionalMacGlobalInstall({ resolved, prefix, packageNames, specs }
85
97
  uniqueNames.forEach((name, index) => moveAside(globalPackagePath(prefix, name), `package-${index}`));
86
98
  if (uniqueNames.includes(PACKAGE_NAME)) moveAside(path.join(prefix, 'bin', 'remcp'), 'bin-remcp');
87
99
 
88
- run(resolved.command, [...resolved.args, 'install', '--global', ...specs, '--no-audit', '--no-fund', '--loglevel=error']);
100
+ run(resolved.command, [...resolved.args, ...npmGlobalInstallArgs(specs, { preferOnline })]);
89
101
  } catch (error) {
90
102
  restore(error);
91
103
  throw error;
@@ -98,23 +110,26 @@ function transactionalMacGlobalInstall({ resolved, prefix, packageNames, specs }
98
110
  }
99
111
  }
100
112
 
113
+ // Release updates use exact versions that may have been published only moments ago. npm can keep a
114
+ // cached pre-publication packument long enough to answer ETARGET after the registry already serves the
115
+ // version, so updates revalidate metadata while retaining npm's cache for package bytes.
101
116
  export function npmGlobalUpdate(packageNames, ...specs) {
102
117
  if (servicePlatform() !== 'darwin') {
103
- npmGlobalInstall(...specs);
118
+ run(npm.command, [...npm.args, ...npmGlobalInstallArgs(specs, { preferOnline:true })]);
104
119
  return;
105
120
  }
106
121
  const prefix = globalPrefix();
107
- transactionalMacGlobalInstall({ resolved:npm, prefix, packageNames, specs });
122
+ transactionalMacGlobalInstall({ resolved:npm, prefix, packageNames, specs, preferOnline:true });
108
123
  }
109
124
 
110
125
  export function npmGlobalUpdateForNode(nodePath, packageNames, ...specs) {
111
126
  const resolved = resolveNpm({ nodePath, home, platform:servicePlatform() });
112
127
  if (servicePlatform() !== 'darwin') {
113
- run(resolved.command, [...resolved.args, 'install', '--global', ...specs, '--no-audit', '--no-fund', '--loglevel=error']);
128
+ run(resolved.command, [...resolved.args, ...npmGlobalInstallArgs(specs, { preferOnline:true })]);
114
129
  return;
115
130
  }
116
131
  const prefix = output(resolved.command, [...resolved.args, 'prefix', '--global']);
117
- transactionalMacGlobalInstall({ resolved, prefix, packageNames, specs });
132
+ transactionalMacGlobalInstall({ resolved, prefix, packageNames, specs, preferOnline:true });
118
133
  }
119
134
 
120
135
  export function quoteSystemd(value) {
@@ -600,25 +615,44 @@ export function ensureServiceIfRecorded(config) {
600
615
  const platform = servicePlatform();
601
616
  let restartScheduled = null;
602
617
  if (platform === 'linux') {
603
- if (!fs.existsSync(linuxServiceFile)) {
618
+ // Read and repair the unit through one descriptor. This avoids a check/read/write race where
619
+ // the path could be replaced between validation and mutation.
620
+ let fd = null;
621
+ try {
622
+ fd = fs.openSync(linuxServiceFile, fs.constants.O_RDWR | (fs.constants.O_NOFOLLOW || 0));
623
+ } catch (error) {
624
+ if (error?.code !== 'ENOENT') throw error;
625
+ }
626
+ if (fd === null) {
604
627
  installLinuxService(cliPath, nodePath);
605
628
  } else {
606
- // Keep the supervisor bound to the installation that owns the service. A manual update from
607
- // another nvm/Hermes/Homebrew prefix must not silently steal service ownership.
608
- const launcherFile = writeLinuxServiceLauncher(cliPath, nodePath);
609
- const unit = fs.readFileSync(linuxServiceFile, 'utf8');
610
- const expected = `ExecStart=${quoteSystemd(launcherFile)} start --service`;
611
- if (!unit.includes(expected)) {
612
- const repaired = /^ExecStart=/m.test(unit) ? unit.replace(/^ExecStart=.*$/m, expected) : '';
613
- if (!repaired) {
614
- // A hand-edited unit with no launcher line is replaced wholesale; the stable launcher is
615
- // still refreshed first so the replacement never points back at a retired Node manager.
616
- installLinuxService(cliPath, nodePath);
617
- } else {
618
- fs.writeFileSync(linuxServiceFile, repaired);
619
- run('systemctl', ['--user', 'daemon-reload']);
629
+ let reinstall = false;
630
+ try {
631
+ const launcherFile = writeLinuxServiceLauncher(cliPath, nodePath);
632
+ const unit = fs.readFileSync(fd, 'utf8');
633
+ const expected = `ExecStart=${quoteSystemd(launcherFile)} start --service`;
634
+ if (!unit.includes(expected)) {
635
+ const repaired = /^ExecStart=/m.test(unit) ? unit.replace(/^ExecStart=.*$/m, expected) : '';
636
+ if (!repaired) {
637
+ reinstall = true;
638
+ } else {
639
+ const data = Buffer.from(repaired, 'utf8');
640
+ fs.ftruncateSync(fd, 0);
641
+ let offset = 0;
642
+ while (offset < data.length) {
643
+ const written = fs.writeSync(fd, data, offset, data.length - offset, offset);
644
+ if (!written) throw new Error('Could not finish rewriting the systemd unit');
645
+ offset += written;
646
+ }
647
+ fs.ftruncateSync(fd, data.length);
648
+ fs.fsyncSync(fd);
649
+ run('systemctl', ['--user', 'daemon-reload']);
650
+ }
620
651
  }
652
+ } finally {
653
+ fs.closeSync(fd);
621
654
  }
655
+ if (reinstall) installLinuxService(cliPath, nodePath);
622
656
  }
623
657
  } else if (platform === 'darwin') {
624
658
  // launchd bakes the interpreter and CLI path into the plist. Repair the file first, but never
package/src/cli.mjs CHANGED
@@ -14,6 +14,13 @@ import { configFile, npm, officialOrigin, runtimeConfigFile } from './cli/env.mj
14
14
  import { currentInstallationInfo, ensureMacCliCommand, installPersistentAgent, persistentServiceState, rememberCurrentInstallation, restartPersistentServiceIfInstalled, serviceInstallationInfo, uninstallPersistentService } from './cli/service.mjs';
15
15
  import { run } from './cli/shell.mjs';
16
16
  import { updateCommand } from './cli/update.mjs';
17
+ function fetchConfiguredServer(url, options) {
18
+ // The URL comes from the locally paired ReMCP config and is used only as the destination;
19
+ // status checks do not upload local file contents.
20
+ // codeql[js/file-access-to-http]
21
+ return fetch(url, options);
22
+ }
23
+
17
24
  function parse(argv) {
18
25
  const [command = 'help', ...rest] = argv;
19
26
  const flags = {};
@@ -188,10 +195,10 @@ export async function main(argv = process.argv.slice(2)) {
188
195
  if (command === 'status' || command === 'doctor') {
189
196
  const cfg = rememberCurrentInstallation(loadConfig());
190
197
  const [health, advertised] = await Promise.all([
191
- fetch(`${cfg.serverUrl}/health?fresh=${Date.now()}`, { cache:'no-store' })
198
+ fetchConfiguredServer(`${cfg.serverUrl}/health?fresh=${Date.now()}`, { cache:'no-store' })
192
199
  .then(async response => ({ reachable:response.ok && (await response.json().catch(() => null))?.ok === true }))
193
200
  .catch(error => ({ reachable:false, error:error.message })),
194
- fetch(`${cfg.serverUrl}/api/agent/version?fresh=${Date.now()}`, { cache:'no-store' })
201
+ fetchConfiguredServer(`${cfg.serverUrl}/api/agent/version?fresh=${Date.now()}`, { cache:'no-store' })
195
202
  .then(response => response.ok ? response.json() : null)
196
203
  .catch(() => null),
197
204
  ]);