@remcp/remcp 0.2.34 → 0.2.35
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-update.mjs +180 -0
- package/src/agent.mjs +12 -179
- package/src/macos-permissions.mjs +0 -38
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remcp/remcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.35",
|
|
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/agent.mjs && node --check src/cli.mjs && node --check src/cli/config.mjs && node --check src/cli/connect.mjs && node --check src/cli/doctor.mjs && node --check src/cli/env.mjs && node --check src/cli/service.mjs && node --check src/cli/shell.mjs && node --check src/cli/update.mjs && node --check src/fs-access.mjs && node --check src/npm.mjs && node --check src/runtime.mjs && node --check src/version.mjs",
|
|
21
|
+
"check": "node --check bin/remcp.mjs && node --check src/agent-update.mjs && node --check src/agent.mjs && node --check src/cli.mjs && node --check src/cli/config.mjs && node --check src/cli/connect.mjs && node --check src/cli/doctor.mjs && node --check src/cli/env.mjs && node --check src/cli/service.mjs && node --check src/cli/shell.mjs && node --check src/cli/update.mjs && node --check src/fs-access.mjs && node --check src/npm.mjs && node --check src/runtime.mjs && node --check src/version.mjs",
|
|
22
22
|
"test": "node --test test/*.test.mjs"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
5
|
+
import { resolveNpm } from './npm.mjs';
|
|
6
|
+
import { isRuntimeSpecFor, normalizeRuntime } from './runtime.mjs';
|
|
7
|
+
import { VERSION } from './version.mjs';
|
|
8
|
+
|
|
9
|
+
const npm = resolveNpm();
|
|
10
|
+
// How long a handing-over agent waits for its replacement to take the device over before it keeps
|
|
11
|
+
// running itself. Long enough for a fresh process to connect and be registered.
|
|
12
|
+
const REPLACEMENT_HANDOVER_TIMEOUT_MS = 20_000;
|
|
13
|
+
|
|
14
|
+
function globalNodeModules() {
|
|
15
|
+
const result = spawnSync(npm.command, [...npm.args, 'root', '--global'], { encoding: 'utf8' });
|
|
16
|
+
if (result.error || result.status !== 0) throw new Error('Could not locate the global npm modules directory');
|
|
17
|
+
return String(result.stdout || '').trim();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function localRuntimeEntry(runtimeValue) {
|
|
21
|
+
const runtime = normalizeRuntime(runtimeValue);
|
|
22
|
+
const candidate = path.join(globalNodeModules(), ...runtime.packageName.split('/'), ...runtime.entry.split(/[\\/]+/));
|
|
23
|
+
if (!existsSync(candidate)) throw new Error('ReMCP local runtime is not installed. Run `remcp install`.');
|
|
24
|
+
return candidate;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseVersion(value) {
|
|
28
|
+
// Prerelease and build metadata are kept, because comparing only the numeric core made
|
|
29
|
+
// 1.0.0 look newer than 1.0.0-beta.2 and left a machine stuck on the prerelease forever.
|
|
30
|
+
const match = String(value || '').match(/(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);
|
|
31
|
+
return match ? { parts: [Number(match[1]), Number(match[2]), Number(match[3])], prerelease: match[4] || '' } : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isNewer(candidate, current) {
|
|
35
|
+
const a = parseVersion(candidate);
|
|
36
|
+
const b = parseVersion(current);
|
|
37
|
+
if (!a || !b) return false;
|
|
38
|
+
for (let index = 0; index < 3; index += 1) {
|
|
39
|
+
if (a.parts[index] > b.parts[index]) return true;
|
|
40
|
+
if (a.parts[index] < b.parts[index]) return false;
|
|
41
|
+
}
|
|
42
|
+
// Same numeric core: a release is newer than a prerelease, and two prereleases compare by
|
|
43
|
+
// identifier (numeric identifiers order numerically, as semver requires).
|
|
44
|
+
if (!a.prerelease && b.prerelease) return true;
|
|
45
|
+
if (a.prerelease && !b.prerelease) return false;
|
|
46
|
+
if (!a.prerelease && !b.prerelease) return false;
|
|
47
|
+
const left = a.prerelease.split('.');
|
|
48
|
+
const right = b.prerelease.split('.');
|
|
49
|
+
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
50
|
+
const one = left[index];
|
|
51
|
+
const two = right[index];
|
|
52
|
+
if (one === undefined) return false;
|
|
53
|
+
if (two === undefined) return true;
|
|
54
|
+
if (one === two) continue;
|
|
55
|
+
const oneNumeric = /^\d+$/.test(one);
|
|
56
|
+
const twoNumeric = /^\d+$/.test(two);
|
|
57
|
+
if (oneNumeric && twoNumeric) return Number(one) > Number(two);
|
|
58
|
+
if (oneNumeric !== twoNumeric) return oneNumeric;
|
|
59
|
+
return one > two;
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// What the agent should install, if anything. The client version alone is not enough: a machine
|
|
65
|
+
// that already runs the newest client but an older local runtime would otherwise never catch up,
|
|
66
|
+
// because its runtime is what executes the tools.
|
|
67
|
+
export function updateDecision({ advertised, cliVersion, runtimeVersion, runtimePackageName, runtimeDown = false }) {
|
|
68
|
+
const cliSpec = String(advertised?.cli || '');
|
|
69
|
+
const advertisedRuntime = String(advertised?.runtime || '');
|
|
70
|
+
// A spec that is not a plain version of the configured runtime package is ignored rather than
|
|
71
|
+
// installed: this is the only place a server-chosen string reaches npm.
|
|
72
|
+
const runtimeSpec = runtimePackageName && advertisedRuntime && !isRuntimeSpecFor(runtimePackageName, advertisedRuntime) ? '' : advertisedRuntime;
|
|
73
|
+
const installedRuntime = String(runtimeVersion || '');
|
|
74
|
+
const runtimeKnown = Boolean(installedRuntime) && !/^unknown$/i.test(installedRuntime);
|
|
75
|
+
if (isNewer(cliSpec, cliVersion)) return { needed: true, target: cliSpec, runtime: runtimeSpec, reason: 'client' };
|
|
76
|
+
if (runtimeSpec && runtimeKnown && isNewer(runtimeSpec, installedRuntime)) {
|
|
77
|
+
return { needed: true, target: cliSpec || `@remcp/remcp@${cliVersion}`, runtime: runtimeSpec, reason: 'runtime' };
|
|
78
|
+
}
|
|
79
|
+
// A runtime that never reported a version cannot be compared, so a device whose runtime is down
|
|
80
|
+
// (or was never installed) would never repair itself. The cooldown in checkForUpdate keeps this
|
|
81
|
+
// from becoming an install loop.
|
|
82
|
+
if (runtimeSpec && !runtimeKnown && runtimeDown) {
|
|
83
|
+
return { needed: true, target: cliSpec || `@remcp/remcp@${cliVersion}`, runtime: runtimeSpec, reason: 'runtime-repair' };
|
|
84
|
+
}
|
|
85
|
+
return { needed: false, target: cliSpec, runtime: runtimeSpec, reason: 'current' };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Applies a freshly installed version. Exiting is what a supervisor needs; without one the new CLI is
|
|
89
|
+
// started in this process' place. Either way the agent stops holding a stale runtime, which is what
|
|
90
|
+
// makes an update actually take effect on a machine that no service manager watches.
|
|
91
|
+
export async function restartToApplyUpdate(cli, stopAgent, markStopping, onRuntimeRepaired, isStopping) {
|
|
92
|
+
try {
|
|
93
|
+
const installed = globalInstalledVersion();
|
|
94
|
+
if (installed && !isNewer(installed, VERSION)) {
|
|
95
|
+
// The client is current, so the update was a runtime repair: restart the runtime rather than
|
|
96
|
+
// the whole agent, or a device with no usable runtime would stay broken.
|
|
97
|
+
console.log(`ReMCP ${VERSION} is already the installed version; restarting the local runtime.`);
|
|
98
|
+
await onRuntimeRepaired?.();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
// Handing over to a version that cannot start would take the machine offline with nobody left to
|
|
102
|
+
// retry. The new CLI has to answer `--version` before this process steps aside.
|
|
103
|
+
const probe = spawnSync(process.execPath, [cli, '--version'], { encoding: 'utf8', timeout: 30000 });
|
|
104
|
+
const reported = String(probe.stdout || '').trim();
|
|
105
|
+
if (probe.error || probe.status !== 0 || !/^\d+\.\d+\.\d+/.test(reported)) {
|
|
106
|
+
console.error(`The installed ReMCP ${installed || 'update'} did not run (${probe.error?.message || `exit ${probe.status}`}${reported ? `: ${reported}` : ''}). Keeping ${VERSION} running; retry with: remcp update`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
console.log(`ReMCP ${installed || 'a newer version'} installed and verified (${reported}); restarting to apply it.`);
|
|
110
|
+
if (!supervisorRestart()) {
|
|
111
|
+
spawn(process.execPath, [cli, 'start'], { detached: true, stdio: 'ignore', env: { ...process.env } }).unref();
|
|
112
|
+
// Stepping aside is only safe once the replacement really holds the device: the relay closes
|
|
113
|
+
// this socket with 1012 ('replaced') the moment another agent takes the machine over, and that
|
|
114
|
+
// close is what stops this process. Without the wait, a replacement that cannot start left the
|
|
115
|
+
// machine connected in `/health` and offline everywhere else, with nobody left to retry.
|
|
116
|
+
await new Promise(resolve => setTimeout(resolve, REPLACEMENT_HANDOVER_TIMEOUT_MS));
|
|
117
|
+
if (!isStopping()) {
|
|
118
|
+
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`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
markStopping();
|
|
123
|
+
await stopAgent().catch(() => {});
|
|
124
|
+
setTimeout(() => process.exit(0), 100);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
console.error(`Could not restart after the update: ${error instanceof Error ? error.message : String(error)}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The version of the globally installed client, read from the package the CLI resolves to.
|
|
131
|
+
export function globalInstalledVersion() {
|
|
132
|
+
try {
|
|
133
|
+
const cli = globalCliEntry();
|
|
134
|
+
if (!cli) return null;
|
|
135
|
+
const base = path.dirname(cli);
|
|
136
|
+
const candidates = [
|
|
137
|
+
path.join(base, '..', 'lib', 'node_modules', '@remcp', 'remcp', 'package.json'),
|
|
138
|
+
path.join(base, '..', 'node_modules', '@remcp', 'remcp', 'package.json'),
|
|
139
|
+
];
|
|
140
|
+
for (const manifest of candidates) {
|
|
141
|
+
try { return JSON.parse(readFileSync(manifest, 'utf8')).version || null; } catch {}
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// True when this process is the one a service manager owns: launchd and systemd's system manager run
|
|
150
|
+
// a unit's main process as a child of PID 1, and `systemd --user` runs it as a child of the user
|
|
151
|
+
// manager. Anything else — a terminal, a shell inside another unit, a CI runner job — has nobody
|
|
152
|
+
// waiting to start the agent again.
|
|
153
|
+
function parentIsServiceManager() {
|
|
154
|
+
if (process.ppid === 1) return true;
|
|
155
|
+
if (process.platform === 'win32') return false;
|
|
156
|
+
try { return readFileSync(`/proc/${process.ppid}/comm`, 'utf8').trim() === 'systemd'; } catch { return false; }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// What starts the agent again after it exits to apply an update, or null when it has to start its own
|
|
160
|
+
// replacement. systemd sets INVOCATION_ID and JOURNAL_STREAM for a unit and every child of that unit
|
|
161
|
+
// inherits them, so a `remcp start` run from a shell inside a service (a CI runner, a systemd-run
|
|
162
|
+
// scope, another agent) believed a supervisor would bring it back: the update exited into nothing and
|
|
163
|
+
// the workspace showed the machine offline until someone started the agent by hand. Only the unit's
|
|
164
|
+
// own main process is restarted, so that is what the check requires.
|
|
165
|
+
//
|
|
166
|
+
// Injectable for tests: the verdict must not depend on the machine that runs them.
|
|
167
|
+
export function supervisorRestart({ platform = process.platform, dockerenv = existsSync('/.dockerenv'), parentOurs = parentIsServiceManager() } = {}) {
|
|
168
|
+
if (parentOurs) return platform === 'darwin' ? 'launchd' : 'systemd';
|
|
169
|
+
if (dockerenv) return 'docker';
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function globalCliEntry() {
|
|
174
|
+
const prefix = spawnSync(npm.command, [...npm.args, 'prefix', '--global'], { encoding: 'utf8' });
|
|
175
|
+
if (prefix.error || prefix.status !== 0) return null;
|
|
176
|
+
const base = String(prefix.stdout || '').trim();
|
|
177
|
+
return process.platform === 'win32'
|
|
178
|
+
? path.join(base, 'remcp.cmd')
|
|
179
|
+
: path.join(base, 'bin', 'remcp');
|
|
180
|
+
}
|
package/src/agent.mjs
CHANGED
|
@@ -1,25 +1,26 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
|
-
import { existsSync
|
|
3
|
-
import path from 'node:path';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
4
3
|
import process from 'node:process';
|
|
5
|
-
import { spawn
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
6
5
|
import WebSocket from 'ws';
|
|
7
6
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
8
7
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
9
|
-
import { resolveNpm } from './npm.mjs';
|
|
10
|
-
import { isRuntimeSpecFor, normalizeRuntime } from './runtime.mjs';
|
|
11
8
|
import { VERSION } from './version.mjs';
|
|
9
|
+
import {
|
|
10
|
+
globalCliEntry,
|
|
11
|
+
globalInstalledVersion,
|
|
12
|
+
localRuntimeEntry,
|
|
13
|
+
restartToApplyUpdate,
|
|
14
|
+
supervisorRestart,
|
|
15
|
+
updateDecision,
|
|
16
|
+
} from './agent-update.mjs';
|
|
17
|
+
|
|
18
|
+
export { localRuntimeEntry, supervisorRestart, updateDecision } from './agent-update.mjs';
|
|
12
19
|
|
|
13
|
-
// See src/npm.mjs: a service started by launchd or systemd has a minimal PATH, so npm is resolved
|
|
14
|
-
// from the running node instead of being looked up on PATH.
|
|
15
|
-
const npm = resolveNpm();
|
|
16
20
|
const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
17
21
|
const UPDATE_CHECK_TIMEOUT_MS = 5000;
|
|
18
22
|
// A version that failed to install is retried after this cooldown instead of on every reconnect.
|
|
19
23
|
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;
|
|
23
24
|
const METRICS_INTERVAL_MS = 60_000;
|
|
24
25
|
const TELEMETRY_QUEUE_LIMIT = 500;
|
|
25
26
|
const TELEMETRY_BATCH_LIMIT = 100;
|
|
@@ -38,174 +39,6 @@ const RUNTIME_RESTART_MAX_MS = 30_000;
|
|
|
38
39
|
// timeout while the device keeps working invisibly.
|
|
39
40
|
const CALL_TIMEOUT_MARGIN_MS = 10_000;
|
|
40
41
|
|
|
41
|
-
function globalNodeModules() {
|
|
42
|
-
const result = spawnSync(npm.command, [...npm.args, 'root', '--global'], { encoding: 'utf8' });
|
|
43
|
-
if (result.error || result.status !== 0) throw new Error('Could not locate the global npm modules directory');
|
|
44
|
-
return String(result.stdout || '').trim();
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function localRuntimeEntry(runtimeValue) {
|
|
48
|
-
const runtime = normalizeRuntime(runtimeValue);
|
|
49
|
-
const candidate = path.join(globalNodeModules(), ...runtime.packageName.split('/'), ...runtime.entry.split(/[\\/]+/));
|
|
50
|
-
if (!existsSync(candidate)) throw new Error('ReMCP local runtime is not installed. Run `remcp install`.');
|
|
51
|
-
return candidate;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function parseVersion(value) {
|
|
55
|
-
// Prerelease and build metadata are kept, because comparing only the numeric core made
|
|
56
|
-
// 1.0.0 look newer than 1.0.0-beta.2 and left a machine stuck on the prerelease forever.
|
|
57
|
-
const match = String(value || '').match(/(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);
|
|
58
|
-
return match ? { parts: [Number(match[1]), Number(match[2]), Number(match[3])], prerelease: match[4] || '' } : null;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function isNewer(candidate, current) {
|
|
62
|
-
const a = parseVersion(candidate);
|
|
63
|
-
const b = parseVersion(current);
|
|
64
|
-
if (!a || !b) return false;
|
|
65
|
-
for (let index = 0; index < 3; index += 1) {
|
|
66
|
-
if (a.parts[index] > b.parts[index]) return true;
|
|
67
|
-
if (a.parts[index] < b.parts[index]) return false;
|
|
68
|
-
}
|
|
69
|
-
// Same numeric core: a release is newer than a prerelease, and two prereleases compare by
|
|
70
|
-
// identifier (numeric identifiers order numerically, as semver requires).
|
|
71
|
-
if (!a.prerelease && b.prerelease) return true;
|
|
72
|
-
if (a.prerelease && !b.prerelease) return false;
|
|
73
|
-
if (!a.prerelease && !b.prerelease) return false;
|
|
74
|
-
const left = a.prerelease.split('.');
|
|
75
|
-
const right = b.prerelease.split('.');
|
|
76
|
-
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
77
|
-
const one = left[index];
|
|
78
|
-
const two = right[index];
|
|
79
|
-
if (one === undefined) return false;
|
|
80
|
-
if (two === undefined) return true;
|
|
81
|
-
if (one === two) continue;
|
|
82
|
-
const oneNumeric = /^\d+$/.test(one);
|
|
83
|
-
const twoNumeric = /^\d+$/.test(two);
|
|
84
|
-
if (oneNumeric && twoNumeric) return Number(one) > Number(two);
|
|
85
|
-
if (oneNumeric !== twoNumeric) return oneNumeric;
|
|
86
|
-
return one > two;
|
|
87
|
-
}
|
|
88
|
-
return false;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// What the agent should install, if anything. The client version alone is not enough: a machine
|
|
92
|
-
// that already runs the newest client but an older local runtime would otherwise never catch up,
|
|
93
|
-
// because its runtime is what executes the tools.
|
|
94
|
-
export function updateDecision({ advertised, cliVersion, runtimeVersion, runtimePackageName, runtimeDown = false }) {
|
|
95
|
-
const cliSpec = String(advertised?.cli || '');
|
|
96
|
-
const advertisedRuntime = String(advertised?.runtime || '');
|
|
97
|
-
// A spec that is not a plain version of the configured runtime package is ignored rather than
|
|
98
|
-
// installed: this is the only place a server-chosen string reaches npm.
|
|
99
|
-
const runtimeSpec = runtimePackageName && advertisedRuntime && !isRuntimeSpecFor(runtimePackageName, advertisedRuntime) ? '' : advertisedRuntime;
|
|
100
|
-
const installedRuntime = String(runtimeVersion || '');
|
|
101
|
-
const runtimeKnown = Boolean(installedRuntime) && !/^unknown$/i.test(installedRuntime);
|
|
102
|
-
if (isNewer(cliSpec, cliVersion)) return { needed: true, target: cliSpec, runtime: runtimeSpec, reason: 'client' };
|
|
103
|
-
if (runtimeSpec && runtimeKnown && isNewer(runtimeSpec, installedRuntime)) {
|
|
104
|
-
return { needed: true, target: cliSpec || `@remcp/remcp@${cliVersion}`, runtime: runtimeSpec, reason: 'runtime' };
|
|
105
|
-
}
|
|
106
|
-
// A runtime that never reported a version cannot be compared, so a device whose runtime is down
|
|
107
|
-
// (or was never installed) would never repair itself. The cooldown in checkForUpdate keeps this
|
|
108
|
-
// from becoming an install loop.
|
|
109
|
-
if (runtimeSpec && !runtimeKnown && runtimeDown) {
|
|
110
|
-
return { needed: true, target: cliSpec || `@remcp/remcp@${cliVersion}`, runtime: runtimeSpec, reason: 'runtime-repair' };
|
|
111
|
-
}
|
|
112
|
-
return { needed: false, target: cliSpec, runtime: runtimeSpec, reason: 'current' };
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Applies a freshly installed version. Exiting is what a supervisor needs; without one the new CLI is
|
|
116
|
-
// started in this process' place. Either way the agent stops holding a stale runtime, which is what
|
|
117
|
-
// makes an update actually take effect on a machine that no service manager watches.
|
|
118
|
-
async function restartToApplyUpdate(cli, stopAgent, markStopping, onRuntimeRepaired, isStopping) {
|
|
119
|
-
try {
|
|
120
|
-
const installed = globalInstalledVersion();
|
|
121
|
-
if (installed && !isNewer(installed, VERSION)) {
|
|
122
|
-
// The client is current, so the update was a runtime repair: restart the runtime rather than
|
|
123
|
-
// the whole agent, or a device with no usable runtime would stay broken.
|
|
124
|
-
console.log(`ReMCP ${VERSION} is already the installed version; restarting the local runtime.`);
|
|
125
|
-
await onRuntimeRepaired?.();
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
// Handing over to a version that cannot start would take the machine offline with nobody left to
|
|
129
|
-
// retry. The new CLI has to answer `--version` before this process steps aside.
|
|
130
|
-
const probe = spawnSync(process.execPath, [cli, '--version'], { encoding: 'utf8', timeout: 30000 });
|
|
131
|
-
const reported = String(probe.stdout || '').trim();
|
|
132
|
-
if (probe.error || probe.status !== 0 || !/^\d+\.\d+\.\d+/.test(reported)) {
|
|
133
|
-
console.error(`The installed ReMCP ${installed || 'update'} did not run (${probe.error?.message || `exit ${probe.status}`}${reported ? `: ${reported}` : ''}). Keeping ${VERSION} running; retry with: remcp update`);
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
console.log(`ReMCP ${installed || 'a newer version'} installed and verified (${reported}); restarting to apply it.`);
|
|
137
|
-
if (!supervisorRestart()) {
|
|
138
|
-
spawn(process.execPath, [cli, 'start'], { detached: true, stdio: 'ignore', env: { ...process.env } }).unref();
|
|
139
|
-
// Stepping aside is only safe once the replacement really holds the device: the relay closes
|
|
140
|
-
// this socket with 1012 ('replaced') the moment another agent takes the machine over, and that
|
|
141
|
-
// close is what stops this process. Without the wait, a replacement that cannot start left the
|
|
142
|
-
// machine connected in `/health` and offline everywhere else, with nobody left to retry.
|
|
143
|
-
await new Promise(resolve => setTimeout(resolve, REPLACEMENT_HANDOVER_TIMEOUT_MS));
|
|
144
|
-
if (!isStopping()) {
|
|
145
|
-
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`);
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
markStopping();
|
|
150
|
-
await stopAgent().catch(() => {});
|
|
151
|
-
setTimeout(() => process.exit(0), 100);
|
|
152
|
-
} catch (error) {
|
|
153
|
-
console.error(`Could not restart after the update: ${error instanceof Error ? error.message : String(error)}`);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// The version of the globally installed client, read from the package the CLI resolves to.
|
|
158
|
-
function globalInstalledVersion() {
|
|
159
|
-
try {
|
|
160
|
-
const cli = globalCliEntry();
|
|
161
|
-
if (!cli) return null;
|
|
162
|
-
const base = path.dirname(cli);
|
|
163
|
-
const candidates = [
|
|
164
|
-
path.join(base, '..', 'lib', 'node_modules', '@remcp', 'remcp', 'package.json'),
|
|
165
|
-
path.join(base, '..', 'node_modules', '@remcp', 'remcp', 'package.json'),
|
|
166
|
-
];
|
|
167
|
-
for (const manifest of candidates) {
|
|
168
|
-
try { return JSON.parse(readFileSync(manifest, 'utf8')).version || null; } catch {}
|
|
169
|
-
}
|
|
170
|
-
return null;
|
|
171
|
-
} catch {
|
|
172
|
-
return null;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// True when this process is the one a service manager owns: launchd and systemd's system manager run
|
|
177
|
-
// a unit's main process as a child of PID 1, and `systemd --user` runs it as a child of the user
|
|
178
|
-
// manager. Anything else — a terminal, a shell inside another unit, a CI runner job — has nobody
|
|
179
|
-
// waiting to start the agent again.
|
|
180
|
-
function parentIsServiceManager() {
|
|
181
|
-
if (process.ppid === 1) return true;
|
|
182
|
-
if (process.platform === 'win32') return false;
|
|
183
|
-
try { return readFileSync(`/proc/${process.ppid}/comm`, 'utf8').trim() === 'systemd'; } catch { return false; }
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// What starts the agent again after it exits to apply an update, or null when it has to start its own
|
|
187
|
-
// replacement. systemd sets INVOCATION_ID and JOURNAL_STREAM for a unit and every child of that unit
|
|
188
|
-
// inherits them, so a `remcp start` run from a shell inside a service (a CI runner, a systemd-run
|
|
189
|
-
// scope, another agent) believed a supervisor would bring it back: the update exited into nothing and
|
|
190
|
-
// the workspace showed the machine offline until someone started the agent by hand. Only the unit's
|
|
191
|
-
// own main process is restarted, so that is what the check requires.
|
|
192
|
-
//
|
|
193
|
-
// Injectable for tests: the verdict must not depend on the machine that runs them.
|
|
194
|
-
export function supervisorRestart({ platform = process.platform, dockerenv = existsSync('/.dockerenv'), parentOurs = parentIsServiceManager() } = {}) {
|
|
195
|
-
if (parentOurs) return platform === 'darwin' ? 'launchd' : 'systemd';
|
|
196
|
-
if (dockerenv) return 'docker';
|
|
197
|
-
return null;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function globalCliEntry() {
|
|
201
|
-
const prefix = spawnSync(npm.command, [...npm.args, 'prefix', '--global'], { encoding: 'utf8' });
|
|
202
|
-
if (prefix.error || prefix.status !== 0) return null;
|
|
203
|
-
const base = String(prefix.stdout || '').trim();
|
|
204
|
-
return process.platform === 'win32'
|
|
205
|
-
? path.join(base, 'remcp.cmd')
|
|
206
|
-
: path.join(base, 'bin', 'remcp');
|
|
207
|
-
}
|
|
208
|
-
|
|
209
42
|
function jitter(ms) {
|
|
210
43
|
return Math.round(ms * (0.75 + Math.random() * 0.5));
|
|
211
44
|
}
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import os from 'node:os';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { readdir } from 'node:fs/promises';
|
|
4
|
-
|
|
5
|
-
// macOS gates Desktop, Documents, Downloads and iCloud Drive behind TCC. A paired Mac can be online,
|
|
6
|
-
// healthy and answering tools, and still fail every write into Desktop with `EACCES: permission
|
|
7
|
-
// denied` — the failure people actually report, because the workspace and the model only ever see the
|
|
8
|
-
// errno. `remcp doctor` is the one place that can look at the machine itself, so it reports which of
|
|
9
|
-
// those folders this process may use.
|
|
10
|
-
//
|
|
11
|
-
// The probe is read-only on purpose: reading a directory is what TCC gates, so a directory that
|
|
12
|
-
// cannot be listed is a directory that cannot be written either, and listing one cannot change it.
|
|
13
|
-
export const MACOS_PROTECTED_FOLDERS = Object.freeze(['Desktop', 'Documents', 'Downloads']);
|
|
14
|
-
|
|
15
|
-
export function macosPermissionHint(execPath = process.execPath) {
|
|
16
|
-
return `macOS is blocking one or more protected folders. Grant access by hand: System Settings → Privacy & Security → Full Disk Access → + → ${execPath}, then restart the agent with \`remcp start\`. Folders outside Desktop, Documents, Downloads and iCloud Drive need no new permission.`;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export async function probeMacosFolderAccess({ platform = process.platform, home = os.homedir(), list = readdir } = {}) {
|
|
20
|
-
if (platform !== 'darwin' || !home) return { supported: false, folders: [] };
|
|
21
|
-
const candidates = MACOS_PROTECTED_FOLDERS.map(name => ({ name, path: path.join(home, name) }));
|
|
22
|
-
// iCloud Drive only exists when it is switched on; a missing folder is not a permission problem.
|
|
23
|
-
candidates.push({ name: 'iCloud Drive', path: path.join(home, 'Library', 'Mobile Documents') });
|
|
24
|
-
const folders = [];
|
|
25
|
-
for (const candidate of candidates) {
|
|
26
|
-
try {
|
|
27
|
-
await list(candidate.path);
|
|
28
|
-
folders.push({ ...candidate, state: 'ok' });
|
|
29
|
-
} catch (error) {
|
|
30
|
-
const code = typeof error?.code === 'string' ? error.code : '';
|
|
31
|
-
if (code === 'ENOENT') folders.push({ ...candidate, state: 'missing' });
|
|
32
|
-
else if (code === 'EACCES' || code === 'EPERM') folders.push({ ...candidate, state: 'denied', code });
|
|
33
|
-
else folders.push({ ...candidate, state: 'error', code: code || 'unknown' });
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
const denied = folders.filter(folder => folder.state === 'denied');
|
|
37
|
-
return { supported: true, folders, ...(denied.length ? { denied: denied.map(folder => folder.name), hint: macosPermissionHint() } : {}) };
|
|
38
|
-
}
|