mcp-pacemaker 2.0.2 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +75 -1
- package/CONTRIBUTING.md +39 -13
- package/README.md +255 -8
- 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-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 +134 -0
- package/bin/account-worker-session.mjs +215 -0
- package/bin/cli-dispatch.mjs +154 -0
- package/bin/cli-main.mjs +1007 -0
- package/bin/cli-selection.mjs +43 -0
- package/bin/cli.mjs +7 -876
- package/bin/legacy-1.3.json +32 -0
- package/bin/legacy-installation.mjs +217 -0
- package/bin/legacy-task.mjs +52 -0
- package/bin/legacy-upgrade.mjs +471 -0
- package/bin/managed-runtime.mjs +170 -0
- package/bin/managed-state.mjs +257 -0
- package/bin/managed-upgrade.mjs +491 -0
- package/bin/mcp-bridge.mjs +132 -4
- package/bin/pooling-batches.mjs +13 -0
- package/bin/pooling-writer.mjs +4 -0
- package/bin/prewarm.mjs +33 -21
- package/bin/runtime-context.mjs +100 -0
- package/bin/service-control.mjs +27 -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 +8 -0
- package/bin/upgrade-dependencies.mjs +122 -0
- package/bin/upgrade-package.mjs +203 -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 +783 -0
- package/bin/windows-legacy/src/LegacyProcessBroker.cs +841 -0
- package/bin/windows-legacy-process.mjs +241 -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 +205 -0
- package/docs/execution-context.md +113 -0
- package/package.json +12 -5
- package/supervisor/supervise.mjs +226 -40
- package/tools/account-upgrade/qualify-two-account.ps1 +66 -0
- package/tools/windows-legacy/README.md +225 -0
- package/tools/windows-legacy/build.ps1 +122 -0
- package/tools/windows-legacy/inventory.mjs +90 -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,205 @@
|
|
|
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.
|
|
50
|
+
|
|
51
|
+
## Where code executes
|
|
52
|
+
|
|
53
|
+
Invoke the management CLI from an independently trusted installation. The
|
|
54
|
+
elevated controller never imports the target's CLI or recovery code and never
|
|
55
|
+
runs its Node/npm, dependency hooks, authentication commands or workloads.
|
|
56
|
+
Target records are bounded data. Elevated code roots with untrusted writers or
|
|
57
|
+
redirectable ancestors refuse; a hash does not turn writable code into trusted code.
|
|
58
|
+
|
|
59
|
+
Ordinary selected-CLI commands use a fresh native effective-context observation.
|
|
60
|
+
Only a non-elevated, non-administrator primary token plus a complete stable set
|
|
61
|
+
of held parent threads with no observed impersonation token can avoid the
|
|
62
|
+
administrator-path recursive root checks. Primary-token elevation alone is not
|
|
63
|
+
enough. Unknown, denied, changed or impersonating observations retain the strict
|
|
64
|
+
checks with a reason; unverified helper exit is fatal. Target JSON cannot select
|
|
65
|
+
this path. Package inventory verification still runs, and the observation does
|
|
66
|
+
not promise that the process can never change context afterward.
|
|
67
|
+
|
|
68
|
+
The existing task launches a bounded worker under its **unchanged original
|
|
69
|
+
principal, InteractiveToken logon type and run level**. That worker performs
|
|
70
|
+
staging, dependency acquisition, preflight, the existing same-user stop protocol,
|
|
71
|
+
runtime startup and rollback. Task Scheduler success is only correlation:
|
|
72
|
+
disabled tasks and `AllowDemandStart=false` do not prove a worker ran.
|
|
73
|
+
An eligible existing interactive session and actual authenticated worker are required.
|
|
74
|
+
Explicit account inspection also requires the selected user's profile facts from
|
|
75
|
+
`Win32_UserProfile`. An unavailable provider or unreadable profile stops the operation;
|
|
76
|
+
the adapter does not infer another user's profile from environment variables or repair WMI.
|
|
77
|
+
Only non-elevated (`RunLevel=0`) InteractiveToken registrations are supported;
|
|
78
|
+
other logon/run-level choices refuse rather than being converted.
|
|
79
|
+
|
|
80
|
+
The guard queries only the selected session. Full account-name resolution must
|
|
81
|
+
match the target SID; the session must be active or disconnected but still logged
|
|
82
|
+
on. Stable WTS snapshots bind its logon generation before bootstrap, after worker
|
|
83
|
+
connection and at each authorization. Missing, denied, changed or unmapped facts
|
|
84
|
+
refuse. The approval prompt is followed by another check before staging.
|
|
85
|
+
These are **point-in-time eligibility checks**, not held session ownership:
|
|
86
|
+
Windows does not atomically reserve that desktop across `RunEx`. Detected drift
|
|
87
|
+
aborts; no session is created, switched, disconnected or impersonated.
|
|
88
|
+
|
|
89
|
+
The selected task's owner may be its runtime user or canonical
|
|
90
|
+
`BUILTIN\Administrators` (`S-1-5-32-544`). Other accounts called “Administrator”
|
|
91
|
+
are not assumed privileged. Owner, group, DACL, SACL and integrity are captured,
|
|
92
|
+
bound and preserved independently from run-as identity. Missing, denied or
|
|
93
|
+
changed full security refuses; it is never repaired or relaxed.
|
|
94
|
+
|
|
95
|
+
## Controller capability and channel
|
|
96
|
+
|
|
97
|
+
The controller can only inspect/hold/repoint/restore/release the exact approved
|
|
98
|
+
registration. Paths, account, original XML/security and allowed variants are
|
|
99
|
+
sealed before worker requests. Worker data cannot select a different task,
|
|
100
|
+
executable, principal or privileged configuration write.
|
|
101
|
+
|
|
102
|
+
A separate query-only native guard owns one local pipe connection and retains
|
|
103
|
+
the actual worker PID/creation/token/session identity. It has no process-kill
|
|
104
|
+
interface. A protected per-operation manifest carries an ephemeral public key;
|
|
105
|
+
Node's built-in Ed25519 signatures bind a fresh challenge, operation digest,
|
|
106
|
+
guard-observed peer and monotonic request/response transcript. Private keys stay
|
|
107
|
+
in controller memory. There is no reconnect-by-PID or nonce-only fallback.
|
|
108
|
+
Signed replies also bind the checked session SID, ID and logon generation.
|
|
109
|
+
Peer identity is not loaded-code attestation; the fixed capability remains safe
|
|
110
|
+
even if an authenticated target-user worker lies.
|
|
111
|
+
|
|
112
|
+
Only new operation-directory/file/pipe security is established at creation.
|
|
113
|
+
The target must already be able to read the trusted bootstrap and traverse/read
|
|
114
|
+
the protected operation store. `--controller-root <existing-directory>` selects
|
|
115
|
+
an already suitable store; it does not install into Program Files or repair ACLs.
|
|
116
|
+
The default store parent is the controller's local application-data directory.
|
|
117
|
+
If its existing ancestors do not support the required target access/protection,
|
|
118
|
+
the operation refuses before stopping the backend.
|
|
119
|
+
|
|
120
|
+
Automatic triggers/restarts are held while deliberate demand start remains
|
|
121
|
+
possible. Manual/external task starts are not claimed suppressed. Conflicting
|
|
122
|
+
generations or revisions refuse. Because `IgnoreNew` can block a second task run
|
|
123
|
+
while the worker is active, verified legacy rollback launches the original
|
|
124
|
+
supervisor locally **inside the original-user worker**, not as the administrator.
|
|
125
|
+
Legacy captured roots and observed descendants are checked for the worker's
|
|
126
|
+
session before stop; native held-generation revalidation remains stop authority.
|
|
127
|
+
Excluded or unattributed historical processes are never session-query or kill targets.
|
|
128
|
+
The compact session request accepts at most 512 unique captured identities and
|
|
129
|
+
128 KiB of UTF-8. PID, FILETIME and current-account/session fields are checked
|
|
130
|
+
before discovery. Session queries use at most 32 exact IDs per batch and four
|
|
131
|
+
separate local CIM sessions, within the unchanged 15-second caller bound.
|
|
132
|
+
Missing, duplicate, stale or unexpected results refuse the whole operation.
|
|
133
|
+
|
|
134
|
+
## Failure and recovery
|
|
135
|
+
|
|
136
|
+
Declining the runtime-upgrade prompt after worker bootstrap restores the exact
|
|
137
|
+
pre-bootstrap task revision after fresh authorization. No staging or stop is
|
|
138
|
+
authorized by that decline. Failed authorization or restoration remains an
|
|
139
|
+
explicit uncertain outcome.
|
|
140
|
+
|
|
141
|
+
### Controller-only failure before instance publication
|
|
142
|
+
|
|
143
|
+
If bootstrap happened but automatic restoration failed before any managed
|
|
144
|
+
instance was published, **there is no automatic CLI recovery command for that
|
|
145
|
+
state**. `--instance <missing-directory>` cannot work, and explicit-account
|
|
146
|
+
`--task ... upgrade --recover` is unsupported. Do not create an instance record,
|
|
147
|
+
guess an operation, or edit target configuration to make either command run.
|
|
148
|
+
|
|
149
|
+
The error and `Recovery evidence:` output name the exact protected operation
|
|
150
|
+
directory. Keep both files:
|
|
151
|
+
|
|
152
|
+
- `manifest.json`: `document.initialTask` is the pre-bootstrap task snapshot;
|
|
153
|
+
`document.binding.initialTaskRevision` binds it.
|
|
154
|
+
- `controller-journal.json`: `initial`, the last verified `current` revision,
|
|
155
|
+
full task security, and the uncertain phase.
|
|
156
|
+
|
|
157
|
+
The supported path is **manual verified restoration of that exact task** by an
|
|
158
|
+
authorized operator using the existing Task Scheduler COM adapter/tooling:
|
|
159
|
+
|
|
160
|
+
1. Verify the protected evidence, task path, SID and initial revision agree.
|
|
161
|
+
Missing or inconsistent evidence is a refusal, not permission to reconstruct it.
|
|
162
|
+
2. Re-establish the controller's required actual authority and fresh target
|
|
163
|
+
SID/session/logon-generation eligibility. Saved token/session facts are not
|
|
164
|
+
authorization; denied or changed checks remain blocking.
|
|
165
|
+
3. Read the actual task XML and complete security descriptor again. Reconcile
|
|
166
|
+
it with the captured revisions before any write; do not overwrite an unknown
|
|
167
|
+
intervening change.
|
|
168
|
+
4. Restore the saved initial definition using update-only registration while
|
|
169
|
+
preserving the principal, logon type, run level and raw full descriptor.
|
|
170
|
+
Re-read and verify the result. Importing XML alone or repairing permissions
|
|
171
|
+
is not this procedure.
|
|
172
|
+
|
|
173
|
+
This manual path restores task registration only. It does not authorize runtime
|
|
174
|
+
stop/start, replay, account/session changes, or administrator writes to target
|
|
175
|
+
state. If those checks cannot be completed, retain the evidence and leave the
|
|
176
|
+
operation blocked.
|
|
177
|
+
|
|
178
|
+
Controller and worker journals share an operation ID but have separate authority.
|
|
179
|
+
Loss of the peer/channel ends privileged RPC; it does not authorize killing an
|
|
180
|
+
orphan worker. Uncertain task, stop, launch or admission outcomes remain explicit.
|
|
181
|
+
No unknown tool outcome is replayed.
|
|
182
|
+
|
|
183
|
+
Recovery requires the exact managed instance and its protected prior controller
|
|
184
|
+
record, fresh actor/task/account authorization and a new bootstrap/handshake.
|
|
185
|
+
The initial published legacy instance already contains that operation reference;
|
|
186
|
+
recovery does not depend on a later HOLD acknowledgement or administrator edits
|
|
187
|
+
to target state. The reference, protected manifest, controller record and target
|
|
188
|
+
config/port must agree. Settled committed, aborted and rolled-back transactions,
|
|
189
|
+
including their legacy forms, return without another worker or task mutation.
|
|
190
|
+
An old authorization flag is never reused. Existing stale-lock and uncertain-
|
|
191
|
+
admission refusals still apply; the controller does not edit target configuration
|
|
192
|
+
or force-remove its locks.
|
|
193
|
+
|
|
194
|
+
## Qualification boundary
|
|
195
|
+
|
|
196
|
+
Owned same-user pipe/process tests exercise the real query-only guard and worker.
|
|
197
|
+
The production Task Scheduler adapters are separately exercised against injected
|
|
198
|
+
COM objects. Neither establishes successful elevated **two-account** task/runtime
|
|
199
|
+
behavior. Real task security visibility does not establish mutation preservation.
|
|
200
|
+
|
|
201
|
+
`tools/account-upgrade/qualify-two-account.ps1` is a runnable qualification entry
|
|
202
|
+
for a separately authorized Windows environment with pre-existing accounts,
|
|
203
|
+
session, task, protected toolchain and operation store. It defaults to read-only
|
|
204
|
+
planning; `-Execute` still requires the product's interactive confirmations.
|
|
205
|
+
It must not be run on a live installation merely because the code is present.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# User-runtime execution contracts
|
|
2
|
+
|
|
3
|
+
The default runtime is an ordinary process belonging to its user. Starting the
|
|
4
|
+
bridge or supervisor, registering autostart, upgrading and recovering do not
|
|
5
|
+
silently elevate, demote, impersonate or select another account. An unavailable
|
|
6
|
+
or privileged context refuses before runtime work. Read-only planning and
|
|
7
|
+
installation with both `--no-start` and `--no-autostart` remain separate.
|
|
8
|
+
|
|
9
|
+
`bin/runtime-context.mjs` is the shared observation boundary:
|
|
10
|
+
|
|
11
|
+
| Platform | Required effective context |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| Windows | The existing native ordinary-caller proof: no elevation, enabled administrator membership or observed thread impersonation |
|
|
14
|
+
| Linux | Stable non-root real/effective IDs, matching saved/filesystem IDs, and zero inherited/permitted/effective/ambient capabilities |
|
|
15
|
+
| macOS | Stable non-root real/effective user IDs and matching real/effective group IDs |
|
|
16
|
+
|
|
17
|
+
These are snapshots, not a promise that a process can never change context.
|
|
18
|
+
The guard does not grant access to an arbitrary installation. Existing instance,
|
|
19
|
+
file-security, service-registration and account-authorization checks still apply.
|
|
20
|
+
Explicit original-account administration remains the separate Windows-only
|
|
21
|
+
capability described in [account upgrades](account-upgrades.md).
|
|
22
|
+
|
|
23
|
+
## Test contracts
|
|
24
|
+
|
|
25
|
+
`tools/ci/test-contracts.mjs` declares the execution role, command and required
|
|
26
|
+
capabilities. It verifies that the source contract includes every registered
|
|
27
|
+
source test and preserves the declared test concurrency.
|
|
28
|
+
|
|
29
|
+
| Contract | Required role and coverage |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| `source` | Ordinary runtime; complete registered source suite. Windows also needs the real caller's WTS logon because its account/session tests use that API. |
|
|
32
|
+
| `source-ordinary` | Windows only. Ordinary runtime without a logon session; every registered file except the four in `SESSION_REQUIRED_TESTS`. The two Windows lanes must together cover every registered file exactly once. |
|
|
33
|
+
| `compatibility` | Ordinary runtime; complete CLI/API compatibility command |
|
|
34
|
+
| `browser` | Ordinary runtime; declared browser compatibility command |
|
|
35
|
+
| `audit` | Verified privileged Windows fixture; the unchanged mandatory audit test, including its ordinary worker and complete result checks |
|
|
36
|
+
| `service-context` | Read-only capability observation; **not** test coverage |
|
|
37
|
+
|
|
38
|
+
Run the same entry point locally or in CI:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
npm run test:contract -- plan
|
|
42
|
+
npm run test:contract -- source --check
|
|
43
|
+
npm run test:contract -- source
|
|
44
|
+
npm run test:contract -- compatibility
|
|
45
|
+
npm run test:contract -- service-context --check
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The service-context observation uses the current user's WTS session on Windows,
|
|
49
|
+
owned `/run/user/<uid>` and user `systemd` manager on Linux, or the caller's `gui`
|
|
50
|
+
launchd domain on macOS. A CLI-only or browser contract does not require a desktop
|
|
51
|
+
merely for consistency with Windows.
|
|
52
|
+
|
|
53
|
+
No adapter creates a user or session, changes UAC, edits privileges, rewrites a
|
|
54
|
+
token, or falls back to another identity. Setup capability and ordinary runtime
|
|
55
|
+
capability are not interchangeable. A missing required capability is a blocked
|
|
56
|
+
qualification, not a successful skip.
|
|
57
|
+
|
|
58
|
+
Windows integration fixtures also need a private checkout, home and temporary
|
|
59
|
+
tree whose ancestors satisfy the existing path-security checks. A shared writable
|
|
60
|
+
drive can correctly fail with `UNTRUSTED_PATH_WRITER` even when the process is
|
|
61
|
+
ordinary. Use short, trusted profile-owned test roots: the nested upgrade packages
|
|
62
|
+
must remain within the native .NET executable path limits, not just Node's file
|
|
63
|
+
API limits. Do not change drive ACLs, enable long-path policy or relax the native
|
|
64
|
+
checks to make an unsuitable test root pass.
|
|
65
|
+
|
|
66
|
+
## CI and release boundaries
|
|
67
|
+
|
|
68
|
+
The public workflow separates three roles rather than assuming an OS label implies a runtime.
|
|
69
|
+
|
|
70
|
+
| Lane | Runner | What it proves |
|
|
71
|
+
| --- | --- | --- |
|
|
72
|
+
| `test (ubuntu-latest\|macos-latest, 20\|22\|24)` | Stock hosted | Ordinary user-runtime source and compatibility suites |
|
|
73
|
+
| `windows-ordinary (20\|22\|24)` | Stock hosted Windows | Install, syntax, pinned native toolchain, three reproducible native builds, the actual refusal of an elevated built-in Administrator, then the `source-ordinary` and `compatibility` contracts run as a provisioned disposable standard account |
|
|
74
|
+
| Windows session lane | Ordinary interactive Windows session | The four `SESSION_REQUIRED_TESTS` files, through the local publication gate |
|
|
75
|
+
|
|
76
|
+
A stock hosted Windows runner signs in as the built-in Administrator with a full elevated token, and
|
|
77
|
+
Task Scheduler ignores `RunLevel` for that account, so that account cannot host the ordinary
|
|
78
|
+
user-runtime contract. The lane therefore does not try to demote it. It provisions a disposable
|
|
79
|
+
standard account, which is never an administrator, and runs the suite as that account. It also
|
|
80
|
+
proves the elevated refusal itself, and `tools/ci/windows-runner-role.mjs` fails loudly if a Windows
|
|
81
|
+
runner ever does satisfy the ordinary contract, so real coverage is promoted rather than left
|
|
82
|
+
unexecuted.
|
|
83
|
+
|
|
84
|
+
A provisioned account signs in with a password logon and has no interactive logon session, so the
|
|
85
|
+
four `SESSION_REQUIRED_TESTS` files that bind one stay in the session lane. They are still required,
|
|
86
|
+
not skipped: `tools/npm-publication/local-gate.mjs` runs the complete source suite for the current
|
|
87
|
+
release and requires `role: user-runtime` with `sessionVerified: true`. A Windows container without a
|
|
88
|
+
real logged-on account cannot satisfy that, and neither can a hosted job. `testContract` asserts the
|
|
89
|
+
two Windows lanes cover every registered file exactly once, so no file can fall out of both.
|
|
90
|
+
|
|
91
|
+
The CI provider bounds job lifetime and owns job teardown; the contract entry
|
|
92
|
+
point does not claim to provide VM or network isolation.
|
|
93
|
+
|
|
94
|
+
The local command forwards cancellation to its owned test child and waits for
|
|
95
|
+
exit/output closure, escalating termination after five seconds. Unverified
|
|
96
|
+
closure after another five seconds is an error, not successful cleanup. Source
|
|
97
|
+
tests retain a 60-minute command budget and compatibility tests a 45-minute budget;
|
|
98
|
+
the audit keeps its existing 210-second budget and a 16 MiB output limit.
|
|
99
|
+
Windows commands also use the existing native process-lifetime mechanism.
|
|
100
|
+
None of these command bounds changes application startup or shutdown deadlines.
|
|
101
|
+
|
|
102
|
+
No integration runner is provisioned by this change. An approved environment
|
|
103
|
+
must already supply the required ordinary identity and session. Do not attach a
|
|
104
|
+
personal workstation or unrestricted persistent runner to public pull requests.
|
|
105
|
+
Runner admission, trusted-source selection, disposable-machine isolation and
|
|
106
|
+
credential policy require independent infrastructure approval.
|
|
107
|
+
|
|
108
|
+
The existing exact-source CI and local publication requirements remain enforced.
|
|
109
|
+
The local source gate invokes the same read-only preflight and its verifier
|
|
110
|
+
requires that original command receipt. A Windows container without WTS cannot
|
|
111
|
+
stand in for an interactive user session. The privileged audit companion remains
|
|
112
|
+
required and cannot replace the source suite. No missing integration result is
|
|
113
|
+
relabelled as a pass.
|