@remcp/remcp 0.2.93 → 0.2.96

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.93",
3
+ "version": "0.2.96",
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();
@@ -615,25 +615,44 @@ export function ensureServiceIfRecorded(config) {
615
615
  const platform = servicePlatform();
616
616
  let restartScheduled = null;
617
617
  if (platform === 'linux') {
618
- 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) {
619
627
  installLinuxService(cliPath, nodePath);
620
628
  } else {
621
- // Keep the supervisor bound to the installation that owns the service. A manual update from
622
- // another nvm/Hermes/Homebrew prefix must not silently steal service ownership.
623
- const launcherFile = writeLinuxServiceLauncher(cliPath, nodePath);
624
- const unit = fs.readFileSync(linuxServiceFile, 'utf8');
625
- const expected = `ExecStart=${quoteSystemd(launcherFile)} start --service`;
626
- if (!unit.includes(expected)) {
627
- const repaired = /^ExecStart=/m.test(unit) ? unit.replace(/^ExecStart=.*$/m, expected) : '';
628
- if (!repaired) {
629
- // A hand-edited unit with no launcher line is replaced wholesale; the stable launcher is
630
- // still refreshed first so the replacement never points back at a retired Node manager.
631
- installLinuxService(cliPath, nodePath);
632
- } else {
633
- fs.writeFileSync(linuxServiceFile, repaired);
634
- 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
+ }
635
651
  }
652
+ } finally {
653
+ fs.closeSync(fd);
636
654
  }
655
+ if (reinstall) installLinuxService(cliPath, nodePath);
637
656
  }
638
657
  } else if (platform === 'darwin') {
639
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
  ]);