@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/remcp",
3
- "version": "0.2.43",
3
+ "version": "0.2.45",
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",
@@ -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 value = JSON.parse(fs.readFileSync(configFile, 'utf8'));
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(value, null, 2) + '\n', { mode: 0o600 });
67
+ fs.writeFileSync(configFile, JSON.stringify(persisted, null, 2) + '\n', { mode: 0o600 });
25
68
  fs.chmodSync(configFile, 0o600);
26
69
  }
27
70
 
@@ -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
- export function installMacService(cliPath = globalCliPath()) {
53
- const domain = macLaunchDomain();
54
- const target = `${domain}/${macServiceLabel}`;
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
- spawnSync('launchctl', ['bootout', domain, macServiceFile], { stdio: 'ignore' });
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?.serviceInstalled !== true) return false;
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 the CLI path into the plist, so a Node manager that moves
167
- // its global prefix leaves the agent launching a file that no longer exists the same loop the
168
- // Linux unit above is repaired for. Reinstalling is idempotent (bootout, bootstrap, enable,
169
- // kickstart) and is what the Windows task already does on every update.
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
- run('launchctl', ['kickstart', '-k', `${macLaunchDomain()}/${macServiceLabel}`]);
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') {
@@ -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
  }