mcp-pacemaker 2.0.2 → 2.1.1

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.
Files changed (90) hide show
  1. package/CHANGELOG.md +171 -1
  2. package/CONTRIBUTING.md +46 -13
  3. package/README.md +109 -524
  4. package/THIRD_PARTY_NOTICES.txt +1 -28
  5. package/autostart/windows/account-task.ps1 +167 -0
  6. package/autostart/windows/inspect-task.ps1 +42 -0
  7. package/autostart/windows/inspect-worker-process-sessions.ps1 +155 -0
  8. package/autostart/windows/legacy-access.ps1 +359 -0
  9. package/autostart/windows/legacy-task.ps1 +301 -0
  10. package/autostart/windows/register-task.ps1 +11 -2
  11. package/bin/account-task-adapter.mjs +47 -0
  12. package/bin/account-upgrade-controller.mjs +198 -0
  13. package/bin/account-upgrade-worker.mjs +141 -0
  14. package/bin/account-worker-session.mjs +215 -0
  15. package/bin/cli-dispatch.mjs +154 -0
  16. package/bin/cli-main.mjs +1063 -0
  17. package/bin/cli-selection.mjs +43 -0
  18. package/bin/cli.mjs +7 -876
  19. package/bin/dependency-lock.json +642 -0
  20. package/bin/legacy-1.3.json +32 -0
  21. package/bin/legacy-access.mjs +73 -0
  22. package/bin/legacy-installation.mjs +273 -0
  23. package/bin/legacy-task.mjs +76 -0
  24. package/bin/legacy-upgrade.mjs +527 -0
  25. package/bin/managed-runtime.mjs +170 -0
  26. package/bin/managed-state.mjs +257 -0
  27. package/bin/managed-upgrade.mjs +581 -0
  28. package/bin/mcp-bridge.mjs +150 -4
  29. package/bin/pooling-batches.mjs +13 -0
  30. package/bin/pooling-writer.mjs +4 -0
  31. package/bin/prewarm.mjs +59 -25
  32. package/bin/runtime-context.mjs +100 -0
  33. package/bin/service-control.mjs +32 -4
  34. package/bin/stable-launcher.mjs +54 -0
  35. package/bin/task-only-controller.mjs +226 -0
  36. package/bin/task-scope.mjs +65 -0
  37. package/bin/task-transaction-protocol.mjs +173 -0
  38. package/bin/top.mjs +22 -10
  39. package/bin/update-check.mjs +13 -2
  40. package/bin/upgrade-autostart.mjs +47 -0
  41. package/bin/upgrade-capability.json +9 -0
  42. package/bin/upgrade-dependencies.mjs +183 -0
  43. package/bin/upgrade-package.mjs +205 -0
  44. package/bin/windows-legacy/LegacyProcessBroker.build.json +42 -0
  45. package/bin/windows-legacy/LegacyProcessBroker.exe +0 -0
  46. package/bin/windows-legacy/src/AssemblyInfo.cs +8 -0
  47. package/bin/windows-legacy/src/LegacyNative.cs +832 -0
  48. package/bin/windows-legacy/src/LegacyProcessBroker.cs +961 -0
  49. package/bin/windows-legacy-process.mjs +248 -0
  50. package/bin/windows-lifetime/ProcessLifetimeHelper.build.json +38 -0
  51. package/bin/windows-lifetime/ProcessLifetimeHelper.exe +0 -0
  52. package/bin/windows-lifetime/src/AssemblyInfo.cs +8 -0
  53. package/bin/windows-lifetime/src/ProcessLifetimeHelper.cs +468 -0
  54. package/bin/windows-process-lifetime.mjs +218 -0
  55. package/bin/windows-task-channel/TaskChannelGuard.build.json +45 -0
  56. package/bin/windows-task-channel/TaskChannelGuard.exe +0 -0
  57. package/bin/windows-task-channel/src/AssemblyInfo.cs +8 -0
  58. package/bin/windows-task-channel/src/ChannelCliContext.cs +210 -0
  59. package/bin/windows-task-channel/src/ChannelFiles.cs +231 -0
  60. package/bin/windows-task-channel/src/ChannelNative.cs +477 -0
  61. package/bin/windows-task-channel/src/ChannelSession.cs +203 -0
  62. package/bin/windows-task-channel/src/TaskChannelGuard.cs +686 -0
  63. package/bin/windows-task-channel.mjs +335 -0
  64. package/docs/account-upgrades.md +218 -0
  65. package/docs/compatibility.md +39 -0
  66. package/docs/configuration.md +146 -0
  67. package/docs/dashboards.md +125 -0
  68. package/docs/execution-context.md +119 -0
  69. package/docs/http-api.md +47 -0
  70. package/docs/install.md +52 -0
  71. package/docs/operations.md +99 -0
  72. package/docs/upgrades.md +324 -0
  73. package/package.json +19 -5
  74. package/supervisor/supervise.mjs +226 -40
  75. package/tools/account-upgrade/qualify-two-account.ps1 +66 -0
  76. package/tools/windows-legacy/README.md +251 -0
  77. package/tools/windows-legacy/build.ps1 +122 -0
  78. package/tools/windows-legacy/inventory.mjs +92 -0
  79. package/tools/windows-process-lifetime/README.md +119 -0
  80. package/tools/windows-process-lifetime/build.ps1 +122 -0
  81. package/tools/windows-process-lifetime/inventory.mjs +86 -0
  82. package/tools/windows-task-channel/README.md +329 -0
  83. package/tools/windows-task-channel/build.ps1 +107 -0
  84. package/tools/windows-task-channel/inventory.mjs +117 -0
  85. package/ui/dist/THIRD_PARTY_NOTICES.txt +1 -28
  86. package/ui/dist/assets/{index-CgPruWYi.js → index-CcwMo2sI.js} +1 -1
  87. package/ui/dist/assets/index-PVcuDNrN.css +1 -0
  88. package/ui/dist/index.html +2 -2
  89. package/ui/dist/third-party-manifest.json +37 -25
  90. package/ui/dist/assets/index-CaLPg0c-.css +0 -1
@@ -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
- const PORT = parseInt(getArg('--port', '8791'), 10);
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 upgradeCalls = 0;
84
+ let upgradeTraffic = 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)) recoverPoolingConfig(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,
@@ -416,6 +450,13 @@ function handlePoolingChange(encodedName, req, res) {
416
450
  trace?.record('body-end', { bytes });
417
451
  clearTimeout(bodyTimer);
418
452
  if (rejected) return;
453
+ // A body that arrives after an upgrade closed admission must not start config work while
454
+ // the bridge is being stopped.
455
+ if (upgradeHeld ||
456
+ shuttingDown) {
457
+ reply(503, { error: 'Managed upgrade in progress; no change was submitted.' });
458
+ return;
459
+ }
419
460
  let body;
420
461
  let name;
421
462
  try {
@@ -1159,6 +1200,7 @@ function spawnWarm(name) {
1159
1200
  return child;
1160
1201
  }
1161
1202
  function refillPool(name) {
1203
+ if (upgradeHeld) return;
1162
1204
  const target = poolTarget(name);
1163
1205
  if (!target ||
1164
1206
  shuttingDown) {
@@ -1224,7 +1266,8 @@ const resumable = new Map(); // sessionId -> { server, initialize, at }
1224
1266
  const resuming = new Map(); // sessionId -> handshake promise, before any asynchronous admission
1225
1267
 
1226
1268
  function saveResumable() {
1227
- if (!RESUME_ENABLED) return;
1269
+ if (!RESUME_ENABLED ||
1270
+ upgradeHeld) return;
1228
1271
  try { writeFileSync(RESUME_FILE, JSON.stringify(Object.fromEntries(resumable))); } catch { /* best effort */ }
1229
1272
  }
1230
1273
  // Records past the TTL are dropped. Without this the window was only ever applied when the file
@@ -1376,6 +1419,7 @@ function ensureRecycleTimer() {
1376
1419
  // Check often enough to honour the shortest configured period, but never busier than needed.
1377
1420
  const tick = Math.max(200, Math.min(30_000, (Math.min(...periods) * 60_000) / 2));
1378
1421
  recycleTimer = setInterval(() => {
1422
+ if (upgradeHeld) return;
1379
1423
  const now = Date.now();
1380
1424
  for (const name of Object.keys(servers)) {
1381
1425
  const mins = recycleMinutesFor(name);
@@ -1515,6 +1559,7 @@ if (process.env.MCP_CONFIG_WATCH !== '0') {
1515
1559
  watcher = watch(CONFIG, () => {
1516
1560
  clearTimeout(timer);
1517
1561
  timer = setTimeout(() => {
1562
+ if (upgradeHeld) { rearm(); return; }
1518
1563
  poolingConfig.reloadSaved('file changed')
1519
1564
  .catch(() => log('config watcher could not reload the saved configuration'))
1520
1565
  .finally(rearm);
@@ -1540,12 +1585,14 @@ if (process.env.MCP_CONFIG_WATCH !== '0') {
1540
1585
  const HEALTH_INTERVAL_MS = Number(process.env.MCP_HEALTH_INTERVAL_MS || 0);
1541
1586
 
1542
1587
  function probeServer(name) {
1588
+ if (upgradeHeld) return;
1543
1589
  const def = servers[name];
1544
1590
  if (!def || def.type !== 'http' || !def.url) return;
1545
1591
  // A server the client authenticates cannot be probed usefully: the bridge holds no credential
1546
1592
  // for it, so every probe would 401 and report a working server as broken.
1547
1593
  if (!resolveAuth(def) && !def.headers) return;
1548
1594
  stat(name).lastProbe = Date.now();
1595
+ upgradeRequests++;
1549
1596
  const body = JSON.stringify({
1550
1597
  jsonrpc: '2.0', id: `probe-${Date.now()}`, method: 'initialize',
1551
1598
  params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'mcp-pacemaker-health', version: VERSION } },
@@ -1568,7 +1615,8 @@ function probeServer(name) {
1568
1615
  r.setTimeout(10_000, () => { r.destroy(new Error('timed out')); });
1569
1616
  r.end(body);
1570
1617
  });
1571
- })().catch((e) => noteFailure(name, `health probe: ${e.message}`));
1618
+ })().catch((e) => noteFailure(name, `health probe: ${e.message}`))
1619
+ .finally(() => { upgradeRequests--; });
1572
1620
  }
1573
1621
 
1574
1622
  if (HEALTH_INTERVAL_MS > 0) {
@@ -1600,6 +1648,7 @@ if (TOKEN_REFRESH_LEAD_MS > 0) {
1600
1648
  // Check often enough to act inside the lead window, but no busier than that requires.
1601
1649
  const tick = Math.max(500, Math.min(60_000, TOKEN_REFRESH_LEAD_MS / 2));
1602
1650
  setInterval(() => {
1651
+ if (upgradeHeld) return;
1603
1652
  const now = Date.now();
1604
1653
  for (const [name, def] of Object.entries(servers)) {
1605
1654
  const key = authCacheKey(def);
@@ -2046,6 +2095,25 @@ function proxyHttp(name, def, req, res) {
2046
2095
  const server = http.createServer((req, res) => {
2047
2096
  const url = new URL(req.url, `http://${req.headers.host}`);
2048
2097
  const [name, kind] = url.pathname.split('/').filter(Boolean);
2098
+ const readOnlyStatus = (name === 'status' && !kind) ||
2099
+ (name === 'api' && kind === 'status');
2100
+ if (!readOnlyStatus) {
2101
+ if (upgradeHeld) {
2102
+ res.writeHead(503, { 'Retry-After': '2' }).end('Managed upgrade in progress; retry after the restart.');
2103
+ return;
2104
+ }
2105
+ if (!['api', 'ui', '.well-known'].includes(name)) {
2106
+ // A GET is a listening stream that lasts as long as its session; anything else is a call.
2107
+ const call = req.method !== 'GET';
2108
+ upgradeRequests++;
2109
+ if (call) upgradeCalls++;
2110
+ upgradeTraffic = true;
2111
+ res.once('close', () => {
2112
+ upgradeRequests--;
2113
+ if (call) upgradeCalls--;
2114
+ });
2115
+ }
2116
+ }
2049
2117
 
2050
2118
  if (name === 'status' && !kind) {
2051
2119
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -2133,12 +2201,90 @@ server.keepAliveTimeout = 65_000;
2133
2201
  server.headersTimeout = 70_000;
2134
2202
 
2135
2203
  server.listen(PORT, HOST, () => {
2204
+ PORT = server.address().port;
2136
2205
  try { writeFileSync(NONCE_FILE, ADMIN_NONCE); } catch { /* noop */ }
2137
2206
  log(`listening on http://${HOST}:${PORT} (config ${CONFIG}, base cwd ${BASE_CWD})`);
2138
2207
  log(`dashboard: http://${HOST}:${PORT}/ui · api /api/status|/api/events|/api/logs · admin nonce -> ${NONCE_FILE}`);
2139
2208
  for (const [n, d] of Object.entries(servers)) {
2140
2209
  log(` ${n} -> ${d.type === 'http' ? `http proxy ${d.url}` : `stdio /${n}/sse | /${n}/mcp`}`);
2141
2210
  }
2211
+ if (process.connected) process.send({
2212
+ type: 'upgrade-ready', protocol: 1, port: PORT, version: VERSION, instanceId, held: upgradeHeld,
2213
+ lifetime: windowsLifetime,
2214
+ });
2215
+ });
2216
+
2217
+ function upgradeState() {
2218
+ const pooling = poolingConfig.upgradeState();
2219
+ const unsafe = pooling.unsafe ||
2220
+ hasPoolingTransaction(CONFIG);
2221
+ const calls = upgradeCalls > 0 ||
2222
+ resuming.size > 0 ||
2223
+ tokenInFlight.size > 0;
2224
+ const busy = pooling.busy ||
2225
+ calls ||
2226
+ upgradeRequests > 0 ||
2227
+ sessions.size > 0 ||
2228
+ httpSessions.size > 0;
2229
+ return { unsafe, busy, calls, configBusy: pooling.busy, held: upgradeHeld, traffic: upgradeTraffic, port: PORT, version: VERSION, instanceId };
2230
+ }
2231
+
2232
+ // Quiesce waits for calls and config work, never for open sessions. A staged pooling batch
2233
+ // still commits on its own timer while admission is held.
2234
+ function draining() {
2235
+ const state = upgradeState();
2236
+ return state.unsafe ||
2237
+ state.configBusy ||
2238
+ state.calls;
2239
+ }
2240
+
2241
+ let upgradeControlBusy = false;
2242
+ process.on('message', async (message) => {
2243
+ if (message?.type !== 'upgrade-control') return;
2244
+ const reply = (result) => {
2245
+ if (process.connected) process.send({ type: 'upgrade-result', requestId: message.requestId, ...result });
2246
+ };
2247
+ if (upgradeControlBusy) { reply({ ok: false, error: 'Admission operation already in progress.' }); return; }
2248
+ upgradeControlBusy = true;
2249
+ try {
2250
+ if (message.action === 'quiesce') {
2251
+ upgradeHeld = true;
2252
+ // Admission is closed now. Calls in progress and config work get up to eight seconds;
2253
+ // calls still running at the restart end with their sessions. Config work that is still
2254
+ // unfinished blocks the upgrade, because an upgrade must never change servers.json.
2255
+ const deadline = Date.now() + Math.min(8000, Math.max(0, message.timeoutMs ?? 8000));
2256
+ while (draining() &&
2257
+ Date.now() < deadline) {
2258
+ await new Promise(resolveWait => setTimeout(resolveWait, 25));
2259
+ }
2260
+ const state = upgradeState();
2261
+ if (state.unsafe ||
2262
+ state.configBusy) {
2263
+ throw new Error('Unfinished config work blocks upgrade. Let it finish and retry; nothing was replayed.');
2264
+ }
2265
+ } else if (message.action === 'activate' ||
2266
+ message.action === 'resume') {
2267
+ if (upgradeState().unsafe) throw new Error('Unresolved config transaction prevents admission.');
2268
+ if (message.action === 'activate') {
2269
+ // Session records may change while the old version runs; only the config must match.
2270
+ if (!isDeepStrictEqual(new PoolingFiles(CONFIG).inspect(CONFIG), message.expectedAuthority)) {
2271
+ throw new Error('Configuration changed before admission; the bridge remains held.');
2272
+ }
2273
+ } else {
2274
+ await poolingConfig.reloadSaved('upgrade resumed');
2275
+ }
2276
+ upgradeHeld = false;
2277
+ for (const name of Object.keys(servers)) refillPool(name);
2278
+ } else if (message.action !== 'inspect') {
2279
+ throw new Error('Unknown upgrade action.');
2280
+ }
2281
+ const authority = new PoolingFiles(CONFIG).inspect(CONFIG);
2282
+ reply({ ok: true, ...upgradeState(), authority });
2283
+ } catch (error) {
2284
+ reply({ ok: false, error: error.message, ...upgradeState() });
2285
+ } finally {
2286
+ upgradeControlBusy = false;
2287
+ }
2142
2288
  });
2143
2289
 
2144
2290
  // Ending the streams before exiting matters: a client that sees its SSE stream close cleanly
@@ -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) {
@@ -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,32 +1,45 @@
1
1
  import { readFile } from 'node:fs/promises';
2
+ import { request } from 'node:http';
2
3
  import { dirname, join } from 'node:path';
4
+ import { text as readText } from 'node:stream/consumers';
3
5
  import { fileURLToPath } from 'node:url';
6
+ import { canonicalPort } from './cli-selection.mjs';
4
7
 
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;
8
+ const REQUEST_TIMEOUT_MS = 10_000;
9
+
10
+ // node:http, not fetch: on Windows, Node 23, 24 before 24.20.0, 25, and 26 before 26.7.0 can abort
11
+ // with a libuv assertion when the CLI exits soon after fetch (nodejs/node#56645).
12
+ async function sendRequest(port, path, { method = 'GET', headers = {}, body } = {}) {
13
+ const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
14
+ try {
15
+ const response = await new Promise((resolve, reject) => {
16
+ const outgoing = request({ hostname: '127.0.0.1', port, path, method, headers, signal }, resolve);
17
+ outgoing.on('error', reject);
18
+ outgoing.end(body);
19
+ });
20
+ return { status: response.statusCode, text: await readText(response) };
21
+ } catch (error) {
22
+ if (signal.aborted) {
23
+ const advice = method === 'GET' ? '' : ' The change may still apply; reread settings before retrying.';
24
+ throw new Error(`Bridge on :${port} did not answer within ${REQUEST_TIMEOUT_MS / 1000}s.${advice}`);
25
+ }
26
+ throw error;
27
+ }
13
28
  }
14
29
 
15
30
  async function jsonRequest(port, path, options = {}) {
16
- const response = await fetch(`http://127.0.0.1:${port}${path}`, {
17
- ...options,
18
- signal: AbortSignal.timeout(10_000),
19
- });
31
+ const response = await sendRequest(port, path, options);
20
32
  if (response.status === 401) {
21
33
  throw new Error('Admin nonce was rejected. Check --config points to the running bridge, then retry.');
22
34
  }
23
35
  let result;
24
36
  try {
25
- result = await response.json();
37
+ result = JSON.parse(response.text);
26
38
  } catch {
27
39
  throw new Error(`Bridge on :${port} returned a non-JSON response (${response.status})`);
28
40
  }
29
- if (!response.ok) {
41
+ const succeeded = response.status >= 200 && response.status <= 299;
42
+ if (!succeeded) {
30
43
  throw new Error(result.error || `Bridge on :${port} rejected the request (${response.status})`);
31
44
  }
32
45
  return { status: response.status, body: result };
@@ -118,25 +131,45 @@ function shellArgument(text) {
118
131
  return `'${text.replaceAll("'", "'\\''")}'`;
119
132
  }
120
133
 
121
- export function formatUndoCommand(port, name, undoId, config) {
134
+ function windowsArgument(value) {
135
+ let result = '"';
136
+ let slashes = 0;
137
+ for (const character of value) {
138
+ if (character === '\\') {
139
+ slashes++;
140
+ continue;
141
+ }
142
+ result += '\\'.repeat(character === '"' ? slashes * 2 + 1 : slashes) + character;
143
+ slashes = 0;
144
+ }
145
+ return result + '\\'.repeat(slashes * 2) + '"';
146
+ }
147
+
148
+ export function formatUndoCommand(port, name, undoId, config, instance) {
149
+ port = canonicalPort(port);
122
150
  const executable = process.execPath;
123
151
  const entry = fileURLToPath(new URL('./cli.mjs', import.meta.url));
124
- const values = [executable, entry, name, undoId, ...(config ? [config] : [])];
152
+ const values = [executable, entry, name, undoId, ...(config ? [config] : []), ...(instance ? [instance] : [])];
125
153
  const needsExplicitShell = process.platform === 'win32' &&
126
- values.some((value) => /[%!$`"^\r\n]|\\$/.test(value));
154
+ (Boolean(instance) || values.some((value) => /[%!$`"^\r\n]|\\$/.test(value)));
127
155
  if (needsExplicitShell) {
128
156
  const quote = (value) => `'${value.replaceAll("'", "''")}'`;
129
- const configArg = config ? ` --config ${quote(config)}` : '';
130
- const command = `& ${quote(executable)} ${quote(entry)} prewarm --port ${port}${configArg} --server ${quote(name)} --undo ${quote(undoId)}`;
157
+ const argumentsList = [entry, ...(instance ? ['--instance', instance] : []),
158
+ 'prewarm', '--port', String(port), ...(config ? ['--config', config] : []), '--server', name, '--undo', undoId];
159
+ const commandLine = argumentsList.map(windowsArgument).join(' ');
160
+ const command = `$info = New-Object System.Diagnostics.ProcessStartInfo; $info.FileName = ${quote(executable)}; ` +
161
+ `$info.UseShellExecute = $false; $info.Arguments = ${quote(commandLine)}; ` +
162
+ '$child = [System.Diagnostics.Process]::Start($info); $child.WaitForExit(); exit $child.ExitCode';
131
163
  return `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${Buffer.from(command, 'utf16le').toString('base64')}`;
132
164
  }
133
165
  const configArg = config ? ` --config ${shellArgument(config)}` : '';
166
+ const instanceArg = instance ? ` --instance ${shellArgument(instance)}` : '';
134
167
  const node = process.platform === 'win32' ? 'node.exe' : shellArgument(executable);
135
- return `${node} ${shellArgument(entry)} prewarm --port ${port}${configArg} --server ${shellArgument(name)} --undo ${undoId}`;
168
+ return `${node} ${shellArgument(entry)}${instanceArg} prewarm --port ${port}${configArg} --server ${shellArgument(name)} --undo ${instance ? shellArgument(undoId) : undoId}`;
136
169
  }
137
170
 
138
- export async function runPrewarm(options, { configPath, ports, write = console.log } = {}) {
139
- const selectedPorts = options.port ? [portNumber(options.port)] : ports.map(portNumber);
171
+ export async function runPrewarm(options, { configPath, ports, instance, write = console.log } = {}) {
172
+ const selectedPorts = options.port === undefined ? ports.map(canonicalPort) : [canonicalPort(options.port)];
140
173
  const actionCount = [options.enable, options.disable, options.undo].filter(Boolean).length;
141
174
  if (actionCount > 1) throw new Error('Choose only one of --enable, --disable or --undo');
142
175
  if (actionCount &&
@@ -156,6 +189,7 @@ export async function runPrewarm(options, { configPath, ports, write = console.l
156
189
  for (const port of selectedPorts) {
157
190
  const { body: snapshot } = await jsonRequest(port, '/api/status');
158
191
  if (snapshot.service !== 'mcp-pacemaker') throw new Error(`:${port} is not a pacemaker bridge`);
192
+ if (snapshot.port !== port) throw new Error('Bridge reported a different port; no admin nonce was sent.');
159
193
  snapshots.push(snapshot);
160
194
  }
161
195
 
@@ -194,7 +228,7 @@ export async function runPrewarm(options, { configPath, ports, write = console.l
194
228
  typeof snapshot.prewarm.saveWarning === 'string') {
195
229
  write(`File save notice: ${snapshot.prewarm.saveWarning}`);
196
230
  }
197
- const response = await jsonRequest(snapshot.port, `/admin/servers/${encodeURIComponent(name)}/pooling`, {
231
+ const response = await jsonRequest(selectedPorts[0], `/admin/servers/${encodeURIComponent(name)}/pooling`, {
198
232
  method: 'POST',
199
233
  headers: { 'content-type': 'application/json', 'x-mcp-nonce': nonce, 'x-mcp-pooling-batch': '1' },
200
234
  body: JSON.stringify(body),
@@ -220,14 +254,14 @@ export async function runPrewarm(options, { configPath, ports, write = console.l
220
254
  else action = 'pre-warming disable staged';
221
255
  write(`${name}: ${action}. Pending reload${timing}; not active yet.`);
222
256
  if (result.undoId) {
223
- write(`Cancel while pending / undo after apply (entire batch: ${scope}): ${formatUndoCommand(snapshot.port, name, result.undoId, options.config)}`);
257
+ write(`Cancel while pending / undo after apply (entire batch: ${scope}): ${formatUndoCommand(selectedPorts[0], name, result.undoId, options.config, instance)}`);
224
258
  }
225
259
  } else if (result.cancelled) {
226
260
  write(`${name}: pending batch cancelled. Active settings were not changed.`);
227
261
  } else {
228
262
  write(`${name}: ${options.undo ? 'restored previous pooling settings' : body.mode === 'pool' ? `enabled ${body.minWarm} warm slot(s)` : 'disabled pre-warming'}`);
229
263
  if (result.undoId) {
230
- write(`Undo: ${formatUndoCommand(snapshot.port, name, result.undoId, options.config)}`);
264
+ write(`Undo: ${formatUndoCommand(selectedPorts[0], name, result.undoId, options.config, instance)}`);
231
265
  }
232
266
  }
233
267
  }
@@ -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
+ }
@@ -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,41 @@ 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
+ const identified = response.id === record.id &&
142
+ response.instanceId === record.instanceId &&
143
+ response.version === record.version &&
144
+ response.port === port;
145
+ if (!identified ||
146
+ response.ok !== true) {
147
+ // Only a refusal from the identified bridge is definite; anything else leaves the outcome unknown.
148
+ throw Object.assign(new Error(response.error || 'Managed bridge identity/admission could not be verified.'),
149
+ { refused: identified && response.ok === false });
150
+ }
151
+ return response;
152
+ }
@@ -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
+ }