@remcp/remcp 0.2.234 → 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 +4 -1
- package/src/agent-update-monitor.mjs +13 -1
- package/src/agent-update-rollout.mjs +92 -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": {
|
|
@@ -18,6 +18,7 @@ export function createAgentRelayConnection({
|
|
|
18
18
|
setSocket, getHelloState, respond, inFlight,
|
|
19
19
|
sendMetricsSample, reportInstallOnce, flushTelemetry,
|
|
20
20
|
checkForUpdate, schedulePostConnectUpdateChecks, clearPostConnectUpdateChecks, stop,
|
|
21
|
+
checkUpdatesOnConnect = false,
|
|
21
22
|
heartbeatIntervalMs = HEARTBEAT_INTERVAL_MS, handshakeTimeoutMs = HANDSHAKE_TIMEOUT_MS,
|
|
22
23
|
}) {
|
|
23
24
|
let revoked = false;
|
|
@@ -163,7 +164,9 @@ export function createAgentRelayConnection({
|
|
|
163
164
|
sendMetricsSample();
|
|
164
165
|
reportInstallOnce();
|
|
165
166
|
flushTelemetry();
|
|
166
|
-
|
|
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();
|
|
167
170
|
schedulePostConnectUpdateChecks();
|
|
168
171
|
});
|
|
169
172
|
ws.on('pong', () => { if (activeSocket === ws) awaitingPong = false; });
|
|
@@ -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.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;
|