@remcp/remcp 0.2.15 → 0.2.19
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 +2 -2
- package/src/agent.mjs +35 -5
- package/src/cli.mjs +103 -23
- package/src/npm.mjs +7 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remcp/remcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.19",
|
|
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",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"README.md"
|
|
19
19
|
],
|
|
20
20
|
"scripts": {
|
|
21
|
-
"check": "node --check bin/remcp.mjs && node --check src/cli.mjs && node --check src/agent.mjs && node --check src/runtime.mjs && node --check src/version.mjs",
|
|
21
|
+
"check": "node --check bin/remcp.mjs && node --check src/cli.mjs && node --check src/agent.mjs && node --check src/runtime.mjs && node --check src/version.mjs && node --check src/npm.mjs",
|
|
22
22
|
"test": "node --test test/*.test.mjs"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
package/src/agent.mjs
CHANGED
|
@@ -17,6 +17,9 @@ const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
|
17
17
|
const UPDATE_CHECK_TIMEOUT_MS = 5000;
|
|
18
18
|
// A version that failed to install is retried after this cooldown instead of on every reconnect.
|
|
19
19
|
const UPDATE_RETRY_COOLDOWN_MS = 30 * 60 * 1000;
|
|
20
|
+
// How long a handing-over agent waits for its replacement to take the device over before it keeps
|
|
21
|
+
// running itself. Long enough for a fresh process to install nothing, connect and be registered.
|
|
22
|
+
const REPLACEMENT_HANDOVER_TIMEOUT_MS = 20_000;
|
|
20
23
|
const METRICS_INTERVAL_MS = 60_000;
|
|
21
24
|
const TELEMETRY_QUEUE_LIMIT = 500;
|
|
22
25
|
const TELEMETRY_BATCH_LIMIT = 100;
|
|
@@ -109,7 +112,7 @@ export function updateDecision({ advertised, cliVersion, runtimeVersion, runtime
|
|
|
109
112
|
// Applies a freshly installed version. Exiting is what a supervisor needs; without one the new CLI is
|
|
110
113
|
// started in this process' place. Either way the agent stops holding a stale runtime, which is what
|
|
111
114
|
// makes an update actually take effect on a machine that no service manager watches.
|
|
112
|
-
async function restartToApplyUpdate(cli, stopAgent, markStopping, onRuntimeRepaired) {
|
|
115
|
+
async function restartToApplyUpdate(cli, stopAgent, markStopping, onRuntimeRepaired, isStopping) {
|
|
113
116
|
try {
|
|
114
117
|
const installed = globalInstalledVersion();
|
|
115
118
|
if (installed && !isNewer(installed, VERSION)) {
|
|
@@ -130,6 +133,15 @@ async function restartToApplyUpdate(cli, stopAgent, markStopping, onRuntimeRepai
|
|
|
130
133
|
console.log(`ReMCP ${installed || 'a newer version'} installed and verified (${reported}); restarting to apply it.`);
|
|
131
134
|
if (!supervisorRestart()) {
|
|
132
135
|
spawn(process.execPath, [cli, 'start'], { detached: true, stdio: 'ignore', env: { ...process.env } }).unref();
|
|
136
|
+
// Stepping aside is only safe once the replacement really holds the device: the relay closes
|
|
137
|
+
// this socket with 1012 ('replaced') the moment another agent takes the machine over, and that
|
|
138
|
+
// close is what stops this process. Without the wait, a replacement that cannot start left the
|
|
139
|
+
// machine connected in `/health` and offline everywhere else, with nobody left to retry.
|
|
140
|
+
await new Promise(resolve => setTimeout(resolve, REPLACEMENT_HANDOVER_TIMEOUT_MS));
|
|
141
|
+
if (!isStopping()) {
|
|
142
|
+
console.error(`The replacement agent did not take over within ${Math.round(REPLACEMENT_HANDOVER_TIMEOUT_MS / 1000)}s; keeping ${VERSION} running. Retry with: remcp update`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
133
145
|
}
|
|
134
146
|
markStopping();
|
|
135
147
|
await stopAgent().catch(() => {});
|
|
@@ -158,9 +170,27 @@ function globalInstalledVersion() {
|
|
|
158
170
|
}
|
|
159
171
|
}
|
|
160
172
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
173
|
+
// True when this process is the one a service manager owns: launchd and systemd's system manager run
|
|
174
|
+
// a unit's main process as a child of PID 1, and `systemd --user` runs it as a child of the user
|
|
175
|
+
// manager. Anything else — a terminal, a shell inside another unit, a CI runner job — has nobody
|
|
176
|
+
// waiting to start the agent again.
|
|
177
|
+
function parentIsServiceManager() {
|
|
178
|
+
if (process.ppid === 1) return true;
|
|
179
|
+
if (process.platform === 'win32') return false;
|
|
180
|
+
try { return readFileSync(`/proc/${process.ppid}/comm`, 'utf8').trim() === 'systemd'; } catch { return false; }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// What starts the agent again after it exits to apply an update, or null when it has to start its own
|
|
184
|
+
// replacement. systemd sets INVOCATION_ID and JOURNAL_STREAM for a unit and every child of that unit
|
|
185
|
+
// inherits them, so a `remcp start` run from a shell inside a service (a CI runner, a systemd-run
|
|
186
|
+
// scope, another agent) believed a supervisor would bring it back: the update exited into nothing and
|
|
187
|
+
// the workspace showed the machine offline until someone started the agent by hand. Only the unit's
|
|
188
|
+
// own main process is restarted, so that is what the check requires.
|
|
189
|
+
//
|
|
190
|
+
// Injectable for tests: the verdict must not depend on the machine that runs them.
|
|
191
|
+
export function supervisorRestart({ platform = process.platform, dockerenv = existsSync('/.dockerenv'), parentOurs = parentIsServiceManager() } = {}) {
|
|
192
|
+
if (parentOurs) return platform === 'darwin' ? 'launchd' : 'systemd';
|
|
193
|
+
if (dockerenv) return 'docker';
|
|
164
194
|
return null;
|
|
165
195
|
}
|
|
166
196
|
|
|
@@ -500,7 +530,7 @@ export async function runAgent(options) {
|
|
|
500
530
|
runtimeError = error instanceof Error ? error.message : String(error);
|
|
501
531
|
}
|
|
502
532
|
if (!runtimeDown && !stopping) await startRuntime();
|
|
503
|
-
});
|
|
533
|
+
}, () => stopping);
|
|
504
534
|
});
|
|
505
535
|
child.on('error', error => {
|
|
506
536
|
updateInFlight = false;
|
package/src/cli.mjs
CHANGED
|
@@ -2,9 +2,9 @@ import fs from 'node:fs';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import process from 'node:process';
|
|
5
|
-
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
7
|
-
import { localRuntimeEntry, runAgent } from './agent.mjs';
|
|
7
|
+
import { localRuntimeEntry, runAgent, supervisorRestart } from './agent.mjs';
|
|
8
8
|
import { npmVersion, resolveNpm } from './npm.mjs';
|
|
9
9
|
import { isRuntimeSpecFor, normalizeRuntime } from './runtime.mjs';
|
|
10
10
|
import { PACKAGE_NAME, VERSION } from './version.mjs';
|
|
@@ -217,6 +217,63 @@ function ensureServiceIfRecorded(config) {
|
|
|
217
217
|
}
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
// Opens the approval page in the person's browser. A machine that nobody is looking at only gets the
|
|
221
|
+
// printed URL, so every failure here is silent and non-fatal.
|
|
222
|
+
function openInBrowser(url) {
|
|
223
|
+
try {
|
|
224
|
+
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
225
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
226
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true });
|
|
227
|
+
child.on('error', () => {});
|
|
228
|
+
child.unref();
|
|
229
|
+
} catch {}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
233
|
+
|
|
234
|
+
// Device authorization (RFC 8628): the computer asks for a code, the person approves it in the
|
|
235
|
+
// browser while signed in, and this process collects the credential by polling. The device never
|
|
236
|
+
// sees a browser session or an account password.
|
|
237
|
+
async function pairWithDeviceCode(server, flags) {
|
|
238
|
+
const authorization = await fetch(`${server}/oauth/device_authorization`, {
|
|
239
|
+
method: 'POST',
|
|
240
|
+
headers: { 'content-type': 'application/json' },
|
|
241
|
+
body: JSON.stringify({
|
|
242
|
+
name: String(flags.name || os.hostname()),
|
|
243
|
+
hostname: os.hostname(),
|
|
244
|
+
platform: process.platform,
|
|
245
|
+
arch: process.arch,
|
|
246
|
+
machineId: ensureMachineId(),
|
|
247
|
+
}),
|
|
248
|
+
});
|
|
249
|
+
if (!authorization.ok) throw new Error(`Pairing failed (${authorization.status}): ${await authorization.text()}`);
|
|
250
|
+
const grant = await authorization.json();
|
|
251
|
+
const approvalUrl = grant.verification_uri_complete || grant.verification_uri;
|
|
252
|
+
console.log(`Approve this computer in your browser: ${approvalUrl}`);
|
|
253
|
+
console.log(`Pairing code: ${grant.user_code} (expires in ${Math.max(1, Math.round(Number(grant.expires_in || 600) / 60))} minutes)`);
|
|
254
|
+
openInBrowser(approvalUrl);
|
|
255
|
+
const deadline = Date.now() + (Number(grant.expires_in) || 600) * 1000;
|
|
256
|
+
const intervalMs = Math.max(1, Number(grant.interval) || 5) * 1000;
|
|
257
|
+
while (Date.now() < deadline) {
|
|
258
|
+
await sleep(intervalMs);
|
|
259
|
+
const response = await fetch(`${server}/oauth/token`, {
|
|
260
|
+
method: 'POST',
|
|
261
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
262
|
+
body: new URLSearchParams({
|
|
263
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
264
|
+
device_code: String(grant.device_code || ''),
|
|
265
|
+
}).toString(),
|
|
266
|
+
});
|
|
267
|
+
const data = await response.json().catch(() => ({}));
|
|
268
|
+
if (response.ok && data.device_token) return data;
|
|
269
|
+
if (data.error === 'authorization_pending' || data.error === 'slow_down') continue;
|
|
270
|
+
if (data.error === 'access_denied') throw new Error('That pairing request was denied in the browser. Run the command again if it was not you.');
|
|
271
|
+
if (data.error === 'expired_token') break;
|
|
272
|
+
throw new Error(`Pairing failed (${response.status}): ${JSON.stringify(data)}`);
|
|
273
|
+
}
|
|
274
|
+
throw new Error('The pairing code expired before it was approved. Run the command again.');
|
|
275
|
+
}
|
|
276
|
+
|
|
220
277
|
function restartPersistentServiceIfInstalled() {
|
|
221
278
|
const platform = servicePlatform();
|
|
222
279
|
if (platform === 'linux' && fs.existsSync(linuxServiceFile)) {
|
|
@@ -241,15 +298,9 @@ function restartPersistentServiceIfInstalled() {
|
|
|
241
298
|
// The agent the user installed with `remcp install` is the one this CLI manages. A machine can also
|
|
242
299
|
// be supervised by its own systemd unit, by Docker, or by a terminal, and in those cases installing
|
|
243
300
|
// a new version is not enough: the running process keeps the old code until something restarts it.
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
function supervisorRestart() {
|
|
248
|
-
if (process.env.INVOCATION_ID || process.env.JOURNAL_STREAM) return 'systemd';
|
|
249
|
-
try { if (fs.existsSync('/.dockerenv')) return 'docker'; } catch {}
|
|
250
|
-
return null;
|
|
251
|
-
}
|
|
252
|
-
|
|
301
|
+
// `supervisorRestart` (agent.mjs) answers which of those is true, and only reports a service manager
|
|
302
|
+
// when it really owns this process: a terminal gets an explicit instruction instead of a silent exit
|
|
303
|
+
// that would take the device offline.
|
|
253
304
|
// One real handshake with the local runtime, plus everything needed to explain a failure: where the
|
|
254
305
|
// entry resolved, whether the package is installed, the node that would run it, and the exact error.
|
|
255
306
|
async function diagnoseLocalRuntime(cfg) {
|
|
@@ -351,21 +402,30 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
351
402
|
}
|
|
352
403
|
|
|
353
404
|
if (command === 'connect') {
|
|
354
|
-
|
|
405
|
+
// Like the desktop-app flow this mirrors: with no flags the command talks to the official
|
|
406
|
+
// server, prints a code, opens the browser to approve it, and pairs. `--code` keeps working for
|
|
407
|
+
// the workspace-generated command and for CI, and `--server` for self-hosted deployments.
|
|
408
|
+
const server = String(flags.server || officialOrigin).replace(/\/$/, '');
|
|
355
409
|
const code = String(flags.code || '').replace(/\s+/g, '').toUpperCase();
|
|
356
|
-
if (!server || !code) throw new Error('--server and --code are required');
|
|
357
410
|
assertRuntimeTrust(server, flags);
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
411
|
+
let paired;
|
|
412
|
+
let deviceInitiated = false;
|
|
413
|
+
if (code) {
|
|
414
|
+
const response = await fetch(`${server}/api/pair/claim`, {
|
|
415
|
+
method: 'POST',
|
|
416
|
+
headers: { 'content-type': 'application/json' },
|
|
417
|
+
body: JSON.stringify({ code, machineId: ensureMachineId(), name: String(flags.name || os.hostname()), hostname: os.hostname(), platform: process.platform, arch: process.arch }),
|
|
418
|
+
});
|
|
419
|
+
if (!response.ok) throw new Error(`Pairing failed (${response.status}): ${await response.text()}`);
|
|
420
|
+
paired = await response.json();
|
|
421
|
+
} else {
|
|
422
|
+
deviceInitiated = true;
|
|
423
|
+
paired = await pairWithDeviceCode(server, flags);
|
|
424
|
+
}
|
|
365
425
|
const config = {
|
|
366
426
|
serverUrl: server,
|
|
367
|
-
deviceId: paired.deviceId,
|
|
368
|
-
deviceToken: paired.deviceToken,
|
|
427
|
+
deviceId: paired.deviceId || paired.device_id,
|
|
428
|
+
deviceToken: paired.deviceToken || paired.device_token,
|
|
369
429
|
deviceName: String(flags.name || os.hostname()),
|
|
370
430
|
runtime: normalizeRuntime(paired.runtime),
|
|
371
431
|
machineId: ensureMachineId(),
|
|
@@ -375,7 +435,27 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
375
435
|
};
|
|
376
436
|
saveConfig(config);
|
|
377
437
|
console.log(`Paired ${os.hostname()} with ${server}`);
|
|
378
|
-
if (flags.install)
|
|
438
|
+
if (flags.install) {
|
|
439
|
+
installPersistentAgent(config);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
// Only the command that asked for its own code keeps running: someone who ran `remcp connect` on
|
|
443
|
+
// a fresh machine expects the connection to be live when the command finishes. A workspace code
|
|
444
|
+
// keeps its old meaning (pair, then `remcp start` or `--install`).
|
|
445
|
+
if (!deviceInitiated) return;
|
|
446
|
+
// Nobody supervises this machine yet, so the agent runs in this window: Ctrl+C disconnects it,
|
|
447
|
+
// which is the behaviour people expect from a command they just ran themselves.
|
|
448
|
+
console.log('ReMCP is connected. Keep this window open, or run `remcp install` for a background service. Press Ctrl+C to stop.');
|
|
449
|
+
const telemetry = telemetryState();
|
|
450
|
+
await runAgent({
|
|
451
|
+
...config,
|
|
452
|
+
autoUpdate: config.autoUpdate !== false,
|
|
453
|
+
trustRuntime: config.trustRuntime === true,
|
|
454
|
+
telemetryEnabled: telemetry.enabled,
|
|
455
|
+
installReported: telemetry.installReported,
|
|
456
|
+
installSpec: `${PACKAGE_NAME}@${VERSION}`,
|
|
457
|
+
persistState: patch => saveConfig({ ...config, ...patch }),
|
|
458
|
+
});
|
|
379
459
|
return;
|
|
380
460
|
}
|
|
381
461
|
|
package/src/npm.mjs
CHANGED
|
@@ -43,15 +43,19 @@ export function npmCandidates({ nodePath = process.execPath, home = os.homedir()
|
|
|
43
43
|
|
|
44
44
|
// Resolves how to run npm. `source` is reported by `remcp doctor` and logged at agent startup so a
|
|
45
45
|
// machine where npm cannot be found is obvious before an update is needed.
|
|
46
|
-
|
|
46
|
+
//
|
|
47
|
+
// `exists` is injectable so a test can describe a machine with no npm at all instead of asking the
|
|
48
|
+
// machine running the test: the Linux candidate list carries fixed prefixes (/usr, /usr/local) that
|
|
49
|
+
// a CI runner or a developer laptop usually does have, which made that case untestable there.
|
|
50
|
+
export function resolveNpm({ nodePath = process.execPath, home = os.homedir(), platform = process.platform, exists = existsSync } = {}) {
|
|
47
51
|
const override = String(process.env.REMCP_NPM || '').trim();
|
|
48
52
|
if (override) return { command: override, args: [], source: `REMCP_NPM=${override}` };
|
|
49
53
|
const { cli, binaries } = npmCandidates({ nodePath, home, platform });
|
|
50
54
|
for (const candidate of cli) {
|
|
51
|
-
if (
|
|
55
|
+
if (exists(candidate)) return { command: nodePath, args: [candidate], source: `node ${candidate}` };
|
|
52
56
|
}
|
|
53
57
|
for (const candidate of binaries) {
|
|
54
|
-
if (
|
|
58
|
+
if (exists(candidate)) return { command: candidate, args: [], source: candidate };
|
|
55
59
|
}
|
|
56
60
|
return { command: platform === 'win32' ? 'npm.cmd' : 'npm', args: [], source: 'PATH' };
|
|
57
61
|
}
|