@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 +1 -1
- package/src/agent.mjs +29 -10
- package/src/cli/service.mjs +56 -22
- 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
|
@@ -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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
-
|
|
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
|
]);
|