@remcp/remcp 0.2.93 → 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 +1 -1
- package/src/agent.mjs +29 -10
- package/src/cli/service.mjs +34 -15
- package/src/cli.mjs +9 -2
package/package.json
CHANGED
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 =
|
|
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
|
-
|
|
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
|
-
|
|
369
|
+
sendRelayMessage(ws, { type: 'response', id: message.id, result });
|
|
356
370
|
} catch (error) {
|
|
357
371
|
const cancelled = controller.signal.aborted;
|
|
358
|
-
|
|
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
|
|
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();
|
package/src/cli/service.mjs
CHANGED
|
@@ -615,25 +615,44 @@ export function ensureServiceIfRecorded(config) {
|
|
|
615
615
|
const platform = servicePlatform();
|
|
616
616
|
let restartScheduled = null;
|
|
617
617
|
if (platform === 'linux') {
|
|
618
|
-
|
|
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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
]);
|