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
@@ -0,0 +1,335 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { readFileSync, lstatSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const HELPER = fileURLToPath(new URL('./windows-task-channel/TaskChannelGuard.exe', import.meta.url));
7
+ const METADATA = fileURLToPath(new URL('./windows-task-channel/TaskChannelGuard.build.json', import.meta.url));
8
+ // A detached guard must not hold the caller's working directory open; that directory can be an
9
+ // instance, config or staging path the caller is entitled to replace or remove. Its own packaged
10
+ // directory is already locked while the running image is mapped, so it adds no new constraint.
11
+ const HELPER_DIRECTORY = fileURLToPath(new URL('./windows-task-channel/', import.meta.url));
12
+
13
+ function deferred() {
14
+ let resolve, reject;
15
+ const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
16
+ promise.catch(() => {});
17
+ return { promise, resolve, reject };
18
+ }
19
+
20
+ class TaskChannel {
21
+ constructor() {
22
+ this.started = deferred();
23
+ this.worker = deferred();
24
+ this.completion = deferred();
25
+ this.pending = new Map();
26
+ this.frames = [];
27
+ this.waiters = [];
28
+ this.sequence = 0;
29
+ this.buffer = '';
30
+ this.failure = null;
31
+ this.intentionalClose = false;
32
+ }
33
+
34
+ verifyHelper() {
35
+ const metadataBytes = readFileSync(METADATA);
36
+ if (!lstatSync(METADATA).isFile() ||
37
+ lstatSync(METADATA).isSymbolicLink()) throw new Error('Invalid packaged task channel metadata path');
38
+ if (metadataBytes.length > 65536) throw new Error('Task channel metadata exceeds bounds');
39
+ const metadata = JSON.parse(metadataBytes);
40
+ const digest = metadata.binarySha256;
41
+ const valid = metadata.schemaVersion === 1 &&
42
+ metadata.binary === 'TaskChannelGuard.exe' &&
43
+ typeof digest === 'string' &&
44
+ digest.length === 64 &&
45
+ [...digest].every(character => '0123456789abcdef'.includes(character));
46
+ if (!valid) throw new Error('Invalid packaged task channel digest');
47
+ const stat = lstatSync(HELPER);
48
+ if (!stat.isFile() ||
49
+ stat.isSymbolicLink() ||
50
+ stat.size < 1 ||
51
+ stat.size > 4 * 1024 * 1024 ||
52
+ createHash('sha256').update(readFileSync(HELPER)).digest('hex') !== digest) {
53
+ throw new Error('Packaged task channel helper identity mismatch');
54
+ }
55
+ }
56
+
57
+ async start(request) {
58
+ if (process.platform !== 'win32') throw new Error('Task channel requires Windows');
59
+ this.verifyHelper();
60
+ const bytes = this.encode(request, 65536);
61
+ this.child = spawn(HELPER, [], {
62
+ cwd: HELPER_DIRECTORY, detached: true, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
63
+ });
64
+ this.child.stdout.setEncoding('utf8');
65
+ this.child.stderr.setEncoding('utf8');
66
+ this.child.on('error', error => this.fail(error));
67
+ this.child.stdin.on('error', error => this.fail(error));
68
+ this.child.stdout.on('error', error => this.fail(error));
69
+ this.child.stderr.on('error', error => this.fail(error));
70
+ let diagnosticBytes = 0;
71
+ this.child.stderr.on('data', text => {
72
+ diagnosticBytes += Buffer.byteLength(text);
73
+ if (diagnosticBytes > 2048) this.fail(new Error('Unexpected task guard diagnostics'));
74
+ });
75
+ this.child.stdout.on('data', text => this.consume(text));
76
+ this.child.on('close', (code, signal) => {
77
+ clearTimeout(this.timer);
78
+ const receipt = { code, signal,
79
+ reason: this.failure?.message ?? this.closeReason ?? (code === 0 ? 'completed' : `native-exit-${code}`) };
80
+ this.exited = true;
81
+ this.exitReceipt = receipt;
82
+ this.fail(this.failure ?? new Error(`Task channel closed (native exit ${code})`));
83
+ this.completion.resolve(receipt);
84
+ });
85
+ this.timer = setTimeout(() => this.fail(new Error('Task channel ready deadline exceeded')), 15000);
86
+ this.child.stdin.write(bytes);
87
+ try {
88
+ const ready = await this.started.promise;
89
+ clearTimeout(this.timer);
90
+ this.timer = setTimeout(() => this.fail(new Error('Task channel lifetime exceeded')),
91
+ (request.lifetimeMs ?? 60000) + 3000);
92
+ return ready;
93
+ } catch (error) {
94
+ error.guardIdentity ??= structuredClone(this.guardIdentity);
95
+ try {
96
+ error.helperExit = await this.close();
97
+ error.cleanupUnverified = false;
98
+ } catch (closeError) {
99
+ error.cleanupUnverified = true;
100
+ error.cleanupError = closeError.message;
101
+ error.message += `; ${closeError.message}`;
102
+ }
103
+ throw error;
104
+ }
105
+ }
106
+
107
+ encode(value, maximum = 16384) {
108
+ if (!value ||
109
+ typeof value !== 'object' ||
110
+ Array.isArray(value)) throw new Error('Task channel requires an object frame');
111
+ const bytes = Buffer.from(`${JSON.stringify(value)}\n`, 'utf8');
112
+ if (bytes.length > maximum) throw new Error('Task channel frame exceeds bounds');
113
+ return bytes;
114
+ }
115
+
116
+ consume(text) {
117
+ this.buffer += text;
118
+ if (Buffer.byteLength(this.buffer) > 131072) return this.fail(new Error('Task channel output exceeds bounds'));
119
+ let newline;
120
+ while ((newline = this.buffer.indexOf('\n')) >= 0) {
121
+ const line = this.buffer.slice(0, newline);
122
+ this.buffer = this.buffer.slice(newline + 1);
123
+ try { this.event(JSON.parse(line)); }
124
+ catch (error) { this.fail(error); return; }
125
+ }
126
+ }
127
+
128
+ event(message) {
129
+ if (!message ||
130
+ typeof message.type !== 'string') throw new Error('Invalid task channel event');
131
+ if (message.type === 'started') {
132
+ const identity = message.guardIdentity;
133
+ if (this.guardIdentity ||
134
+ identity?.pid !== this.child.pid ||
135
+ typeof identity.creationTime !== 'string') throw new Error('Invalid task guard startup identity');
136
+ this.guardIdentity = structuredClone(identity);
137
+ } else if (message.type === 'failed') {
138
+ const error = new Error(`Task channel refused: ${message.reason}`);
139
+ error.code = message.reason;
140
+ error.guardIdentity = message.guardIdentity;
141
+ error.detail = message;
142
+ this.fail(error);
143
+ } else if (message.type === 'ready' ||
144
+ message.type === 'result') {
145
+ if (this.ready) throw new Error('Duplicate task channel readiness');
146
+ if (message.guardIdentity?.pid !== this.child.pid) throw new Error('Task guard process binding mismatch');
147
+ this.ready = message;
148
+ this.started.resolve(structuredClone(message));
149
+ } else if (message.type === 'worker') {
150
+ if (!this.ready ||
151
+ this.peer) throw new Error('Unexpected task channel peer');
152
+ this.peer = structuredClone(message.peer);
153
+ this.workerSession = structuredClone(message.targetSession);
154
+ this.worker.resolve(structuredClone(this.peer));
155
+ } else if (message.type === 'frame') {
156
+ if (!this.peer ||
157
+ JSON.stringify(message.peer) !== JSON.stringify(this.peer)) throw new Error('Task channel peer changed');
158
+ const received = { peer: structuredClone(this.peer), frame: message.frame };
159
+ if (this.waiters.length) this.waiters.shift().resolve(received);
160
+ else {
161
+ if (this.frames.length >= 32) throw new Error('Task channel receive queue exceeds bounds');
162
+ this.frames.push(received);
163
+ }
164
+ } else if (message.type === 'sent' ||
165
+ message.type === 'authorized') {
166
+ const pending = this.pending.get(message.id);
167
+ if (!pending) throw new Error('Unexpected task channel acknowledgement');
168
+ this.pending.delete(message.id);
169
+ pending.resolve(structuredClone(message));
170
+ } else if (message.type === 'closed') {
171
+ this.closeReason = message.reason;
172
+ } else throw new Error('Unknown task channel event');
173
+ }
174
+
175
+ fail(error) {
176
+ this.failure ??= error;
177
+ this.failure.guardIdentity ??= structuredClone(this.guardIdentity);
178
+ this.started.reject(this.failure);
179
+ this.worker.reject(this.failure);
180
+ for (const pending of this.pending.values()) pending.reject(this.failure);
181
+ this.pending.clear();
182
+ for (const waiter of this.waiters) waiter.reject(this.failure);
183
+ this.waiters = [];
184
+ this.frames = [];
185
+ if (this.child?.stdin.writable) this.child.stdin.end();
186
+ }
187
+
188
+ request(action, frame) {
189
+ if (this.failure) return Promise.reject(this.failure);
190
+ if (!this.peer &&
191
+ action !== 'authorize') return Promise.reject(new Error('Task channel peer is not verified'));
192
+ if (this.pending.size >= 32) return Promise.reject(new Error('Task channel command queue exceeds bounds'));
193
+ if (frame !== undefined) this.encode(frame);
194
+ const id = ++this.sequence;
195
+ const pending = deferred();
196
+ this.pending.set(id, pending);
197
+ this.child.stdin.write(this.encode({ action, id, ...(frame === undefined ? {} : { frame }) }, 65536));
198
+ return pending.promise;
199
+ }
200
+
201
+ receive() {
202
+ if (this.failure) return Promise.reject(this.failure);
203
+ if (this.frames.length) return Promise.resolve(this.frames.shift());
204
+ if (this.waiters.length >= 32) return Promise.reject(new Error('Task channel waiter queue exceeds bounds'));
205
+ const pending = deferred();
206
+ this.waiters.push(pending);
207
+ return pending.promise;
208
+ }
209
+
210
+ async close() {
211
+ if (!this.child) return;
212
+ if (!this.exited &&
213
+ !this.intentionalClose) {
214
+ this.intentionalClose = true;
215
+ if (this.child.stdin.writable) this.child.stdin.write('{"action":"close"}\n');
216
+ }
217
+ let timer;
218
+ try {
219
+ return await Promise.race([this.completion.promise, new Promise((_, reject) => {
220
+ timer = setTimeout(() => {
221
+ if (this.child.stdin.writable) this.child.stdin.end();
222
+ const error = new Error('Task guard exit unverified; retain owned cleanup evidence');
223
+ error.guardIdentity = structuredClone(this.guardIdentity);
224
+ error.cleanupUnverified = true;
225
+ reject(error);
226
+ }, 5000);
227
+ })]);
228
+ } finally { clearTimeout(timer); }
229
+ }
230
+ }
231
+
232
+ export async function createTaskChannel(options) {
233
+ const channel = new TaskChannel();
234
+ const { operationId, targetSid, sessionId, bootstrap, manifestParent, manifest,
235
+ expectedCreationTime, handshakeTimeoutMs = 30000, lifetimeMs = 900000 } = options;
236
+ const ready = await channel.start({
237
+ action: 'serve', operationId, targetSid, sessionId, bootstrap, manifestParent,
238
+ document: manifest, expectedCreationTime, handshakeTimeoutMs, lifetimeMs,
239
+ });
240
+ return {
241
+ ...ready,
242
+ pid: channel.child.pid,
243
+ awaitWorker: () => channel.worker.promise,
244
+ get workerSession() { return structuredClone(channel.workerSession); },
245
+ send: frame => channel.request('send', frame),
246
+ authorize: () => channel.request('authorize'),
247
+ receive: () => channel.receive(),
248
+ closed: channel.completion.promise,
249
+ close: () => channel.close(),
250
+ };
251
+ }
252
+
253
+ async function query(request) {
254
+ const channel = new TaskChannel();
255
+ const result = await channel.start(request);
256
+ const exit = await channel.completion.promise;
257
+ if (exit.code !== 0 ||
258
+ exit.signal !== null) throw new Error(`Task channel query failed: ${exit.reason}`);
259
+ return { ...result, helperExit: exit };
260
+ }
261
+
262
+ export function queryTaskChannelCaller() {
263
+ return query({ action: 'caller' });
264
+ }
265
+
266
+ export async function queryCliCallerContext() {
267
+ const result = await query({ action: 'cli-context' });
268
+ if (result.proofScope !== 'cli-effective-context-snapshot' ||
269
+ result.identity?.pid !== process.pid ||
270
+ typeof result.ordinaryEligible !== 'boolean') {
271
+ throw new Error('CLI context caller binding or proof scope is unverified');
272
+ }
273
+ return result;
274
+ }
275
+
276
+ export async function queryCurrentUserSession() {
277
+ const result = await query({ action: 'current-session' });
278
+ const session = result.session;
279
+ if (result.proofScope !== 'current-user-session-snapshot' ||
280
+ result.identity?.pid !== process.pid ||
281
+ typeof result.identity?.ownerSid !== 'string' ||
282
+ result.identity.ownerSid.length === 0 ||
283
+ !Number.isInteger(result.identity.sessionId) ||
284
+ result.identity.sessionId <= 0 ||
285
+ session?.ownerSid !== result.identity.ownerSid ||
286
+ session?.sessionId !== result.identity.sessionId ||
287
+ !['active', 'disconnected'].includes(session?.state) ||
288
+ typeof session?.logonTime !== 'string' ||
289
+ !/^[1-9][0-9]{0,18}$/.test(session.logonTime)) {
290
+ throw new Error('Current user session binding is unverified');
291
+ }
292
+ return result;
293
+ }
294
+
295
+ export async function requireOrdinaryUpgradeCaller() {
296
+ if (process.platform !== 'win32') return;
297
+ const caller = await queryCliCallerContext();
298
+ const facts = caller.actorFacts;
299
+ const observation = caller.observation;
300
+ const ordinary = caller.ordinaryEligible === true &&
301
+ facts?.ownerSid === caller.identity.ownerSid &&
302
+ facts?.sessionId === caller.identity.sessionId &&
303
+ facts?.elevated === false &&
304
+ facts?.enabledAdministrator === false &&
305
+ facts?.guardThreadImpersonating === false &&
306
+ facts?.parentThreadImpersonation === 'observed-none' &&
307
+ observation?.method === 'pss-threads-held-token-query' &&
308
+ observation?.processAccess === '0x101400' &&
309
+ observation?.captureFlags === '0x80' &&
310
+ observation?.threadContextFlags === 0 &&
311
+ observation?.completeStableThreadSet === true &&
312
+ observation?.primaryStable === true &&
313
+ observation?.atomicFutureProtection === false &&
314
+ Number.isInteger(observation?.threadCount) &&
315
+ observation.threadCount > 0 &&
316
+ observation.threadCount <= 128 &&
317
+ caller.helperExit?.code === 0 &&
318
+ caller.helperExit?.signal === null;
319
+ if (!ordinary) {
320
+ // Include the native reason: an ambiguous observation and a genuinely privileged caller are
321
+ // both refused, and without this they are indistinguishable after the fact.
322
+ const reason = typeof caller.reason === 'string' ? caller.reason : 'UNREPORTED';
323
+ throw new Error('Windows upgrade execution requires a verified ordinary caller; no elevation or runtime-authority fallback was attempted. ' +
324
+ `Observed: ${reason}.`);
325
+ }
326
+ return caller;
327
+ }
328
+
329
+ export function readProtectedManifest(path, { operationId, manifestParent }) {
330
+ return query({ action: 'read-manifest', path, operationId, manifestParent });
331
+ }
332
+
333
+ export function inspectTrustedCodeRoot(root) {
334
+ return query({ action: 'inspect-root', root });
335
+ }
@@ -0,0 +1,218 @@
1
+ # Explicit original-account upgrades on Windows
2
+
3
+ The default scope is **the current user**, even from an elevated terminal.
4
+ Scope does not authorize elevated runtime execution: without `--user`, Windows
5
+ upgrade and recovery require a freshly verified ordinary caller before staging
6
+ or mutation. Read-only planning remains available from an elevated terminal.
7
+ The shared [user-runtime contract](execution-context.md) also applies to normal
8
+ startup and to non-root Linux/macOS runtime operations.
9
+ `--all-users` is not implemented. Selecting every host in `init` is not an
10
+ all-users installation, and managing one other user's instance does not convert
11
+ it into a machine service or LocalSystem service.
12
+
13
+ An explicit account operation requires a canonical numeric SID and exactly one
14
+ existing task or managed-instance selector:
15
+
16
+ ```text
17
+ mcp-pacemaker --user <SID> --task <exact-task-path> upgrade --to <version> --plan
18
+ mcp-pacemaker --user <SID> --task <exact-task-path> upgrade --to <version>
19
+ mcp-pacemaker --user <SID> --instance <directory> upgrade --recover
20
+ ```
21
+
22
+ ### Supported option combinations
23
+
24
+ These limits apply whenever `--user` selects the task-controller path, including
25
+ an explicit selection of the current user's SID.
26
+
27
+ | Form | Explicit account behavior |
28
+ |---|---|
29
+ | `--user SID --task PATH upgrade --to VERSION [--plan]` | One existing legacy task; preserve its account and port |
30
+ | `--user SID --instance DIR upgrade --to VERSION [--plan]` | One registered managed instance |
31
+ | `--user SID --instance DIR upgrade --recover` | Recover only its recorded operation; interactive authorization remains required |
32
+ | `--sxs`, with or without `--port` | Unsupported; refuse before native/task discovery |
33
+ | `--port` without SxS | Unsupported; normal replacement preserves the selected port |
34
+ | Bare `upgrade` or `upgrade --self` | Unsupported in explicit account scope; select `--to` or `--recover` |
35
+ | Recovery with `--task`, `--to`, `--plan` or `--registry` | Unsupported; refuse rather than ignore or override an option |
36
+ | Both `--task` and `--instance`, `--config`, or `--all-users` | Refuse; no scope conversion or new registration is inferred |
37
+ | Execution with `--yes` | Refuse; it cannot replace the two interactive confirmations. With read-only `--to ... --plan`, it authorizes no write |
38
+
39
+ Selectors accept separate or equals syntax before or after `upgrade`.
40
+ Without `--user`, current-user commands include bare repair,
41
+ `--self` guidance, managed SxS with optional port, and selected-instance recovery.
42
+ No machine-wide or cross-account SxS task-creation path is added.
43
+
44
+ Another user requires an **already-elevated** administrator token, enabled
45
+ Administrators membership, and complete native authorization checks. Restricted,
46
+ AppContainer, impersonating or unverified cross-account contexts refuse.
47
+ No account label, saved administrator flag or group membership alone grants
48
+ authority. The product does not request UAC, enable privileges, impersonate,
49
+ supply passwords, create accounts or change existing permissions. The one
50
+ exception is outside this path: the default-scope `upgrade --fix-legacy-access`
51
+ step, which an administrator runs explicitly for a 1.3 install that was set up
52
+ from an administrator terminal. It changes only that task's run-as user entry and
53
+ the owner of that install's `bin\cli.mjs`.
54
+
55
+ ## Where code executes
56
+
57
+ Invoke the management CLI from an independently trusted installation. The
58
+ elevated controller never imports the target's CLI or recovery code and never
59
+ runs its Node/npm, dependency hooks, authentication commands or workloads.
60
+ Target records are bounded data. Elevated code roots with untrusted writers or
61
+ redirectable ancestors refuse; a hash does not turn writable code into trusted code.
62
+
63
+ Ordinary selected-CLI commands use a fresh native effective-context observation.
64
+ Only a non-elevated, non-administrator primary token plus a complete stable set
65
+ of held parent threads with no observed impersonation token can avoid the
66
+ administrator-path recursive root checks. Primary-token elevation alone is not
67
+ enough. Unknown, denied, changed or impersonating observations retain the strict
68
+ checks with a reason; unverified helper exit is fatal. Target JSON cannot select
69
+ this path. Package inventory verification still runs, and the observation does
70
+ not promise that the process can never change context afterward.
71
+
72
+ The existing task launches a bounded worker under its **unchanged original
73
+ principal, InteractiveToken logon type and run level**. That worker performs
74
+ staging, dependency acquisition, preflight, the existing same-user stop protocol,
75
+ runtime startup and rollback. Task Scheduler success is only correlation:
76
+ disabled tasks and `AllowDemandStart=false` do not prove a worker ran.
77
+ An eligible existing interactive session and actual authenticated worker are required.
78
+ Explicit account inspection also requires the selected user's profile facts from
79
+ `Win32_UserProfile`. An unavailable provider or unreadable profile stops the operation;
80
+ the adapter does not infer another user's profile from environment variables or repair WMI.
81
+ Only non-elevated (`RunLevel=0`) InteractiveToken registrations are supported;
82
+ other logon/run-level choices refuse rather than being converted.
83
+
84
+ The guard queries only the selected session. Full account-name resolution must
85
+ match the target SID; the session must be active or disconnected but still logged
86
+ on. Stable WTS snapshots bind its logon generation before bootstrap, after worker
87
+ connection and at each authorization. Missing, denied, changed or unmapped facts
88
+ refuse. The approval prompt is followed by another check before staging.
89
+ These are **point-in-time eligibility checks**, not held session ownership:
90
+ Windows does not atomically reserve that desktop across `RunEx`. Detected drift
91
+ aborts; no session is created, switched, disconnected or impersonated.
92
+
93
+ The selected task's owner may be its runtime user or canonical
94
+ `BUILTIN\Administrators` (`S-1-5-32-544`). Other accounts called “Administrator”
95
+ are not assumed privileged. Owner, group, DACL, SACL and integrity are captured,
96
+ bound and preserved independently from run-as identity. Missing, denied or
97
+ changed full security refuses; it is never repaired or relaxed.
98
+
99
+ ## Controller capability and channel
100
+
101
+ The controller can only inspect/hold/repoint/restore/release the exact approved
102
+ registration. Paths, account, original XML/security and allowed variants are
103
+ sealed before worker requests. Worker data cannot select a different task,
104
+ executable, principal or privileged configuration write.
105
+
106
+ A separate query-only native guard owns one local pipe connection and retains
107
+ the actual worker PID/creation/token/session identity. It has no process-kill
108
+ interface. A protected per-operation manifest carries an ephemeral public key;
109
+ Node's built-in Ed25519 signatures bind a fresh challenge, operation digest,
110
+ guard-observed peer and monotonic request/response transcript. Private keys stay
111
+ in controller memory. There is no reconnect-by-PID or nonce-only fallback.
112
+ Signed replies also bind the checked session SID, ID and logon generation.
113
+ Peer identity is not loaded-code attestation; the fixed capability remains safe
114
+ even if an authenticated target-user worker lies.
115
+
116
+ Only new operation-directory/file/pipe security is established at creation.
117
+ The target must already be able to read the trusted bootstrap and traverse/read
118
+ the protected operation store. `--controller-root <existing-directory>` selects
119
+ an already suitable store; it does not install into Program Files or repair ACLs.
120
+ The default store parent is the controller's local application-data directory.
121
+ If its existing ancestors do not support the required target access/protection,
122
+ the operation refuses before stopping the backend.
123
+
124
+ Automatic triggers/restarts are held while deliberate demand start remains
125
+ possible. Manual/external task starts are not claimed suppressed. Conflicting
126
+ generations or revisions refuse. Because `IgnoreNew` can block a second task run
127
+ while the worker is active, verified legacy rollback launches the original
128
+ supervisor locally **inside the original-user worker**, not as the administrator.
129
+ Legacy captured roots are checked for the worker's session before stop; native
130
+ held-generation revalidation remains stop authority. Plans no longer list
131
+ descendants: the processes the 1.3 bridge started are found and stopped by the
132
+ broker during the stop, through the same handles, without a separate session query.
133
+ Processes that cannot be traced to the bridge are never session-query or kill targets.
134
+ The compact session request accepts at most 512 unique captured identities and
135
+ 128 KiB of UTF-8. PID, FILETIME and current-account/session fields are checked
136
+ before discovery. Session queries use at most 32 exact IDs per batch and four
137
+ separate local CIM sessions, within the unchanged 15-second caller bound.
138
+ Missing, duplicate, stale or unexpected results refuse the whole operation.
139
+
140
+ ## Failure and recovery
141
+
142
+ Declining the runtime-upgrade prompt after worker bootstrap restores the exact
143
+ pre-bootstrap task revision after fresh authorization. No staging or stop is
144
+ authorized by that decline. Failed authorization or restoration remains an
145
+ explicit uncertain outcome.
146
+
147
+ An upgrade refused before anything stops, for example because `servers.json`
148
+ changed while the target downloaded, puts the current version back in service
149
+ and restores the original task before its journal settles, so a later
150
+ `--recover` changes nothing. If the current version could not be resumed or the
151
+ task could not be restored, the journal stays open and `--recover` finishes the
152
+ job.
153
+
154
+ ### Controller-only failure before instance publication
155
+
156
+ If bootstrap happened but automatic restoration failed before any managed
157
+ instance was published, **there is no automatic CLI recovery command for that
158
+ state**. `--instance <missing-directory>` cannot work, and explicit-account
159
+ `--task ... upgrade --recover` is unsupported. Do not create an instance record,
160
+ guess an operation, or edit target configuration to make either command run.
161
+
162
+ The error and `Recovery evidence:` output name the exact protected operation
163
+ directory. Keep both files:
164
+
165
+ - `manifest.json`: `document.initialTask` is the pre-bootstrap task snapshot;
166
+ `document.binding.initialTaskRevision` binds it.
167
+ - `controller-journal.json`: `initial`, the last verified `current` revision,
168
+ full task security, and the uncertain phase.
169
+
170
+ The supported path is **manual verified restoration of that exact task** by an
171
+ authorized operator using the existing Task Scheduler COM adapter/tooling:
172
+
173
+ 1. Verify the protected evidence, task path, SID and initial revision agree.
174
+ Missing or inconsistent evidence is a refusal, not permission to reconstruct it.
175
+ 2. Re-establish the controller's required actual authority and fresh target
176
+ SID/session/logon-generation eligibility. Saved token/session facts are not
177
+ authorization; denied or changed checks remain blocking.
178
+ 3. Read the actual task XML and complete security descriptor again. Reconcile
179
+ it with the captured revisions before any write; do not overwrite an unknown
180
+ intervening change.
181
+ 4. Restore the saved initial definition using update-only registration while
182
+ preserving the principal, logon type, run level and raw full descriptor.
183
+ Re-read and verify the result. Importing XML alone or repairing permissions
184
+ is not this procedure.
185
+
186
+ This manual path restores task registration only. It does not authorize runtime
187
+ stop/start, replay, account/session changes, or administrator writes to target
188
+ state. If those checks cannot be completed, retain the evidence and leave the
189
+ operation blocked.
190
+
191
+ Controller and worker journals share an operation ID but have separate authority.
192
+ Loss of the peer/channel ends privileged RPC; it does not authorize killing an
193
+ orphan worker. Uncertain task, stop, launch or admission outcomes remain explicit.
194
+ No unknown tool outcome is replayed.
195
+
196
+ Recovery requires the exact managed instance and its protected prior controller
197
+ record, fresh actor/task/account authorization and a new bootstrap/handshake.
198
+ The initial published legacy instance already contains that operation reference;
199
+ recovery does not depend on a later HOLD acknowledgement or administrator edits
200
+ to target state. The reference, protected manifest, controller record and target
201
+ config/port must agree. Settled committed, aborted and rolled-back transactions,
202
+ including their legacy forms, return without another worker or task mutation.
203
+ An old authorization flag is never reused. Existing stale-lock and uncertain-
204
+ admission refusals still apply; the controller does not edit target configuration
205
+ or force-remove its locks.
206
+
207
+ ## Qualification boundary
208
+
209
+ Owned same-user pipe/process tests exercise the real query-only guard and worker.
210
+ The production Task Scheduler adapters are separately exercised against injected
211
+ COM objects. Neither establishes successful elevated **two-account** task/runtime
212
+ behavior. Real task security visibility does not establish mutation preservation.
213
+
214
+ `tools/account-upgrade/qualify-two-account.ps1` is a runnable qualification entry
215
+ for a separately authorized Windows environment with pre-existing accounts,
216
+ session, task, protected toolchain and operation store. It defaults to read-only
217
+ planning; `-Execute` still requires the product's interactive confirmations.
218
+ It must not be run on a live installation merely because the code is present.
@@ -0,0 +1,39 @@
1
+ # Version compatibility
2
+
3
+ How the 1.3.x and 2.x lines work together. For npm channels and exact-version installs, see
4
+ [install details](install.md). To replace a running installation, see
5
+ [upgrades and rollback](upgrades.md).
6
+
7
+ This documentation describes **2.1.1**. For 1.3.x immediate-save behavior, see the
8
+ [legacy documentation](https://github.com/girishkvs/mcp-pacemaker/blob/v1.3.1/README.md)
9
+ and [support policy](../SECURITY.md#supported-versions). Those documents do not establish
10
+ that a legacy version is available on npm.
11
+
12
+ The historical baselines remain **1.3.0 / 2.0.0**. The earlier **1.3.1 / 2.0.1** and
13
+ **1.3.1 / 2.0.2** patch-pair evidence is not transferable to a new release. The **2.1.1**
14
+ candidate requires fresh exact-byte compatibility and service-replacement gates.
15
+ A 2.x client handles legacy immediate saves. A 1.x client cannot write pooling settings to a
16
+ 2.x bridge: update the CLI and refresh old dashboard tabs. Before downgrading, settle pending
17
+ or interrupted transactions with 2.x. See the [real-version gates](../CONTRIBUTING.md#real-version-compatibility-gates)
18
+ for exact pairs; only the exact executed pairs are covered.
19
+
20
+ ## Upgrading from 1.3.0
21
+
22
+ Version 2.0.0 changes the configuration-save protocol; it does not require a new `servers.json`
23
+ format. Update the bridge and CLI together, then reload any open dashboard pages. A 1.3.0
24
+ client with a current nonce gets HTTP 409 before staging, with an instruction to update the
25
+ client. An already-open tab retains the old nonce after a bridge restart and gets HTTP 401;
26
+ refresh it to load the new dashboard and admin session.
27
+
28
+ Custom API clients must send `x-mcp-pooling-batch: 1`. Treat HTTP 202 as acceptance, not
29
+ completion, and follow the returned batch ID in `prewarm.batches` until it is applied,
30
+ cancelled or failed. CLI scripts must likewise wait for application before relying on new
31
+ settings. Undo now restores the complete batch and may affect several servers.
32
+ Updated clients still understand the immediate HTTP 200 response from a 1.3.0 bridge.
33
+
34
+ Before downgrading, cancel or apply pending changes with 2.0 and resolve any interrupted
35
+ transaction using 2.0 recovery. Stop that bridge before starting 1.3.0 with the active config.
36
+ Do not use a 1.3.0 binary to recover 2.0 transaction files or delete those files to bypass a
37
+ recovery error. Keep a protected backup of the configuration before changing versions.
38
+ If the writer worker exits, further automatic edits are refused until the bridge restarts;
39
+ existing MCP sessions continue.