mcp-pacemaker 2.0.1 → 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.
Files changed (80) hide show
  1. package/CHANGELOG.md +105 -1
  2. package/CONTRIBUTING.md +49 -13
  3. package/README.md +361 -60
  4. package/THIRD_PARTY_NOTICES.txt +1 -28
  5. package/autostart/windows/account-task.ps1 +167 -0
  6. package/autostart/windows/inspect-task.ps1 +42 -0
  7. package/autostart/windows/inspect-worker-process-sessions.ps1 +155 -0
  8. package/autostart/windows/legacy-task.ps1 +301 -0
  9. package/autostart/windows/register-task.ps1 +11 -2
  10. package/bin/account-task-adapter.mjs +47 -0
  11. package/bin/account-upgrade-controller.mjs +198 -0
  12. package/bin/account-upgrade-worker.mjs +134 -0
  13. package/bin/account-worker-session.mjs +215 -0
  14. package/bin/cli-dispatch.mjs +154 -0
  15. package/bin/cli-main.mjs +1007 -0
  16. package/bin/cli-selection.mjs +43 -0
  17. package/bin/cli.mjs +7 -876
  18. package/bin/legacy-1.3.json +32 -0
  19. package/bin/legacy-installation.mjs +217 -0
  20. package/bin/legacy-task.mjs +52 -0
  21. package/bin/legacy-upgrade.mjs +471 -0
  22. package/bin/managed-runtime.mjs +170 -0
  23. package/bin/managed-state.mjs +257 -0
  24. package/bin/managed-upgrade.mjs +491 -0
  25. package/bin/mcp-bridge.mjs +132 -4
  26. package/bin/pooling-batches.mjs +13 -0
  27. package/bin/pooling-writer.mjs +4 -0
  28. package/bin/prewarm.mjs +33 -21
  29. package/bin/runtime-context.mjs +100 -0
  30. package/bin/service-control.mjs +27 -4
  31. package/bin/stable-launcher.mjs +54 -0
  32. package/bin/task-only-controller.mjs +226 -0
  33. package/bin/task-scope.mjs +65 -0
  34. package/bin/task-transaction-protocol.mjs +173 -0
  35. package/bin/top.mjs +22 -10
  36. package/bin/update-check.mjs +13 -2
  37. package/bin/upgrade-autostart.mjs +47 -0
  38. package/bin/upgrade-capability.json +8 -0
  39. package/bin/upgrade-dependencies.mjs +122 -0
  40. package/bin/upgrade-package.mjs +203 -0
  41. package/bin/windows-legacy/LegacyProcessBroker.build.json +42 -0
  42. package/bin/windows-legacy/LegacyProcessBroker.exe +0 -0
  43. package/bin/windows-legacy/src/AssemblyInfo.cs +8 -0
  44. package/bin/windows-legacy/src/LegacyNative.cs +783 -0
  45. package/bin/windows-legacy/src/LegacyProcessBroker.cs +841 -0
  46. package/bin/windows-legacy-process.mjs +241 -0
  47. package/bin/windows-lifetime/ProcessLifetimeHelper.build.json +38 -0
  48. package/bin/windows-lifetime/ProcessLifetimeHelper.exe +0 -0
  49. package/bin/windows-lifetime/src/AssemblyInfo.cs +8 -0
  50. package/bin/windows-lifetime/src/ProcessLifetimeHelper.cs +468 -0
  51. package/bin/windows-process-lifetime.mjs +218 -0
  52. package/bin/windows-task-channel/TaskChannelGuard.build.json +45 -0
  53. package/bin/windows-task-channel/TaskChannelGuard.exe +0 -0
  54. package/bin/windows-task-channel/src/AssemblyInfo.cs +8 -0
  55. package/bin/windows-task-channel/src/ChannelCliContext.cs +210 -0
  56. package/bin/windows-task-channel/src/ChannelFiles.cs +231 -0
  57. package/bin/windows-task-channel/src/ChannelNative.cs +477 -0
  58. package/bin/windows-task-channel/src/ChannelSession.cs +203 -0
  59. package/bin/windows-task-channel/src/TaskChannelGuard.cs +686 -0
  60. package/bin/windows-task-channel.mjs +335 -0
  61. package/docs/account-upgrades.md +205 -0
  62. package/docs/execution-context.md +113 -0
  63. package/package.json +12 -5
  64. package/supervisor/supervise.mjs +226 -40
  65. package/tools/account-upgrade/qualify-two-account.ps1 +66 -0
  66. package/tools/windows-legacy/README.md +225 -0
  67. package/tools/windows-legacy/build.ps1 +122 -0
  68. package/tools/windows-legacy/inventory.mjs +90 -0
  69. package/tools/windows-process-lifetime/README.md +119 -0
  70. package/tools/windows-process-lifetime/build.ps1 +122 -0
  71. package/tools/windows-process-lifetime/inventory.mjs +86 -0
  72. package/tools/windows-task-channel/README.md +329 -0
  73. package/tools/windows-task-channel/build.ps1 +107 -0
  74. package/tools/windows-task-channel/inventory.mjs +117 -0
  75. package/ui/dist/THIRD_PARTY_NOTICES.txt +1 -28
  76. package/ui/dist/assets/{index-CgPruWYi.js → index-CcwMo2sI.js} +1 -1
  77. package/ui/dist/assets/index-PVcuDNrN.css +1 -0
  78. package/ui/dist/index.html +2 -2
  79. package/ui/dist/third-party-manifest.json +37 -25
  80. package/ui/dist/assets/index-CaLPg0c-.css +0 -1
@@ -0,0 +1,226 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ import { dirname } from 'node:path';
3
+ import { durableJson } from './managed-state.mjs';
4
+ import { canonicalJson, ControllerSignatures, operationDigest, peerBinding, taskRevision, validateTaskRequest, verifyTargetSession } from './task-transaction-protocol.mjs';
5
+ import { authorizeTaskActor } from './task-scope.mjs';
6
+ import { LEGACY_WARNING } from './legacy-installation.mjs';
7
+
8
+ export function workerTaskRecord(record, targetSid) {
9
+ return { ...record, currentUserSid: targetSid };
10
+ }
11
+
12
+ export function minimalTaskRecord(record) {
13
+ return {
14
+ name: record.name, enabled: record.enabled, user: record.runAsName ?? record.userSid,
15
+ logonType: record.logonType, runLevel: record.runLevel, actions: record.actions,
16
+ };
17
+ }
18
+
19
+ export class TaskOnlyController {
20
+ constructor({ binding, original, initial = original, task, channel,
21
+ signatures = new ControllerSignatures(), confirm, journalPath }) {
22
+ this.binding = structuredClone(binding);
23
+ this.original = structuredClone(original);
24
+ this.current = structuredClone(initial);
25
+ this.initial = structuredClone(initial);
26
+ this.task = task;
27
+ this.channel = channel;
28
+ this.targetSession = verifyTargetSession(channel.targetSession, { ownerSid: binding.targetSid, sessionId: binding.sessionId });
29
+ this.signatures = signatures;
30
+ this.confirm = confirm;
31
+ this.journalPath = journalPath;
32
+ this.sequence = 0;
33
+ this.phase = 'new';
34
+ this.digest = operationDigest(binding);
35
+ this.stateDigest = this.digest;
36
+ this.history = [];
37
+ }
38
+
39
+ save(phase) {
40
+ this.phase = phase;
41
+ this.stateDigest = operationDigest({ operationId: this.binding.operationId, phase, sequence: this.sequence,
42
+ taskRevision: taskRevision(this.current) });
43
+ if (this.journalPath) durableJson(this.journalPath, {
44
+ protocol: 1, binding: this.binding, original: this.original, initial: this.initial, current: this.current,
45
+ phase, sequence: this.sequence, stateDigest: this.stateDigest, history: this.history, peer: this.peer ?? null,
46
+ targetSession: this.targetSession,
47
+ });
48
+ }
49
+
50
+ async verifyTask() {
51
+ const result = await this.channel.authorize();
52
+ authorizeTaskActor(result.actorFacts, this.binding.targetSid);
53
+ this.targetSession = verifyTargetSession(result.targetSession, this.targetSession);
54
+ const actual = (await this.task.inspect()).record;
55
+ if (taskRevision(actual) !== taskRevision(this.current)) throw new Error('Approved task revision or full security changed.');
56
+ return actual;
57
+ }
58
+
59
+ assertPreserved(record) {
60
+ if (record.path !== this.original.path ||
61
+ record.userSid !== this.binding.targetSid ||
62
+ record.logonType !== this.original.logonType ||
63
+ record.runLevel !== this.original.runLevel ||
64
+ !isDeepStrictEqual(record.security, this.original.security)) throw new Error('Task operation changed its sealed account or full security.');
65
+ }
66
+
67
+ async mutate(action, phase) {
68
+ await this.verifyTask();
69
+ this.save(`${phase}-intent`);
70
+ const args = [this.current, this.original];
71
+ if (action === 'bootstrap') args.push(this.binding.bootstrapTask);
72
+ if (action === 'repoint') args.push(this.binding.launcher);
73
+ const result = await this.task[action](...args);
74
+ this.assertPreserved(result);
75
+ this.current = result;
76
+ this.history.push({ phase, revision: taskRevision(result) });
77
+ if (this.history.length > 16) throw new Error('Task transaction exceeded its transition bound.');
78
+ this.save(phase);
79
+ }
80
+
81
+ async restoreInitial(phase) {
82
+ try {
83
+ await this.verifyTask();
84
+ this.save(`${phase}-intent`);
85
+ const restored = await this.task.restore(this.current, this.initial);
86
+ this.assertPreserved(restored);
87
+ if (taskRevision(restored) !== taskRevision(this.initial)) throw new Error('Pre-bootstrap task revision was not restored exactly.');
88
+ this.current = restored;
89
+ this.save(phase);
90
+ } catch (error) {
91
+ const evidence = this.journalPath ? dirname(this.journalPath) : this.binding.manifestPath;
92
+ throw new Error(`Automatic task restoration did not verify. Preserve the protected manifest and controller journal at ${JSON.stringify(evidence)}. ` +
93
+ `Manual verified restoration of ${JSON.stringify(this.initial.path)} to its saved pre-bootstrap revision is required. ` +
94
+ 'There is no controller-operation-only recovery command: without a published instance, --instance cannot recover this state, and --task --recover is unsupported. ' +
95
+ 'Do not create target state or relax task security/session checks. ' + error.message, { cause: error });
96
+ }
97
+ }
98
+
99
+ async accept(packet) {
100
+ if (canonicalJson(peerBinding(packet.peer)) !== canonicalJson(this.peer)) throw new Error('Worker connection identity changed.');
101
+ const request = validateTaskRequest(packet.frame, this.binding.operationId, this.sequence,
102
+ this.sequence === 0 ? this.digest : this.stateDigest);
103
+ let body;
104
+ switch (request.verb) {
105
+ case 'HELLO':
106
+ if (this.phase !== 'bootstrap' ||
107
+ canonicalJson(peerBinding(request.body.identity)) !== canonicalJson(this.peer)) throw new Error('Worker hello does not match its held OS identity.');
108
+ this.challenge = request.body.challenge;
109
+ await this.verifyTask();
110
+ this.save('authenticated');
111
+ body = { ok: true, original: workerTaskRecord(this.original, this.binding.targetSid) };
112
+ break;
113
+ case 'PLAN': {
114
+ if (this.phase !== 'authenticated') throw new Error('Plan approval cannot be repeated.');
115
+ const message = [
116
+ `Approve ${this.binding.recover ? 'recovery' : 'upgrade'} for the sealed task ${JSON.stringify(this.binding.taskPath)}?`,
117
+ `Original account: ${this.binding.targetSid}; port ${this.binding.port}; ${JSON.stringify(this.binding.from)} -> ${JSON.stringify(this.binding.to)}.`,
118
+ this.binding.kind === 'legacy' ? LEGACY_WARNING : 'Managed admission and verified shutdown remain required.',
119
+ `Worker plan digest: ${request.body.digest}`,
120
+ ].join('\n');
121
+ const approved = await this.confirm(message);
122
+ if (approved !== true) {
123
+ await this.restoreInitial('cancelled');
124
+ this.result = { status: 'cancelled', directory: this.binding.destination, port: this.binding.port };
125
+ body = { approved: false, digest: request.body.digest };
126
+ break;
127
+ }
128
+ await this.verifyTask();
129
+ this.approvedPlan = request.body.digest;
130
+ this.save('approved');
131
+ body = { approved: true, digest: request.body.digest };
132
+ break;
133
+ }
134
+ case 'INSPECT':
135
+ await this.verifyTask();
136
+ body = { record: workerTaskRecord(this.current, this.binding.targetSid), phase: this.phase };
137
+ break;
138
+ case 'HOLD':
139
+ if (!['approved', 'held', 'selected', 'released'].includes(this.phase)) throw new Error('Task hold is not authorized in this phase.');
140
+ if (this.phase === 'approved' || this.phase === 'held') {
141
+ await this.verifyTask();
142
+ this.save('held');
143
+ } else await this.mutate('hold', 'held');
144
+ body = { record: workerTaskRecord(this.current, this.binding.targetSid), holdKind: 'automatic-triggers' };
145
+ break;
146
+ case 'REPOINT':
147
+ if (this.phase !== 'held') throw new Error('Managed task selection requires a verified hold.');
148
+ await this.mutate('repoint', 'selected');
149
+ body = { record: workerTaskRecord(this.current, this.binding.targetSid) };
150
+ break;
151
+ case 'ENABLE':
152
+ if (this.phase !== 'selected') throw new Error('Task release requires the sealed managed launcher.');
153
+ await this.mutate('release', 'released');
154
+ body = { record: workerTaskRecord(this.current, this.binding.targetSid) };
155
+ break;
156
+ case 'RESTORE':
157
+ if (!['authenticated', 'approved', 'held', 'selected', 'released'].includes(this.phase)) throw new Error('Task restoration outcome is uncertain.');
158
+ await this.mutate('restore', 'restored');
159
+ body = { record: workerTaskRecord(this.current, this.binding.targetSid) };
160
+ break;
161
+ case 'RECORDS':
162
+ await this.verifyTask();
163
+ body = { records: [minimalTaskRecord(this.current)] };
164
+ break;
165
+ case 'DONE':
166
+ if (!['released', 'restored'].includes(this.phase) ||
167
+ request.body.directory !== this.binding.destination ||
168
+ request.body.port !== this.binding.port ||
169
+ ![this.binding.to, this.binding.from].includes(request.body.version)) throw new Error('Completion is outside the sealed installation.');
170
+ await this.verifyTask();
171
+ this.result = request.body;
172
+ this.save('completed');
173
+ body = { accepted: true };
174
+ break;
175
+ case 'FAILED':
176
+ if (['authenticated', 'approved'].includes(this.phase)) await this.restoreInitial('restored');
177
+ this.failure = new Error(`Original-account worker failed; ${this.phase === 'restored' ? 'original task restored' : 'explicit recovery required'}.`);
178
+ this.save(this.phase === 'restored' ? 'failed-restored' : 'uncertain');
179
+ body = { accepted: true };
180
+ break;
181
+ default: throw new Error('Unsupported task request.');
182
+ }
183
+ const response = this.signatures.response({
184
+ operationId: this.binding.operationId, digest: this.digest, challenge: this.challenge, peer: this.peer,
185
+ targetSession: this.targetSession,
186
+ sequence: this.sequence, request, stateDigest: this.stateDigest, body,
187
+ });
188
+ this.sequence++;
189
+ return response;
190
+ }
191
+
192
+ async run() {
193
+ let authenticated = false;
194
+ try {
195
+ await this.mutate('bootstrap', 'bootstrap');
196
+ await this.verifyTask();
197
+ const launch = await this.task.launch(this.current, this.binding.sessionId);
198
+ this.launchCorrelation = launch.instanceGuid;
199
+ this.peer = peerBinding(await this.channel.awaitWorker());
200
+ this.targetSession = verifyTargetSession(this.channel.workerSession, this.targetSession);
201
+ if (this.peer.ownerSid !== this.binding.targetSid ||
202
+ this.peer.sessionId !== this.binding.sessionId) throw new Error('Worker account/session did not verify.');
203
+ while (!this.result && !this.failure) {
204
+ const packet = await this.channel.receive();
205
+ const response = await this.accept(packet);
206
+ await this.channel.send(response);
207
+ authenticated = Boolean(this.challenge);
208
+ }
209
+ if (this.failure) throw this.failure;
210
+ return this.result;
211
+ } catch (error) {
212
+ let failure = error;
213
+ if (!authenticated && this.phase === 'bootstrap') {
214
+ try { await this.restoreInitial('restored-bootstrap'); }
215
+ catch (restoreError) {
216
+ this.save('uncertain');
217
+ failure = new AggregateError([error, restoreError], restoreError.message);
218
+ }
219
+ } else if (!['completed', 'failed-restored', 'restored', 'cancelled'].includes(this.phase)) this.save('uncertain');
220
+ throw failure;
221
+ } finally {
222
+ this.signatures.key = undefined;
223
+ await this.channel.close();
224
+ }
225
+ }
226
+ }
@@ -0,0 +1,65 @@
1
+ import { canonicalSid } from './task-transaction-protocol.mjs';
2
+
3
+ export function managementScope(args) {
4
+ let targetSid;
5
+ let taskPath;
6
+ let instance;
7
+ let controllerRoot = false;
8
+ const remaining = [];
9
+ for (let index = 0; index < args.length; index++) {
10
+ const argument = args[index];
11
+ if (argument === '--') { remaining.push(...args.slice(index)); break; }
12
+ if (argument === '--all-users' || argument.startsWith('--all-users=')) {
13
+ throw new Error('--all-users is not implemented. Installation and implicit discovery default to the current user.');
14
+ }
15
+ const key = ['--user', '--task', '--instance'].find(option => argument === option || argument.startsWith(`${option}=`));
16
+ if (!key) {
17
+ if (argument === '--controller-root' || argument.startsWith('--controller-root=')) controllerRoot = true;
18
+ remaining.push(argument);
19
+ if (['--to', '--registry', '--port', '--config', '--from', '--client', '--grep', '--server', '--controller-root'].includes(argument) &&
20
+ index + 1 < args.length) {
21
+ if (args[index + 1] === '--') { remaining.push(...args.slice(index + 1)); break; }
22
+ remaining.push(args[++index]);
23
+ }
24
+ continue;
25
+ }
26
+ const value = argument === key ? args[++index] : argument.slice(key.length + 1);
27
+ if (!value || value.startsWith('-') || value.length > 4096 || /[\u0000-\u001f\u007f]/.test(value)) throw new Error(`Invalid ${key} selector.`);
28
+ if (key === '--user') {
29
+ canonicalSid(value);
30
+ if (targetSid && targetSid !== value) throw new Error('Conflicting target users.');
31
+ targetSid = value;
32
+ } else if (key === '--task') {
33
+ if (!value.startsWith('\\') || value.includes('/') || value.split('\\').some((part, i) => i > 0 && (!part || part === '.' || part === '..'))) {
34
+ throw new Error('An exact absolute Task Scheduler path is required.');
35
+ }
36
+ if (taskPath && taskPath !== value) throw new Error('Conflicting task selectors.');
37
+ taskPath = value;
38
+ } else {
39
+ if (instance && instance !== value) throw new Error('Conflicting instance selectors.');
40
+ instance = value;
41
+ remaining.push('--instance', value);
42
+ }
43
+ }
44
+ if (targetSid || taskPath) {
45
+ if (!targetSid || Boolean(taskPath) === Boolean(instance)) throw new Error('Explicit user management requires --user <SID> and exactly one --task <path> or --instance <directory>.');
46
+ if (remaining[0] !== 'upgrade' && !remaining.includes('upgrade')) throw new Error('Explicit user/task scope currently supports upgrade only.');
47
+ if (remaining.includes('--sxs') || remaining.includes('--self')) throw new Error('Explicit account management does not support SxS or guidance mode.');
48
+ }
49
+ if (controllerRoot && !targetSid) throw new Error('--controller-root is only for explicit account management.');
50
+ return { targetSid, taskPath, instance, explicit: targetSid !== undefined, args: remaining };
51
+ }
52
+
53
+ export function authorizeTaskActor(actor, targetSid) {
54
+ canonicalSid(targetSid);
55
+ canonicalSid(actor.ownerSid);
56
+ if (targetSid === actor.ownerSid) return 'current-user';
57
+ const admin = actor.elevated === true &&
58
+ actor.enabledAdministrator === true &&
59
+ actor.restricted === false &&
60
+ actor.appContainer === false &&
61
+ actor.guardThreadImpersonating === false &&
62
+ actor.parentThreadImpersonation === 'observed-none';
63
+ if (!admin) throw new Error('Another user requires an already-elevated administrator token with verified enabled membership and no restricted/AppContainer/observed impersonation context. No elevation or privilege change was attempted.');
64
+ return 'explicit-other-user';
65
+ }
@@ -0,0 +1,173 @@
1
+ import { createHash, createPublicKey, generateKeyPairSync, randomBytes, sign, verify } from 'node:crypto';
2
+
3
+ const domain = 'mcp-pacemaker/task-only-controller/v1';
4
+ const verbs = new Set(['HELLO', 'PLAN', 'INSPECT', 'HOLD', 'REPOINT', 'RESTORE', 'ENABLE', 'RECORDS', 'DONE', 'FAILED']);
5
+ export const accountRecoveryTerminalPhases = Object.freeze(['committed', 'rolled-back', 'aborted', 'legacy-rolled-back', 'legacy-aborted']);
6
+ const exact = (value, keys) => value &&
7
+ typeof value === 'object' &&
8
+ !Array.isArray(value) &&
9
+ Object.keys(value).sort().join(',') === [...keys].sort().join(',');
10
+
11
+ export function canonicalJson(value, depth = 0) {
12
+ if (depth > 16) throw new Error('Protocol nesting exceeds its bound.');
13
+ if (value === null || typeof value === 'boolean') return JSON.stringify(value);
14
+ if (typeof value === 'string') {
15
+ if (value.length > 16384) throw new Error('Protocol string exceeds its bound.');
16
+ return JSON.stringify(value);
17
+ }
18
+ if (typeof value === 'number' && Number.isSafeInteger(value)) return String(value);
19
+ if (Array.isArray(value)) {
20
+ if (value.length > 128) throw new Error('Protocol array exceeds its bound.');
21
+ return `[${value.map(item => canonicalJson(item, depth + 1)).join(',')}]`;
22
+ }
23
+ if (!value ||
24
+ typeof value !== 'object' ||
25
+ Object.getPrototypeOf(value) !== Object.prototype) throw new Error('Unsupported protocol value.');
26
+ const keys = Object.keys(value).sort();
27
+ if (keys.length > 128 ||
28
+ keys.some(key => ['__proto__', 'constructor', 'prototype'].includes(key))) throw new Error('Invalid protocol keys.');
29
+ return `{${keys.map(key => `${JSON.stringify(key)}:${canonicalJson(value[key], depth + 1)}`).join(',')}}`;
30
+ }
31
+
32
+ export function operationDigest(value) {
33
+ return createHash('sha256').update(`${domain}/operation\0${canonicalJson(value)}`).digest('hex');
34
+ }
35
+
36
+ export function taskRevision(record) {
37
+ const value = { ...record };
38
+ delete value.currentUserSid;
39
+ return operationDigest(value);
40
+ }
41
+
42
+ export function accountOperationReference(binding) {
43
+ return { manifestPath: binding.manifestPath, operationId: binding.operationId,
44
+ taskPath: binding.taskPath, targetSid: binding.targetSid };
45
+ }
46
+
47
+ export function canonicalSid(value) {
48
+ if (typeof value !== 'string' ||
49
+ value.length > 184 ||
50
+ !value.startsWith('S-1-')) throw new Error('A canonical numeric Windows SID is required.');
51
+ const parts = value.slice(4).split('-');
52
+ if (parts.length < 2 ||
53
+ parts.length > 16 ||
54
+ parts.some(part => !part ||
55
+ part.length > 15 ||
56
+ (part.length > 1 && part[0] === '0') ||
57
+ [...part].some(character => character < '0' || character > '9'))) throw new Error('A canonical numeric Windows SID is required.');
58
+ return value;
59
+ }
60
+
61
+ export function validateTaskRequest(value, operationId, sequence, stateDigest) {
62
+ if (!exact(value, ['protocol', 'operationId', 'sequence', 'stateDigest', 'verb', 'body']) ||
63
+ value.protocol !== 1 ||
64
+ value.operationId !== operationId ||
65
+ value.sequence !== sequence ||
66
+ value.stateDigest !== stateDigest ||
67
+ !verbs.has(value.verb) ||
68
+ Buffer.byteLength(canonicalJson(value)) > 16384) throw new Error('Invalid, stale or out-of-order task request.');
69
+ if (['INSPECT', 'HOLD', 'REPOINT', 'RESTORE', 'ENABLE', 'RECORDS'].includes(value.verb) &&
70
+ value.body !== null) throw new Error('Task requests cannot select a path, principal or definition.');
71
+ if (value.verb === 'HELLO' &&
72
+ (!exact(value.body, ['challenge', 'identity']) ||
73
+ typeof value.body.challenge !== 'string' ||
74
+ !/^[a-f0-9]{64}$/.test(value.body.challenge))) throw new Error('Invalid worker challenge.');
75
+ if (value.verb === 'PLAN' &&
76
+ (!exact(value.body, ['digest', 'summary']) ||
77
+ !/^[a-f0-9]{64}$/.test(value.body.digest) ||
78
+ typeof value.body.summary !== 'string' ||
79
+ value.body.summary.length > 4096)) throw new Error('Invalid worker plan.');
80
+ if (value.verb === 'DONE' &&
81
+ !exact(value.body, ['status', 'directory', 'port', 'version'])) throw new Error('Invalid worker completion.');
82
+ if (value.verb === 'FAILED' &&
83
+ (!exact(value.body, ['message']) || typeof value.body.message !== 'string' || value.body.message.length > 512)) {
84
+ throw new Error('Invalid worker failure.');
85
+ }
86
+ return value;
87
+ }
88
+
89
+ export function peerBinding(identity) {
90
+ if (!identity ||
91
+ !Number.isSafeInteger(identity.pid) ||
92
+ identity.pid < 1 ||
93
+ typeof identity.creationTime !== 'string' ||
94
+ !/^[1-9][0-9]{0,19}$/.test(identity.creationTime) ||
95
+ !Number.isSafeInteger(identity.sessionId) ||
96
+ identity.sessionId < 1) throw new Error('Incomplete observed worker identity.');
97
+ return { pid: identity.pid, creationTime: identity.creationTime,
98
+ ownerSid: canonicalSid(identity.ownerSid), sessionId: identity.sessionId };
99
+ }
100
+
101
+ export function verifyTargetSession(value, expected) {
102
+ if (!value ||
103
+ value.source !== 'wts-account-sid-snapshot' ||
104
+ value.atomicRunExBinding !== false ||
105
+ !['active', 'disconnected'].includes(value.state) ||
106
+ value.ownerSid !== expected.ownerSid ||
107
+ value.sessionId !== expected.sessionId ||
108
+ typeof value.logonTime !== 'string' ||
109
+ !/^[1-9][0-9]{0,19}$/.test(value.logonTime) ||
110
+ (expected.logonTime !== undefined && value.logonTime !== expected.logonTime) ||
111
+ typeof value.observedAt !== 'string' ||
112
+ !Number.isFinite(Date.parse(value.observedAt))) {
113
+ throw new Error('Original-account session eligibility is missing, stale or changed; no atomic desktop reservation is claimed.');
114
+ }
115
+ return value;
116
+ }
117
+
118
+ export class ControllerSignatures {
119
+ constructor() {
120
+ const keys = generateKeyPairSync('ed25519');
121
+ this.key = keys.privateKey;
122
+ this.publicKey = keys.publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
123
+ }
124
+
125
+ response({ operationId, digest, challenge, peer, targetSession, sequence, request, stateDigest, body }) {
126
+ const value = { protocol: 1, operationId, digest, challenge, peer: peerBinding(peer),
127
+ targetSession, sequence, requestDigest: operationDigest(request), stateDigest, body };
128
+ const bytes = Buffer.from(`${domain}/response\0${canonicalJson(value)}`);
129
+ return { ...value, signature: sign(null, bytes, this.key).toString('base64') };
130
+ }
131
+ }
132
+
133
+ export class WorkerSignatures {
134
+ constructor({ publicKey, operationId, digest, identity, targetSession }) {
135
+ this.key = createPublicKey({ key: Buffer.from(publicKey, 'base64'), type: 'spki', format: 'der' });
136
+ if (this.key.asymmetricKeyType !== 'ed25519') throw new Error('Unsupported controller signature key.');
137
+ this.operationId = operationId;
138
+ this.digest = digest;
139
+ this.identity = peerBinding(identity);
140
+ this.targetSession = verifyTargetSession(targetSession, this.identity);
141
+ this.challenge = randomBytes(32).toString('hex');
142
+ this.sequence = 0;
143
+ this.stateDigest = digest;
144
+ }
145
+
146
+ request(verb, body = null) {
147
+ return { protocol: 1, operationId: this.operationId, sequence: this.sequence,
148
+ stateDigest: this.stateDigest, verb, body };
149
+ }
150
+
151
+ accept(response, request) {
152
+ if (!exact(response, ['protocol', 'operationId', 'digest', 'challenge', 'peer', 'targetSession', 'sequence',
153
+ 'requestDigest', 'stateDigest', 'body', 'signature'])) throw new Error('Malformed controller response.');
154
+ const { signature, ...value } = response;
155
+ if (value.protocol !== 1 ||
156
+ value.operationId !== this.operationId ||
157
+ value.digest !== this.digest ||
158
+ value.challenge !== this.challenge ||
159
+ value.sequence !== this.sequence ||
160
+ value.requestDigest !== operationDigest(request) ||
161
+ canonicalJson(value.peer) !== canonicalJson(this.identity) ||
162
+ !/^[a-f0-9]{64}$/.test(value.stateDigest) ||
163
+ typeof signature !== 'string' ||
164
+ signature.length !== 88 ||
165
+ !verify(null, Buffer.from(`${domain}/response\0${canonicalJson(value)}`), this.key, Buffer.from(signature, 'base64'))) {
166
+ throw new Error('Controller authentication or transcript binding failed.');
167
+ }
168
+ verifyTargetSession(value.targetSession, this.targetSession);
169
+ this.sequence++;
170
+ this.stateDigest = value.stateDigest;
171
+ return value.body;
172
+ }
173
+ }
package/bin/top.mjs CHANGED
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * mcp-pacemaker top — live terminal dashboard (Ink). Same data as the web /ui.
4
- * Reads the bridge port from ~/.mcp-pacemaker/state.json (or --port) and the admin
5
- * nonce from ~/.mcp-pacemaker/admin.nonce for the Recycle keybind. No JSX (uses createElement).
4
+ * Explicit instance calls supply both port and config; otherwise reads the default
5
+ * HOME state and nonce for the Recycle keybind. No JSX (uses createElement).
6
6
  */
7
7
  import { createElement as h, useState, useEffect } from 'react';
8
8
  import { render, Box, Text, useApp, useInput } from 'ink';
9
9
  import http from 'node:http';
10
10
  import { readFileSync } from 'node:fs';
11
- import { resolve } from 'node:path';
11
+ import { dirname, resolve } from 'node:path';
12
12
  import { homedir } from 'node:os';
13
13
 
14
14
  const HOME = resolve(homedir(), '.mcp-pacemaker');
@@ -34,7 +34,10 @@ export function recycleServer(port, name, nonce) {
34
34
  });
35
35
  }
36
36
 
37
- const readNonce = () => { try { return readFileSync(resolve(HOME, 'admin.nonce'), 'utf8').trim(); } catch { return ''; } };
37
+ const readNonce = (config) => {
38
+ try { return readFileSync(resolve(config ? dirname(config) : HOME, 'admin.nonce'), 'utf8').trim(); }
39
+ catch { return ''; }
40
+ };
38
41
  // Pure: pick the bridge port from a parsed state object (list shape wins, legacy {port} fallback).
39
42
  export function portFromState(state) {
40
43
  if (state && Array.isArray(state.hosts)) return state.hosts[0]?.port || 8791;
@@ -56,7 +59,16 @@ const healthCell = (s) => {
56
59
  return { text: st === 'failing' && s.health.consecutiveFailures > 1 ? `FAIL×${s.health.consecutiveFailures}` : h.text, color: h.color };
57
60
  };
58
61
 
59
- function App({ port }) {
62
+ export function topClient(port, config) {
63
+ const selectedPort = resolvePort(port);
64
+ const selectedConfig = config ? resolve(config) : undefined;
65
+ return {
66
+ snapshot: () => fetchSnapshot(selectedPort),
67
+ recycle: name => recycleServer(selectedPort, name, readNonce(selectedConfig)),
68
+ };
69
+ }
70
+
71
+ function App({ client }) {
60
72
  const { exit } = useApp();
61
73
  const [snap, setSnap] = useState(null);
62
74
  const [sel, setSel] = useState(0);
@@ -64,11 +76,11 @@ function App({ port }) {
64
76
 
65
77
  useEffect(() => {
66
78
  let alive = true;
67
- const tick = async () => { const s = await fetchSnapshot(port); if (alive) setSnap(s); };
79
+ const tick = async () => { const s = await client.snapshot(); if (alive) setSnap(s); };
68
80
  tick();
69
81
  const t = setInterval(tick, 1500);
70
82
  return () => { alive = false; clearInterval(t); };
71
- }, [port]);
83
+ }, [client]);
72
84
 
73
85
  const servers = snap?.servers ?? [];
74
86
 
@@ -81,7 +93,7 @@ function App({ port }) {
81
93
  if (!s) return;
82
94
  if (s.type === 'http') { setMsg('http servers have no child to recycle'); return; }
83
95
  setMsg(`recycling ${s.name}…`);
84
- recycleServer(port, s.name, readNonce()).then((r) =>
96
+ client.recycle(s.name).then((r) =>
85
97
  setMsg(r && r.status === 200 ? `recycled ${s.name} (${r.body?.recycled ?? 0})` : `recycle failed (${r?.status ?? 'no nonce/bridge'})`),
86
98
  );
87
99
  }
@@ -110,6 +122,6 @@ function App({ port }) {
110
122
  );
111
123
  }
112
124
 
113
- export function runTop(port) {
114
- render(h(App, { port: resolvePort(port) }));
125
+ export function runTop(port, config, options) {
126
+ return render(h(App, { client: topClient(port, config) }), options);
115
127
  }
@@ -75,7 +75,7 @@ export function validateRegistry(registry) {
75
75
  return url.href;
76
76
  }
77
77
 
78
- function npmCliPath() {
78
+ export function npmCliPath() {
79
79
  const candidates = [];
80
80
  if (process.env.npm_execpath) candidates.push(process.env.npm_execpath);
81
81
  candidates.push(join(dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'));
@@ -105,12 +105,23 @@ export async function readNpmMetadata(spec, field, { registry, cache, run = runF
105
105
  throw new Error('Registry metadata unavailable (offline, denied, missing, or npm failed). Check your approved npm configuration; no fallback selected.');
106
106
  }
107
107
  try {
108
- return JSON.parse(stdout);
108
+ return normalizeNpmMetadata(JSON.parse(stdout));
109
109
  } catch {
110
110
  throw new Error('npm returned invalid metadata. No fallback selected.');
111
111
  }
112
112
  }
113
113
 
114
+ export function normalizeNpmMetadata(value) {
115
+ if (Array.isArray(value)) {
116
+ if (value.length !== 1) throw new Error('Expected one exact npm metadata result.');
117
+ value = value[0];
118
+ }
119
+ if (!value ||
120
+ typeof value !== 'object' ||
121
+ Array.isArray(value)) throw new Error('Expected an npm metadata object.');
122
+ return value;
123
+ }
124
+
114
125
  export async function checkForUpdate(pkg, { registry, read = readNpmMetadata } = {}) {
115
126
  updateChannel(pkg);
116
127
  registry = validateRegistry(registry);
@@ -0,0 +1,47 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { isDeepStrictEqual } from 'node:util';
7
+
8
+ export function readAutostart(port) {
9
+ if (port === 0) return [];
10
+ if (process.platform === 'win32') {
11
+ const script = fileURLToPath(new URL('../autostart/windows/inspect-task.ps1', import.meta.url));
12
+ let stdout;
13
+ try {
14
+ stdout = execFileSync('pwsh', ['-NoProfile', '-File', script, '-Port', String(port)], {
15
+ encoding: 'utf8', windowsHide: true, timeout: 10000,
16
+ });
17
+ } catch {
18
+ throw new Error('Selected Windows autostart could not be inspected. No migration or stop was attempted.');
19
+ }
20
+ const result = JSON.parse(stdout);
21
+ if (!Array.isArray(result)) throw new Error('Invalid Windows autostart inspection.');
22
+ return result;
23
+ }
24
+ const paths = process.platform === 'darwin'
25
+ ? [
26
+ join(homedir(), 'Library', 'LaunchAgents', `io.github.girishkvs.mcp-pacemaker.${port}.plist`),
27
+ join(homedir(), 'Library', 'LaunchAgents', 'io.github.girishkvs.mcp-pacemaker.plist'),
28
+ ]
29
+ : [
30
+ join(homedir(), '.config', 'systemd', 'user', `mcp-pacemaker-${port}.service`),
31
+ join(homedir(), '.config', 'systemd', 'user', 'mcp-pacemaker.service'),
32
+ ];
33
+ return paths.filter(existsSync).map(path => ({ path, content: readFileSync(path, 'utf8') }));
34
+ }
35
+
36
+ export function inspectAutostart(instance) {
37
+ const actual = readAutostart(instance.port);
38
+ if (instance.autostart?.kind === 'none') {
39
+ if (actual.length) throw new Error('Unknown OS autostart registration exists. Exact legacy migration is required before upgrade.');
40
+ return { kind: 'none' };
41
+ }
42
+ if (instance.autostart?.kind !== 'stable' ||
43
+ !isDeepStrictEqual(actual, instance.autostart.records)) {
44
+ throw new Error('Selected autostart registration changed or is unknown; refusing before stop.');
45
+ }
46
+ return { kind: 'stable', launcher: join(instance.directory, 'stable-launcher.mjs') };
47
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "protocol": 1,
3
+ "portZero": true,
4
+ "admission": true,
5
+ "cli": true,
6
+ "legacyRestartProtocol": 1,
7
+ "accountWorkerProtocol": 1
8
+ }