@remcp/remcp 0.2.233 → 0.2.234
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
|
@@ -9,6 +9,8 @@ import { nextDeployReconnect } from './agent-deploy-reconnect.mjs';
|
|
|
9
9
|
const RECONNECT_BASE_MS = 2_000;
|
|
10
10
|
const RECONNECT_FIRST_MS = 500;
|
|
11
11
|
const RECONNECT_MAX_MS = 60_000;
|
|
12
|
+
const HEARTBEAT_INTERVAL_MS = 15_000;
|
|
13
|
+
const HANDSHAKE_TIMEOUT_MS = 12_000;
|
|
12
14
|
// Higher-level agent owns runtime supervision/telemetry; this controller owns only relay transport.
|
|
13
15
|
export function createAgentRelayConnection({
|
|
14
16
|
agentUrl, serverUrl, deviceToken, deviceId, deviceName,
|
|
@@ -16,6 +18,7 @@ export function createAgentRelayConnection({
|
|
|
16
18
|
setSocket, getHelloState, respond, inFlight,
|
|
17
19
|
sendMetricsSample, reportInstallOnce, flushTelemetry,
|
|
18
20
|
checkForUpdate, schedulePostConnectUpdateChecks, clearPostConnectUpdateChecks, stop,
|
|
21
|
+
heartbeatIntervalMs = HEARTBEAT_INTERVAL_MS, handshakeTimeoutMs = HANDSHAKE_TIMEOUT_MS,
|
|
19
22
|
}) {
|
|
20
23
|
let revoked = false;
|
|
21
24
|
let reconnectTimer = null;
|
|
@@ -24,6 +27,13 @@ export function createAgentRelayConnection({
|
|
|
24
27
|
let requestedReconnectReason = '';
|
|
25
28
|
let connectedAt = 0;
|
|
26
29
|
let rapidDeployDisconnects = 0;
|
|
30
|
+
// Timing overrides are used only by isolated transport regression tests.
|
|
31
|
+
const heartbeatInterval = Number.isFinite(heartbeatIntervalMs) && heartbeatIntervalMs >= 20
|
|
32
|
+
? Math.min(60_000, Math.floor(heartbeatIntervalMs)) : HEARTBEAT_INTERVAL_MS;
|
|
33
|
+
const handshakeTimeout = Number.isFinite(handshakeTimeoutMs) && handshakeTimeoutMs >= 100
|
|
34
|
+
? Math.min(120_000, Math.floor(handshakeTimeoutMs)) : HANDSHAKE_TIMEOUT_MS;
|
|
35
|
+
let clearActiveHeartbeat = () => {};
|
|
36
|
+
let activeSocket = null;
|
|
27
37
|
function scheduleReconnect(delay) {
|
|
28
38
|
if (isStopping() || revoked || reconnectTimer) return;
|
|
29
39
|
// Keep the upper-bound guard in control flow immediately before the timer sink. CodeQL's
|
|
@@ -60,15 +70,35 @@ export function createAgentRelayConnection({
|
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
function connect() {
|
|
63
|
-
if (isStopping()) return;
|
|
73
|
+
if (isStopping() || revoked) return;
|
|
74
|
+
if (activeSocket && (activeSocket.readyState === WebSocket.OPEN ||
|
|
75
|
+
activeSocket.readyState === WebSocket.CONNECTING)) return;
|
|
64
76
|
// The device token is intentionally sent only to the configured ReMCP relay during the
|
|
65
77
|
// authenticated WebSocket handshake.
|
|
66
78
|
// codeql[js/file-access-to-http]
|
|
67
|
-
const ws = new WebSocket(agentUrl, {
|
|
79
|
+
const ws = new WebSocket(agentUrl, {
|
|
80
|
+
headers: { Authorization: `Bearer ${deviceToken}` },
|
|
81
|
+
handshakeTimeout,
|
|
82
|
+
});
|
|
83
|
+
activeSocket = ws;
|
|
84
|
+
let heartbeatTimer = null;
|
|
85
|
+
let awaitingPong = false;
|
|
86
|
+
function clearHeartbeat() {
|
|
87
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
88
|
+
heartbeatTimer = null;
|
|
89
|
+
}
|
|
90
|
+
clearActiveHeartbeat = clearHeartbeat;
|
|
68
91
|
// The ws client leaves cleanup/retry to the caller when an unexpected-response listener exists.
|
|
69
92
|
// A temporary workspace pause therefore needs an explicit retry; otherwise the first 423 leaves
|
|
70
93
|
// this WebSocket stuck in CONNECTING forever and turning the device back on cannot recover it.
|
|
71
94
|
ws.on('unexpected-response', (request, response) => {
|
|
95
|
+
if (activeSocket !== ws) {
|
|
96
|
+
response.resume();
|
|
97
|
+
request.destroy();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
activeSocket = null;
|
|
101
|
+
setSocket(null);
|
|
72
102
|
const handshakeRevoked = String(response.headers['x-remcp-revoked'] || '') === '1';
|
|
73
103
|
const handshakeDisabled = String(response.headers['x-remcp-disabled'] || '') === '1';
|
|
74
104
|
if (handshakeRevoked) revoked = true;
|
|
@@ -82,23 +112,42 @@ export function createAgentRelayConnection({
|
|
|
82
112
|
scheduleReconnect(jitter(RECONNECT_BASE_MS));
|
|
83
113
|
return;
|
|
84
114
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
// so this branch must schedule its own retry or a still-running agent can stay offline forever.
|
|
115
|
+
if (!revoked) {
|
|
116
|
+
// Even a captive-portal redirect or unexpected proxy rejection may be temporary.
|
|
117
|
+
// ws does not reliably emit close with an unexpected-response listener, so retry
|
|
118
|
+
// all non-revocation failures with bounded backoff rather than staying offline forever.
|
|
119
|
+
// The explicit revocation signal above remains final and cannot be bypassed.
|
|
91
120
|
scheduleBackoffReconnect();
|
|
92
121
|
}
|
|
93
122
|
});
|
|
94
123
|
setSocket(ws);
|
|
95
124
|
ws.on('open', () => {
|
|
125
|
+
if (activeSocket !== ws || isStopping()) {
|
|
126
|
+
ws.terminate();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
96
129
|
connectedAt = Date.now();
|
|
97
130
|
if (reconnectTimer) {
|
|
98
131
|
clearTimeout(reconnectTimer);
|
|
99
132
|
reconnectTimer = null;
|
|
100
133
|
}
|
|
101
134
|
setReconnects(0);
|
|
135
|
+
// A network switch can strand a TCP/WebSocket session without close or error.
|
|
136
|
+
// Probe relay liveness client-side and terminate a stale socket to reconnect.
|
|
137
|
+
heartbeatTimer = setInterval(() => {
|
|
138
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
139
|
+
if (awaitingPong) {
|
|
140
|
+
console.error('ReMCP relay heartbeat timeout; terminating stale connection.');
|
|
141
|
+
ws.terminate();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
awaitingPong = true;
|
|
145
|
+
try { ws.ping(); } catch {
|
|
146
|
+
console.error('ReMCP relay ping failed; terminating stale connection.');
|
|
147
|
+
ws.terminate();
|
|
148
|
+
}
|
|
149
|
+
}, heartbeatInterval);
|
|
150
|
+
heartbeatTimer.unref?.();
|
|
102
151
|
sendRelayMessage(ws, {
|
|
103
152
|
type: 'hello',
|
|
104
153
|
deviceId,
|
|
@@ -117,7 +166,9 @@ export function createAgentRelayConnection({
|
|
|
117
166
|
void checkForUpdate();
|
|
118
167
|
schedulePostConnectUpdateChecks();
|
|
119
168
|
});
|
|
169
|
+
ws.on('pong', () => { if (activeSocket === ws) awaitingPong = false; });
|
|
120
170
|
ws.on('message', raw => {
|
|
171
|
+
if (activeSocket !== ws) return;
|
|
121
172
|
let message;
|
|
122
173
|
try { message = JSON.parse(raw.toString()); } catch { return; }
|
|
123
174
|
if (message?.type === 'reconnect') {
|
|
@@ -134,6 +185,10 @@ export function createAgentRelayConnection({
|
|
|
134
185
|
}
|
|
135
186
|
});
|
|
136
187
|
ws.on('close', code => {
|
|
188
|
+
clearHeartbeat();
|
|
189
|
+
if (activeSocket !== ws) return;
|
|
190
|
+
activeSocket = null;
|
|
191
|
+
setSocket(null);
|
|
137
192
|
clearPostConnectUpdateChecks();
|
|
138
193
|
for (const controller of inFlight.values()) controller.abort();
|
|
139
194
|
if (isStopping()) return;
|
|
@@ -173,6 +228,10 @@ export function createAgentRelayConnection({
|
|
|
173
228
|
|
|
174
229
|
return {
|
|
175
230
|
connect,
|
|
176
|
-
clearReconnect() {
|
|
231
|
+
clearReconnect() {
|
|
232
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
233
|
+
reconnectTimer = null;
|
|
234
|
+
clearActiveHeartbeat();
|
|
235
|
+
},
|
|
177
236
|
};
|
|
178
237
|
}
|
package/src/agent-utilities.mjs
CHANGED
|
@@ -12,8 +12,19 @@ export function normalizedServerUrl(value, { allowInsecure = false } = {}) {
|
|
|
12
12
|
return url.href.replace(/\/$/, '');
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
// Above the maximum legal runtime response, but bounded so dead links cannot retain
|
|
16
|
+
// unbounded telemetry or concurrent tool results in the agent process.
|
|
17
|
+
export const MAX_RELAY_BUFFERED_BYTES = 64 * 1024 * 1024;
|
|
18
|
+
|
|
15
19
|
export function sendRelayMessage(socket, message) {
|
|
16
20
|
if (socket?.readyState !== 1) return false;
|
|
21
|
+
if (socket.bufferedAmount > MAX_RELAY_BUFFERED_BYTES) {
|
|
22
|
+
// The WebSocket can look OPEN after an interface switch while its TCP send queue
|
|
23
|
+
// cannot drain. Ending it lets the bounded reconnect loop restore delivery.
|
|
24
|
+
console.error('ReMCP relay outbound buffer saturated; reconnecting.');
|
|
25
|
+
socket.terminate?.();
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
17
28
|
// The authenticated relay is ReMCP's explicit data boundary: model-requested tool results are
|
|
18
29
|
// intentionally returned to the paired workspace and never to an arbitrary third-party URL.
|
|
19
30
|
// codeql[js/file-access-to-http]
|