mcp-pacemaker 2.0.2 → 2.1.0
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/CHANGELOG.md +75 -1
- package/CONTRIBUTING.md +39 -13
- package/README.md +255 -8
- package/THIRD_PARTY_NOTICES.txt +1 -28
- package/autostart/windows/account-task.ps1 +167 -0
- package/autostart/windows/inspect-task.ps1 +42 -0
- package/autostart/windows/inspect-worker-process-sessions.ps1 +155 -0
- package/autostart/windows/legacy-task.ps1 +301 -0
- package/autostart/windows/register-task.ps1 +11 -2
- package/bin/account-task-adapter.mjs +47 -0
- package/bin/account-upgrade-controller.mjs +198 -0
- package/bin/account-upgrade-worker.mjs +134 -0
- package/bin/account-worker-session.mjs +215 -0
- package/bin/cli-dispatch.mjs +154 -0
- package/bin/cli-main.mjs +1007 -0
- package/bin/cli-selection.mjs +43 -0
- package/bin/cli.mjs +7 -876
- package/bin/legacy-1.3.json +32 -0
- package/bin/legacy-installation.mjs +217 -0
- package/bin/legacy-task.mjs +52 -0
- package/bin/legacy-upgrade.mjs +471 -0
- package/bin/managed-runtime.mjs +170 -0
- package/bin/managed-state.mjs +257 -0
- package/bin/managed-upgrade.mjs +491 -0
- package/bin/mcp-bridge.mjs +132 -4
- package/bin/pooling-batches.mjs +13 -0
- package/bin/pooling-writer.mjs +4 -0
- package/bin/prewarm.mjs +33 -21
- package/bin/runtime-context.mjs +100 -0
- package/bin/service-control.mjs +27 -4
- package/bin/stable-launcher.mjs +54 -0
- package/bin/task-only-controller.mjs +226 -0
- package/bin/task-scope.mjs +65 -0
- package/bin/task-transaction-protocol.mjs +173 -0
- package/bin/top.mjs +22 -10
- package/bin/update-check.mjs +13 -2
- package/bin/upgrade-autostart.mjs +47 -0
- package/bin/upgrade-capability.json +8 -0
- package/bin/upgrade-dependencies.mjs +122 -0
- package/bin/upgrade-package.mjs +203 -0
- package/bin/windows-legacy/LegacyProcessBroker.build.json +42 -0
- package/bin/windows-legacy/LegacyProcessBroker.exe +0 -0
- package/bin/windows-legacy/src/AssemblyInfo.cs +8 -0
- package/bin/windows-legacy/src/LegacyNative.cs +783 -0
- package/bin/windows-legacy/src/LegacyProcessBroker.cs +841 -0
- package/bin/windows-legacy-process.mjs +241 -0
- package/bin/windows-lifetime/ProcessLifetimeHelper.build.json +38 -0
- package/bin/windows-lifetime/ProcessLifetimeHelper.exe +0 -0
- package/bin/windows-lifetime/src/AssemblyInfo.cs +8 -0
- package/bin/windows-lifetime/src/ProcessLifetimeHelper.cs +468 -0
- package/bin/windows-process-lifetime.mjs +218 -0
- package/bin/windows-task-channel/TaskChannelGuard.build.json +45 -0
- package/bin/windows-task-channel/TaskChannelGuard.exe +0 -0
- package/bin/windows-task-channel/src/AssemblyInfo.cs +8 -0
- package/bin/windows-task-channel/src/ChannelCliContext.cs +210 -0
- package/bin/windows-task-channel/src/ChannelFiles.cs +231 -0
- package/bin/windows-task-channel/src/ChannelNative.cs +477 -0
- package/bin/windows-task-channel/src/ChannelSession.cs +203 -0
- package/bin/windows-task-channel/src/TaskChannelGuard.cs +686 -0
- package/bin/windows-task-channel.mjs +335 -0
- package/docs/account-upgrades.md +205 -0
- package/docs/execution-context.md +113 -0
- package/package.json +12 -5
- package/supervisor/supervise.mjs +226 -40
- package/tools/account-upgrade/qualify-two-account.ps1 +66 -0
- package/tools/windows-legacy/README.md +225 -0
- package/tools/windows-legacy/build.ps1 +122 -0
- package/tools/windows-legacy/inventory.mjs +90 -0
- package/tools/windows-process-lifetime/README.md +119 -0
- package/tools/windows-process-lifetime/build.ps1 +122 -0
- package/tools/windows-process-lifetime/inventory.mjs +86 -0
- package/tools/windows-task-channel/README.md +329 -0
- package/tools/windows-task-channel/build.ps1 +107 -0
- package/tools/windows-task-channel/inventory.mjs +117 -0
- package/ui/dist/THIRD_PARTY_NOTICES.txt +1 -28
- package/ui/dist/assets/{index-CgPruWYi.js → index-CcwMo2sI.js} +1 -1
- package/ui/dist/assets/index-PVcuDNrN.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/third-party-manifest.json +37 -25
- package/ui/dist/assets/index-CaLPg0c-.css +0 -1
package/bin/mcp-bridge.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
32
32
|
import { dirname, resolve, extname } from 'node:path';
|
|
33
33
|
import { homedir } from 'node:os';
|
|
34
34
|
import { createInterface } from 'node:readline';
|
|
35
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
35
36
|
import { checkServerPaths, checkReservedName } from './config-checks.mjs';
|
|
36
37
|
import { ServerMetrics } from './server-metrics.mjs';
|
|
37
38
|
import { MAX_MIN_WARM, hasPoolingTransaction, recoverPoolingConfig } from './pooling-config.mjs';
|
|
@@ -39,6 +40,28 @@ import { PoolingBatches } from './pooling-batches.mjs';
|
|
|
39
40
|
import { POOLING_BUDGET_MS } from './pooling-execution.mjs';
|
|
40
41
|
import { poolingTrace } from './pooling-trace.mjs';
|
|
41
42
|
import { SharedSessionManager, SHARED_DEFAULTS } from './shared-sessions.mjs';
|
|
43
|
+
import { ensureWindowsProcessLifetime } from './windows-process-lifetime.mjs';
|
|
44
|
+
import { PoolingFiles } from './pooling-files.mjs';
|
|
45
|
+
import { requireUserRuntimeContext } from './runtime-context.mjs';
|
|
46
|
+
|
|
47
|
+
await requireUserRuntimeContext();
|
|
48
|
+
// Assign the bridge before config can start pooled, shared, stdio or auth children.
|
|
49
|
+
const windowsLifetime = await ensureWindowsProcessLifetime();
|
|
50
|
+
if (windowsLifetime &&
|
|
51
|
+
process.connected &&
|
|
52
|
+
process.env.MCP_PACEMAKER_MANAGED_INSTANCE) {
|
|
53
|
+
await new Promise((resolveObserved, reject) => {
|
|
54
|
+
const timer = setTimeout(() => reject(new Error('Supervisor did not verify process lifetime ownership.')), 8000);
|
|
55
|
+
const observed = message => {
|
|
56
|
+
if (message !== 'upgrade-lifetime-observed') return;
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
process.off('message', observed);
|
|
59
|
+
resolveObserved();
|
|
60
|
+
};
|
|
61
|
+
process.on('message', observed);
|
|
62
|
+
process.send({ type: 'upgrade-lifetime', lifetime: windowsLifetime });
|
|
63
|
+
});
|
|
64
|
+
}
|
|
42
65
|
|
|
43
66
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
44
67
|
const argv = process.argv.slice(2);
|
|
@@ -53,14 +76,25 @@ const defaultConfig = existsSync(localConfig) || hasPoolingTransaction(localConf
|
|
|
53
76
|
? resolve(process.cwd(), 'servers.json')
|
|
54
77
|
: resolve(HOME, 'servers.json');
|
|
55
78
|
const CONFIG = resolve(process.cwd(), getArg('--config', defaultConfig));
|
|
56
|
-
|
|
79
|
+
let PORT = Number(getArg('--port', '8791'));
|
|
57
80
|
const HOST = getArg('--host', '127.0.0.1');
|
|
81
|
+
let upgradeHeld = process.env.MCP_PACEMAKER_UPGRADE_PENDING === '1';
|
|
82
|
+
let upgradeRequests = 0;
|
|
83
|
+
let upgradeTraffic = false;
|
|
84
|
+
let upgradeUncertain = false;
|
|
85
|
+
if (PORT === 0 &&
|
|
86
|
+
(!process.connected || !process.env.MCP_PACEMAKER_MANAGED_INSTANCE || HOST !== '127.0.0.1')) {
|
|
87
|
+
throw new Error('Port 0 requires a managed loopback startup with actual-port readiness.');
|
|
88
|
+
}
|
|
58
89
|
const VERSION = (() => { try { return JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf8')).version; } catch { return '0'; } })();
|
|
59
90
|
// Base working directory for relative server commands/args. Defaults to the config's folder.
|
|
60
91
|
const BASE_CWD = resolve(getArg('--cwd', dirname(CONFIG)));
|
|
61
92
|
|
|
62
93
|
/** @type {Record<string, any>} */
|
|
63
|
-
if (hasPoolingTransaction(CONFIG))
|
|
94
|
+
if (hasPoolingTransaction(CONFIG)) {
|
|
95
|
+
if (upgradeHeld) throw new Error('Unresolved config transaction blocks managed startup.');
|
|
96
|
+
recoverPoolingConfig(CONFIG);
|
|
97
|
+
}
|
|
64
98
|
const servers = JSON.parse(readFileSync(CONFIG, 'utf8'));
|
|
65
99
|
const poolingConfig = new PoolingBatches({
|
|
66
100
|
configPath: CONFIG,
|
|
@@ -1159,6 +1193,7 @@ function spawnWarm(name) {
|
|
|
1159
1193
|
return child;
|
|
1160
1194
|
}
|
|
1161
1195
|
function refillPool(name) {
|
|
1196
|
+
if (upgradeHeld) return;
|
|
1162
1197
|
const target = poolTarget(name);
|
|
1163
1198
|
if (!target ||
|
|
1164
1199
|
shuttingDown) {
|
|
@@ -1224,7 +1259,8 @@ const resumable = new Map(); // sessionId -> { server, initialize, at }
|
|
|
1224
1259
|
const resuming = new Map(); // sessionId -> handshake promise, before any asynchronous admission
|
|
1225
1260
|
|
|
1226
1261
|
function saveResumable() {
|
|
1227
|
-
if (!RESUME_ENABLED
|
|
1262
|
+
if (!RESUME_ENABLED ||
|
|
1263
|
+
upgradeHeld) return;
|
|
1228
1264
|
try { writeFileSync(RESUME_FILE, JSON.stringify(Object.fromEntries(resumable))); } catch { /* best effort */ }
|
|
1229
1265
|
}
|
|
1230
1266
|
// Records past the TTL are dropped. Without this the window was only ever applied when the file
|
|
@@ -1376,6 +1412,7 @@ function ensureRecycleTimer() {
|
|
|
1376
1412
|
// Check often enough to honour the shortest configured period, but never busier than needed.
|
|
1377
1413
|
const tick = Math.max(200, Math.min(30_000, (Math.min(...periods) * 60_000) / 2));
|
|
1378
1414
|
recycleTimer = setInterval(() => {
|
|
1415
|
+
if (upgradeHeld) return;
|
|
1379
1416
|
const now = Date.now();
|
|
1380
1417
|
for (const name of Object.keys(servers)) {
|
|
1381
1418
|
const mins = recycleMinutesFor(name);
|
|
@@ -1515,6 +1552,7 @@ if (process.env.MCP_CONFIG_WATCH !== '0') {
|
|
|
1515
1552
|
watcher = watch(CONFIG, () => {
|
|
1516
1553
|
clearTimeout(timer);
|
|
1517
1554
|
timer = setTimeout(() => {
|
|
1555
|
+
if (upgradeHeld) { rearm(); return; }
|
|
1518
1556
|
poolingConfig.reloadSaved('file changed')
|
|
1519
1557
|
.catch(() => log('config watcher could not reload the saved configuration'))
|
|
1520
1558
|
.finally(rearm);
|
|
@@ -1540,12 +1578,14 @@ if (process.env.MCP_CONFIG_WATCH !== '0') {
|
|
|
1540
1578
|
const HEALTH_INTERVAL_MS = Number(process.env.MCP_HEALTH_INTERVAL_MS || 0);
|
|
1541
1579
|
|
|
1542
1580
|
function probeServer(name) {
|
|
1581
|
+
if (upgradeHeld) return;
|
|
1543
1582
|
const def = servers[name];
|
|
1544
1583
|
if (!def || def.type !== 'http' || !def.url) return;
|
|
1545
1584
|
// A server the client authenticates cannot be probed usefully: the bridge holds no credential
|
|
1546
1585
|
// for it, so every probe would 401 and report a working server as broken.
|
|
1547
1586
|
if (!resolveAuth(def) && !def.headers) return;
|
|
1548
1587
|
stat(name).lastProbe = Date.now();
|
|
1588
|
+
upgradeRequests++;
|
|
1549
1589
|
const body = JSON.stringify({
|
|
1550
1590
|
jsonrpc: '2.0', id: `probe-${Date.now()}`, method: 'initialize',
|
|
1551
1591
|
params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'mcp-pacemaker-health', version: VERSION } },
|
|
@@ -1568,7 +1608,8 @@ function probeServer(name) {
|
|
|
1568
1608
|
r.setTimeout(10_000, () => { r.destroy(new Error('timed out')); });
|
|
1569
1609
|
r.end(body);
|
|
1570
1610
|
});
|
|
1571
|
-
})().catch((e) => noteFailure(name, `health probe: ${e.message}`))
|
|
1611
|
+
})().catch((e) => noteFailure(name, `health probe: ${e.message}`))
|
|
1612
|
+
.finally(() => { upgradeRequests--; });
|
|
1572
1613
|
}
|
|
1573
1614
|
|
|
1574
1615
|
if (HEALTH_INTERVAL_MS > 0) {
|
|
@@ -1600,6 +1641,7 @@ if (TOKEN_REFRESH_LEAD_MS > 0) {
|
|
|
1600
1641
|
// Check often enough to act inside the lead window, but no busier than that requires.
|
|
1601
1642
|
const tick = Math.max(500, Math.min(60_000, TOKEN_REFRESH_LEAD_MS / 2));
|
|
1602
1643
|
setInterval(() => {
|
|
1644
|
+
if (upgradeHeld) return;
|
|
1603
1645
|
const now = Date.now();
|
|
1604
1646
|
for (const [name, def] of Object.entries(servers)) {
|
|
1605
1647
|
const key = authCacheKey(def);
|
|
@@ -2046,6 +2088,24 @@ function proxyHttp(name, def, req, res) {
|
|
|
2046
2088
|
const server = http.createServer((req, res) => {
|
|
2047
2089
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
2048
2090
|
const [name, kind] = url.pathname.split('/').filter(Boolean);
|
|
2091
|
+
const readOnlyStatus = (name === 'status' && !kind) ||
|
|
2092
|
+
(name === 'api' && kind === 'status');
|
|
2093
|
+
if (!readOnlyStatus) {
|
|
2094
|
+
if (upgradeHeld) {
|
|
2095
|
+
res.writeHead(503, { 'Retry-After': '2' }).end('Managed upgrade in progress; retry after the restart.');
|
|
2096
|
+
return;
|
|
2097
|
+
}
|
|
2098
|
+
if (!['api', 'ui', '.well-known'].includes(name)) {
|
|
2099
|
+
upgradeRequests++;
|
|
2100
|
+
upgradeTraffic = true;
|
|
2101
|
+
let finished = false;
|
|
2102
|
+
res.once('finish', () => { finished = true; });
|
|
2103
|
+
res.once('close', () => {
|
|
2104
|
+
upgradeRequests--;
|
|
2105
|
+
if (!finished) upgradeUncertain = true;
|
|
2106
|
+
});
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2049
2109
|
|
|
2050
2110
|
if (name === 'status' && !kind) {
|
|
2051
2111
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -2133,12 +2193,80 @@ server.keepAliveTimeout = 65_000;
|
|
|
2133
2193
|
server.headersTimeout = 70_000;
|
|
2134
2194
|
|
|
2135
2195
|
server.listen(PORT, HOST, () => {
|
|
2196
|
+
PORT = server.address().port;
|
|
2136
2197
|
try { writeFileSync(NONCE_FILE, ADMIN_NONCE); } catch { /* noop */ }
|
|
2137
2198
|
log(`listening on http://${HOST}:${PORT} (config ${CONFIG}, base cwd ${BASE_CWD})`);
|
|
2138
2199
|
log(`dashboard: http://${HOST}:${PORT}/ui · api /api/status|/api/events|/api/logs · admin nonce -> ${NONCE_FILE}`);
|
|
2139
2200
|
for (const [n, d] of Object.entries(servers)) {
|
|
2140
2201
|
log(` ${n} -> ${d.type === 'http' ? `http proxy ${d.url}` : `stdio /${n}/sse | /${n}/mcp`}`);
|
|
2141
2202
|
}
|
|
2203
|
+
if (process.connected) process.send({
|
|
2204
|
+
type: 'upgrade-ready', protocol: 1, port: PORT, version: VERSION, instanceId, held: upgradeHeld,
|
|
2205
|
+
lifetime: windowsLifetime,
|
|
2206
|
+
});
|
|
2207
|
+
});
|
|
2208
|
+
|
|
2209
|
+
function upgradeState() {
|
|
2210
|
+
const pooling = poolingConfig.upgradeState();
|
|
2211
|
+
const unsafe = upgradeUncertain ||
|
|
2212
|
+
pooling.unsafe ||
|
|
2213
|
+
hasPoolingTransaction(CONFIG);
|
|
2214
|
+
const busy = pooling.busy ||
|
|
2215
|
+
upgradeRequests > 0 ||
|
|
2216
|
+
sessions.size > 0 ||
|
|
2217
|
+
httpSessions.size > 0 ||
|
|
2218
|
+
resuming.size > 0 ||
|
|
2219
|
+
tokenInFlight.size > 0;
|
|
2220
|
+
return { unsafe, busy, configBusy: pooling.busy, held: upgradeHeld, traffic: upgradeTraffic, port: PORT, version: VERSION, instanceId };
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
let upgradeControlBusy = false;
|
|
2224
|
+
process.on('message', async (message) => {
|
|
2225
|
+
if (message?.type !== 'upgrade-control') return;
|
|
2226
|
+
const reply = (result) => {
|
|
2227
|
+
if (process.connected) process.send({ type: 'upgrade-result', requestId: message.requestId, ...result });
|
|
2228
|
+
};
|
|
2229
|
+
if (upgradeControlBusy) { reply({ ok: false, error: 'Admission operation already in progress.' }); return; }
|
|
2230
|
+
upgradeControlBusy = true;
|
|
2231
|
+
try {
|
|
2232
|
+
if (message.action === 'quiesce') {
|
|
2233
|
+
upgradeHeld = true;
|
|
2234
|
+
const deadline = Date.now() + Math.min(8000, Math.max(0, message.timeoutMs ?? 8000));
|
|
2235
|
+
while (upgradeState().busy &&
|
|
2236
|
+
!upgradeState().unsafe &&
|
|
2237
|
+
Date.now() < deadline) {
|
|
2238
|
+
await new Promise(resolveWait => setTimeout(resolveWait, 25));
|
|
2239
|
+
}
|
|
2240
|
+
const state = upgradeState();
|
|
2241
|
+
if (state.busy ||
|
|
2242
|
+
state.unsafe) {
|
|
2243
|
+
throw new Error('Active work or unresolved config transaction blocks upgrade. Settle work and retry; nothing was replayed.');
|
|
2244
|
+
}
|
|
2245
|
+
} else if (message.action === 'activate' ||
|
|
2246
|
+
message.action === 'resume') {
|
|
2247
|
+
if (upgradeState().unsafe) throw new Error('Unresolved config transaction prevents admission.');
|
|
2248
|
+
if (message.action === 'activate') {
|
|
2249
|
+
const files = new PoolingFiles(CONFIG);
|
|
2250
|
+
const sessions = existsSync(RESUME_FILE) ? files.inspect(RESUME_FILE) : null;
|
|
2251
|
+
if (!isDeepStrictEqual(files.inspect(CONFIG), message.expectedAuthority) ||
|
|
2252
|
+
!isDeepStrictEqual(sessions, message.expectedSessionState)) {
|
|
2253
|
+
throw new Error('Configuration or persisted session state changed before admission; the bridge remains held.');
|
|
2254
|
+
}
|
|
2255
|
+
} else {
|
|
2256
|
+
await poolingConfig.reloadSaved('upgrade resumed');
|
|
2257
|
+
}
|
|
2258
|
+
upgradeHeld = false;
|
|
2259
|
+
for (const name of Object.keys(servers)) refillPool(name);
|
|
2260
|
+
} else if (message.action !== 'inspect') {
|
|
2261
|
+
throw new Error('Unknown upgrade action.');
|
|
2262
|
+
}
|
|
2263
|
+
const authority = new PoolingFiles(CONFIG).inspect(CONFIG);
|
|
2264
|
+
reply({ ok: true, ...upgradeState(), authority });
|
|
2265
|
+
} catch (error) {
|
|
2266
|
+
reply({ ok: false, error: error.message, ...upgradeState() });
|
|
2267
|
+
} finally {
|
|
2268
|
+
upgradeControlBusy = false;
|
|
2269
|
+
}
|
|
2142
2270
|
});
|
|
2143
2271
|
|
|
2144
2272
|
// Ending the streams before exiting matters: a client that sees its SSE stream close cleanly
|
package/bin/pooling-batches.mjs
CHANGED
|
@@ -47,6 +47,19 @@ export class PoolingBatches {
|
|
|
47
47
|
};
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
upgradeState() {
|
|
51
|
+
const writer = this.#writer.upgradeState();
|
|
52
|
+
return {
|
|
53
|
+
busy: this.#queued > 0 ||
|
|
54
|
+
Boolean(this.#applying) ||
|
|
55
|
+
Boolean(this.#watchReload) ||
|
|
56
|
+
writer.pending > 0,
|
|
57
|
+
unsafe: writer.failed ||
|
|
58
|
+
Boolean(this.#pending) ||
|
|
59
|
+
[...this.#records.values()].some(record => !['applied', 'cancelled'].includes(record.status)),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
50
63
|
stage(request, options = {}) {
|
|
51
64
|
if (this.#closed) return Promise.reject(this.#closedError());
|
|
52
65
|
if (this.#queued >= 16) {
|
package/bin/pooling-writer.mjs
CHANGED
|
@@ -65,6 +65,10 @@ export class PoolingConfigWriter {
|
|
|
65
65
|
this.#configPath = configPath;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
upgradeState() {
|
|
69
|
+
return { pending: this.#pending.size, failed: Boolean(this.#failure) };
|
|
70
|
+
}
|
|
71
|
+
|
|
68
72
|
apply(request, options = {}) {
|
|
69
73
|
return this.#send('apply', request, options);
|
|
70
74
|
}
|
package/bin/prewarm.mjs
CHANGED
|
@@ -1,16 +1,7 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
-
|
|
5
|
-
function portNumber(value) {
|
|
6
|
-
const port = Number(value);
|
|
7
|
-
if (!Number.isInteger(port) ||
|
|
8
|
-
port < 1 ||
|
|
9
|
-
port > 65535) {
|
|
10
|
-
throw new Error('Port must be an integer between 1 and 65535');
|
|
11
|
-
}
|
|
12
|
-
return port;
|
|
13
|
-
}
|
|
4
|
+
import { canonicalPort } from './cli-selection.mjs';
|
|
14
5
|
|
|
15
6
|
async function jsonRequest(port, path, options = {}) {
|
|
16
7
|
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
|
@@ -118,25 +109,45 @@ function shellArgument(text) {
|
|
|
118
109
|
return `'${text.replaceAll("'", "'\\''")}'`;
|
|
119
110
|
}
|
|
120
111
|
|
|
121
|
-
|
|
112
|
+
function windowsArgument(value) {
|
|
113
|
+
let result = '"';
|
|
114
|
+
let slashes = 0;
|
|
115
|
+
for (const character of value) {
|
|
116
|
+
if (character === '\\') {
|
|
117
|
+
slashes++;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
result += '\\'.repeat(character === '"' ? slashes * 2 + 1 : slashes) + character;
|
|
121
|
+
slashes = 0;
|
|
122
|
+
}
|
|
123
|
+
return result + '\\'.repeat(slashes * 2) + '"';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function formatUndoCommand(port, name, undoId, config, instance) {
|
|
127
|
+
port = canonicalPort(port);
|
|
122
128
|
const executable = process.execPath;
|
|
123
129
|
const entry = fileURLToPath(new URL('./cli.mjs', import.meta.url));
|
|
124
|
-
const values = [executable, entry, name, undoId, ...(config ? [config] : [])];
|
|
130
|
+
const values = [executable, entry, name, undoId, ...(config ? [config] : []), ...(instance ? [instance] : [])];
|
|
125
131
|
const needsExplicitShell = process.platform === 'win32' &&
|
|
126
|
-
values.some((value) => /[%!$`"^\r\n]|\\$/.test(value));
|
|
132
|
+
(Boolean(instance) || values.some((value) => /[%!$`"^\r\n]|\\$/.test(value)));
|
|
127
133
|
if (needsExplicitShell) {
|
|
128
134
|
const quote = (value) => `'${value.replaceAll("'", "''")}'`;
|
|
129
|
-
const
|
|
130
|
-
|
|
135
|
+
const argumentsList = [entry, ...(instance ? ['--instance', instance] : []),
|
|
136
|
+
'prewarm', '--port', String(port), ...(config ? ['--config', config] : []), '--server', name, '--undo', undoId];
|
|
137
|
+
const commandLine = argumentsList.map(windowsArgument).join(' ');
|
|
138
|
+
const command = `$info = New-Object System.Diagnostics.ProcessStartInfo; $info.FileName = ${quote(executable)}; ` +
|
|
139
|
+
`$info.UseShellExecute = $false; $info.Arguments = ${quote(commandLine)}; ` +
|
|
140
|
+
'$child = [System.Diagnostics.Process]::Start($info); $child.WaitForExit(); exit $child.ExitCode';
|
|
131
141
|
return `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${Buffer.from(command, 'utf16le').toString('base64')}`;
|
|
132
142
|
}
|
|
133
143
|
const configArg = config ? ` --config ${shellArgument(config)}` : '';
|
|
144
|
+
const instanceArg = instance ? ` --instance ${shellArgument(instance)}` : '';
|
|
134
145
|
const node = process.platform === 'win32' ? 'node.exe' : shellArgument(executable);
|
|
135
|
-
return `${node} ${shellArgument(entry)} prewarm --port ${port}${configArg} --server ${shellArgument(name)} --undo ${undoId}`;
|
|
146
|
+
return `${node} ${shellArgument(entry)}${instanceArg} prewarm --port ${port}${configArg} --server ${shellArgument(name)} --undo ${instance ? shellArgument(undoId) : undoId}`;
|
|
136
147
|
}
|
|
137
148
|
|
|
138
|
-
export async function runPrewarm(options, { configPath, ports, write = console.log } = {}) {
|
|
139
|
-
const selectedPorts = options.port ? [
|
|
149
|
+
export async function runPrewarm(options, { configPath, ports, instance, write = console.log } = {}) {
|
|
150
|
+
const selectedPorts = options.port === undefined ? ports.map(canonicalPort) : [canonicalPort(options.port)];
|
|
140
151
|
const actionCount = [options.enable, options.disable, options.undo].filter(Boolean).length;
|
|
141
152
|
if (actionCount > 1) throw new Error('Choose only one of --enable, --disable or --undo');
|
|
142
153
|
if (actionCount &&
|
|
@@ -156,6 +167,7 @@ export async function runPrewarm(options, { configPath, ports, write = console.l
|
|
|
156
167
|
for (const port of selectedPorts) {
|
|
157
168
|
const { body: snapshot } = await jsonRequest(port, '/api/status');
|
|
158
169
|
if (snapshot.service !== 'mcp-pacemaker') throw new Error(`:${port} is not a pacemaker bridge`);
|
|
170
|
+
if (snapshot.port !== port) throw new Error('Bridge reported a different port; no admin nonce was sent.');
|
|
159
171
|
snapshots.push(snapshot);
|
|
160
172
|
}
|
|
161
173
|
|
|
@@ -194,7 +206,7 @@ export async function runPrewarm(options, { configPath, ports, write = console.l
|
|
|
194
206
|
typeof snapshot.prewarm.saveWarning === 'string') {
|
|
195
207
|
write(`File save notice: ${snapshot.prewarm.saveWarning}`);
|
|
196
208
|
}
|
|
197
|
-
const response = await jsonRequest(
|
|
209
|
+
const response = await jsonRequest(selectedPorts[0], `/admin/servers/${encodeURIComponent(name)}/pooling`, {
|
|
198
210
|
method: 'POST',
|
|
199
211
|
headers: { 'content-type': 'application/json', 'x-mcp-nonce': nonce, 'x-mcp-pooling-batch': '1' },
|
|
200
212
|
body: JSON.stringify(body),
|
|
@@ -220,14 +232,14 @@ export async function runPrewarm(options, { configPath, ports, write = console.l
|
|
|
220
232
|
else action = 'pre-warming disable staged';
|
|
221
233
|
write(`${name}: ${action}. Pending reload${timing}; not active yet.`);
|
|
222
234
|
if (result.undoId) {
|
|
223
|
-
write(`Cancel while pending / undo after apply (entire batch: ${scope}): ${formatUndoCommand(
|
|
235
|
+
write(`Cancel while pending / undo after apply (entire batch: ${scope}): ${formatUndoCommand(selectedPorts[0], name, result.undoId, options.config, instance)}`);
|
|
224
236
|
}
|
|
225
237
|
} else if (result.cancelled) {
|
|
226
238
|
write(`${name}: pending batch cancelled. Active settings were not changed.`);
|
|
227
239
|
} else {
|
|
228
240
|
write(`${name}: ${options.undo ? 'restored previous pooling settings' : body.mode === 'pool' ? `enabled ${body.minWarm} warm slot(s)` : 'disabled pre-warming'}`);
|
|
229
241
|
if (result.undoId) {
|
|
230
|
-
write(`Undo: ${formatUndoCommand(
|
|
242
|
+
write(`Undo: ${formatUndoCommand(selectedPorts[0], name, result.undoId, options.config, instance)}`);
|
|
231
243
|
}
|
|
232
244
|
}
|
|
233
245
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { closeSync, openSync, readSync } from 'node:fs';
|
|
2
|
+
import { requireOrdinaryUpgradeCaller } from './windows-task-channel.mjs';
|
|
3
|
+
|
|
4
|
+
export class UserRuntimeContext {
|
|
5
|
+
constructor({ host = process, linuxStatus, windowsCheck = requireOrdinaryUpgradeCaller } = {}) {
|
|
6
|
+
this.host = host;
|
|
7
|
+
this.linuxStatus = linuxStatus ?? (() => this.readLinuxStatus());
|
|
8
|
+
this.windowsCheck = windowsCheck;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
refuse(reason, code = 'USER_RUNTIME_CONTEXT_UNVERIFIED') {
|
|
12
|
+
throw Object.assign(new Error(`User-runtime execution refused: ${reason}. No identity or privilege change was attempted.`),
|
|
13
|
+
{ code });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
identity() {
|
|
17
|
+
const value = {};
|
|
18
|
+
for (const [name, method] of [['uid', 'getuid'], ['euid', 'geteuid'], ['gid', 'getgid'], ['egid', 'getegid']]) {
|
|
19
|
+
if (typeof this.host[method] !== 'function') this.refuse('effective identity is unavailable');
|
|
20
|
+
value[name] = this.host[method]();
|
|
21
|
+
if (!Number.isSafeInteger(value[name]) ||
|
|
22
|
+
value[name] < 0 ||
|
|
23
|
+
value[name] > 0xffffffff) this.refuse('effective identity is invalid');
|
|
24
|
+
}
|
|
25
|
+
if (value.uid === 0 ||
|
|
26
|
+
value.euid === 0) this.refuse("run this operation from the installation owner's non-root session", 'USER_RUNTIME_PRIVILEGED');
|
|
27
|
+
if (value.uid !== value.euid ||
|
|
28
|
+
value.gid !== value.egid) this.refuse('real and effective identities differ', 'USER_RUNTIME_PRIVILEGED');
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
readLinuxStatus() {
|
|
33
|
+
const buffer = Buffer.alloc(16385);
|
|
34
|
+
const file = openSync('/proc/self/status', 'r');
|
|
35
|
+
try {
|
|
36
|
+
let length = 0;
|
|
37
|
+
while (length < buffer.length) {
|
|
38
|
+
const read = readSync(file, buffer, length, buffer.length - length, null);
|
|
39
|
+
if (read === 0) break;
|
|
40
|
+
length += read;
|
|
41
|
+
}
|
|
42
|
+
if (length > 16384) this.refuse('Linux process status exceeds its bound');
|
|
43
|
+
return buffer.toString('utf8', 0, length);
|
|
44
|
+
} finally { closeSync(file); }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
capabilities(status, identity) {
|
|
48
|
+
if (typeof status !== 'string' ||
|
|
49
|
+
Buffer.byteLength(status) > 16384) this.refuse('Linux process status is invalid');
|
|
50
|
+
const lines = status.split('\n');
|
|
51
|
+
for (const [name, expected] of [['Uid', identity.uid], ['Gid', identity.gid]]) {
|
|
52
|
+
const selected = lines.filter(line => line.startsWith(`${name}:`));
|
|
53
|
+
if (selected.length !== 1) this.refuse(`Linux ${name} observation is unavailable`);
|
|
54
|
+
const fields = selected[0].slice(name.length + 1).trim().split(/\s+/);
|
|
55
|
+
if (fields.length !== 4 ||
|
|
56
|
+
fields.some(field => !/^(0|[1-9][0-9]*)$/.test(field) || Number(field) !== expected)) {
|
|
57
|
+
this.refuse(`Linux real, effective, saved or filesystem ${name} differs`, 'USER_RUNTIME_PRIVILEGED');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const capabilities = {};
|
|
61
|
+
for (const name of ['CapInh', 'CapPrm', 'CapEff', 'CapAmb']) {
|
|
62
|
+
const selected = lines.filter(line => line.startsWith(`${name}:`));
|
|
63
|
+
if (selected.length !== 1) this.refuse(`Linux ${name} observation is unavailable`);
|
|
64
|
+
const value = selected[0].slice(name.length + 1).trim();
|
|
65
|
+
if (!/^[0-9a-fA-F]{16}$/.test(value)) this.refuse(`Linux ${name} observation is invalid`);
|
|
66
|
+
if (BigInt(`0x${value}`) !== 0n) this.refuse(`Linux ${name} contains privileges`, 'USER_RUNTIME_PRIVILEGED');
|
|
67
|
+
capabilities[name] = value.toLowerCase();
|
|
68
|
+
}
|
|
69
|
+
return capabilities;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async require() {
|
|
73
|
+
if (this.host.platform === 'win32') {
|
|
74
|
+
const proof = await this.windowsCheck();
|
|
75
|
+
if (proof?.ordinaryEligible !== true ||
|
|
76
|
+
proof.proofScope !== 'cli-effective-context-snapshot') this.refuse('Windows caller proof is unavailable');
|
|
77
|
+
return { platform: 'win32', proofScope: 'user-runtime-context-snapshot', ordinary: true,
|
|
78
|
+
native: proof, atomicFutureProtection: false };
|
|
79
|
+
}
|
|
80
|
+
if (!['linux', 'darwin'].includes(this.host.platform)) this.refuse('this platform has no supported identity observer');
|
|
81
|
+
const before = this.identity();
|
|
82
|
+
let capabilities;
|
|
83
|
+
if (this.host.platform === 'linux') {
|
|
84
|
+
try { capabilities = this.capabilities(this.linuxStatus(), before); }
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (error.code?.startsWith('USER_RUNTIME_')) throw error;
|
|
87
|
+
throw Object.assign(new Error('User-runtime execution refused: Linux process status could not be read.', { cause: error }),
|
|
88
|
+
{ code: 'USER_RUNTIME_CONTEXT_UNVERIFIED' });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const after = this.identity();
|
|
92
|
+
if (Object.keys(before).some(name => before[name] !== after[name])) this.refuse('effective identity changed during observation');
|
|
93
|
+
return { platform: this.host.platform, proofScope: 'user-runtime-context-snapshot', ordinary: true,
|
|
94
|
+
identity: after, ...(capabilities ? { capabilities } : {}), atomicFutureProtection: false };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function requireUserRuntimeContext() {
|
|
99
|
+
return new UserRuntimeContext().require();
|
|
100
|
+
}
|
package/bin/service-control.mjs
CHANGED
|
@@ -21,6 +21,7 @@ export function servicePaths(config, port) {
|
|
|
21
21
|
return {
|
|
22
22
|
record: join(directory, `service-${port}.json`),
|
|
23
23
|
hold: join(directory, `service-${port}.stopped`),
|
|
24
|
+
admissionHold: join(directory, `service-${port}.admission-held`),
|
|
24
25
|
socket,
|
|
25
26
|
};
|
|
26
27
|
}
|
|
@@ -90,7 +91,7 @@ export function requestService(socket, request) {
|
|
|
90
91
|
});
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
export async function stopManagedService({ root, config, port, request = requestService }) {
|
|
94
|
+
export async function stopManagedService({ root, config, port, quiescedInstanceId, request = requestService }) {
|
|
94
95
|
const paths = servicePaths(config, port);
|
|
95
96
|
let record;
|
|
96
97
|
try {
|
|
@@ -99,6 +100,7 @@ export async function stopManagedService({ root, config, port, request = request
|
|
|
99
100
|
if (error.code !== 'ENOENT') throw error;
|
|
100
101
|
throw new Error(`No managed supervisor record for :${port}. Older or direct bridges require verified OS-service shutdown and disabled autostart; no process was stopped.`);
|
|
101
102
|
}
|
|
103
|
+
|
|
102
104
|
validateServiceRecord(record, { root, config, port });
|
|
103
105
|
let receipt;
|
|
104
106
|
try {
|
|
@@ -110,15 +112,36 @@ export async function stopManagedService({ root, config, port, request = request
|
|
|
110
112
|
receipt.id === record.id &&
|
|
111
113
|
receipt.root === record.root &&
|
|
112
114
|
receipt.config === record.config &&
|
|
113
|
-
receipt.port === record.port
|
|
115
|
+
receipt.port === record.port &&
|
|
116
|
+
(quiescedInstanceId === undefined || receipt.quiescedInstanceId === quiescedInstanceId);
|
|
114
117
|
if (completed) {
|
|
115
118
|
return { root: record.root, config: record.config, port, supervisorId: record.id, alreadyStopped: true };
|
|
116
119
|
}
|
|
117
|
-
const response = await request(paths.socket, { action: 'stop', id: record.id, token: record.token
|
|
120
|
+
const response = await request(paths.socket, { action: 'stop', id: record.id, token: record.token,
|
|
121
|
+
...(quiescedInstanceId ? { quiescedInstanceId } : {}) });
|
|
118
122
|
if (response?.id !== record.id ||
|
|
119
123
|
response.stopped !== true ||
|
|
120
|
-
response.autostartHeld !== true
|
|
124
|
+
response.autostartHeld !== true ||
|
|
125
|
+
(quiescedInstanceId !== undefined && response.quiescedInstanceId !== quiescedInstanceId)) {
|
|
121
126
|
throw new Error('Supervisor did not verify shutdown and its autostart hold. Do not replace the installation root.');
|
|
122
127
|
}
|
|
123
128
|
return { root: record.root, config: record.config, port, supervisorId: record.id };
|
|
124
129
|
}
|
|
130
|
+
|
|
131
|
+
export async function controlManagedService({ root, config, port }, action, expected = {}) {
|
|
132
|
+
const record = validateServiceRecord(
|
|
133
|
+
JSON.parse(readFileSync(servicePaths(config, port).record, 'utf8')), { root, config, port });
|
|
134
|
+
if (record.upgradeProtocol !== 1) {
|
|
135
|
+
throw new Error('This supervisor has no managed admission protocol. Legacy migration requires exact OS-service inspection; no process was stopped.');
|
|
136
|
+
}
|
|
137
|
+
const response = await requestService(record.socket, {
|
|
138
|
+
id: record.id, token: record.token, action,
|
|
139
|
+
expectedAuthority: expected.authority, expectedSessionState: expected.sessionState,
|
|
140
|
+
});
|
|
141
|
+
if (response.id !== record.id ||
|
|
142
|
+
response.ok !== true ||
|
|
143
|
+
response.instanceId !== record.instanceId ||
|
|
144
|
+
response.version !== record.version ||
|
|
145
|
+
response.port !== port) throw new Error(response.error || 'Managed bridge identity/admission could not be verified.');
|
|
146
|
+
return response;
|
|
147
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { fork } from 'node:child_process';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { durableJson, loadInstance, readManagedJson } from './managed-state.mjs';
|
|
7
|
+
|
|
8
|
+
const directory = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const instance = loadInstance(directory);
|
|
10
|
+
let boundPort = instance.port;
|
|
11
|
+
const journalPath = join(directory, 'journal.json');
|
|
12
|
+
if (existsSync(journalPath)) {
|
|
13
|
+
const journal = readManagedJson(journalPath);
|
|
14
|
+
const legacyStart = journal.phase === 'legacy-launching' &&
|
|
15
|
+
journal.legacy?.protocol === 1 &&
|
|
16
|
+
journal.legacy.acknowledged === 'restart-with-uncertain-http-and-partial-tree-v1';
|
|
17
|
+
const authorizedStart = (['launching', 'rollback-launching'].includes(journal.phase) || legacyStart) &&
|
|
18
|
+
journal.launchToken === process.env.MCP_PACEMAKER_UPGRADE_TOKEN &&
|
|
19
|
+
process.env.MCP_PACEMAKER_UPGRADE_PENDING === '1';
|
|
20
|
+
if (!['committed', 'rolled-back', 'aborted'].includes(journal.phase) &&
|
|
21
|
+
!authorizedStart) {
|
|
22
|
+
throw new Error('Upgrade recovery required; stable autostart remains held. Run upgrade --recover --instance <directory>.');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (existsSync(join(directory, 'stopped'))) {
|
|
26
|
+
console.log('Managed instance is held stopped. Use an explicit start.');
|
|
27
|
+
} else {
|
|
28
|
+
const child = fork(join(instance.active.root, 'supervisor', 'supervise.mjs'), [
|
|
29
|
+
'--port', String(instance.port), '--config', instance.config, '--cwd', instance.cwd,
|
|
30
|
+
], {
|
|
31
|
+
execPath: instance.node, execArgv: [], windowsHide: true,
|
|
32
|
+
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
|
|
33
|
+
env: { ...process.env, MCP_PACEMAKER_MANAGED_INSTANCE: directory },
|
|
34
|
+
});
|
|
35
|
+
child.on('message', (message) => {
|
|
36
|
+
if (message?.type === 'supervisor-ready' &&
|
|
37
|
+
boundPort === 0) {
|
|
38
|
+
const current = readManagedJson(join(directory, 'instance.json'));
|
|
39
|
+
if (current.id !== instance.id ||
|
|
40
|
+
current.port !== 0) throw new Error('Managed port identity changed during allocation.');
|
|
41
|
+
durableJson(join(directory, 'instance.json'), { ...current, port: message.port });
|
|
42
|
+
boundPort = message.port;
|
|
43
|
+
}
|
|
44
|
+
if (message?.type === 'supervisor-ready' &&
|
|
45
|
+
message.port !== boundPort) throw new Error('Managed restart changed the persisted listener port.');
|
|
46
|
+
if (process.connected) process.send(message);
|
|
47
|
+
});
|
|
48
|
+
process.on('message', (message) => { if (child.connected) child.send(message); });
|
|
49
|
+
const shutdown = () => { if (child.connected) child.send('stop'); };
|
|
50
|
+
process.on('SIGTERM', shutdown);
|
|
51
|
+
process.on('SIGINT', shutdown);
|
|
52
|
+
child.on('error', (error) => { console.error(error.message); process.exitCode = 1; });
|
|
53
|
+
child.on('exit', (code) => { process.exitCode = code ?? 1; if (process.connected) process.disconnect(); });
|
|
54
|
+
}
|