mcp-pacemaker 2.0.1 → 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.
Files changed (80) hide show
  1. package/CHANGELOG.md +105 -1
  2. package/CONTRIBUTING.md +49 -13
  3. package/README.md +361 -60
  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-task.ps1 +301 -0
  9. package/autostart/windows/register-task.ps1 +11 -2
  10. package/bin/account-task-adapter.mjs +47 -0
  11. package/bin/account-upgrade-controller.mjs +198 -0
  12. package/bin/account-upgrade-worker.mjs +134 -0
  13. package/bin/account-worker-session.mjs +215 -0
  14. package/bin/cli-dispatch.mjs +154 -0
  15. package/bin/cli-main.mjs +1007 -0
  16. package/bin/cli-selection.mjs +43 -0
  17. package/bin/cli.mjs +7 -876
  18. package/bin/legacy-1.3.json +32 -0
  19. package/bin/legacy-installation.mjs +217 -0
  20. package/bin/legacy-task.mjs +52 -0
  21. package/bin/legacy-upgrade.mjs +471 -0
  22. package/bin/managed-runtime.mjs +170 -0
  23. package/bin/managed-state.mjs +257 -0
  24. package/bin/managed-upgrade.mjs +491 -0
  25. package/bin/mcp-bridge.mjs +132 -4
  26. package/bin/pooling-batches.mjs +13 -0
  27. package/bin/pooling-writer.mjs +4 -0
  28. package/bin/prewarm.mjs +33 -21
  29. package/bin/runtime-context.mjs +100 -0
  30. package/bin/service-control.mjs +27 -4
  31. package/bin/stable-launcher.mjs +54 -0
  32. package/bin/task-only-controller.mjs +226 -0
  33. package/bin/task-scope.mjs +65 -0
  34. package/bin/task-transaction-protocol.mjs +173 -0
  35. package/bin/top.mjs +22 -10
  36. package/bin/update-check.mjs +13 -2
  37. package/bin/upgrade-autostart.mjs +47 -0
  38. package/bin/upgrade-capability.json +8 -0
  39. package/bin/upgrade-dependencies.mjs +122 -0
  40. package/bin/upgrade-package.mjs +203 -0
  41. package/bin/windows-legacy/LegacyProcessBroker.build.json +42 -0
  42. package/bin/windows-legacy/LegacyProcessBroker.exe +0 -0
  43. package/bin/windows-legacy/src/AssemblyInfo.cs +8 -0
  44. package/bin/windows-legacy/src/LegacyNative.cs +783 -0
  45. package/bin/windows-legacy/src/LegacyProcessBroker.cs +841 -0
  46. package/bin/windows-legacy-process.mjs +241 -0
  47. package/bin/windows-lifetime/ProcessLifetimeHelper.build.json +38 -0
  48. package/bin/windows-lifetime/ProcessLifetimeHelper.exe +0 -0
  49. package/bin/windows-lifetime/src/AssemblyInfo.cs +8 -0
  50. package/bin/windows-lifetime/src/ProcessLifetimeHelper.cs +468 -0
  51. package/bin/windows-process-lifetime.mjs +218 -0
  52. package/bin/windows-task-channel/TaskChannelGuard.build.json +45 -0
  53. package/bin/windows-task-channel/TaskChannelGuard.exe +0 -0
  54. package/bin/windows-task-channel/src/AssemblyInfo.cs +8 -0
  55. package/bin/windows-task-channel/src/ChannelCliContext.cs +210 -0
  56. package/bin/windows-task-channel/src/ChannelFiles.cs +231 -0
  57. package/bin/windows-task-channel/src/ChannelNative.cs +477 -0
  58. package/bin/windows-task-channel/src/ChannelSession.cs +203 -0
  59. package/bin/windows-task-channel/src/TaskChannelGuard.cs +686 -0
  60. package/bin/windows-task-channel.mjs +335 -0
  61. package/docs/account-upgrades.md +205 -0
  62. package/docs/execution-context.md +113 -0
  63. package/package.json +12 -5
  64. package/supervisor/supervise.mjs +226 -40
  65. package/tools/account-upgrade/qualify-two-account.ps1 +66 -0
  66. package/tools/windows-legacy/README.md +225 -0
  67. package/tools/windows-legacy/build.ps1 +122 -0
  68. package/tools/windows-legacy/inventory.mjs +90 -0
  69. package/tools/windows-process-lifetime/README.md +119 -0
  70. package/tools/windows-process-lifetime/build.ps1 +122 -0
  71. package/tools/windows-process-lifetime/inventory.mjs +86 -0
  72. package/tools/windows-task-channel/README.md +329 -0
  73. package/tools/windows-task-channel/build.ps1 +107 -0
  74. package/tools/windows-task-channel/inventory.mjs +117 -0
  75. package/ui/dist/THIRD_PARTY_NOTICES.txt +1 -28
  76. package/ui/dist/assets/{index-CgPruWYi.js → index-CcwMo2sI.js} +1 -1
  77. package/ui/dist/assets/index-PVcuDNrN.css +1 -0
  78. package/ui/dist/index.html +2 -2
  79. package/ui/dist/third-party-manifest.json +37 -25
  80. 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 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)) 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,
@@ -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) return;
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
@@ -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,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
- export function formatUndoCommand(port, name, undoId, config) {
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 configArg = config ? ` --config ${quote(config)}` : '';
130
- const command = `& ${quote(executable)} ${quote(entry)} prewarm --port ${port}${configArg} --server ${quote(name)} --undo ${quote(undoId)}`;
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 ? [portNumber(options.port)] : ports.map(portNumber);
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(snapshot.port, `/admin/servers/${encodeURIComponent(name)}/pooling`, {
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(snapshot.port, name, result.undoId, options.config)}`);
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(snapshot.port, name, result.undoId, options.config)}`);
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
+ }
@@ -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
+ }