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.
- package/CHANGELOG.md +171 -1
- package/CONTRIBUTING.md +46 -13
- package/README.md +109 -524
- package/THIRD_PARTY_NOTICES.txt +1 -28
- package/autostart/windows/account-task.ps1 +167 -0
- package/autostart/windows/inspect-task.ps1 +42 -0
- package/autostart/windows/inspect-worker-process-sessions.ps1 +155 -0
- package/autostart/windows/legacy-access.ps1 +359 -0
- package/autostart/windows/legacy-task.ps1 +301 -0
- package/autostart/windows/register-task.ps1 +11 -2
- package/bin/account-task-adapter.mjs +47 -0
- package/bin/account-upgrade-controller.mjs +198 -0
- package/bin/account-upgrade-worker.mjs +141 -0
- package/bin/account-worker-session.mjs +215 -0
- package/bin/cli-dispatch.mjs +154 -0
- package/bin/cli-main.mjs +1063 -0
- package/bin/cli-selection.mjs +43 -0
- package/bin/cli.mjs +7 -876
- package/bin/dependency-lock.json +642 -0
- package/bin/legacy-1.3.json +32 -0
- package/bin/legacy-access.mjs +73 -0
- package/bin/legacy-installation.mjs +273 -0
- package/bin/legacy-task.mjs +76 -0
- package/bin/legacy-upgrade.mjs +527 -0
- package/bin/managed-runtime.mjs +170 -0
- package/bin/managed-state.mjs +257 -0
- package/bin/managed-upgrade.mjs +581 -0
- package/bin/mcp-bridge.mjs +150 -4
- package/bin/pooling-batches.mjs +13 -0
- package/bin/pooling-writer.mjs +4 -0
- package/bin/prewarm.mjs +59 -25
- package/bin/runtime-context.mjs +100 -0
- package/bin/service-control.mjs +32 -4
- package/bin/stable-launcher.mjs +54 -0
- package/bin/task-only-controller.mjs +226 -0
- package/bin/task-scope.mjs +65 -0
- package/bin/task-transaction-protocol.mjs +173 -0
- package/bin/top.mjs +22 -10
- package/bin/update-check.mjs +13 -2
- package/bin/upgrade-autostart.mjs +47 -0
- package/bin/upgrade-capability.json +9 -0
- package/bin/upgrade-dependencies.mjs +183 -0
- package/bin/upgrade-package.mjs +205 -0
- package/bin/windows-legacy/LegacyProcessBroker.build.json +42 -0
- package/bin/windows-legacy/LegacyProcessBroker.exe +0 -0
- package/bin/windows-legacy/src/AssemblyInfo.cs +8 -0
- package/bin/windows-legacy/src/LegacyNative.cs +832 -0
- package/bin/windows-legacy/src/LegacyProcessBroker.cs +961 -0
- package/bin/windows-legacy-process.mjs +248 -0
- package/bin/windows-lifetime/ProcessLifetimeHelper.build.json +38 -0
- package/bin/windows-lifetime/ProcessLifetimeHelper.exe +0 -0
- package/bin/windows-lifetime/src/AssemblyInfo.cs +8 -0
- package/bin/windows-lifetime/src/ProcessLifetimeHelper.cs +468 -0
- package/bin/windows-process-lifetime.mjs +218 -0
- package/bin/windows-task-channel/TaskChannelGuard.build.json +45 -0
- package/bin/windows-task-channel/TaskChannelGuard.exe +0 -0
- package/bin/windows-task-channel/src/AssemblyInfo.cs +8 -0
- package/bin/windows-task-channel/src/ChannelCliContext.cs +210 -0
- package/bin/windows-task-channel/src/ChannelFiles.cs +231 -0
- package/bin/windows-task-channel/src/ChannelNative.cs +477 -0
- package/bin/windows-task-channel/src/ChannelSession.cs +203 -0
- package/bin/windows-task-channel/src/TaskChannelGuard.cs +686 -0
- package/bin/windows-task-channel.mjs +335 -0
- package/docs/account-upgrades.md +218 -0
- package/docs/compatibility.md +39 -0
- package/docs/configuration.md +146 -0
- package/docs/dashboards.md +125 -0
- package/docs/execution-context.md +119 -0
- package/docs/http-api.md +47 -0
- package/docs/install.md +52 -0
- package/docs/operations.md +99 -0
- package/docs/upgrades.md +324 -0
- package/package.json +19 -5
- package/supervisor/supervise.mjs +226 -40
- package/tools/account-upgrade/qualify-two-account.ps1 +66 -0
- package/tools/windows-legacy/README.md +251 -0
- package/tools/windows-legacy/build.ps1 +122 -0
- package/tools/windows-legacy/inventory.mjs +92 -0
- package/tools/windows-process-lifetime/README.md +119 -0
- package/tools/windows-process-lifetime/build.ps1 +122 -0
- package/tools/windows-process-lifetime/inventory.mjs +86 -0
- package/tools/windows-task-channel/README.md +329 -0
- package/tools/windows-task-channel/build.ps1 +107 -0
- package/tools/windows-task-channel/inventory.mjs +117 -0
- package/ui/dist/THIRD_PARTY_NOTICES.txt +1 -28
- package/ui/dist/assets/{index-CgPruWYi.js → index-CcwMo2sI.js} +1 -1
- package/ui/dist/assets/index-PVcuDNrN.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/third-party-manifest.json +37 -25
- package/ui/dist/assets/index-CaLPg0c-.css +0 -1
|
@@ -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.
|