@remcp/remcp 0.2.43 → 0.2.45
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/cli/config.mjs +45 -2
- package/src/cli/service.mjs +93 -15
- package/src/cli/update.mjs +1 -1
- package/src/cli.mjs +7 -4
package/package.json
CHANGED
package/src/cli/config.mjs
CHANGED
|
@@ -7,21 +7,64 @@ import { normalizeRuntime } from '../runtime.mjs';
|
|
|
7
7
|
|
|
8
8
|
import { configDir, configFile, machineIdFile, officialOrigin, runtimeConfigFile } from './env.mjs';
|
|
9
9
|
|
|
10
|
+
export const CONFIG_SCHEMA_VERSION = 1;
|
|
11
|
+
const legacyOfficialOrigins = new Set(['https://remcp.delio24.com']);
|
|
12
|
+
|
|
13
|
+
function migrateConfig(value) {
|
|
14
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('ReMCP config must be a JSON object');
|
|
15
|
+
const declared = Number(value.configSchemaVersion || 0);
|
|
16
|
+
// A newer client may have added fields this client does not understand. Preserve that document
|
|
17
|
+
// byte-for-meaning and only use the known fields below; never downgrade its schema marker.
|
|
18
|
+
if (Number.isSafeInteger(declared) && declared > CONFIG_SCHEMA_VERSION) return { value: { ...value }, changed: false };
|
|
19
|
+
|
|
20
|
+
let next = { ...value };
|
|
21
|
+
let changed = false;
|
|
22
|
+
if (declared < 1) {
|
|
23
|
+
try {
|
|
24
|
+
const server = new URL(String(next.serverUrl || ''));
|
|
25
|
+
if (legacyOfficialOrigins.has(server.origin)) {
|
|
26
|
+
next.serverUrl = officialOrigin;
|
|
27
|
+
if (next.trustRuntime === undefined) next.trustRuntime = true;
|
|
28
|
+
changed = true;
|
|
29
|
+
}
|
|
30
|
+
} catch {}
|
|
31
|
+
if (next.configSchemaVersion !== CONFIG_SCHEMA_VERSION) {
|
|
32
|
+
next.configSchemaVersion = CONFIG_SCHEMA_VERSION;
|
|
33
|
+
changed = true;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { value: next, changed };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function configForWrite(value) {
|
|
40
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('ReMCP config must be a JSON object');
|
|
41
|
+
const declared = Number(value.configSchemaVersion || 0);
|
|
42
|
+
return {
|
|
43
|
+
...value,
|
|
44
|
+
configSchemaVersion: Number.isSafeInteger(declared) && declared > CONFIG_SCHEMA_VERSION
|
|
45
|
+
? declared
|
|
46
|
+
: CONFIG_SCHEMA_VERSION,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
10
50
|
export function loadConfig(required = true) {
|
|
11
51
|
if (!fs.existsSync(configFile)) {
|
|
12
52
|
if (!required) return undefined;
|
|
13
53
|
throw new Error(`ReMCP is not paired. Generate a pairing command at ${officialOrigin}/app/connect`);
|
|
14
54
|
}
|
|
15
|
-
const
|
|
55
|
+
const migrated = migrateConfig(JSON.parse(fs.readFileSync(configFile, 'utf8')));
|
|
56
|
+
const value = migrated.value;
|
|
16
57
|
// A configuration that only carries preferences (for example after `remcp auto-update off`
|
|
17
58
|
// before pairing) has no runtime yet; it must not fail as if it were corrupt.
|
|
18
59
|
if (value.runtime !== undefined) value.runtime = normalizeRuntime(value.runtime);
|
|
60
|
+
if (migrated.changed) saveConfig(value);
|
|
19
61
|
return value;
|
|
20
62
|
}
|
|
21
63
|
|
|
22
64
|
export function saveConfig(value) {
|
|
65
|
+
const persisted = configForWrite(value);
|
|
23
66
|
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
24
|
-
fs.writeFileSync(configFile, JSON.stringify(
|
|
67
|
+
fs.writeFileSync(configFile, JSON.stringify(persisted, null, 2) + '\n', { mode: 0o600 });
|
|
25
68
|
fs.chmodSync(configFile, 0o600);
|
|
26
69
|
}
|
|
27
70
|
|
package/src/cli/service.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
8
8
|
import { PACKAGE_NAME, VERSION } from '../version.mjs';
|
|
9
9
|
|
|
10
10
|
import { saveConfig } from './config.mjs';
|
|
11
|
-
import { home, linuxServiceFile, macLogFile, macServiceFile, macServiceLabel, npm, windowsTaskName } from './env.mjs';
|
|
11
|
+
import { configDir, home, linuxServiceFile, macLogFile, macServiceFile, macServiceLabel, npm, windowsTaskName } from './env.mjs';
|
|
12
12
|
import { output, run } from './shell.mjs';
|
|
13
13
|
|
|
14
14
|
export function servicePlatform() {
|
|
@@ -49,21 +49,72 @@ export function macLaunchDomain() {
|
|
|
49
49
|
return `gui/${process.getuid()}`;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
function shellQuote(value) {
|
|
53
|
+
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function macServicePlist(cliPath) {
|
|
55
57
|
// launchd wants an absolute path; a symlinked prefix that npm has not materialised yet (or a path
|
|
56
58
|
// that is about to be replaced by the next install) must not abort the repair — a stale plist is
|
|
57
59
|
// exactly the loop this function exists to break.
|
|
58
60
|
const cliScript = fs.existsSync(cliPath) ? fs.realpathSync(cliPath) : path.resolve(cliPath);
|
|
61
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n<key>Label</key><string>${macServiceLabel}</string>\n<key>ProgramArguments</key><array><string>${xmlEscape(process.execPath)}</string><string>${xmlEscape(cliScript)}</string><string>start</string></array>\n<key>RunAtLoad</key><true/><key>KeepAlive</key><true/>\n<key>ProcessType</key><string>Background</string>\n<key>StandardOutPath</key><string>${xmlEscape(macLogFile)}</string>\n<key>StandardErrorPath</key><string>${xmlEscape(macLogFile)}</string>\n</dict></plist>\n`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function macJobLoaded(target) {
|
|
65
|
+
return spawnSync('launchctl', ['print', target], { stdio: 'ignore' }).status === 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function scheduleMacServiceReload(domain, target) {
|
|
69
|
+
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
70
|
+
const helperFile = path.join(configDir, `launchd-reload-${process.pid}.sh`);
|
|
71
|
+
const helperLabel = `${macServiceLabel}.reload.${process.pid}`;
|
|
72
|
+
const script = [
|
|
73
|
+
'#!/bin/sh',
|
|
74
|
+
'sleep 1',
|
|
75
|
+
`launchctl bootout ${shellQuote(target)} >/dev/null 2>&1 || true`,
|
|
76
|
+
`launchctl bootstrap ${shellQuote(domain)} ${shellQuote(macServiceFile)}`,
|
|
77
|
+
`launchctl enable ${shellQuote(target)}`,
|
|
78
|
+
`launchctl kickstart -k ${shellQuote(target)}`,
|
|
79
|
+
`rm -f ${shellQuote(helperFile)}`,
|
|
80
|
+
'',
|
|
81
|
+
].join('\n');
|
|
82
|
+
fs.writeFileSync(helperFile, script, { mode: 0o700 });
|
|
83
|
+
fs.chmodSync(helperFile, 0o700);
|
|
84
|
+
run('launchctl', ['submit', '-l', helperLabel, '--', '/bin/sh', helperFile]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function installMacService(cliPath = globalCliPath(), { restart = true } = {}) {
|
|
88
|
+
const domain = macLaunchDomain();
|
|
89
|
+
const target = `${domain}/${macServiceLabel}`;
|
|
90
|
+
const plist = macServicePlist(cliPath);
|
|
91
|
+
let previous = '';
|
|
92
|
+
try { previous = fs.readFileSync(macServiceFile, 'utf8'); } catch {}
|
|
93
|
+
const loaded = macJobLoaded(target);
|
|
94
|
+
|
|
59
95
|
fs.mkdirSync(path.dirname(macServiceFile), { recursive: true });
|
|
60
96
|
fs.mkdirSync(path.dirname(macLogFile), { recursive: true });
|
|
61
|
-
const plist = `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n<key>Label</key><string>${macServiceLabel}</string>\n<key>ProgramArguments</key><array><string>${xmlEscape(process.execPath)}</string><string>${xmlEscape(cliScript)}</string><string>start</string></array>\n<key>RunAtLoad</key><true/><key>KeepAlive</key><true/>\n<key>ProcessType</key><string>Background</string>\n<key>StandardOutPath</key><string>${xmlEscape(macLogFile)}</string>\n<key>StandardErrorPath</key><string>${xmlEscape(macLogFile)}</string>\n</dict></plist>\n`;
|
|
62
97
|
fs.writeFileSync(macServiceFile, plist, { mode: 0o600 });
|
|
63
|
-
|
|
98
|
+
|
|
99
|
+
if (loaded && previous === plist) {
|
|
100
|
+
run('launchctl', ['enable', target]);
|
|
101
|
+
// Never boot out a healthy loaded job just to refresh an in-place npm install. The final
|
|
102
|
+
// kickstart keeps launchd responsible for bringing the replacement agent back.
|
|
103
|
+
if (restart) run('launchctl', ['kickstart', '-k', target]);
|
|
104
|
+
return 'loaded';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (loaded) {
|
|
108
|
+
// A changed Node/npm prefix requires launchd to re-read ProgramArguments. A separate transient
|
|
109
|
+
// launchd job survives booting out com.remcp.agent even when the updater was launched by it.
|
|
110
|
+
scheduleMacServiceReload(domain, target);
|
|
111
|
+
return 'reload-scheduled';
|
|
112
|
+
}
|
|
113
|
+
|
|
64
114
|
run('launchctl', ['bootstrap', domain, macServiceFile]);
|
|
65
115
|
run('launchctl', ['enable', target]);
|
|
66
|
-
run('launchctl', ['kickstart', '-k', target]);
|
|
116
|
+
if (restart) run('launchctl', ['kickstart', '-k', target]);
|
|
117
|
+
return 'bootstrapped';
|
|
67
118
|
}
|
|
68
119
|
|
|
69
120
|
export function installWindowsService(cliPath = globalCliPath()) {
|
|
@@ -72,6 +123,22 @@ export function installWindowsService(cliPath = globalCliPath()) {
|
|
|
72
123
|
run('schtasks.exe', ['/Run', '/TN', windowsTaskName]);
|
|
73
124
|
}
|
|
74
125
|
|
|
126
|
+
// `serviceInstalled` was added after background services already existed in the wild. Treat an
|
|
127
|
+
// explicit false as the user's opt-out, an explicit true as the current marker, and only infer the
|
|
128
|
+
// old intent from an OS service artifact when the marker is absent. This keeps legacy installs
|
|
129
|
+
// repairable without resurrecting a service that a newer client explicitly disabled.
|
|
130
|
+
export function persistentServiceExpected(config) {
|
|
131
|
+
if (config?.serviceInstalled === false) return false;
|
|
132
|
+
if (config?.serviceInstalled === true) return true;
|
|
133
|
+
const platform = servicePlatform();
|
|
134
|
+
if (platform === 'linux') return fs.existsSync(linuxServiceFile);
|
|
135
|
+
if (platform === 'darwin') return fs.existsSync(macServiceFile);
|
|
136
|
+
if (platform === 'win32') {
|
|
137
|
+
return spawnSync('schtasks.exe', ['/Query', '/TN', windowsTaskName], { stdio: 'ignore' }).status === 0;
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
|
|
75
142
|
export function configurePostInstallAccess() {
|
|
76
143
|
const platform = servicePlatform();
|
|
77
144
|
try {
|
|
@@ -136,7 +203,7 @@ export function installPersistentAgent(config) {
|
|
|
136
203
|
// (a failed install, a cleaned LaunchAgents directory, a re-imaged user), the next update recreates
|
|
137
204
|
// it instead of leaving a hand-over to a process nobody supervises.
|
|
138
205
|
export function ensureServiceIfRecorded(config) {
|
|
139
|
-
if (config
|
|
206
|
+
if (!persistentServiceExpected(config)) return false;
|
|
140
207
|
try {
|
|
141
208
|
const cliPath = globalCliPath();
|
|
142
209
|
const platform = servicePlatform();
|
|
@@ -163,12 +230,15 @@ export function ensureServiceIfRecorded(config) {
|
|
|
163
230
|
}
|
|
164
231
|
}
|
|
165
232
|
} else if (platform === 'darwin') {
|
|
166
|
-
// launchd bakes the interpreter and
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
installMacService(cliPath);
|
|
233
|
+
// launchd bakes the interpreter and CLI path into the plist. Repair the file first, but never
|
|
234
|
+
// boot out a loaded agent inline: this updater may itself be a descendant of that LaunchAgent.
|
|
235
|
+
// installMacService either leaves an unchanged loaded job alone, bootstraps an unloaded job, or
|
|
236
|
+
// hands a changed launcher to an independent transient launchd helper.
|
|
237
|
+
installMacService(cliPath, { restart: false });
|
|
171
238
|
} else if (platform === 'win32') installWindowsService(cliPath);
|
|
239
|
+
// Upgrade the legacy inferred state only after the supervisor repair succeeded. A failed repair
|
|
240
|
+
// must not turn a stale artifact into a permanent "managed service" declaration.
|
|
241
|
+
if (config?.serviceInstalled !== true) saveConfig({ ...config, serviceInstalled: true });
|
|
172
242
|
return true;
|
|
173
243
|
} catch (error) {
|
|
174
244
|
console.error(`Could not ensure the background service: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -176,7 +246,8 @@ export function ensureServiceIfRecorded(config) {
|
|
|
176
246
|
}
|
|
177
247
|
}
|
|
178
248
|
|
|
179
|
-
export function restartPersistentServiceIfInstalled() {
|
|
249
|
+
export function restartPersistentServiceIfInstalled(config) {
|
|
250
|
+
if (!persistentServiceExpected(config)) return null;
|
|
180
251
|
const platform = servicePlatform();
|
|
181
252
|
if (platform === 'linux' && fs.existsSync(linuxServiceFile)) {
|
|
182
253
|
run('systemctl', ['--user', 'daemon-reload']);
|
|
@@ -184,7 +255,14 @@ export function restartPersistentServiceIfInstalled() {
|
|
|
184
255
|
return 'remcp-agent.service';
|
|
185
256
|
}
|
|
186
257
|
if (platform === 'darwin' && fs.existsSync(macServiceFile)) {
|
|
187
|
-
|
|
258
|
+
try {
|
|
259
|
+
run('launchctl', ['kickstart', '-k', `${macLaunchDomain()}/${macServiceLabel}`]);
|
|
260
|
+
} catch {
|
|
261
|
+
// A plist can survive while launchd has no loaded job (older installs, logout/login cleanup,
|
|
262
|
+
// manual bootout, or a failed previous update). Re-register the job instead of surfacing the
|
|
263
|
+
// opaque launchctl 113 error. installMacService is idempotent and rewrites stale Node paths too.
|
|
264
|
+
installMacService(globalCliPath());
|
|
265
|
+
}
|
|
188
266
|
return macServiceLabel;
|
|
189
267
|
}
|
|
190
268
|
if (platform === 'win32') {
|
package/src/cli/update.mjs
CHANGED
|
@@ -200,7 +200,7 @@ export async function updateCommand(flags) {
|
|
|
200
200
|
// Only a validated spec is persisted, so a failed update cannot leave the install unable to start.
|
|
201
201
|
if (targets.persistRuntime) saveConfig({ ...cfg, runtime: { ...cfg.runtime, packageSpec: targets.runtimeSpec } });
|
|
202
202
|
const after = { cli: installedVersion(PACKAGE_NAME), runtime: installedVersion(cfg.runtime.packageName) };
|
|
203
|
-
const restarted = restartPersistentServiceIfInstalled();
|
|
203
|
+
const restarted = restartPersistentServiceIfInstalled(cfg);
|
|
204
204
|
if (restarted) {
|
|
205
205
|
console.log(`ReMCP updated and ${restarted} restarted (client ${before.cli} → ${after.cli ?? '?'}, runtime ${before.runtime ?? '?'} → ${after.runtime ?? '?'}).`);
|
|
206
206
|
return;
|
package/src/cli.mjs
CHANGED
|
@@ -128,13 +128,13 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
128
128
|
}
|
|
129
129
|
if (action === 'on' || action === 'enable') {
|
|
130
130
|
console.log(JSON.stringify(setTelemetry(true), null, 2));
|
|
131
|
-
restartPersistentServiceIfInstalled();
|
|
131
|
+
restartPersistentServiceIfInstalled(loadConfig(false));
|
|
132
132
|
console.log('Usage metrics enabled and the agent restarted to apply it.');
|
|
133
133
|
return;
|
|
134
134
|
}
|
|
135
135
|
if (action === 'off' || action === 'disable') {
|
|
136
136
|
console.log(JSON.stringify(setTelemetry(false), null, 2));
|
|
137
|
-
restartPersistentServiceIfInstalled();
|
|
137
|
+
restartPersistentServiceIfInstalled(loadConfig(false));
|
|
138
138
|
console.log('Usage metrics disabled and the agent restarted to apply it.');
|
|
139
139
|
return;
|
|
140
140
|
}
|
|
@@ -162,7 +162,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
162
162
|
if (!['on', 'off', 'enable', 'disable'].includes(action)) throw new Error('Usage: remcp godmode [status|on|off]');
|
|
163
163
|
const enabled = action === 'on' || action === 'enable';
|
|
164
164
|
writeJsonFile(runtimeConfigFile, { ...readJsonFile(runtimeConfigFile), unrestricted: enabled });
|
|
165
|
-
const restarted = restartPersistentServiceIfInstalled();
|
|
165
|
+
const restarted = restartPersistentServiceIfInstalled(loadConfig(false));
|
|
166
166
|
if (enabled) {
|
|
167
167
|
console.error('Unrestricted mode is ON for this computer: every path and every command is allowed, including sudo. Anything the model is asked to do -- and anything a prompt injection asks it to do -- can now change this machine. Turn it off with `remcp godmode off` when you are done.');
|
|
168
168
|
} else {
|
|
@@ -213,9 +213,12 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
213
213
|
}
|
|
214
214
|
|
|
215
215
|
if (command === 'uninstall') {
|
|
216
|
+
const cfg = loadConfig(false);
|
|
216
217
|
uninstallPersistentService();
|
|
218
|
+
// Keep the pairing unless --purge was requested, but remember that the owner explicitly removed
|
|
219
|
+
// the supervisor. Future updates must not infer an old service artifact and recreate it.
|
|
220
|
+
if (cfg) saveConfig({ ...cfg, serviceInstalled: false });
|
|
217
221
|
if (flags.purge) {
|
|
218
|
-
const cfg = loadConfig(false);
|
|
219
222
|
const specs = [PACKAGE_NAME, ...(cfg?.runtime?.packageName ? [cfg.runtime.packageName] : [])];
|
|
220
223
|
run(npm.command, [...npm.args, 'uninstall', '--global', ...specs, '--no-audit', '--no-fund', '--loglevel=error']);
|
|
221
224
|
}
|