@remcp/remcp 0.2.233 → 0.2.235
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/README.md +1 -1
- package/package.json +2 -2
- package/src/agent-relay-connection.mjs +72 -10
- package/src/agent-update-monitor.mjs +13 -1
- package/src/agent-update-rollout.mjs +92 -0
- package/src/agent-utilities.mjs +11 -0
- package/src/agent.mjs +12 -2
- package/src/cli.mjs +2 -2
- package/src/fetch-update-manifest.mjs +3 -0
package/README.md
CHANGED
|
@@ -90,7 +90,7 @@ permission, and `remcp doctor` reports the state of each one.
|
|
|
90
90
|
|
|
91
91
|
## Updates
|
|
92
92
|
|
|
93
|
-
The agent
|
|
93
|
+
The agent checks the server on a stable per-device phase within a 30-minute cycle, rather than on every reconnect. New automatic installs are spread over a deterministic 24-hour window per version; older unattended clients are admitted through a shared Redis budget of 120 update hints per minute. The exact trusted client/runtime release pair is installed in the background and the managed service restarts to apply it. An explicit manual `remcp update` remains immediate. The updater resolves itself and the sibling runtime from the package that is actually running instead of depending on an interactive shell PATH. When a computer has several Node.js environments (for example NVM, FNM or a system installation), ReMCP updates only the canonical managed service packages. It does not upgrade Node.js, rewrite unrelated npm packages, or silently remove old ReMCP copies. `remcp status` counts package roots verified from the current CLI/service and bounded installation history; aliases and vanished paths are not extra installations. `remcp doctor` adds the exact read-only package roots, corresponding client/runtime versions, aliases and historical/cache classifications. These are **physical package copies, not a count of running agents**. Old copies are never automatically removed; verifying process, startup and script dependencies is required before an explicit owner-approved cleanup. A failed install is retried no more often than every thirty minutes and update-check failures are logged, so a broken release cannot become a silent install loop. `remcp update` does the same immediately; `remcp auto-update off` disables automatic checks.
|
|
94
94
|
|
|
95
95
|
## Computer-use routing
|
|
96
96
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remcp/remcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.235",
|
|
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-deploy-reconnect.mjs && node --check src/agent-network-probe-client.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/installation-audit.mjs && node --check src/cli/mac-native-temperature.mjs && node --check src/cli/npm-global.mjs && node --check src/cli/pairing-qr.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/supervised-agent-lifecycle.mjs && node --check src/cli/update.mjs && node --check src/cli/windows-cli-shim.mjs && node --check src/cli/windows-owned-job-source.mjs && node --check src/cli/windows-owned-job.mjs && node --check src/cli/windows-supervisor.mjs && node --check src/cli/windows-update-lock.mjs && node --check src/cli/windows-user-supervisor.mjs && node --check src/fetch-update-manifest.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-deploy-reconnect.mjs && node --check src/agent-network-probe-client.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-rollout.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/installation-audit.mjs && node --check src/cli/mac-native-temperature.mjs && node --check src/cli/npm-global.mjs && node --check src/cli/pairing-qr.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/supervised-agent-lifecycle.mjs && node --check src/cli/update.mjs && node --check src/cli/windows-cli-shim.mjs && node --check src/cli/windows-owned-job-source.mjs && node --check src/cli/windows-owned-job.mjs && node --check src/cli/windows-supervisor.mjs && node --check src/cli/windows-update-lock.mjs && node --check src/cli/windows-user-supervisor.mjs && node --check src/fetch-update-manifest.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": {
|
|
@@ -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,8 @@ export function createAgentRelayConnection({
|
|
|
16
18
|
setSocket, getHelloState, respond, inFlight,
|
|
17
19
|
sendMetricsSample, reportInstallOnce, flushTelemetry,
|
|
18
20
|
checkForUpdate, schedulePostConnectUpdateChecks, clearPostConnectUpdateChecks, stop,
|
|
21
|
+
checkUpdatesOnConnect = false,
|
|
22
|
+
heartbeatIntervalMs = HEARTBEAT_INTERVAL_MS, handshakeTimeoutMs = HANDSHAKE_TIMEOUT_MS,
|
|
19
23
|
}) {
|
|
20
24
|
let revoked = false;
|
|
21
25
|
let reconnectTimer = null;
|
|
@@ -24,6 +28,13 @@ export function createAgentRelayConnection({
|
|
|
24
28
|
let requestedReconnectReason = '';
|
|
25
29
|
let connectedAt = 0;
|
|
26
30
|
let rapidDeployDisconnects = 0;
|
|
31
|
+
// Timing overrides are used only by isolated transport regression tests.
|
|
32
|
+
const heartbeatInterval = Number.isFinite(heartbeatIntervalMs) && heartbeatIntervalMs >= 20
|
|
33
|
+
? Math.min(60_000, Math.floor(heartbeatIntervalMs)) : HEARTBEAT_INTERVAL_MS;
|
|
34
|
+
const handshakeTimeout = Number.isFinite(handshakeTimeoutMs) && handshakeTimeoutMs >= 100
|
|
35
|
+
? Math.min(120_000, Math.floor(handshakeTimeoutMs)) : HANDSHAKE_TIMEOUT_MS;
|
|
36
|
+
let clearActiveHeartbeat = () => {};
|
|
37
|
+
let activeSocket = null;
|
|
27
38
|
function scheduleReconnect(delay) {
|
|
28
39
|
if (isStopping() || revoked || reconnectTimer) return;
|
|
29
40
|
// Keep the upper-bound guard in control flow immediately before the timer sink. CodeQL's
|
|
@@ -60,15 +71,35 @@ export function createAgentRelayConnection({
|
|
|
60
71
|
}
|
|
61
72
|
|
|
62
73
|
function connect() {
|
|
63
|
-
if (isStopping()) return;
|
|
74
|
+
if (isStopping() || revoked) return;
|
|
75
|
+
if (activeSocket && (activeSocket.readyState === WebSocket.OPEN ||
|
|
76
|
+
activeSocket.readyState === WebSocket.CONNECTING)) return;
|
|
64
77
|
// The device token is intentionally sent only to the configured ReMCP relay during the
|
|
65
78
|
// authenticated WebSocket handshake.
|
|
66
79
|
// codeql[js/file-access-to-http]
|
|
67
|
-
const ws = new WebSocket(agentUrl, {
|
|
80
|
+
const ws = new WebSocket(agentUrl, {
|
|
81
|
+
headers: { Authorization: `Bearer ${deviceToken}` },
|
|
82
|
+
handshakeTimeout,
|
|
83
|
+
});
|
|
84
|
+
activeSocket = ws;
|
|
85
|
+
let heartbeatTimer = null;
|
|
86
|
+
let awaitingPong = false;
|
|
87
|
+
function clearHeartbeat() {
|
|
88
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
89
|
+
heartbeatTimer = null;
|
|
90
|
+
}
|
|
91
|
+
clearActiveHeartbeat = clearHeartbeat;
|
|
68
92
|
// The ws client leaves cleanup/retry to the caller when an unexpected-response listener exists.
|
|
69
93
|
// A temporary workspace pause therefore needs an explicit retry; otherwise the first 423 leaves
|
|
70
94
|
// this WebSocket stuck in CONNECTING forever and turning the device back on cannot recover it.
|
|
71
95
|
ws.on('unexpected-response', (request, response) => {
|
|
96
|
+
if (activeSocket !== ws) {
|
|
97
|
+
response.resume();
|
|
98
|
+
request.destroy();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
activeSocket = null;
|
|
102
|
+
setSocket(null);
|
|
72
103
|
const handshakeRevoked = String(response.headers['x-remcp-revoked'] || '') === '1';
|
|
73
104
|
const handshakeDisabled = String(response.headers['x-remcp-disabled'] || '') === '1';
|
|
74
105
|
if (handshakeRevoked) revoked = true;
|
|
@@ -82,23 +113,42 @@ export function createAgentRelayConnection({
|
|
|
82
113
|
scheduleReconnect(jitter(RECONNECT_BASE_MS));
|
|
83
114
|
return;
|
|
84
115
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
// so this branch must schedule its own retry or a still-running agent can stay offline forever.
|
|
116
|
+
if (!revoked) {
|
|
117
|
+
// Even a captive-portal redirect or unexpected proxy rejection may be temporary.
|
|
118
|
+
// ws does not reliably emit close with an unexpected-response listener, so retry
|
|
119
|
+
// all non-revocation failures with bounded backoff rather than staying offline forever.
|
|
120
|
+
// The explicit revocation signal above remains final and cannot be bypassed.
|
|
91
121
|
scheduleBackoffReconnect();
|
|
92
122
|
}
|
|
93
123
|
});
|
|
94
124
|
setSocket(ws);
|
|
95
125
|
ws.on('open', () => {
|
|
126
|
+
if (activeSocket !== ws || isStopping()) {
|
|
127
|
+
ws.terminate();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
96
130
|
connectedAt = Date.now();
|
|
97
131
|
if (reconnectTimer) {
|
|
98
132
|
clearTimeout(reconnectTimer);
|
|
99
133
|
reconnectTimer = null;
|
|
100
134
|
}
|
|
101
135
|
setReconnects(0);
|
|
136
|
+
// A network switch can strand a TCP/WebSocket session without close or error.
|
|
137
|
+
// Probe relay liveness client-side and terminate a stale socket to reconnect.
|
|
138
|
+
heartbeatTimer = setInterval(() => {
|
|
139
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
140
|
+
if (awaitingPong) {
|
|
141
|
+
console.error('ReMCP relay heartbeat timeout; terminating stale connection.');
|
|
142
|
+
ws.terminate();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
awaitingPong = true;
|
|
146
|
+
try { ws.ping(); } catch {
|
|
147
|
+
console.error('ReMCP relay ping failed; terminating stale connection.');
|
|
148
|
+
ws.terminate();
|
|
149
|
+
}
|
|
150
|
+
}, heartbeatInterval);
|
|
151
|
+
heartbeatTimer.unref?.();
|
|
102
152
|
sendRelayMessage(ws, {
|
|
103
153
|
type: 'hello',
|
|
104
154
|
deviceId,
|
|
@@ -114,10 +164,14 @@ export function createAgentRelayConnection({
|
|
|
114
164
|
sendMetricsSample();
|
|
115
165
|
reportInstallOnce();
|
|
116
166
|
flushTelemetry();
|
|
117
|
-
|
|
167
|
+
// Normal fleet clients poll on a stable per-device phase, not all on
|
|
168
|
+
// every relay handshake. Explicit short QA rechecks retain hot-path coverage.
|
|
169
|
+
if (checkUpdatesOnConnect) void checkForUpdate();
|
|
118
170
|
schedulePostConnectUpdateChecks();
|
|
119
171
|
});
|
|
172
|
+
ws.on('pong', () => { if (activeSocket === ws) awaitingPong = false; });
|
|
120
173
|
ws.on('message', raw => {
|
|
174
|
+
if (activeSocket !== ws) return;
|
|
121
175
|
let message;
|
|
122
176
|
try { message = JSON.parse(raw.toString()); } catch { return; }
|
|
123
177
|
if (message?.type === 'reconnect') {
|
|
@@ -134,6 +188,10 @@ export function createAgentRelayConnection({
|
|
|
134
188
|
}
|
|
135
189
|
});
|
|
136
190
|
ws.on('close', code => {
|
|
191
|
+
clearHeartbeat();
|
|
192
|
+
if (activeSocket !== ws) return;
|
|
193
|
+
activeSocket = null;
|
|
194
|
+
setSocket(null);
|
|
137
195
|
clearPostConnectUpdateChecks();
|
|
138
196
|
for (const controller of inFlight.values()) controller.abort();
|
|
139
197
|
if (isStopping()) return;
|
|
@@ -173,6 +231,10 @@ export function createAgentRelayConnection({
|
|
|
173
231
|
|
|
174
232
|
return {
|
|
175
233
|
connect,
|
|
176
|
-
clearReconnect() {
|
|
234
|
+
clearReconnect() {
|
|
235
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
236
|
+
reconnectTimer = null;
|
|
237
|
+
clearActiveHeartbeat();
|
|
238
|
+
},
|
|
177
239
|
};
|
|
178
240
|
}
|
|
@@ -3,6 +3,7 @@ import { spawn } from 'node:child_process';
|
|
|
3
3
|
import process from 'node:process';
|
|
4
4
|
import { VERSION } from './version.mjs';
|
|
5
5
|
import { fetchUpdateManifest } from './fetch-update-manifest.mjs';
|
|
6
|
+
import { updateRolloutEligibility } from './agent-update-rollout.mjs';
|
|
6
7
|
import { globalCliEntry, globalInstalledVersion, isNewer, restartToApplyUpdate, updateDecision, updateInvocationArgs } from './agent-update.mjs';
|
|
7
8
|
|
|
8
9
|
// Poll releases while connected too: relying on reconnect alone can leave a
|
|
@@ -11,7 +12,9 @@ export const UPDATE_CHECK_INTERVAL_MS = 30 * 60 * 1000;
|
|
|
11
12
|
const UPDATE_CHECK_TIMEOUT_MS = 12000;
|
|
12
13
|
// A flapping relay must not trigger update discovery on every successful reconnect.
|
|
13
14
|
const UPDATE_CHECK_RECONNECT_GAP_MS = 30000;
|
|
14
|
-
|
|
15
|
+
// Fleet clients already have a per-device wall-clock polling phase. Avoid
|
|
16
|
+
// bursty reconnect-triggered rechecks; explicit short QA fixtures may opt in.
|
|
17
|
+
const POST_CONNECT_UPDATE_RECHECK_DELAYS_MS = Object.freeze([]);
|
|
15
18
|
const POST_CONNECT_UPDATE_RECHECK_JITTER_MS = 30_000;
|
|
16
19
|
const UPDATE_RETRY_COOLDOWN_MS = 30 * 60 * 1000;
|
|
17
20
|
|
|
@@ -99,6 +102,15 @@ export function createAgentUpdateMonitor({ options, serverUrl, queueEvent, getRu
|
|
|
99
102
|
// proves convergence only after the replacement agent reconnects.
|
|
100
103
|
queueEvent({ event: 'agent_update_available', at: observedAt, reason: decision.reason.slice(0, 32) });
|
|
101
104
|
}
|
|
105
|
+
const eligible = updateRolloutEligibility({
|
|
106
|
+
deviceId: options.deviceId,
|
|
107
|
+
target: availabilityKey,
|
|
108
|
+
rollout: advertised.rollout,
|
|
109
|
+
// An explicitly raised minimum version or broken local runtime needs
|
|
110
|
+
// repair without the normal gradual fleet-release delay.
|
|
111
|
+
urgent: decision.reason === 'runtime-repair' || Boolean(minimum && isNewer(minimum, VERSION)),
|
|
112
|
+
}, observedAt);
|
|
113
|
+
if (!eligible.ready) return;
|
|
102
114
|
const cli = resolveCli();
|
|
103
115
|
if (!cli || !existsSync(cli)) {
|
|
104
116
|
if (announce) console.error(`ReMCP ${target} is available; run: remcp update`);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
// A 24-hour fleet rollout makes 100,000 downloads roughly 1.16/sec instead
|
|
4
|
+
// of initiating all installers when a new version first becomes visible.
|
|
5
|
+
export const ROLLOUT_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
6
|
+
const ROLLOUT_MAX_WINDOW_MS = 7 * ROLLOUT_WINDOW_MS;
|
|
7
|
+
const ROLLOUT_STATE_TTL_SECONDS = 120 * 24 * 60 * 60;
|
|
8
|
+
|
|
9
|
+
export function updateRolloutEligibility({ deviceId, target, rollout, urgent = false }, now = Date.now()) {
|
|
10
|
+
if (urgent || !rollout) return { ready:true, readyAt:now };
|
|
11
|
+
const startAtMs = Number(rollout.startAtMs);
|
|
12
|
+
const windowMs = Number(rollout.windowMs);
|
|
13
|
+
// A malformed policy must not accidentally release the whole fleet at once.
|
|
14
|
+
if (!String(deviceId || '').trim() || !String(target || '').trim()
|
|
15
|
+
|| !Number.isSafeInteger(startAtMs) || startAtMs < 1_600_000_000_000
|
|
16
|
+
|| !Number.isSafeInteger(windowMs) || windowMs < 60_000 || windowMs > ROLLOUT_MAX_WINDOW_MS) {
|
|
17
|
+
return { ready:false, readyAt:null };
|
|
18
|
+
}
|
|
19
|
+
// A stable local device identifier is hashed, not transmitted to the public
|
|
20
|
+
// update endpoint. A distinct release has a distinct deterministic slot.
|
|
21
|
+
const hash = createHash('sha256').update('remcp-agent-rollout-v1\0')
|
|
22
|
+
.update(String(deviceId)).update('\0').update(String(target)).digest();
|
|
23
|
+
const offsetMs = Math.floor(hash.readUInt32BE(0) / 0x100000000 * windowMs);
|
|
24
|
+
const readyAt = startAtMs + offsetMs;
|
|
25
|
+
// The response's server timestamp prevents incorrect client wall clocks
|
|
26
|
+
// from synchronously skipping all fleet slots.
|
|
27
|
+
const serverNowMs = Number(rollout.serverNowMs);
|
|
28
|
+
const effectiveNow = Number.isSafeInteger(serverNowMs) && serverNowMs >= 1_600_000_000_000
|
|
29
|
+
? serverNowMs : now;
|
|
30
|
+
return { ready:effectiveNow >= readyAt, readyAt };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Fleet update checks are phased on the wall clock as well. A 100k-device
|
|
34
|
+
// reconnect wave must not turn into 100k immediate manifest GETs or a synchronized
|
|
35
|
+
// 30-minute polling loop. The phase survives agent restarts with no disk state.
|
|
36
|
+
export function nextFleetManifestDelayMs(deviceId, now = Date.now(), intervalMs = 30 * 60 * 1000) {
|
|
37
|
+
if (!String(deviceId || '').trim() || !Number.isSafeInteger(intervalMs) || intervalMs < 1000) {
|
|
38
|
+
throw new Error('Fleet manifest scheduling requires a paired device and a valid interval');
|
|
39
|
+
}
|
|
40
|
+
const hash = createHash('sha256').update('remcp-update-check-phase-v1\0').update(String(deviceId)).digest();
|
|
41
|
+
const phase = Math.floor(hash.readUInt32BE(0) / 0x100000000 * intervalMs);
|
|
42
|
+
const elapsed = ((now % intervalMs) + intervalMs) % intervalMs;
|
|
43
|
+
return (phase - elapsed + intervalMs) % intervalMs || intervalMs;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Legacy clients (before scheduler v1) cannot defer installs themselves.
|
|
47
|
+
// Admit a bounded number of old automatic update checks per minute across the
|
|
48
|
+
// entire HA cluster; the rest retry their next normal check. Manual status,
|
|
49
|
+
// bootstrap and manually requested updates use their own unthrottled paths.
|
|
50
|
+
export async function claimLegacyManifestSlot(command, version, {
|
|
51
|
+
now = Date.now, limitPerMinute = 120, isClosed = () => false,
|
|
52
|
+
} = {}) {
|
|
53
|
+
if (isClosed() || !command?.isReady) throw new Error('Shared legacy update admission unavailable');
|
|
54
|
+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(String(version))) {
|
|
55
|
+
throw new Error('Invalid version for legacy update admission');
|
|
56
|
+
}
|
|
57
|
+
if (!Number.isSafeInteger(limitPerMinute) || limitPerMinute < 1 || limitPerMinute > 6000) {
|
|
58
|
+
throw new Error('Invalid legacy update admission quota');
|
|
59
|
+
}
|
|
60
|
+
const minute = Math.floor(now() / 60_000);
|
|
61
|
+
const key = 'remcp:deploy:legacy-update-admit:v1:' + version + ':' + minute;
|
|
62
|
+
const script = "local n=redis.call('INCR',KEYS[1]); if n == 1 then redis.call('EXPIRE',KEYS[1],90) end; return n <= tonumber(ARGV[1]) and 1 or 0";
|
|
63
|
+
return Number(await command.eval(script, { keys:[key], arguments:[String(limitPerMinute)] })) === 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Use only immutable HTTP traits that identify the old agent's no-header
|
|
67
|
+
// automatic fetch. The trusted user-requested manual update uses Accept JSON.
|
|
68
|
+
export function isLegacyAutomaticUpdateRequest(req) {
|
|
69
|
+
const header = name => String(req?.get?.(name) || req?.headers?.[name.toLowerCase()] || '').toLowerCase();
|
|
70
|
+
return /^(?:node|undici)(?:\/[0-9.]+)?$/.test(header('user-agent'))
|
|
71
|
+
&& header('accept').trim() === '*/*'
|
|
72
|
+
&& header('x-remcp-update-scheduler') !== 'v1'
|
|
73
|
+
&& !req?.query?.fresh;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The shared Redis epoch is created exactly once for each released version.
|
|
77
|
+
// A rolling HA deploy or process restart must not reset the fleet schedule.
|
|
78
|
+
// Use the same Redis coordination store as the existing release pause gate.
|
|
79
|
+
export async function getOrCreateRolloutStart(command, version, now = Date.now, isClosed = () => false) {
|
|
80
|
+
if (isClosed() || !command?.isReady) throw new Error('Shared rollout control plane unavailable');
|
|
81
|
+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(String(version))) {
|
|
82
|
+
throw new Error('Invalid release version for update rollout');
|
|
83
|
+
}
|
|
84
|
+
const key = 'remcp:deploy:agent-rollout-start:v1:' + version;
|
|
85
|
+
const proposed = String(now());
|
|
86
|
+
await command.set(key, proposed, { NX:true, EX:ROLLOUT_STATE_TTL_SECONDS });
|
|
87
|
+
const persisted = Number(await command.get(key));
|
|
88
|
+
if (!Number.isSafeInteger(persisted) || persisted < 1_600_000_000_000) {
|
|
89
|
+
throw new Error('Shared rollout start is unavailable');
|
|
90
|
+
}
|
|
91
|
+
return persisted;
|
|
92
|
+
}
|
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]
|
package/src/agent.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { localRuntimeEntry } from './agent-update.mjs';
|
|
|
8
8
|
export { localRuntimeEntry, supervisorRestart, updateDecision } from './agent-update.mjs';
|
|
9
9
|
import { normalizedServerUrl, sendRelayMessage, jitter, deviceMetrics } from './agent-utilities.mjs';
|
|
10
10
|
import { createAgentUpdateMonitor, UPDATE_CHECK_INTERVAL_MS } from './agent-update-monitor.mjs';
|
|
11
|
+
import { nextFleetManifestDelayMs } from './agent-update-rollout.mjs';
|
|
11
12
|
import { createAgentResponder } from './agent-responder.mjs';
|
|
12
13
|
import { createAgentRelayConnection } from './agent-relay-connection.mjs';
|
|
13
14
|
import { createRuntimeCapabilities, normalizeRuntimeToolNames } from './agent-runtime-capabilities.mjs';
|
|
@@ -288,12 +289,20 @@ export async function runAgent(options) {
|
|
|
288
289
|
}),
|
|
289
290
|
respond, inFlight, sendMetricsSample, reportInstallOnce, flushTelemetry,
|
|
290
291
|
checkForUpdate, schedulePostConnectUpdateChecks, clearPostConnectUpdateChecks, stop,
|
|
292
|
+
checkUpdatesOnConnect: Array.isArray(options.updateRecheckDelaysMs),
|
|
291
293
|
});
|
|
292
294
|
|
|
293
295
|
scheduleMetricsSample();
|
|
294
296
|
const telemetryFlushTimer = setInterval(flushTelemetry, TELEMETRY_SEND_INTERVAL_MS);
|
|
295
297
|
telemetryFlushTimer.unref?.();
|
|
296
|
-
|
|
298
|
+
// Spread 100k devices across the entire 30-minute manifest polling cycle.
|
|
299
|
+
// The stable device phase survives reconnects/restarts without local writes.
|
|
300
|
+
let recurringUpdateTimer = null;
|
|
301
|
+
const updateTimer = setTimeout(() => {
|
|
302
|
+
void checkForUpdate({ scheduled:true });
|
|
303
|
+
recurringUpdateTimer = setInterval(() => void checkForUpdate({ scheduled:true }), UPDATE_CHECK_INTERVAL_MS);
|
|
304
|
+
recurringUpdateTimer.unref?.();
|
|
305
|
+
}, nextFleetManifestDelayMs(deviceId, Date.now(), UPDATE_CHECK_INTERVAL_MS));
|
|
297
306
|
updateTimer.unref?.();
|
|
298
307
|
|
|
299
308
|
async function stop() {
|
|
@@ -305,7 +314,8 @@ export async function runAgent(options) {
|
|
|
305
314
|
clearPostConnectUpdateChecks();
|
|
306
315
|
if (telemetryTimer) clearTimeout(telemetryTimer);
|
|
307
316
|
clearInterval(telemetryFlushTimer);
|
|
308
|
-
|
|
317
|
+
clearTimeout(updateTimer);
|
|
318
|
+
if (recurringUpdateTimer) clearInterval(recurringUpdateTimer);
|
|
309
319
|
for (const controller of inFlight.values()) controller.abort();
|
|
310
320
|
activeSocket?.terminate();
|
|
311
321
|
stopPromise = Promise.allSettled([mcp?.close(), transport?.close()]).then(results => {
|
package/src/cli.mjs
CHANGED
|
@@ -44,7 +44,7 @@ function printHelp() {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
// The running agent snapshots update policy at startup. A saved toggle alone does not
|
|
47
|
-
// change its
|
|
47
|
+
// change its per-device polling schedule, so reload a managed service after a policy change.
|
|
48
48
|
// Defer Linux handoff to avoid terminating a remote command before it returns.
|
|
49
49
|
export function applyAutoUpdatePreference(cfg, enabled, {
|
|
50
50
|
persist = saveConfig,
|
|
@@ -219,7 +219,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
219
219
|
const action = String(positional[0] || 'status').toLowerCase();
|
|
220
220
|
const cfg = loadConfig(false) || {};
|
|
221
221
|
if (action === 'status') {
|
|
222
|
-
console.log(JSON.stringify({ autoUpdate: autoUpdateEnabled(cfg), checkIntervalHours:
|
|
222
|
+
console.log(JSON.stringify({ autoUpdate: autoUpdateEnabled(cfg), checkIntervalHours: 0.5, fleetRolloutHours:24 }, null, 2));
|
|
223
223
|
return;
|
|
224
224
|
}
|
|
225
225
|
if (['on', 'enable', 'off', 'disable'].includes(action)) {
|
|
@@ -29,6 +29,9 @@ export async function fetchUpdateManifest(url, {
|
|
|
29
29
|
(async () => {
|
|
30
30
|
const response = await fetchImpl(url, {
|
|
31
31
|
redirect: 'error',
|
|
32
|
+
// Signals support for deterministic fleet rollout; old agents send
|
|
33
|
+
// plain fetch requests and use a shared Redis admission budget.
|
|
34
|
+
headers: { 'x-remcp-update-scheduler':'v1' },
|
|
32
35
|
signal: controller.signal,
|
|
33
36
|
});
|
|
34
37
|
if (!response.ok) return null;
|