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
@@ -1,34 +1,38 @@
1
1
  import { fork } from 'node:child_process';
2
2
  import { randomBytes, randomUUID } from 'node:crypto';
3
- import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
3
+ import { existsSync, mkdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs';
4
4
  import { createServer } from 'node:net';
5
5
  import { homedir } from 'node:os';
6
6
  import { dirname, resolve, join } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { parseArgs } from 'node:util';
9
9
  import { servicePaths, assertDurableRoot } from '../bin/service-control.mjs';
10
+ import { requireUserRuntimeContext } from '../bin/runtime-context.mjs';
10
11
 
11
12
  const { values } = parseArgs({
12
- options: { port: { type: 'string', default: '8791' }, config: { type: 'string' } },
13
+ options: { port: { type: 'string', default: '8791' }, config: { type: 'string' }, cwd: { type: 'string' } },
13
14
  });
14
- const port = Number(values.port);
15
+ let port = Number(values.port);
15
16
  const root = realpathSync(resolve(dirname(fileURLToPath(import.meta.url)), '..'));
16
17
  const config = resolve(values.config || join(homedir(), '.mcp-pacemaker', 'servers.json'));
17
- const paths = servicePaths(config, port);
18
+ let paths = port === 0 && process.connected && process.env.MCP_PACEMAKER_MANAGED_INSTANCE
19
+ ? undefined : servicePaths(config, port);
18
20
  assertDurableRoot(root);
19
21
  if (Number(process.versions.node.split('.')[0]) < 20) throw new Error('Node.js >=20 is required.');
20
22
 
21
- if (existsSync(paths.hold)) {
23
+ if (paths && existsSync(paths.hold)) {
22
24
  console.log(`Service :${port} is held stopped. Use the matching CLI start --port ${port} to resume deliberately.`);
23
25
  } else {
24
26
  await supervise();
25
27
  }
26
28
 
27
29
  async function supervise() {
30
+ await requireUserRuntimeContext();
28
31
  const identity = {
29
- protocol: 1, root, config, port, socket: paths.socket, pid: process.pid, execPath: process.execPath,
32
+ protocol: 1, root, config, port, socket: paths?.socket, pid: process.pid, execPath: process.execPath,
30
33
  id: randomUUID(), token: randomBytes(32).toString('hex'),
31
34
  version: JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version,
35
+ upgradeProtocol: 1, managedInstance: process.env.MCP_PACEMAKER_MANAGED_INSTANCE,
32
36
  };
33
37
  let child;
34
38
  let childExit;
@@ -37,6 +41,14 @@ async function supervise() {
37
41
  let stopRequested = false;
38
42
  let holdRequested = false;
39
43
  const clients = new Set();
44
+ const pending = new Map();
45
+ let readiness;
46
+ const failStartup = process.env.MCP_PACEMAKER_UPGRADE_PENDING === '1';
47
+ let initialStartup = failStartup;
48
+ let pendingStart = failStartup || Boolean(paths && existsSync(paths.admissionHold));
49
+ let quiescedInstanceId;
50
+ let stopAuthorization;
51
+ const lifetimes = [];
40
52
  const server = createServer((socket) => {
41
53
  clients.add(socket);
42
54
  socket.on('close', () => clients.delete(socket));
@@ -55,12 +67,47 @@ async function supervise() {
55
67
  catch { socket.destroy(); return; }
56
68
  if (request.id !== identity.id ||
57
69
  request.token !== identity.token ||
58
- request.action !== 'stop') {
70
+ !['stop', 'inspect', 'quiesce', 'activate', 'resume'].includes(request.action)) {
59
71
  socket.destroy();
60
72
  return;
61
73
  }
74
+ if (request.action !== 'stop') {
75
+ if (request.action === 'quiesce') {
76
+ pendingStart = true;
77
+ writeFileSync(paths.admissionHold, JSON.stringify({ id: identity.id, root, config, port }) + '\n',
78
+ { mode: 0o600, flush: true });
79
+ }
80
+ bridgeRequest(request).then((result) => {
81
+ if (result.ok &&
82
+ result.instanceId === readiness?.instanceId) {
83
+ if (request.action === 'quiesce' &&
84
+ result.held) quiescedInstanceId = result.instanceId;
85
+ if (['activate', 'resume'].includes(request.action)) {
86
+ if (existsSync(paths.admissionHold)) unlinkSync(paths.admissionHold);
87
+ pendingStart = false;
88
+ initialStartup = false;
89
+ quiescedInstanceId = undefined;
90
+ }
91
+ }
92
+ socket.end(JSON.stringify({ ...result, id: identity.id }) + '\n');
93
+ }, (error) => socket.end(JSON.stringify({ id: identity.id, ok: false, error: error.message }) + '\n'));
94
+ return;
95
+ }
96
+ if (request.quiescedInstanceId !== undefined) {
97
+ const heldGeneration = !child ||
98
+ readiness?.instanceId === quiescedInstanceId ||
99
+ readiness?.held === true;
100
+ if (!pendingStart ||
101
+ request.quiescedInstanceId !== quiescedInstanceId ||
102
+ !heldGeneration) {
103
+ socket.end(JSON.stringify({ id: identity.id, stopped: false }) + '\n');
104
+ return;
105
+ }
106
+ stopAuthorization = request.quiescedInstanceId;
107
+ }
62
108
  stop().then(() => {
63
- socket.end(JSON.stringify({ id: identity.id, stopped: true, autostartHeld: true }) + '\n');
109
+ socket.end(JSON.stringify({ id: identity.id, stopped: true, autostartHeld: true,
110
+ ...(stopAuthorization ? { quiescedInstanceId: stopAuthorization } : {}) }) + '\n');
64
111
  }, (error) => {
65
112
  console.error(error.message);
66
113
  socket.end(JSON.stringify({ id: identity.id, stopped: false }) + '\n');
@@ -69,14 +116,11 @@ async function supervise() {
69
116
  });
70
117
 
71
118
  mkdirSync(dirname(config), { recursive: true, mode: 0o700 });
72
- await new Promise((resolveListen, reject) => {
73
- server.once('error', reject);
74
- server.listen(paths.socket, resolveListen);
75
- });
119
+ if (paths) await listen();
76
120
  server.on('error', (error) => { console.error(error.message); process.exitCode = 1; });
77
121
  try {
78
- writeFileSync(paths.record, JSON.stringify(identity) + '\n', { mode: 0o600 });
79
- if (existsSync(paths.hold)) {
122
+ if (paths) writeFileSync(paths.record, JSON.stringify(identity) + '\n', { mode: 0o600 });
123
+ if (paths && existsSync(paths.hold)) {
80
124
  server.close();
81
125
  return;
82
126
  }
@@ -87,23 +131,152 @@ async function supervise() {
87
131
  }
88
132
  process.on('SIGINT', onSignal);
89
133
  process.on('SIGTERM', onSignal);
134
+ process.on('message', (message) => { if (message === 'stop') onSignal(); });
135
+ process.on('disconnect', onSignal);
136
+
137
+ function listen() {
138
+ return new Promise((resolveListen, reject) => {
139
+ server.once('error', reject);
140
+ server.listen(paths.socket, resolveListen);
141
+ });
142
+ }
143
+
144
+ function bridgeRequest(request) {
145
+ if (!child?.connected ||
146
+ !readiness) return Promise.reject(new Error('Bridge has no verified managed readiness.'));
147
+ const requestId = randomUUID();
148
+ const requestedChild = child;
149
+ return new Promise((resolveRequest, reject) => {
150
+ const timer = setTimeout(() => {
151
+ pending.delete(requestId);
152
+ reject(new Error('Bridge admission result is uncertain; recovery required.'));
153
+ }, 11000);
154
+ pending.set(requestId, (message) => {
155
+ clearTimeout(timer);
156
+ if (requestedChild !== child) {
157
+ reject(new Error('Bridge generation changed during admission; hold remains in effect.'));
158
+ return;
159
+ }
160
+ resolveRequest(message);
161
+ });
162
+ child.send({
163
+ type: 'upgrade-control', requestId, action: request.action, timeoutMs: 8000,
164
+ expectedAuthority: request.expectedAuthority, expectedSessionState: request.expectedSessionState,
165
+ });
166
+ });
167
+ }
168
+
169
+ function observeLifetime(identity) {
170
+ return (async () => {
171
+ const { observeWindowsProcessLifetime } = await import('../bin/windows-process-lifetime.mjs');
172
+ return observeWindowsProcessLifetime(identity);
173
+ })();
174
+ }
90
175
 
91
176
  function launch() {
92
177
  if (stopRequested ||
93
- existsSync(paths.hold)) {
178
+ (paths && existsSync(paths.hold))) {
94
179
  server.close();
95
180
  return;
96
181
  }
97
- child = fork(join(root, 'supervisor', 'bridge-child.mjs'), ['--port', String(port), '--config', config], {
182
+ let completeLifetime;
183
+ let failLifetime;
184
+ let lifetimeArmed;
185
+ if (process.platform === 'win32') {
186
+ const requiredLifetime = new Promise((resolveLifetime, rejectLifetime) => {
187
+ completeLifetime = resolveLifetime;
188
+ failLifetime = rejectLifetime;
189
+ });
190
+ const proof = requiredLifetime.then(observer => observer.done);
191
+ proof.catch(() => {});
192
+ lifetimes.push(proof);
193
+ }
194
+ const armLifetime = identity => {
195
+ lifetimeArmed = observeLifetime(identity);
196
+ if (completeLifetime) lifetimeArmed.then(completeLifetime, failLifetime);
197
+ return lifetimeArmed;
198
+ };
199
+ readiness = undefined;
200
+ const launched = fork(join(root, 'supervisor', 'bridge-child.mjs'), [
201
+ '--port', String(port), '--config', config, ...(values.cwd ? ['--cwd', values.cwd] : []),
202
+ ], {
98
203
  execPath: process.execPath, execArgv: [], stdio: ['ignore', 'inherit', 'inherit', 'ipc'], windowsHide: true,
204
+ env: { ...process.env, MCP_PACEMAKER_UPGRADE_PENDING: pendingStart ? '1' : '0' },
205
+ });
206
+ child = launched;
207
+ launched.on('message', async (message) => {
208
+ if (message?.type === 'upgrade-lifetime') {
209
+ if (lifetimeArmed) return;
210
+ armLifetime(message.lifetime);
211
+ lifetimeArmed.then(() => {
212
+ if (launched.connected) launched.send('upgrade-lifetime-observed');
213
+ }, error => {
214
+ console.error(error.message);
215
+ onSignal();
216
+ });
217
+ return;
218
+ }
219
+ if (message?.type === 'upgrade-result') {
220
+ pending.get(message.requestId)?.(message);
221
+ pending.delete(message.requestId);
222
+ }
223
+ if (message?.type !== 'upgrade-ready') return;
224
+ try {
225
+ if (process.platform === 'win32' &&
226
+ !lifetimeArmed &&
227
+ message.lifetime) armLifetime(message.lifetime);
228
+ if (process.platform === 'win32' &&
229
+ !lifetimeArmed) throw new Error('Windows backend lacks verified process containment observation.');
230
+ if (lifetimeArmed) await lifetimeArmed;
231
+ if (stopRequested ||
232
+ launched !== child) return;
233
+ if (message.protocol !== 1 ||
234
+ message.version !== identity.version ||
235
+ !Number.isInteger(message.port) ||
236
+ message.port < 1 ||
237
+ (port !== 0 && port !== message.port)) throw new Error('Bridge readiness identity mismatch.');
238
+ if (port === 0) {
239
+ port = message.port;
240
+ paths = servicePaths(config, port);
241
+ identity.port = port;
242
+ identity.socket = paths.socket;
243
+ await listen();
244
+ }
245
+ readiness = message;
246
+ identity.instanceId = message.instanceId;
247
+ writeFileSync(paths.record, JSON.stringify(identity) + '\n', { mode: 0o600 });
248
+ if (process.connected) process.send({ ...message, type: 'supervisor-ready', supervisorId: identity.id });
249
+ } catch (error) {
250
+ console.error(error.message);
251
+ await stop(false);
252
+ }
99
253
  });
100
254
  childExit = new Promise((resolveExit, reject) => {
101
- child.once('error', reject);
102
- child.once('exit', (code) => {
255
+ launched.once('error', error => {
256
+ failLifetime?.(error);
257
+ reject(error);
258
+ });
259
+ launched.once('exit', (code) => {
103
260
  child = undefined;
104
261
  resolveExit();
262
+ if (process.platform === 'win32' &&
263
+ !lifetimeArmed) {
264
+ failLifetime(new Error('Current Windows bridge exited without lifetime ownership proof. Older unsupported protocols require separately verified shutdown; no completed receipt is available.'));
265
+ if (!stopRequested) {
266
+ process.exitCode = 1;
267
+ server.close();
268
+ if (process.connected) process.disconnect();
269
+ }
270
+ return;
271
+ }
105
272
  if (stopRequested) return;
106
- if (code === 3) server.close();
273
+ if (code === 3 ||
274
+ initialStartup) {
275
+ process.exitCode = code || 1;
276
+ stop(false).then(() => {
277
+ if (process.connected) process.disconnect();
278
+ }, error => { console.error(error.message); process.exitCode = 1; });
279
+ }
107
280
  else retry = setTimeout(launch, 2000);
108
281
  });
109
282
  });
@@ -125,29 +298,42 @@ async function supervise() {
125
298
  stopRequested = true;
126
299
  clearTimeout(retry);
127
300
  stopping = (async () => {
128
- if (child) {
129
- if (child.connected) {
130
- try {
131
- await new Promise((resolveSend, reject) => child.send('stop', (error) => error ? reject(error) : resolveSend()));
132
- } catch (error) {
133
- if (error.code !== 'ERR_IPC_CHANNEL_CLOSED' &&
134
- error.code !== 'EPIPE') throw error;
135
- }
136
- }
137
- let timeout;
138
- try {
139
- await Promise.race([
140
- childExit,
141
- new Promise((_, reject) => {
142
- timeout = setTimeout(() => reject(new Error('Bridge did not exit; do not replace its root. No force-kill attempted.')), 10000);
143
- }),
144
- ]);
145
- } finally {
146
- clearTimeout(timeout);
147
- }
301
+ let timeout;
302
+ try {
303
+ await Promise.race([
304
+ (async () => {
305
+ if (child?.connected) {
306
+ try {
307
+ await new Promise((resolveSend, reject) => child.send('stop', error => error ? reject(error) : resolveSend()));
308
+ } catch (error) {
309
+ if (error.code !== 'ERR_IPC_CHANNEL_CLOSED' &&
310
+ error.code !== 'EPIPE') throw error;
311
+ }
312
+ }
313
+ const [, ...receipts] = await Promise.all([childExit, ...lifetimes]);
314
+ for (const receipt of receipts) {
315
+ if (receipt?.verified !== true ||
316
+ receipt.ownerExited !== true ||
317
+ receipt.activeProcesses !== 0) throw new Error('Windows descendant teardown was not verified; retain the installation.');
318
+ }
319
+ })(),
320
+ new Promise((_, reject) => {
321
+ timeout = setTimeout(() => reject(new Error('Bridge did not exit; do not replace its root. No force-kill attempted.')), 10000);
322
+ }),
323
+ ]);
324
+ } finally {
325
+ clearTimeout(timeout);
148
326
  }
149
327
  if (holdRequested) {
150
- writeFileSync(paths.hold, JSON.stringify({ id: identity.id, root, config, port, stopped: true }) + '\n', { mode: 0o600 });
328
+ writeFileSync(paths.hold, JSON.stringify({ id: identity.id, root, config, port, stopped: true,
329
+ ...(stopAuthorization ? { quiescedInstanceId: stopAuthorization } : {}) }) + '\n', { mode: 0o600, flush: true });
330
+ }
331
+ if (process.connected) {
332
+ await new Promise((resolveSent, reject) => {
333
+ process.send({ type: 'supervisor-stopped', id: identity.id, stopped: true },
334
+ error => error ? reject(error) : resolveSent());
335
+ });
336
+ process.disconnect();
151
337
  }
152
338
  server.close();
153
339
  process.removeListener('SIGINT', onSignal);
@@ -0,0 +1,66 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Runs an explicitly authorized two-account Windows upgrade qualification.
4
+ .DESCRIPTION
5
+ Requires an existing elevated controller, existing logged-on target account and
6
+ existing target task. Does not create accounts, request elevation, change ACLs or
7
+ install toolchains. Without Execute it runs only the product's read-only plan.
8
+ .PARAMETER TrustedRoot
9
+ Existing protected, target-readable controller package directory.
10
+ .PARAMETER NodePath
11
+ Existing trusted Node executable.
12
+ .PARAMETER TargetSid
13
+ Canonical SID of the different existing target account.
14
+ .PARAMETER TaskPath
15
+ Exact existing per-user task.
16
+ .PARAMETER Version
17
+ Exact target version approved for this qualification.
18
+ .PARAMETER ControllerRoot
19
+ Existing protected operation-store parent, readable/traversable by the target.
20
+ .PARAMETER Registry
21
+ Explicit approved registry; use an owned registry for fixture qualification.
22
+ .PARAMETER Execute
23
+ Runs the interactive product transaction after its read-only plan succeeds.
24
+ .OUTPUTS
25
+ Product plan and operation results. This script does not attest readiness by itself.
26
+ .EXAMPLE
27
+ .\qualify-two-account.ps1 -TrustedRoot C:\ExistingProtectedTools\Pacemaker -NodePath C:\ExistingNode\node.exe -TargetSid S-1-5-21-1-2-3-1001 -TaskPath \OwnedPacemakerTask -Version 2.1.0 -ControllerRoot C:\ExistingProtectedOperations -Registry https://approved.example.test/npm/
28
+ #>
29
+ [CmdletBinding()]
30
+ param(
31
+ [Parameter(Mandatory)][string]$TrustedRoot,
32
+ [Parameter(Mandatory)][string]$NodePath,
33
+ [Parameter(Mandatory)][string]$TargetSid,
34
+ [Parameter(Mandatory)][string]$TaskPath,
35
+ [Parameter(Mandatory)][string]$Version,
36
+ [Parameter(Mandatory)][string]$ControllerRoot,
37
+ [Parameter(Mandatory)][string]$Registry,
38
+ [switch]$Execute
39
+ )
40
+ $ErrorActionPreference = 'Stop'
41
+ if (-not $IsWindows) { throw 'Windows qualification only.' }
42
+ $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
43
+ $principal = [Security.Principal.WindowsPrincipal]::new($identity)
44
+ if ($identity.User.Value -ceq $TargetSid -or
45
+ -not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
46
+ throw 'Use an already-elevated different controller account. This script never elevates.'
47
+ }
48
+ foreach ($path in @($TrustedRoot, $ControllerRoot)) {
49
+ if (-not (Test-Path -LiteralPath $path -PathType Container)) { throw 'Required existing protected directory is absent.' }
50
+ }
51
+ if (-not (Test-Path -LiteralPath $NodePath -PathType Leaf)) { throw 'Required existing Node executable is absent.' }
52
+ $cli = Join-Path $TrustedRoot 'bin\cli.mjs'
53
+ $arguments = @(
54
+ $cli, '--user', $TargetSid, '--task', $TaskPath, 'upgrade', '--to', $Version,
55
+ '--controller-root', $ControllerRoot, '--registry', $Registry
56
+ )
57
+ & $NodePath @arguments --plan
58
+ if ($LASTEXITCODE -ne 0) { throw 'Read-only account/task/path/session eligibility did not pass.' }
59
+ if (-not $Execute) {
60
+ Write-Output 'Plan only. Actual two-account task mutation, original-account runtime and rollback remain NOT RUN.'
61
+ return
62
+ }
63
+ if ([Console]::IsInputRedirected) { throw 'Interactive confirmation is required; no unattended approval fallback.' }
64
+ & $NodePath @arguments
65
+ if ($LASTEXITCODE -ne 0) { throw 'Operation did not complete. Preserve both journals; do not force cleanup or replay calls.' }
66
+ Write-Output 'Inspect recorded worker/backend SID, session, version, task full-SD continuity, protected state and cleanup receipts before accepting qualification.'
@@ -0,0 +1,251 @@
1
+ # Legacy process broker
2
+
3
+ This separate own-source .NET Framework 4.6.2 executable supports an explicitly
4
+ partial, restart-only legacy handoff. It does not change the modern lifetime
5
+ helper or its Job Object proof. No npm runtime dependencies, compiler, PowerShell,
6
+ downloads, privilege changes or service/task mutations run inside this broker.
7
+
8
+ ## API and caller responsibilities
9
+
10
+ ```js
11
+ const session = await prepareLegacyProcesses({ port, root, expected });
12
+ const plan = session.plan;
13
+ const current = await session.status(); // read-only held-handle status
14
+ const receipt = await session.stop(); // consumes this session's stop once
15
+ await session.close(); // closes handles; does not stop targets
16
+ const recovered = await verifyLegacyProcessesGone(plan); // read-only
17
+ ```
18
+
19
+ `root` is an absolute existing package directory. `port` is an integer 1..65535.
20
+ Discovery requires exactly one IPv4 127.0.0.1 listener on that port; wildcard or
21
+ ambiguous listeners refuse. It opens the bridge and its native parent, verifies
22
+ both user SIDs match the broker's user, and requires these parsed argument lists:
23
+
24
+ - `node.exe <root>/bin/mcp-bridge.mjs --port <N>`
25
+ - `pwsh.exe|powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden
26
+ -File <root>/supervisor/supervise.ps1 -Port <N>`
27
+
28
+ Actual image paths and SHA256 values are recorded, not assumed to equal the
29
+ upgrader's Node executable. Parsed argv is hashed and not emitted. Native
30
+ creation time is an exact decimal Windows FILETIME string. WMI command-line
31
+ reads are checked against the held process generation. No cwd/PEB read, process
32
+ injection or debugging is used.
33
+
34
+ Both script arguments must be fully qualified Windows drive-absolute or ordinary
35
+ UNC paths before comparison. Relative, drive-relative (`C:script.ps1`),
36
+ root-relative (`\script.ps1`) and device-namespace spellings refuse; the broker
37
+ never resolves target arguments against its own cwd or guesses the target cwd.
38
+ Existing case-insensitive full-path comparison and native executable identity
39
+ queries are unchanged.
40
+
41
+ Process discovery uses documented `TH32CS_SNAPPROCESS` snapshots: one per capture
42
+ or revalidation phase to bind the two roots, and one per stop sweep round. At most
43
+ 16,384 PID/parent pairs are retained in memory. The native entry buffer also
44
+ contains other OS fields; the broker reads only the PID/parent offsets, never
45
+ marshals names, and clears that buffer after each entry. It closes the snapshot
46
+ before opening candidates. No global pair list is returned or logged, and no
47
+ unrelated entries receive process/token/image queries. Snapshot overflow,
48
+ layout/duplicate/enumeration failure and `ERROR_BAD_LENGTH` refuse a capture or
49
+ revalidation phase without retry; during a stop they are reported, and the sweep
50
+ still stops every process it already holds and tries again in its next round.
51
+
52
+ Snapshot pairs are discovery hints, not process authority or an atomic tree.
53
+ A sweep candidate must still bind to a held process through its own native
54
+ parent, creation-time and owner checks. Unknown historical branches stay
55
+ untouched.
56
+
57
+ The argv query covers only the two held roots, in one query per phase run by a
58
+ worker that owns its connected WMI scope/searcher/cursor. Missing, extra,
59
+ duplicate or stale rows refuse the entire phase; the native watchdog remains the
60
+ hard whole-helper bound for a blocked call.
61
+
62
+ Each phase separately hashes at most 64 executable files, 256 MiB per file and
63
+ 512 MiB total. Read handles deny writes/deletion until the phase finishes.
64
+ Every process image path is reopened and matched by volume/file identity before
65
+ reusing a digest; paths alone and prior-phase digests are not cache keys.
66
+
67
+ The caller must independently verify the exact known legacy package, selected
68
+ launcher/task ownership, source hashes, and config-directory nonce/revision.
69
+ Matching process arguments and captured script hashes do **not** make an arbitrary
70
+ script an approved legacy package. Cwd is not a supported config binding.
71
+
72
+ Prepare is read-only. It captures **only the two roots** and binds them to the
73
+ listener and to each other. It does not list the bridge's session processes: a
74
+ busy 1.3 bridge starts and ends them every few seconds, so a list would be stale
75
+ before the stop. It also walks its own parent chain, checking creation order at
76
+ each step, and refuses with `Run the upgrade from a terminal the 1.3 bridge did
77
+ not start` if that chain reaches the supervisor or the bridge: such an upgrade
78
+ would stop itself. Script read handles deny script modification/deletion during
79
+ this session. The plan contains:
80
+
81
+ ```text
82
+ protocol: 1
83
+ kind: "legacy-observed-process-set"
84
+ roots: { supervisor, bridge }
85
+ observed: [] (2.1.0 plans listed descendants here; recovery still verifies them)
86
+ excluded: []
87
+ observedSetSha256
88
+ treeCompleteness: "unproven"
89
+ scope
90
+ planSha256
91
+ ```
92
+
93
+ Root identities include PID, creation time, parent PID/creation time, owner SID,
94
+ image path/hash, argv hash and root script hash. No opaque commit token is in
95
+ `plan`. Passing a previous `plan` as `expected` binds its roots: protocol, kind,
96
+ port, root, owner and both root identities must match exactly. Descendants are
97
+ not compared, so sessions that started or ended since the plan do not refuse it.
98
+ A changed root generation still requires a new plan and confirmation.
99
+
100
+ Only after the caller's per-plan confirmation and holding and rechecking the
101
+ **exact selected** autostart registration may the caller invoke `stop()`. This
102
+ broker does not attest that the task is held. It rechecks the roots, listener and
103
+ argv/image identity, then stops through retained native handles, never by a
104
+ fresh PID lookup:
105
+
106
+ 1. Terminate the supervisor and wait for it, so it cannot start another bridge.
107
+ 2. Pin: take a snapshot and hold a handle to each process whose live parent PID
108
+ (read from its own handle, not the snapshot) is a held process and which
109
+ started no earlier than that process, walking the whole tree in one pass.
110
+ Repeat until a snapshot shows nothing new, at most eight times. Nothing is
111
+ stopped here.
112
+ 3. Terminate the bridge and wait for it.
113
+ 4. Sweep: pin again, then terminate every held process not yet stopped and wait
114
+ for them, even if that pin found the tree still growing or could not read a
115
+ snapshot. Repeat until a round's pin settles with nothing new and nothing is
116
+ left to stop.
117
+
118
+ Pinning comes first because Node ends a process's direct children together with
119
+ it through a job object, and 1.3 runs each bare server command under a `cmd.exe`
120
+ wrapper. Without the pin, ending the bridge would end the wrappers at once and
121
+ leave the real servers under them with a parent nobody holds. A process that has
122
+ already exited is held too while it can still be opened, so its children stay
123
+ traceable. A wrapper the bridge starts in the few milliseconds between the last
124
+ pin snapshot and the bridge's exit is not held; if it has started its server by
125
+ then, that server is left running like any other untraceable process.
126
+
127
+ Held handles keep their PIDs from being reused, so a process that names a held
128
+ PID as its parent and started no earlier than it was started by it. A process
129
+ older than that parent got the PID from an earlier owner and is left alone; a
130
+ held root that appears under a pinned process (its creator's PID was reused) is
131
+ already held and is skipped. The broker and its own parent chain, matched by PID
132
+ and creation time, are never adopted. A live process owned by another user is
133
+ reported, not stopped.
134
+ Processes whose parent had already exited before the stop cannot be traced and
135
+ stay untouched.
136
+
137
+ Root capture ambiguities refuse. After mutation starts, failures are explicit and
138
+ may be partial; never automatically relaunch the old supervisor, replay tools, or
139
+ broaden cleanup. The caller must preserve its task hold and journal when the root
140
+ stop is uncertain.
141
+
142
+ ### Failure diagnostics
143
+
144
+ The existing refusal envelope keeps its exception type, native error code,
145
+ stage, reason and `mutationStarted` flag. When available, `captureFailure`
146
+ contains fixed API/substage labels and nullable stored PID, creation, parent,
147
+ liveness and owner-match facts. An initial query-handle denial reports the
148
+ requested PID but `generationKnown: false` and `creationTime: null`. Other call
149
+ sites can report an API with unknown substage/target; unknown fields are not
150
+ inferred from a PID or exception.
151
+
152
+ `observation: "stored-observations-not-fresh-proof"` identifies these as prior
153
+ observations, not a new identity check or stop authority. No diagnostic recovery
154
+ query, retry, stronger access, argv, image path, SID or raw exception text is
155
+ added. Metadata is attached to the original exception, separately for each
156
+ operation/argv worker; collection failures leave the original refusal intact.
157
+ Success frames, plans, digests, approvals and stop behavior do not change.
158
+
159
+ ## Honest receipt
160
+
161
+ ```text
162
+ legacyRootStopVerified: true|false
163
+ observedDescendantsStopped: true|false (every process the sweep found was stopped)
164
+ descendantsFound
165
+ descendantsStopped
166
+ treeCompleteness: "unproven"
167
+ observedCount
168
+ excludedCount
169
+ planSha256
170
+ errors: [...]
171
+ unattributed: "Not discovered or not provable; left untouched."
172
+ ```
173
+
174
+ There is no `stopped:true` or `activeProcesses:0` field. A wrapper that exits
175
+ before the stop leaves its children with no traceable parent. Matching snapshots
176
+ do not prove no such processes exist. This broker neither counts nor kills
177
+ historical/unattributed orphans. The caller requires `legacyRootStopVerified`;
178
+ a process the sweep could not stop is reported in `errors` and left running.
179
+
180
+ `verifyLegacyProcessesGone(plan)` validates the protected plan's shape/digest,
181
+ then opens **only its captured IDs** (the roots, plus any descendants a 2.1.0 plan
182
+ listed) with query/synchronize access. A different creation time is an unrelated
183
+ reused PID and is untouched; a matching live identity keeps the receipt false;
184
+ inaccessible identities keep it unverified. It never requests terminate access or
185
+ performs discovery, so its `descendantsFound` and `descendantsStopped` are null,
186
+ and for a plan that lists no descendants `observedDescendantsStopped` is false:
187
+ the check cannot vouch for them. The journal's authentication/ownership and phase
188
+ authorization belong to the caller. A hash inside the plan is an integrity check,
189
+ not an authentication credential.
190
+
191
+ ## Bounds and lifecycle
192
+
193
+ - Plans hold at most 512 identities, including both roots, so a 2.1.0 plan is
194
+ still accepted for recovery checks. A plan written now holds only the roots.
195
+ - The stop holds at most 2048 processes and reads at most 2048 children of one
196
+ process. Each pin takes at most eight snapshots; a tree still growing after
197
+ that is pinned further by the next sweep round. The sweep runs at most 16
198
+ rounds. One busy 1.3 host had 533 processes in its tree. Reaching the process,
199
+ child or round bound is reported in `errors`; the root stop stands.
200
+ - One argv query during capture and one during revalidation.
201
+ - The native watchdog allows 15 seconds to receive the request. Capture and
202
+ revalidation then each have 120 seconds, timed from the request rather than
203
+ from broker start; verification is bounded by the watchdog alone. Each phase
204
+ adds a 10-second watchdog margin, longer than one 8-second WMI query, so a
205
+ phase refusal is reported rather than cut off. Node waits 150 seconds for a
206
+ prepare or verification response.
207
+ - Uncommitted session: 120 seconds; expiration requires fresh prepare.
208
+ - Stop: the supervisor, the pin, the bridge and every sweep round share **one
209
+ 20-second** budget. Once it is spent, remaining exits are polled, not awaited,
210
+ and the sweep ends. Node's whole stop exchange, including revalidation, is
211
+ bounded to 170 seconds.
212
+ - Close/exit verification: three seconds.
213
+ - Plans: at most 240 KiB UTF-8 before emit/accept. Requests and responses: at most
214
+ 256 KiB UTF-8. No full command lines or config contents are emitted.
215
+ - Native parent watch: caller loss before commit releases handles without target
216
+ termination. After commit the already-authorized bounded stop finishes.
217
+
218
+ Prepare/stop responses include bounded phase timing/resource diagnostics and
219
+ the broker's own PID/FILETIME for owned exit evidence. These values confer no
220
+ target authority and are not part of the plan digest. A test-only outer
221
+ cleanup job is not proof that the production stop succeeded.
222
+
223
+ Detached native launch avoids inherited console/Node child termination, but is
224
+ not an unbounded daemon. Parent loss and deadlines end the broker. Native target
225
+ handles are non-inherited. No elevation or privilege enablement is attempted.
226
+
227
+ Both native launches (prepare and verify-only) first compare actual executable
228
+ SHA256 to packaged metadata. This detects package drift, not malicious replacement
229
+ of executable plus metadata or the whole package.
230
+ It is not an atomic executable-replacement defense; supported upgrades stage
231
+ separate immutable package roots rather than replacing a running helper in place.
232
+
233
+ ## Build, inventory and tests
234
+
235
+ ```powershell
236
+ .\tools\windows-legacy\build.ps1
237
+ .\tools\windows-legacy\build.ps1 -Verify
238
+ node --test test/windows-legacy-process.test.mjs
239
+ ```
240
+
241
+ The build reuses the installed recorded Roslyn and .NET Framework 4.6.2 reference
242
+ assemblies, including built-in `System.Management` and `System.Web.Extensions`.
243
+ It compiles twice under different owned paths and compares bytes, records source,
244
+ build script, compiler and reference hashes, and installs nothing. `-Verify`
245
+ updates no artifact. `inventory.mjs` checks the exact separate asset layout.
246
+ The immutable historical security helper and modern lifetime binaries stay
247
+ unchanged.
248
+
249
+ Dedicated tests use a synthetic argument-compatible supervisor for bounded
250
+ native identity cases. The upgrader owns the additional byte-exact known-1.3
251
+ package/launcher/config/task E2E; passing these focused tests does not replace it.