@remcp/remcp 0.2.173 → 0.2.175
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-relay-connection.mjs +168 -0
- package/src/agent-runtime-capabilities.mjs +56 -0
- package/src/agent.mjs +35 -208
- package/src/cli/service-macos.mjs +192 -0
- package/src/cli/service-paths.mjs +15 -0
- package/src/cli/service.mjs +6 -197
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remcp/remcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.175",
|
|
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-responder.mjs && node --check src/agent-update-monitor.mjs && node --check src/agent-update.mjs && node --check src/agent-utilities.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/npm-global.mjs && node --check src/cli/service-inventory.mjs && node --check src/cli/service.mjs && node --check src/cli/shell.mjs && node --check src/cli/update.mjs && node --check src/cli/windows-supervisor.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-relay-connection.mjs && node --check src/agent-responder.mjs && node --check src/agent-runtime-capabilities.mjs && node --check src/agent-update-monitor.mjs && node --check src/agent-update.mjs && node --check src/agent-utilities.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/npm-global.mjs && node --check src/cli/service-inventory.mjs && node --check src/cli/service-macos.mjs && node --check src/cli/service-paths.mjs && node --check src/cli/service.mjs && node --check src/cli/shell.mjs && node --check src/cli/update.mjs && node --check src/cli/windows-supervisor.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,168 @@
|
|
|
1
|
+
// Agent relay WebSocket lifecycle, bounded reconnect scheduling and handshake security.
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import WebSocket from 'ws';
|
|
5
|
+
import { VERSION } from './version.mjs';
|
|
6
|
+
import { boundedReconnectDelay, jitter, sendRelayMessage } from './agent-utilities.mjs';
|
|
7
|
+
|
|
8
|
+
const RECONNECT_BASE_MS = 2_000;
|
|
9
|
+
const RECONNECT_FIRST_MS = 500;
|
|
10
|
+
const RECONNECT_MAX_MS = 60_000;
|
|
11
|
+
|
|
12
|
+
// Higher-level agent owns runtime supervision/telemetry; this controller owns only relay transport.
|
|
13
|
+
export function createAgentRelayConnection({
|
|
14
|
+
agentUrl, serverUrl, deviceToken, deviceId, deviceName,
|
|
15
|
+
isStopping, markStopping, getReconnects, setReconnects,
|
|
16
|
+
setSocket, getHelloState, respond, inFlight,
|
|
17
|
+
sendMetricsSample, reportInstallOnce, flushTelemetry,
|
|
18
|
+
checkForUpdate, schedulePostConnectUpdateChecks, clearPostConnectUpdateChecks, stop,
|
|
19
|
+
}) {
|
|
20
|
+
let revoked = false;
|
|
21
|
+
let reconnectTimer = null;
|
|
22
|
+
let askedToReconnect = false;
|
|
23
|
+
let requestedReconnectDelayMs = 0;
|
|
24
|
+
function scheduleReconnect(delay) {
|
|
25
|
+
if (isStopping() || revoked || reconnectTimer) return;
|
|
26
|
+
// Keep the upper-bound guard in control flow immediately before the timer sink. CodeQL's
|
|
27
|
+
// resource-exhaustion model treats this as a barrier, and future callers cannot retain the
|
|
28
|
+
// agent with an attacker-controlled timer lifetime.
|
|
29
|
+
const numericDelay = Number(delay);
|
|
30
|
+
if (!Number.isFinite(numericDelay)) {
|
|
31
|
+
scheduleReconnect(RECONNECT_BASE_MS);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (numericDelay > RECONNECT_MAX_MS) {
|
|
35
|
+
scheduleReconnect(RECONNECT_MAX_MS);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const safeDelay = Math.max(100, Math.floor(numericDelay));
|
|
39
|
+
reconnectTimer = setTimeout(() => {
|
|
40
|
+
reconnectTimer = null;
|
|
41
|
+
connect();
|
|
42
|
+
}, safeDelay);
|
|
43
|
+
reconnectTimer.unref?.();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function scheduleBackoffReconnect() {
|
|
47
|
+
if (isStopping() || revoked || reconnectTimer) return;
|
|
48
|
+
setReconnects(getReconnects() + 1);
|
|
49
|
+
// The first retry after an unexpected drop is quick on purpose: a deploy blip, a proxy restart or
|
|
50
|
+
// a dropped packet should cost a fraction of a second, not the two seconds the backoff starts at.
|
|
51
|
+
const delay = getReconnects() === 1
|
|
52
|
+
? jitter(RECONNECT_FIRST_MS)
|
|
53
|
+
: jitter(Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** Math.min(getReconnects(), 5)));
|
|
54
|
+
scheduleReconnect(delay);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function connect() {
|
|
58
|
+
if (isStopping()) return;
|
|
59
|
+
// The device token is intentionally sent only to the configured ReMCP relay during the
|
|
60
|
+
// authenticated WebSocket handshake.
|
|
61
|
+
// codeql[js/file-access-to-http]
|
|
62
|
+
const ws = new WebSocket(agentUrl, { headers: { Authorization: `Bearer ${deviceToken}` } });
|
|
63
|
+
// The ws client leaves cleanup/retry to the caller when an unexpected-response listener exists.
|
|
64
|
+
// A temporary workspace pause therefore needs an explicit retry; otherwise the first 423 leaves
|
|
65
|
+
// this WebSocket stuck in CONNECTING forever and turning the device back on cannot recover it.
|
|
66
|
+
ws.on('unexpected-response', (request, response) => {
|
|
67
|
+
const handshakeRevoked = String(response.headers['x-remcp-revoked'] || '') === '1';
|
|
68
|
+
const handshakeDisabled = String(response.headers['x-remcp-disabled'] || '') === '1';
|
|
69
|
+
if (handshakeRevoked) revoked = true;
|
|
70
|
+
console.error(`ReMCP relay refused the connection (HTTP ${response.statusCode})${handshakeRevoked ? ': this device was revoked' : handshakeDisabled ? ': this device is temporarily disabled' : ''}.`);
|
|
71
|
+
response.resume();
|
|
72
|
+
request.destroy();
|
|
73
|
+
if (handshakeDisabled && !revoked) {
|
|
74
|
+
setReconnects(getReconnects() + 1);
|
|
75
|
+
// Service access is a user-controlled pause, not an outage. Poll slowly enough not to hammer
|
|
76
|
+
// the relay, but cap recovery so an enable action becomes effective within seconds.
|
|
77
|
+
scheduleReconnect(jitter(RECONNECT_BASE_MS));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const status = Number(response.statusCode || 0);
|
|
81
|
+
const transientHandshakeFailure = status === 408 || status === 425 || status === 429 || (status >= 500 && status <= 599);
|
|
82
|
+
if (transientHandshakeFailure && !revoked) {
|
|
83
|
+
// A reverse proxy or relay deploy can refuse the HTTP upgrade before a WebSocket exists.
|
|
84
|
+
// With an unexpected-response listener the client does not reliably emit close afterwards,
|
|
85
|
+
// so this branch must schedule its own retry or a still-running agent can stay offline forever.
|
|
86
|
+
scheduleBackoffReconnect();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
setSocket(ws);
|
|
90
|
+
ws.on('open', () => {
|
|
91
|
+
if (reconnectTimer) {
|
|
92
|
+
clearTimeout(reconnectTimer);
|
|
93
|
+
reconnectTimer = null;
|
|
94
|
+
}
|
|
95
|
+
setReconnects(0);
|
|
96
|
+
sendRelayMessage(ws, {
|
|
97
|
+
type: 'hello',
|
|
98
|
+
deviceId,
|
|
99
|
+
deviceName,
|
|
100
|
+
hostname: os.hostname(),
|
|
101
|
+
platform: process.platform,
|
|
102
|
+
arch: process.arch,
|
|
103
|
+
agentVersion: VERSION,
|
|
104
|
+
...getHelloState(),
|
|
105
|
+
reconnects: getReconnects(),
|
|
106
|
+
});
|
|
107
|
+
console.log(`Connected to ${serverUrl} as ${deviceName}`);
|
|
108
|
+
sendMetricsSample();
|
|
109
|
+
reportInstallOnce();
|
|
110
|
+
flushTelemetry();
|
|
111
|
+
void checkForUpdate();
|
|
112
|
+
schedulePostConnectUpdateChecks();
|
|
113
|
+
});
|
|
114
|
+
ws.on('message', raw => {
|
|
115
|
+
let message;
|
|
116
|
+
try { message = JSON.parse(raw.toString()); } catch { return; }
|
|
117
|
+
if (message?.type === 'reconnect') {
|
|
118
|
+
askedToReconnect = true;
|
|
119
|
+
requestedReconnectDelayMs = boundedReconnectDelay(message.delayMs, 150);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (message?.type === 'request') void respond(ws, message);
|
|
123
|
+
if (message?.type === 'cancel' && message.id) {
|
|
124
|
+
const controller = inFlight.get(message.id);
|
|
125
|
+
if (controller) controller.abort();
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
ws.on('close', code => {
|
|
129
|
+
clearPostConnectUpdateChecks();
|
|
130
|
+
for (const controller of inFlight.values()) controller.abort();
|
|
131
|
+
if (isStopping()) return;
|
|
132
|
+
// 1013 ('reconnect') is what a replica sends before it is replaced by a deployment. The machine
|
|
133
|
+
// is not down, it is moving: reconnect at once and forget the backoff, so the person and the
|
|
134
|
+
// model see nothing at all.
|
|
135
|
+
if (code === 1013 || askedToReconnect) {
|
|
136
|
+
const delayMs = requestedReconnectDelayMs || 150;
|
|
137
|
+
askedToReconnect = false;
|
|
138
|
+
requestedReconnectDelayMs = 0;
|
|
139
|
+
setReconnects(0);
|
|
140
|
+
console.log(`ReMCP relay is being redeployed; reconnecting in ${delayMs}ms.`);
|
|
141
|
+
scheduleReconnect(delayMs);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (code === 1008 || revoked) {
|
|
145
|
+
// The relay closes with 1008 when the device was revoked. Retrying forever would
|
|
146
|
+
// hide that from the person at the computer.
|
|
147
|
+
console.error('ReMCP access for this device was revoked. Pair the machine again from the ReMCP workspace: remcp connect --server <url> --code <code> --install');
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (code === 1012) {
|
|
151
|
+
// 1012 ('service restart') is what the relay sends when another agent process took over this
|
|
152
|
+
// device. Staying alive would keep a second runtime and a reconnect loop, so this process
|
|
153
|
+
// stops and leaves the device to the agent that owns the connection.
|
|
154
|
+
console.error('Another ReMCP agent connected for this device; this process will stop. Run one agent per machine (a service manager or the remcp start command).');
|
|
155
|
+
markStopping();
|
|
156
|
+
void stop().finally(() => setTimeout(() => process.exit(0), 100));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
scheduleBackoffReconnect();
|
|
160
|
+
});
|
|
161
|
+
ws.on('error', error => console.error(`ReMCP relay: ${error.message}`));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
connect,
|
|
166
|
+
clearReconnect() { if (reconnectTimer) clearTimeout(reconnectTimer); reconnectTimer = null; },
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Maintains the local MCP runtime tool cache and bounded retry lifecycle.
|
|
2
|
+
// The paired-agent connection owns the transport; this module owns only capabilities.
|
|
3
|
+
export function normalizeRuntimeToolNames(listed) {
|
|
4
|
+
const tools = Array.isArray(listed?.tools) ? listed.tools : [];
|
|
5
|
+
return [...new Set(tools.map(tool => String(tool?.name || '').trim().slice(0, 128)).filter(Boolean))].slice(0, 256);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function createRuntimeCapabilities({
|
|
9
|
+
getTools, setTools, isActive, announce, jitter, onError = console.error,
|
|
10
|
+
retryBaseMs = 1_000, retryMaxMs = 30_000,
|
|
11
|
+
}) {
|
|
12
|
+
let retryDelay = retryBaseMs;
|
|
13
|
+
let retryTimer = null;
|
|
14
|
+
|
|
15
|
+
function clearRetry() {
|
|
16
|
+
if (retryTimer) clearTimeout(retryTimer);
|
|
17
|
+
retryTimer = null;
|
|
18
|
+
retryDelay = retryBaseMs;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function apply(next, { announce: shouldAnnounce = true } = {}) {
|
|
22
|
+
const current = getTools();
|
|
23
|
+
if (next.length === current.length && next.every((name, index) => name === current[index])) return current;
|
|
24
|
+
setTools(next);
|
|
25
|
+
if (shouldAnnounce) announce(next);
|
|
26
|
+
return next;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function scheduleRetry(client) {
|
|
30
|
+
if (!isActive(client) || retryTimer) return;
|
|
31
|
+
const delay = jitter(retryDelay);
|
|
32
|
+
retryDelay = Math.min(retryMaxMs, retryDelay * 2);
|
|
33
|
+
retryTimer = setTimeout(() => {
|
|
34
|
+
retryTimer = null;
|
|
35
|
+
void refresh(client);
|
|
36
|
+
}, delay);
|
|
37
|
+
retryTimer.unref?.();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function refresh(client, { announce: shouldAnnounce = true } = {}) {
|
|
41
|
+
if (!client || !isActive(client)) return getTools();
|
|
42
|
+
try {
|
|
43
|
+
const listed = await client.listTools(undefined, { timeout: 3000 });
|
|
44
|
+
// A list request can settle after reconnect/restart. Never publish stale capabilities.
|
|
45
|
+
if (!isActive(client)) return getTools();
|
|
46
|
+
clearRetry();
|
|
47
|
+
apply(normalizeRuntimeToolNames(listed), { announce: shouldAnnounce });
|
|
48
|
+
} catch (error) {
|
|
49
|
+
onError(`ReMCP could not refresh runtime capabilities: ${error instanceof Error ? error.message : String(error)}`);
|
|
50
|
+
scheduleRetry(client);
|
|
51
|
+
}
|
|
52
|
+
return getTools();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { apply, clearRetry, refresh, scheduleRetry };
|
|
56
|
+
}
|
package/src/agent.mjs
CHANGED
|
@@ -1,32 +1,26 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
2
|
import process from 'node:process';
|
|
3
|
-
import WebSocket from 'ws';
|
|
4
3
|
import { Client } from '@modelcontextprotocol/client';
|
|
5
4
|
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
|
|
6
5
|
import { VERSION } from './version.mjs';
|
|
7
6
|
import { localRuntimeEntry } from './agent-update.mjs';
|
|
8
7
|
|
|
9
8
|
export { localRuntimeEntry, supervisorRestart, updateDecision } from './agent-update.mjs';
|
|
10
|
-
import { normalizedServerUrl, sendRelayMessage, jitter,
|
|
9
|
+
import { normalizedServerUrl, sendRelayMessage, jitter, deviceMetrics } from './agent-utilities.mjs';
|
|
11
10
|
import { createAgentUpdateMonitor, UPDATE_CHECK_INTERVAL_MS } from './agent-update-monitor.mjs';
|
|
12
11
|
import { createAgentResponder } from './agent-responder.mjs';
|
|
12
|
+
import { createAgentRelayConnection } from './agent-relay-connection.mjs';
|
|
13
|
+
import { createRuntimeCapabilities, normalizeRuntimeToolNames } from './agent-runtime-capabilities.mjs';
|
|
13
14
|
|
|
14
15
|
const METRICS_INTERVAL_MS = 5 * 60_000;
|
|
15
16
|
const TELEMETRY_QUEUE_LIMIT = 500;
|
|
16
17
|
const TELEMETRY_BATCH_LIMIT = 100;
|
|
17
18
|
const TELEMETRY_SEND_INTERVAL_MS = 5_000;
|
|
18
|
-
const RECONNECT_BASE_MS = 2_000;
|
|
19
|
-
// The first retry after an unexpected drop: fast enough that a blip is invisible, slow enough not to
|
|
20
|
-
// hammer a server that is genuinely down.
|
|
21
|
-
const RECONNECT_FIRST_MS = 500;
|
|
22
19
|
// Must stay above the runtime's own output ceiling (8 MiB), otherwise a large but legal tool result
|
|
23
20
|
// closes the stdio connection and restarts the runtime mid-call.
|
|
24
21
|
const RUNTIME_STDIO_BUFFER_BYTES = 24 * 1024 * 1024;
|
|
25
|
-
const RECONNECT_MAX_MS = 60_000;
|
|
26
22
|
const RUNTIME_RESTART_BASE_MS = 1_000;
|
|
27
23
|
const RUNTIME_RESTART_MAX_MS = 30_000;
|
|
28
|
-
const RUNTIME_TOOLS_RETRY_BASE_MS = 1_000;
|
|
29
|
-
const RUNTIME_TOOLS_RETRY_MAX_MS = 30_000;
|
|
30
24
|
// Below the relay's RPC timeout so the model gets a real error instead of a client-side
|
|
31
25
|
// timeout while the device keeps working invisibly.
|
|
32
26
|
const CALL_TIMEOUT_MARGIN_MS = 10_000;
|
|
@@ -43,14 +37,9 @@ export async function runAgent(options) {
|
|
|
43
37
|
const metricsIntervalMs = Math.max(60_000, Number(options.metricsIntervalMs || process.env.REMCP_AGENT_METRICS_INTERVAL_MS) || METRICS_INTERVAL_MS);
|
|
44
38
|
const persistState = typeof options.persistState === 'function' ? options.persistState : () => {};
|
|
45
39
|
let stopping = false;
|
|
46
|
-
let revoked = false;
|
|
47
40
|
const inFlight = new Map();
|
|
48
41
|
let activeSocket;
|
|
49
42
|
let reconnects = 0;
|
|
50
|
-
let reconnectTimer = null;
|
|
51
|
-
// Set when a replica asks this agent to move before it is replaced; the close handler reads it.
|
|
52
|
-
let askedToReconnect = false;
|
|
53
|
-
let requestedReconnectDelayMs = 0;
|
|
54
43
|
let pendingRequests = 0;
|
|
55
44
|
let runtimeVersion = 'unknown';
|
|
56
45
|
let runtimeRestarts = 0;
|
|
@@ -81,8 +70,6 @@ export async function runAgent(options) {
|
|
|
81
70
|
let transport = null;
|
|
82
71
|
let runtimeRestartDelay = RUNTIME_RESTART_BASE_MS;
|
|
83
72
|
let runtimeRestartTimer = null;
|
|
84
|
-
let runtimeToolsRetryDelay = RUNTIME_TOOLS_RETRY_BASE_MS;
|
|
85
|
-
let runtimeToolsRetryTimer = null;
|
|
86
73
|
let stopPromise = null;
|
|
87
74
|
|
|
88
75
|
function runtimeEnv() {
|
|
@@ -97,51 +84,20 @@ export async function runAgent(options) {
|
|
|
97
84
|
return env;
|
|
98
85
|
}
|
|
99
86
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
runtimeToolsRetryDelay = Math.min(RUNTIME_TOOLS_RETRY_MAX_MS, runtimeToolsRetryDelay * 2);
|
|
109
|
-
runtimeToolsRetryTimer = setTimeout(() => {
|
|
110
|
-
runtimeToolsRetryTimer = null;
|
|
111
|
-
void refreshRuntimeTools(client);
|
|
112
|
-
}, delay);
|
|
113
|
-
runtimeToolsRetryTimer.unref?.();
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function applyRuntimeTools(next, { announce = true } = {}) {
|
|
117
|
-
if (next.length === runtimeTools.length && next.every((name, index) => name === runtimeTools[index])) return runtimeTools;
|
|
118
|
-
runtimeTools = next;
|
|
119
|
-
if (announce) send({ type: 'capabilities', runtimeTools });
|
|
120
|
-
return runtimeTools;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
async function refreshRuntimeTools(client = mcp, { announce = true } = {}) {
|
|
124
|
-
if (!client || runtimeDown) return runtimeTools;
|
|
125
|
-
try {
|
|
126
|
-
const listed = await client.listTools(undefined, { timeout: 3000 });
|
|
127
|
-
if (runtimeToolsRetryTimer) clearTimeout(runtimeToolsRetryTimer);
|
|
128
|
-
runtimeToolsRetryTimer = null;
|
|
129
|
-
runtimeToolsRetryDelay = RUNTIME_TOOLS_RETRY_BASE_MS;
|
|
130
|
-
applyRuntimeTools(toolNames(listed), { announce });
|
|
131
|
-
} catch (error) {
|
|
132
|
-
console.error(`ReMCP could not refresh runtime capabilities: ${error instanceof Error ? error.message : String(error)}`);
|
|
133
|
-
scheduleRuntimeToolsRetry(client);
|
|
134
|
-
}
|
|
135
|
-
return runtimeTools;
|
|
136
|
-
}
|
|
87
|
+
const runtimeCapabilities = createRuntimeCapabilities({
|
|
88
|
+
getTools: () => runtimeTools,
|
|
89
|
+
setTools: next => { runtimeTools = next; },
|
|
90
|
+
isActive: client => !stopping && !runtimeDown && mcp === client,
|
|
91
|
+
announce: next => send({ type: 'capabilities', runtimeTools: next }),
|
|
92
|
+
jitter,
|
|
93
|
+
});
|
|
94
|
+
const refreshRuntimeTools = (client = mcp, options) => runtimeCapabilities.refresh(client, options);
|
|
137
95
|
|
|
138
96
|
async function startRuntime() {
|
|
139
97
|
if (stopping) return;
|
|
140
98
|
clearTimeout(runtimeRestartTimer);
|
|
141
99
|
runtimeRestartTimer = null;
|
|
142
|
-
|
|
143
|
-
runtimeToolsRetryTimer = null;
|
|
144
|
-
runtimeToolsRetryDelay = RUNTIME_TOOLS_RETRY_BASE_MS;
|
|
100
|
+
runtimeCapabilities.clearRetry();
|
|
145
101
|
runtimeDown = true;
|
|
146
102
|
const previous = mcp;
|
|
147
103
|
mcp = null;
|
|
@@ -168,10 +124,11 @@ export async function runAgent(options) {
|
|
|
168
124
|
onChanged(error, tools) {
|
|
169
125
|
if (error) {
|
|
170
126
|
console.error(`ReMCP runtime tool subscription failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
171
|
-
|
|
127
|
+
runtimeCapabilities.scheduleRetry(client);
|
|
172
128
|
return;
|
|
173
129
|
}
|
|
174
|
-
|
|
130
|
+
if (mcp !== client || runtimeDown || stopping) return;
|
|
131
|
+
runtimeCapabilities.apply(normalizeRuntimeToolNames({ tools }));
|
|
175
132
|
},
|
|
176
133
|
},
|
|
177
134
|
},
|
|
@@ -219,9 +176,7 @@ export async function runAgent(options) {
|
|
|
219
176
|
|
|
220
177
|
function handleRuntimeExit(reason) {
|
|
221
178
|
if (stopping || runtimeRestartTimer) return;
|
|
222
|
-
|
|
223
|
-
runtimeToolsRetryTimer = null;
|
|
224
|
-
runtimeToolsRetryDelay = RUNTIME_TOOLS_RETRY_BASE_MS;
|
|
179
|
+
runtimeCapabilities.clearRetry();
|
|
225
180
|
runtimeDown = true;
|
|
226
181
|
runtimeTools = [];
|
|
227
182
|
send({ type: 'capabilities', runtimeTools });
|
|
@@ -290,39 +245,6 @@ export async function runAgent(options) {
|
|
|
290
245
|
}
|
|
291
246
|
}
|
|
292
247
|
|
|
293
|
-
function scheduleReconnect(delay) {
|
|
294
|
-
if (stopping || revoked || reconnectTimer) return;
|
|
295
|
-
// Keep the upper-bound guard in control flow immediately before the timer sink. CodeQL's
|
|
296
|
-
// resource-exhaustion model treats this as a barrier, and future callers cannot retain the
|
|
297
|
-
// agent with an attacker-controlled timer lifetime.
|
|
298
|
-
const numericDelay = Number(delay);
|
|
299
|
-
if (!Number.isFinite(numericDelay)) {
|
|
300
|
-
scheduleReconnect(RECONNECT_BASE_MS);
|
|
301
|
-
return;
|
|
302
|
-
}
|
|
303
|
-
if (numericDelay > RECONNECT_MAX_MS) {
|
|
304
|
-
scheduleReconnect(RECONNECT_MAX_MS);
|
|
305
|
-
return;
|
|
306
|
-
}
|
|
307
|
-
const safeDelay = Math.max(100, Math.floor(numericDelay));
|
|
308
|
-
reconnectTimer = setTimeout(() => {
|
|
309
|
-
reconnectTimer = null;
|
|
310
|
-
connect();
|
|
311
|
-
}, safeDelay);
|
|
312
|
-
reconnectTimer.unref?.();
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
function scheduleBackoffReconnect() {
|
|
316
|
-
if (stopping || revoked || reconnectTimer) return;
|
|
317
|
-
reconnects += 1;
|
|
318
|
-
// The first retry after an unexpected drop is quick on purpose: a deploy blip, a proxy restart or
|
|
319
|
-
// a dropped packet should cost a fraction of a second, not the two seconds the backoff starts at.
|
|
320
|
-
const delay = reconnects === 1
|
|
321
|
-
? jitter(RECONNECT_FIRST_MS)
|
|
322
|
-
: jitter(Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** Math.min(reconnects, 5)));
|
|
323
|
-
scheduleReconnect(delay);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
248
|
const respond = createAgentResponder({
|
|
327
249
|
inFlight,
|
|
328
250
|
onPendingChange: delta => { pendingRequests = Math.max(0, pendingRequests + delta); },
|
|
@@ -349,116 +271,23 @@ export async function runAgent(options) {
|
|
|
349
271
|
},
|
|
350
272
|
});
|
|
351
273
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
reconnects += 1;
|
|
370
|
-
// Service access is a user-controlled pause, not an outage. Poll slowly enough not to hammer
|
|
371
|
-
// the relay, but cap recovery so an enable action becomes effective within seconds.
|
|
372
|
-
scheduleReconnect(jitter(RECONNECT_BASE_MS));
|
|
373
|
-
return;
|
|
374
|
-
}
|
|
375
|
-
const status = Number(response.statusCode || 0);
|
|
376
|
-
const transientHandshakeFailure = status === 408 || status === 425 || status === 429 || (status >= 500 && status <= 599);
|
|
377
|
-
if (transientHandshakeFailure && !revoked) {
|
|
378
|
-
// A reverse proxy or relay deploy can refuse the HTTP upgrade before a WebSocket exists.
|
|
379
|
-
// With an unexpected-response listener the client does not reliably emit close afterwards,
|
|
380
|
-
// so this branch must schedule its own retry or a still-running agent can stay offline forever.
|
|
381
|
-
scheduleBackoffReconnect();
|
|
382
|
-
}
|
|
383
|
-
});
|
|
384
|
-
activeSocket = ws;
|
|
385
|
-
ws.on('open', () => {
|
|
386
|
-
if (reconnectTimer) {
|
|
387
|
-
clearTimeout(reconnectTimer);
|
|
388
|
-
reconnectTimer = null;
|
|
389
|
-
}
|
|
390
|
-
reconnects = 0;
|
|
391
|
-
sendRelayMessage(ws, {
|
|
392
|
-
type: 'hello',
|
|
393
|
-
deviceId,
|
|
394
|
-
deviceName,
|
|
395
|
-
hostname: os.hostname(),
|
|
396
|
-
platform: process.platform,
|
|
397
|
-
arch: process.arch,
|
|
398
|
-
agentVersion: VERSION,
|
|
399
|
-
runtimeVersion,
|
|
400
|
-
runtimeState: runtimeDown ? 'down' : 'ready',
|
|
401
|
-
runtimeError,
|
|
402
|
-
runtimeTools,
|
|
403
|
-
telemetryEnabled,
|
|
404
|
-
reconnects,
|
|
405
|
-
});
|
|
406
|
-
console.log(`Connected to ${serverUrl} as ${deviceName}`);
|
|
407
|
-
sendMetricsSample();
|
|
408
|
-
reportInstallOnce();
|
|
409
|
-
flushTelemetry();
|
|
410
|
-
void checkForUpdate();
|
|
411
|
-
schedulePostConnectUpdateChecks();
|
|
412
|
-
});
|
|
413
|
-
ws.on('message', raw => {
|
|
414
|
-
let message;
|
|
415
|
-
try { message = JSON.parse(raw.toString()); } catch { return; }
|
|
416
|
-
if (message?.type === 'reconnect') {
|
|
417
|
-
askedToReconnect = true;
|
|
418
|
-
requestedReconnectDelayMs = boundedReconnectDelay(message.delayMs, 150);
|
|
419
|
-
return;
|
|
420
|
-
}
|
|
421
|
-
if (message?.type === 'request') void respond(ws, message);
|
|
422
|
-
if (message?.type === 'cancel' && message.id) {
|
|
423
|
-
const controller = inFlight.get(message.id);
|
|
424
|
-
if (controller) controller.abort();
|
|
425
|
-
}
|
|
426
|
-
});
|
|
427
|
-
ws.on('close', code => {
|
|
428
|
-
clearPostConnectUpdateChecks();
|
|
429
|
-
for (const controller of inFlight.values()) controller.abort();
|
|
430
|
-
if (stopping) return;
|
|
431
|
-
// 1013 ('reconnect') is what a replica sends before it is replaced by a deployment. The machine
|
|
432
|
-
// is not down, it is moving: reconnect at once and forget the backoff, so the person and the
|
|
433
|
-
// model see nothing at all.
|
|
434
|
-
if (code === 1013 || askedToReconnect) {
|
|
435
|
-
const delayMs = requestedReconnectDelayMs || 150;
|
|
436
|
-
askedToReconnect = false;
|
|
437
|
-
requestedReconnectDelayMs = 0;
|
|
438
|
-
reconnects = 0;
|
|
439
|
-
console.log(`ReMCP relay is being redeployed; reconnecting in ${delayMs}ms.`);
|
|
440
|
-
scheduleReconnect(delayMs);
|
|
441
|
-
return;
|
|
442
|
-
}
|
|
443
|
-
if (code === 1008 || revoked) {
|
|
444
|
-
// The relay closes with 1008 when the device was revoked. Retrying forever would
|
|
445
|
-
// hide that from the person at the computer.
|
|
446
|
-
console.error('ReMCP access for this device was revoked. Pair the machine again from the ReMCP workspace: remcp connect --server <url> --code <code> --install');
|
|
447
|
-
return;
|
|
448
|
-
}
|
|
449
|
-
if (code === 1012) {
|
|
450
|
-
// 1012 ('service restart') is what the relay sends when another agent process took over this
|
|
451
|
-
// device. Staying alive would keep a second runtime and a reconnect loop, so this process
|
|
452
|
-
// stops and leaves the device to the agent that owns the connection.
|
|
453
|
-
console.error('Another ReMCP agent connected for this device; this process will stop. Run one agent per machine (a service manager or the remcp start command).');
|
|
454
|
-
stopping = true;
|
|
455
|
-
void stop().finally(() => setTimeout(() => process.exit(0), 100));
|
|
456
|
-
return;
|
|
457
|
-
}
|
|
458
|
-
scheduleBackoffReconnect();
|
|
459
|
-
});
|
|
460
|
-
ws.on('error', error => console.error(`ReMCP relay: ${error.message}`));
|
|
461
|
-
}
|
|
274
|
+
const { connect, clearReconnect } = createAgentRelayConnection({
|
|
275
|
+
agentUrl, serverUrl, deviceToken, deviceId, deviceName,
|
|
276
|
+
isStopping: () => stopping,
|
|
277
|
+
markStopping: () => { stopping = true; },
|
|
278
|
+
getReconnects: () => reconnects,
|
|
279
|
+
setReconnects: count => { reconnects = count; },
|
|
280
|
+
setSocket: socket => { activeSocket = socket; },
|
|
281
|
+
getHelloState: () => ({
|
|
282
|
+
runtimeVersion,
|
|
283
|
+
runtimeState: runtimeDown ? 'down' : 'ready',
|
|
284
|
+
runtimeError,
|
|
285
|
+
runtimeTools,
|
|
286
|
+
telemetryEnabled,
|
|
287
|
+
}),
|
|
288
|
+
respond, inFlight, sendMetricsSample, reportInstallOnce, flushTelemetry,
|
|
289
|
+
checkForUpdate, schedulePostConnectUpdateChecks, clearPostConnectUpdateChecks, stop,
|
|
290
|
+
});
|
|
462
291
|
|
|
463
292
|
scheduleMetricsSample();
|
|
464
293
|
const telemetryFlushTimer = setInterval(flushTelemetry, TELEMETRY_SEND_INTERVAL_MS);
|
|
@@ -470,10 +299,8 @@ export async function runAgent(options) {
|
|
|
470
299
|
if (stopPromise) return stopPromise;
|
|
471
300
|
stopping = true;
|
|
472
301
|
clearTimeout(runtimeRestartTimer);
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
clearTimeout(reconnectTimer);
|
|
476
|
-
reconnectTimer = null;
|
|
302
|
+
runtimeCapabilities.clearRetry();
|
|
303
|
+
clearReconnect();
|
|
477
304
|
clearPostConnectUpdateChecks();
|
|
478
305
|
if (telemetryTimer) clearTimeout(telemetryTimer);
|
|
479
306
|
clearInterval(telemetryFlushTimer);
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// macOS launchd service and managed CLI shim; no Linux/Windows service side effects.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { globalCliPath, servicePlatform } from './npm-global.mjs';
|
|
8
|
+
import { persistentServiceExpected, canonicalServiceCliPath, canonicalServiceNodePath } from './service-inventory.mjs';
|
|
9
|
+
import { configDir, home, macLogFile, macServiceFile, macServiceLabel } from './env.mjs';
|
|
10
|
+
import { run } from './shell.mjs';
|
|
11
|
+
import { resolvedCliScript, shellQuote, xmlEscape } from './service-paths.mjs';
|
|
12
|
+
|
|
13
|
+
export function macLaunchDomain() {
|
|
14
|
+
if (typeof process.getuid !== 'function') throw new Error('Could not determine the current macOS user');
|
|
15
|
+
return `gui/${process.getuid()}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const remcpPathBlockStart = '# >>> ReMCP CLI PATH >>>';
|
|
19
|
+
const remcpPathBlockEnd = '# <<< ReMCP CLI PATH <<<';
|
|
20
|
+
const remcpCliShimMarker = '# ReMCP managed CLI shim';
|
|
21
|
+
|
|
22
|
+
function macCliShimPath() {
|
|
23
|
+
return path.join(home, '.local', 'bin', 'remcp');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function interactiveShellProfile() {
|
|
27
|
+
const shell = String(process.env.REMCP_TEST_SHELL || process.env.SHELL || os.userInfo().shell || '/bin/zsh');
|
|
28
|
+
const name = path.basename(shell);
|
|
29
|
+
if (name === 'zsh') return path.join(home, '.zshrc');
|
|
30
|
+
if (name === 'bash') return path.join(home, '.bash_profile');
|
|
31
|
+
return '';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function upsertManagedPathBlock(file) {
|
|
35
|
+
if (!file) return false;
|
|
36
|
+
const block = [
|
|
37
|
+
remcpPathBlockStart,
|
|
38
|
+
'case ":$PATH:" in',
|
|
39
|
+
' *":$HOME/.local/bin:"*) ;;',
|
|
40
|
+
' *) export PATH="$HOME/.local/bin:$PATH" ;;',
|
|
41
|
+
'esac',
|
|
42
|
+
remcpPathBlockEnd,
|
|
43
|
+
].join('\n');
|
|
44
|
+
let current = '';
|
|
45
|
+
try { current = fs.readFileSync(file, 'utf8'); } catch {}
|
|
46
|
+
const start = current.indexOf(remcpPathBlockStart);
|
|
47
|
+
const end = start >= 0 ? current.indexOf(remcpPathBlockEnd, start + remcpPathBlockStart.length) : -1;
|
|
48
|
+
let next;
|
|
49
|
+
if (start >= 0 && end >= 0) {
|
|
50
|
+
next = current.slice(0, start) + block + current.slice(end + remcpPathBlockEnd.length);
|
|
51
|
+
} else {
|
|
52
|
+
const prefix = current && !current.endsWith('\n') ? current + '\n' : current;
|
|
53
|
+
next = prefix + (prefix ? '\n' : '') + block + '\n';
|
|
54
|
+
}
|
|
55
|
+
if (next === current) return false;
|
|
56
|
+
fs.writeFileSync(file, next, { mode:0o600 });
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function removeManagedPathBlock(file) {
|
|
61
|
+
if (!file) return;
|
|
62
|
+
let current = '';
|
|
63
|
+
try { current = fs.readFileSync(file, 'utf8'); } catch { return; }
|
|
64
|
+
const start = current.indexOf(remcpPathBlockStart);
|
|
65
|
+
const end = start >= 0 ? current.indexOf(remcpPathBlockEnd, start + remcpPathBlockStart.length) : -1;
|
|
66
|
+
if (start < 0 || end < 0) return;
|
|
67
|
+
let before = current.slice(0, start);
|
|
68
|
+
let after = current.slice(end + remcpPathBlockEnd.length);
|
|
69
|
+
if (before.endsWith('\n') && after.startsWith('\n')) after = after.slice(1);
|
|
70
|
+
const next = before + after;
|
|
71
|
+
fs.writeFileSync(file, next, { mode:0o600 });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function ensureMacCliCommand(config = {}, { cliPath = '', nodePath = '' } = {}) {
|
|
75
|
+
if (servicePlatform() !== 'darwin') return null;
|
|
76
|
+
const effectiveCli = cliPath || (persistentServiceExpected(config) ? canonicalServiceCliPath(config) : globalCliPath());
|
|
77
|
+
const effectiveNode = nodePath || (persistentServiceExpected(config) ? canonicalServiceNodePath(config) : process.execPath);
|
|
78
|
+
const cliScript = resolvedCliScript(effectiveCli);
|
|
79
|
+
const shim = macCliShimPath();
|
|
80
|
+
try {
|
|
81
|
+
const existing = fs.readFileSync(shim, 'utf8');
|
|
82
|
+
if (!existing.includes(remcpCliShimMarker)) {
|
|
83
|
+
console.error(`ReMCP did not replace ${shim} because it is not a ReMCP-managed command.`);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error?.code && error.code !== 'ENOENT') {
|
|
88
|
+
console.error(`ReMCP could not inspect ${shim}: ${error instanceof Error ? error.message : String(error)}`);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const launcher = `#!/bin/sh\n${remcpCliShimMarker}\nexec ${shellQuote(effectiveNode)} ${shellQuote(cliScript)} "$@"\n`;
|
|
93
|
+
fs.mkdirSync(path.dirname(shim), { recursive:true, mode:0o755 });
|
|
94
|
+
const temporary = `${shim}.${process.pid}.${Date.now()}.tmp`;
|
|
95
|
+
fs.writeFileSync(temporary, launcher, { mode:0o755 });
|
|
96
|
+
fs.chmodSync(temporary, 0o755);
|
|
97
|
+
fs.renameSync(temporary, shim);
|
|
98
|
+
upsertManagedPathBlock(interactiveShellProfile());
|
|
99
|
+
return shim;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function removeMacCliCommand() {
|
|
103
|
+
if (servicePlatform() !== 'darwin') return;
|
|
104
|
+
const shim = macCliShimPath();
|
|
105
|
+
try {
|
|
106
|
+
const existing = fs.readFileSync(shim, 'utf8');
|
|
107
|
+
if (existing.includes(remcpCliShimMarker)) fs.unlinkSync(shim);
|
|
108
|
+
} catch {}
|
|
109
|
+
removeManagedPathBlock(interactiveShellProfile());
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function macServicePlist(cliPath, nodePath = process.execPath) {
|
|
113
|
+
// launchd wants an absolute path; a symlinked prefix that npm has not materialised yet (or a path
|
|
114
|
+
// that is about to be replaced by the next install) must not abort the repair — a stale plist is
|
|
115
|
+
// exactly the loop this function exists to break.
|
|
116
|
+
const cliScript = fs.existsSync(cliPath) ? fs.realpathSync(cliPath) : path.resolve(cliPath);
|
|
117
|
+
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(nodePath)}</string><string>${xmlEscape(cliScript)}</string><string>start</string><string>--service</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`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function macJobLoaded(target) {
|
|
121
|
+
return spawnSync('launchctl', ['print', target], { stdio: 'ignore' }).status === 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function submitMacHelper(kind, lines) {
|
|
125
|
+
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
126
|
+
const nonce = `${process.pid}.${Date.now()}`;
|
|
127
|
+
const helperFile = path.join(configDir, `launchd-${kind}-${nonce}.sh`);
|
|
128
|
+
const helperLabel = `${macServiceLabel}.${kind}.${nonce}`;
|
|
129
|
+
const script = [
|
|
130
|
+
'#!/bin/sh',
|
|
131
|
+
'sleep 1',
|
|
132
|
+
'status=0',
|
|
133
|
+
...lines.map(line => `${line} || status=$?`),
|
|
134
|
+
`if [ "$status" -ne 0 ]; then echo "ReMCP launchd ${kind} helper failed with status $status" >> ${shellQuote(macLogFile)}; fi`,
|
|
135
|
+
`rm -f ${shellQuote(helperFile)}`,
|
|
136
|
+
`launchctl remove ${shellQuote(helperLabel)} >/dev/null 2>&1 || true`,
|
|
137
|
+
'exit 0',
|
|
138
|
+
'',
|
|
139
|
+
].join('\n');
|
|
140
|
+
fs.writeFileSync(helperFile, script, { mode: 0o700 });
|
|
141
|
+
fs.chmodSync(helperFile, 0o700);
|
|
142
|
+
run('launchctl', ['submit', '-l', helperLabel, '--', '/bin/sh', helperFile]);
|
|
143
|
+
return helperLabel;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function scheduleMacServiceReload(domain, target) {
|
|
147
|
+
return submitMacHelper('reload', [
|
|
148
|
+
`launchctl bootout ${shellQuote(target)} >/dev/null 2>&1 || true`,
|
|
149
|
+
`launchctl bootstrap ${shellQuote(domain)} ${shellQuote(macServiceFile)}`,
|
|
150
|
+
`launchctl enable ${shellQuote(target)}`,
|
|
151
|
+
`launchctl kickstart -k ${shellQuote(target)}`,
|
|
152
|
+
]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function scheduleMacServiceRestart(target) {
|
|
156
|
+
return submitMacHelper('restart', [
|
|
157
|
+
`launchctl kickstart -k ${shellQuote(target)}`,
|
|
158
|
+
]);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function installMacService(cliPath = globalCliPath(), { restart = true, nodePath = process.execPath } = {}) {
|
|
162
|
+
const domain = macLaunchDomain();
|
|
163
|
+
const target = `${domain}/${macServiceLabel}`;
|
|
164
|
+
const plist = macServicePlist(cliPath, nodePath);
|
|
165
|
+
let previous = '';
|
|
166
|
+
try { previous = fs.readFileSync(macServiceFile, 'utf8'); } catch {}
|
|
167
|
+
const loaded = macJobLoaded(target);
|
|
168
|
+
|
|
169
|
+
fs.mkdirSync(path.dirname(macServiceFile), { recursive: true });
|
|
170
|
+
fs.mkdirSync(path.dirname(macLogFile), { recursive: true });
|
|
171
|
+
fs.writeFileSync(macServiceFile, plist, { mode: 0o600 });
|
|
172
|
+
|
|
173
|
+
if (loaded && previous === plist) {
|
|
174
|
+
run('launchctl', ['enable', target]);
|
|
175
|
+
// Never boot out a healthy loaded job just to refresh an in-place npm install. The final
|
|
176
|
+
// kickstart keeps launchd responsible for bringing the replacement agent back.
|
|
177
|
+
if (restart) run('launchctl', ['kickstart', '-k', target]);
|
|
178
|
+
return 'loaded';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (loaded) {
|
|
182
|
+
// A changed Node/npm prefix requires launchd to re-read ProgramArguments. A separate transient
|
|
183
|
+
// launchd job survives booting out com.remcp.agent even when the updater was launched by it.
|
|
184
|
+
scheduleMacServiceReload(domain, target);
|
|
185
|
+
return 'reload-scheduled';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
run('launchctl', ['bootstrap', domain, macServiceFile]);
|
|
189
|
+
run('launchctl', ['enable', target]);
|
|
190
|
+
if (restart) run('launchctl', ['kickstart', '-k', target]);
|
|
191
|
+
return 'bootstrapped';
|
|
192
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Shared service launcher/path escaping across the three supported platforms.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export function resolvedCliScript(cliPath) {
|
|
6
|
+
return fs.existsSync(cliPath) ? fs.realpathSync(cliPath) : path.resolve(cliPath);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function shellQuote(value) {
|
|
10
|
+
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function xmlEscape(value) {
|
|
14
|
+
return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
|
15
|
+
}
|
package/src/cli/service.mjs
CHANGED
|
@@ -5,26 +5,24 @@ export { persistentServiceExpected, persistentServiceState, releasePairPresentAt
|
|
|
5
5
|
// Installing, repairing and removing the background agent: one supervisor per platform, plus the
|
|
6
6
|
// write-access tweaks a fresh install needs. Nothing here is reachable from a model or an MCP tool.
|
|
7
7
|
import fs from 'node:fs';
|
|
8
|
-
import os from 'node:os';
|
|
9
8
|
import path from 'node:path';
|
|
10
9
|
import process from 'node:process';
|
|
11
10
|
import { spawnSync } from 'node:child_process';
|
|
12
|
-
import {
|
|
11
|
+
import { resolvedCliScript, shellQuote } from './service-paths.mjs';
|
|
12
|
+
import { macLaunchDomain, ensureMacCliCommand, installMacService, macJobLoaded, scheduleMacServiceRestart, removeMacCliCommand } from './service-macos.mjs';
|
|
13
|
+
export { xmlEscape } from './service-paths.mjs';
|
|
14
|
+
export { macLaunchDomain, ensureMacCliCommand, installMacService } from './service-macos.mjs';
|
|
13
15
|
import { PACKAGE_NAME, VERSION } from '../version.mjs';
|
|
14
16
|
|
|
15
17
|
import { saveConfig } from './config.mjs';
|
|
16
|
-
import { configDir, home, linuxServiceFile, linuxServiceLauncherFile,
|
|
17
|
-
import {
|
|
18
|
+
import { configDir, home, linuxServiceFile, linuxServiceLauncherFile, macServiceFile, macServiceLabel, windowsServiceLauncherFile, windowsTaskName } from './env.mjs';
|
|
19
|
+
import { run } from './shell.mjs';
|
|
18
20
|
import { windowsServiceLauncherText, windowsTaskReliabilityCommand } from './windows-supervisor.mjs';
|
|
19
21
|
|
|
20
22
|
export function quoteSystemd(value) {
|
|
21
23
|
return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
function resolvedCliScript(cliPath) {
|
|
25
|
-
return fs.existsSync(cliPath) ? fs.realpathSync(cliPath) : path.resolve(cliPath);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
26
|
function writeLinuxServiceLauncher(cliPath = globalCliPath(), nodePath = process.execPath) {
|
|
29
27
|
const cliScript = resolvedCliScript(cliPath);
|
|
30
28
|
const launcher = `#!/bin/sh\nexec ${shellQuote(nodePath)} ${shellQuote(cliScript)} \"$@\"\n`;
|
|
@@ -36,10 +34,6 @@ function writeLinuxServiceLauncher(cliPath = globalCliPath(), nodePath = process
|
|
|
36
34
|
return linuxServiceLauncherFile;
|
|
37
35
|
}
|
|
38
36
|
|
|
39
|
-
export function xmlEscape(value) {
|
|
40
|
-
return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
|
41
|
-
}
|
|
42
|
-
|
|
43
37
|
export function installLinuxService(cliPath = globalCliPath(), nodePath = process.execPath) {
|
|
44
38
|
const launcherFile = writeLinuxServiceLauncher(cliPath, nodePath);
|
|
45
39
|
const unit = `[Unit]\nDescription=ReMCP device agent\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nExecStart=${quoteSystemd(launcherFile)} start --service\nRestart=always\nRestartSec=3\nNoNewPrivileges=true\n\n[Install]\nWantedBy=default.target\n`;
|
|
@@ -49,191 +43,6 @@ export function installLinuxService(cliPath = globalCliPath(), nodePath = proces
|
|
|
49
43
|
run('systemctl', ['--user', 'enable', '--now', 'remcp-agent.service']);
|
|
50
44
|
}
|
|
51
45
|
|
|
52
|
-
export function macLaunchDomain() {
|
|
53
|
-
if (typeof process.getuid !== 'function') throw new Error('Could not determine the current macOS user');
|
|
54
|
-
return `gui/${process.getuid()}`;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function shellQuote(value) {
|
|
58
|
-
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const remcpPathBlockStart = '# >>> ReMCP CLI PATH >>>';
|
|
62
|
-
const remcpPathBlockEnd = '# <<< ReMCP CLI PATH <<<';
|
|
63
|
-
const remcpCliShimMarker = '# ReMCP managed CLI shim';
|
|
64
|
-
|
|
65
|
-
function macCliShimPath() {
|
|
66
|
-
return path.join(home, '.local', 'bin', 'remcp');
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function interactiveShellProfile() {
|
|
70
|
-
const shell = String(process.env.REMCP_TEST_SHELL || process.env.SHELL || os.userInfo().shell || '/bin/zsh');
|
|
71
|
-
const name = path.basename(shell);
|
|
72
|
-
if (name === 'zsh') return path.join(home, '.zshrc');
|
|
73
|
-
if (name === 'bash') return path.join(home, '.bash_profile');
|
|
74
|
-
return '';
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function upsertManagedPathBlock(file) {
|
|
78
|
-
if (!file) return false;
|
|
79
|
-
const block = [
|
|
80
|
-
remcpPathBlockStart,
|
|
81
|
-
'case ":$PATH:" in',
|
|
82
|
-
' *":$HOME/.local/bin:"*) ;;',
|
|
83
|
-
' *) export PATH="$HOME/.local/bin:$PATH" ;;',
|
|
84
|
-
'esac',
|
|
85
|
-
remcpPathBlockEnd,
|
|
86
|
-
].join('\n');
|
|
87
|
-
let current = '';
|
|
88
|
-
try { current = fs.readFileSync(file, 'utf8'); } catch {}
|
|
89
|
-
const start = current.indexOf(remcpPathBlockStart);
|
|
90
|
-
const end = start >= 0 ? current.indexOf(remcpPathBlockEnd, start + remcpPathBlockStart.length) : -1;
|
|
91
|
-
let next;
|
|
92
|
-
if (start >= 0 && end >= 0) {
|
|
93
|
-
next = current.slice(0, start) + block + current.slice(end + remcpPathBlockEnd.length);
|
|
94
|
-
} else {
|
|
95
|
-
const prefix = current && !current.endsWith('\n') ? current + '\n' : current;
|
|
96
|
-
next = prefix + (prefix ? '\n' : '') + block + '\n';
|
|
97
|
-
}
|
|
98
|
-
if (next === current) return false;
|
|
99
|
-
fs.writeFileSync(file, next, { mode:0o600 });
|
|
100
|
-
return true;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function removeManagedPathBlock(file) {
|
|
104
|
-
if (!file) return;
|
|
105
|
-
let current = '';
|
|
106
|
-
try { current = fs.readFileSync(file, 'utf8'); } catch { return; }
|
|
107
|
-
const start = current.indexOf(remcpPathBlockStart);
|
|
108
|
-
const end = start >= 0 ? current.indexOf(remcpPathBlockEnd, start + remcpPathBlockStart.length) : -1;
|
|
109
|
-
if (start < 0 || end < 0) return;
|
|
110
|
-
let before = current.slice(0, start);
|
|
111
|
-
let after = current.slice(end + remcpPathBlockEnd.length);
|
|
112
|
-
if (before.endsWith('\n') && after.startsWith('\n')) after = after.slice(1);
|
|
113
|
-
const next = before + after;
|
|
114
|
-
fs.writeFileSync(file, next, { mode:0o600 });
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export function ensureMacCliCommand(config = {}, { cliPath = '', nodePath = '' } = {}) {
|
|
118
|
-
if (servicePlatform() !== 'darwin') return null;
|
|
119
|
-
const effectiveCli = cliPath || (persistentServiceExpected(config) ? canonicalServiceCliPath(config) : globalCliPath());
|
|
120
|
-
const effectiveNode = nodePath || (persistentServiceExpected(config) ? canonicalServiceNodePath(config) : process.execPath);
|
|
121
|
-
const cliScript = resolvedCliScript(effectiveCli);
|
|
122
|
-
const shim = macCliShimPath();
|
|
123
|
-
try {
|
|
124
|
-
const existing = fs.readFileSync(shim, 'utf8');
|
|
125
|
-
if (!existing.includes(remcpCliShimMarker)) {
|
|
126
|
-
console.error(`ReMCP did not replace ${shim} because it is not a ReMCP-managed command.`);
|
|
127
|
-
return null;
|
|
128
|
-
}
|
|
129
|
-
} catch (error) {
|
|
130
|
-
if (error?.code && error.code !== 'ENOENT') {
|
|
131
|
-
console.error(`ReMCP could not inspect ${shim}: ${error instanceof Error ? error.message : String(error)}`);
|
|
132
|
-
return null;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
const launcher = `#!/bin/sh\n${remcpCliShimMarker}\nexec ${shellQuote(effectiveNode)} ${shellQuote(cliScript)} "$@"\n`;
|
|
136
|
-
fs.mkdirSync(path.dirname(shim), { recursive:true, mode:0o755 });
|
|
137
|
-
const temporary = `${shim}.${process.pid}.${Date.now()}.tmp`;
|
|
138
|
-
fs.writeFileSync(temporary, launcher, { mode:0o755 });
|
|
139
|
-
fs.chmodSync(temporary, 0o755);
|
|
140
|
-
fs.renameSync(temporary, shim);
|
|
141
|
-
upsertManagedPathBlock(interactiveShellProfile());
|
|
142
|
-
return shim;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function removeMacCliCommand() {
|
|
146
|
-
if (servicePlatform() !== 'darwin') return;
|
|
147
|
-
const shim = macCliShimPath();
|
|
148
|
-
try {
|
|
149
|
-
const existing = fs.readFileSync(shim, 'utf8');
|
|
150
|
-
if (existing.includes(remcpCliShimMarker)) fs.unlinkSync(shim);
|
|
151
|
-
} catch {}
|
|
152
|
-
removeManagedPathBlock(interactiveShellProfile());
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function macServicePlist(cliPath, nodePath = process.execPath) {
|
|
156
|
-
// launchd wants an absolute path; a symlinked prefix that npm has not materialised yet (or a path
|
|
157
|
-
// that is about to be replaced by the next install) must not abort the repair — a stale plist is
|
|
158
|
-
// exactly the loop this function exists to break.
|
|
159
|
-
const cliScript = fs.existsSync(cliPath) ? fs.realpathSync(cliPath) : path.resolve(cliPath);
|
|
160
|
-
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(nodePath)}</string><string>${xmlEscape(cliScript)}</string><string>start</string><string>--service</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`;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function macJobLoaded(target) {
|
|
164
|
-
return spawnSync('launchctl', ['print', target], { stdio: 'ignore' }).status === 0;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function submitMacHelper(kind, lines) {
|
|
168
|
-
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
169
|
-
const nonce = `${process.pid}.${Date.now()}`;
|
|
170
|
-
const helperFile = path.join(configDir, `launchd-${kind}-${nonce}.sh`);
|
|
171
|
-
const helperLabel = `${macServiceLabel}.${kind}.${nonce}`;
|
|
172
|
-
const script = [
|
|
173
|
-
'#!/bin/sh',
|
|
174
|
-
'sleep 1',
|
|
175
|
-
'status=0',
|
|
176
|
-
...lines.map(line => `${line} || status=$?`),
|
|
177
|
-
`if [ "$status" -ne 0 ]; then echo "ReMCP launchd ${kind} helper failed with status $status" >> ${shellQuote(macLogFile)}; fi`,
|
|
178
|
-
`rm -f ${shellQuote(helperFile)}`,
|
|
179
|
-
`launchctl remove ${shellQuote(helperLabel)} >/dev/null 2>&1 || true`,
|
|
180
|
-
'exit 0',
|
|
181
|
-
'',
|
|
182
|
-
].join('\n');
|
|
183
|
-
fs.writeFileSync(helperFile, script, { mode: 0o700 });
|
|
184
|
-
fs.chmodSync(helperFile, 0o700);
|
|
185
|
-
run('launchctl', ['submit', '-l', helperLabel, '--', '/bin/sh', helperFile]);
|
|
186
|
-
return helperLabel;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function scheduleMacServiceReload(domain, target) {
|
|
190
|
-
return submitMacHelper('reload', [
|
|
191
|
-
`launchctl bootout ${shellQuote(target)} >/dev/null 2>&1 || true`,
|
|
192
|
-
`launchctl bootstrap ${shellQuote(domain)} ${shellQuote(macServiceFile)}`,
|
|
193
|
-
`launchctl enable ${shellQuote(target)}`,
|
|
194
|
-
`launchctl kickstart -k ${shellQuote(target)}`,
|
|
195
|
-
]);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function scheduleMacServiceRestart(target) {
|
|
199
|
-
return submitMacHelper('restart', [
|
|
200
|
-
`launchctl kickstart -k ${shellQuote(target)}`,
|
|
201
|
-
]);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
export function installMacService(cliPath = globalCliPath(), { restart = true, nodePath = process.execPath } = {}) {
|
|
205
|
-
const domain = macLaunchDomain();
|
|
206
|
-
const target = `${domain}/${macServiceLabel}`;
|
|
207
|
-
const plist = macServicePlist(cliPath, nodePath);
|
|
208
|
-
let previous = '';
|
|
209
|
-
try { previous = fs.readFileSync(macServiceFile, 'utf8'); } catch {}
|
|
210
|
-
const loaded = macJobLoaded(target);
|
|
211
|
-
|
|
212
|
-
fs.mkdirSync(path.dirname(macServiceFile), { recursive: true });
|
|
213
|
-
fs.mkdirSync(path.dirname(macLogFile), { recursive: true });
|
|
214
|
-
fs.writeFileSync(macServiceFile, plist, { mode: 0o600 });
|
|
215
|
-
|
|
216
|
-
if (loaded && previous === plist) {
|
|
217
|
-
run('launchctl', ['enable', target]);
|
|
218
|
-
// Never boot out a healthy loaded job just to refresh an in-place npm install. The final
|
|
219
|
-
// kickstart keeps launchd responsible for bringing the replacement agent back.
|
|
220
|
-
if (restart) run('launchctl', ['kickstart', '-k', target]);
|
|
221
|
-
return 'loaded';
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
if (loaded) {
|
|
225
|
-
// A changed Node/npm prefix requires launchd to re-read ProgramArguments. A separate transient
|
|
226
|
-
// launchd job survives booting out com.remcp.agent even when the updater was launched by it.
|
|
227
|
-
scheduleMacServiceReload(domain, target);
|
|
228
|
-
return 'reload-scheduled';
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
run('launchctl', ['bootstrap', domain, macServiceFile]);
|
|
232
|
-
run('launchctl', ['enable', target]);
|
|
233
|
-
if (restart) run('launchctl', ['kickstart', '-k', target]);
|
|
234
|
-
return 'bootstrapped';
|
|
235
|
-
}
|
|
236
|
-
|
|
237
46
|
export function writeWindowsServiceLauncher(cliPath = globalCliPath(), nodePath = process.execPath) {
|
|
238
47
|
const launcher = windowsServiceLauncherText(cliPath, nodePath);
|
|
239
48
|
fs.mkdirSync(configDir, { recursive:true, mode:0o700 });
|