@remcp/remcp 0.2.232 → 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/remcp",
3
- "version": "0.2.232",
3
+ "version": "0.2.234",
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",
@@ -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, { headers: { Authorization: `Bearer ${deviceToken}` } });
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
- const status = Number(response.statusCode || 0);
86
- const transientHandshakeFailure = status === 408 || status === 425 || status === 429 || (status >= 500 && status <= 599);
87
- if (transientHandshakeFailure && !revoked) {
88
- // A reverse proxy or relay deploy can refuse the HTTP upgrade before a WebSocket exists.
89
- // With an unexpected-response listener the client does not reliably emit close afterwards,
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() { if (reconnectTimer) clearTimeout(reconnectTimer); reconnectTimer = null; },
231
+ clearReconnect() {
232
+ if (reconnectTimer) clearTimeout(reconnectTimer);
233
+ reconnectTimer = null;
234
+ clearActiveHeartbeat();
235
+ },
177
236
  };
178
237
  }
@@ -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]