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