mcp-pacemaker 2.0.2 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +171 -1
- package/CONTRIBUTING.md +46 -13
- package/README.md +109 -524
- package/THIRD_PARTY_NOTICES.txt +1 -28
- package/autostart/windows/account-task.ps1 +167 -0
- package/autostart/windows/inspect-task.ps1 +42 -0
- package/autostart/windows/inspect-worker-process-sessions.ps1 +155 -0
- package/autostart/windows/legacy-access.ps1 +359 -0
- package/autostart/windows/legacy-task.ps1 +301 -0
- package/autostart/windows/register-task.ps1 +11 -2
- package/bin/account-task-adapter.mjs +47 -0
- package/bin/account-upgrade-controller.mjs +198 -0
- package/bin/account-upgrade-worker.mjs +141 -0
- package/bin/account-worker-session.mjs +215 -0
- package/bin/cli-dispatch.mjs +154 -0
- package/bin/cli-main.mjs +1063 -0
- package/bin/cli-selection.mjs +43 -0
- package/bin/cli.mjs +7 -876
- package/bin/dependency-lock.json +642 -0
- package/bin/legacy-1.3.json +32 -0
- package/bin/legacy-access.mjs +73 -0
- package/bin/legacy-installation.mjs +273 -0
- package/bin/legacy-task.mjs +76 -0
- package/bin/legacy-upgrade.mjs +527 -0
- package/bin/managed-runtime.mjs +170 -0
- package/bin/managed-state.mjs +257 -0
- package/bin/managed-upgrade.mjs +581 -0
- package/bin/mcp-bridge.mjs +150 -4
- package/bin/pooling-batches.mjs +13 -0
- package/bin/pooling-writer.mjs +4 -0
- package/bin/prewarm.mjs +59 -25
- package/bin/runtime-context.mjs +100 -0
- package/bin/service-control.mjs +32 -4
- package/bin/stable-launcher.mjs +54 -0
- package/bin/task-only-controller.mjs +226 -0
- package/bin/task-scope.mjs +65 -0
- package/bin/task-transaction-protocol.mjs +173 -0
- package/bin/top.mjs +22 -10
- package/bin/update-check.mjs +13 -2
- package/bin/upgrade-autostart.mjs +47 -0
- package/bin/upgrade-capability.json +9 -0
- package/bin/upgrade-dependencies.mjs +183 -0
- package/bin/upgrade-package.mjs +205 -0
- package/bin/windows-legacy/LegacyProcessBroker.build.json +42 -0
- package/bin/windows-legacy/LegacyProcessBroker.exe +0 -0
- package/bin/windows-legacy/src/AssemblyInfo.cs +8 -0
- package/bin/windows-legacy/src/LegacyNative.cs +832 -0
- package/bin/windows-legacy/src/LegacyProcessBroker.cs +961 -0
- package/bin/windows-legacy-process.mjs +248 -0
- package/bin/windows-lifetime/ProcessLifetimeHelper.build.json +38 -0
- package/bin/windows-lifetime/ProcessLifetimeHelper.exe +0 -0
- package/bin/windows-lifetime/src/AssemblyInfo.cs +8 -0
- package/bin/windows-lifetime/src/ProcessLifetimeHelper.cs +468 -0
- package/bin/windows-process-lifetime.mjs +218 -0
- package/bin/windows-task-channel/TaskChannelGuard.build.json +45 -0
- package/bin/windows-task-channel/TaskChannelGuard.exe +0 -0
- package/bin/windows-task-channel/src/AssemblyInfo.cs +8 -0
- package/bin/windows-task-channel/src/ChannelCliContext.cs +210 -0
- package/bin/windows-task-channel/src/ChannelFiles.cs +231 -0
- package/bin/windows-task-channel/src/ChannelNative.cs +477 -0
- package/bin/windows-task-channel/src/ChannelSession.cs +203 -0
- package/bin/windows-task-channel/src/TaskChannelGuard.cs +686 -0
- package/bin/windows-task-channel.mjs +335 -0
- package/docs/account-upgrades.md +218 -0
- package/docs/compatibility.md +39 -0
- package/docs/configuration.md +146 -0
- package/docs/dashboards.md +125 -0
- package/docs/execution-context.md +119 -0
- package/docs/http-api.md +47 -0
- package/docs/install.md +52 -0
- package/docs/operations.md +99 -0
- package/docs/upgrades.md +324 -0
- package/package.json +19 -5
- package/supervisor/supervise.mjs +226 -40
- package/tools/account-upgrade/qualify-two-account.ps1 +66 -0
- package/tools/windows-legacy/README.md +251 -0
- package/tools/windows-legacy/build.ps1 +122 -0
- package/tools/windows-legacy/inventory.mjs +92 -0
- package/tools/windows-process-lifetime/README.md +119 -0
- package/tools/windows-process-lifetime/build.ps1 +122 -0
- package/tools/windows-process-lifetime/inventory.mjs +86 -0
- package/tools/windows-task-channel/README.md +329 -0
- package/tools/windows-task-channel/build.ps1 +107 -0
- package/tools/windows-task-channel/inventory.mjs +117 -0
- package/ui/dist/THIRD_PARTY_NOTICES.txt +1 -28
- package/ui/dist/assets/{index-CgPruWYi.js → index-CcwMo2sI.js} +1 -1
- package/ui/dist/assets/index-PVcuDNrN.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/third-party-manifest.json +37 -25
- package/ui/dist/assets/index-CaLPg0c-.css +0 -1
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { durableJson } from './managed-state.mjs';
|
|
4
|
+
import { WindowsLegacyTaskAdapter } from './legacy-task.mjs';
|
|
5
|
+
import {
|
|
6
|
+
ACCESS_FIXES, legacyAccessProblems, legacyAccessReport, legacyStatePort, parseLegacyRegistration, verifyLegacyPackage,
|
|
7
|
+
} from './legacy-installation.mjs';
|
|
8
|
+
|
|
9
|
+
const NEXT = 'Run mcp-pacemaker upgrade --to <version> from a normal (non-administrator) terminal.';
|
|
10
|
+
|
|
11
|
+
export async function inspectLegacyAccess(home, { task = new WindowsLegacyTaskAdapter() } = {}) {
|
|
12
|
+
if (process.platform !== 'win32') throw new Error('--fix-legacy-access applies only to the known Windows 1.3 registration. Nothing was changed.');
|
|
13
|
+
const { port } = legacyStatePort(home);
|
|
14
|
+
const { root, registration } = parseLegacyRegistration(await task.inspect(port), port);
|
|
15
|
+
verifyLegacyPackage(root);
|
|
16
|
+
const cliPath = join(root, 'bin', 'cli.mjs');
|
|
17
|
+
const report = legacyAccessReport(await task.access(port, cliPath), registration);
|
|
18
|
+
return { port, cliPath, registration, report };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function describeLegacyAccessFix({ port, report }) {
|
|
22
|
+
return [
|
|
23
|
+
`Legacy 1.3 access fix for port ${port}. It will only:`,
|
|
24
|
+
...report.fixable.map(id => ` - ${ACCESS_FIXES[id](report)}`),
|
|
25
|
+
'Unchanged: task XML, triggers and run-as account (it keeps running as you, not elevated), other permissions and file contents.',
|
|
26
|
+
].join('\n');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Elevated, explicit and bound to the inspected descriptors: makes an install done from an
|
|
30
|
+
// administrator terminal look like one done from a normal terminal, so the ordinary upgrade can run.
|
|
31
|
+
export async function fixLegacyAccess({
|
|
32
|
+
home, plan = false, yes = false, interactive = false, confirm,
|
|
33
|
+
task = new WindowsLegacyTaskAdapter(), log = console.log, now = () => new Date(),
|
|
34
|
+
} = {}) {
|
|
35
|
+
const selected = await inspectLegacyAccess(home, { task });
|
|
36
|
+
const { port, cliPath, registration, report } = selected;
|
|
37
|
+
const summary = { port, task: registration.path, cli: cliPath };
|
|
38
|
+
if (!report.missing.length) return { status: 'unchanged', ...summary, next: NEXT };
|
|
39
|
+
if (!report.elevated) {
|
|
40
|
+
throw new Error('Run --fix-legacy-access from an administrator terminal (Run as administrator). Nothing was changed.');
|
|
41
|
+
}
|
|
42
|
+
const manual = report.missing.filter(id => !report.fixable.includes(id));
|
|
43
|
+
if (manual.length) {
|
|
44
|
+
throw new Error([
|
|
45
|
+
'These permissions need an administrator to change them by hand; --fix-legacy-access does not. Nothing was changed.',
|
|
46
|
+
...legacyAccessProblems(report).map(problem => ` - ${problem}`),
|
|
47
|
+
].join('\n'));
|
|
48
|
+
}
|
|
49
|
+
log(describeLegacyAccessFix(selected));
|
|
50
|
+
if (plan) return { status: 'planned', ...summary, changes: report.fixable };
|
|
51
|
+
if (!yes) {
|
|
52
|
+
if (!interactive) throw new Error('--fix-legacy-access needs an interactive confirmation or --yes. Nothing was changed.');
|
|
53
|
+
if (await confirm('Apply this access fix?') !== true) return { status: 'cancelled', ...summary };
|
|
54
|
+
}
|
|
55
|
+
const directory = join(home, 'legacy-upgrade');
|
|
56
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
57
|
+
const journal = join(directory, `access-${now().toISOString().replaceAll(':', '-')}.json`);
|
|
58
|
+
const record = {
|
|
59
|
+
protocol: 1, kind: 'legacy-access-fix', createdAt: now().toISOString(), ...summary,
|
|
60
|
+
changes: report.fixable, before: report.expected,
|
|
61
|
+
};
|
|
62
|
+
durableJson(journal, record);
|
|
63
|
+
let result;
|
|
64
|
+
try { result = await task.grantAccess(port, cliPath, report.expected); }
|
|
65
|
+
catch (error) {
|
|
66
|
+
durableJson(journal, { ...record, failed: error.message, changed: error.changed ?? [] });
|
|
67
|
+
throw new Error(`${error.message}\nRecorded before-state: ${journal}`, { cause: error });
|
|
68
|
+
}
|
|
69
|
+
durableJson(journal, { ...record, changed: result.changed, after: result.after });
|
|
70
|
+
const after = legacyAccessReport(await task.access(port, cliPath), registration);
|
|
71
|
+
if (after.missing.length) throw new Error(`Access is still missing after the fix: ${after.missing.join(', ')}. Recorded before-state: ${journal}`);
|
|
72
|
+
return { status: 'fixed', ...summary, changed: result.changed, journal, next: NEXT };
|
|
73
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { get } from 'node:http';
|
|
4
|
+
import { dirname, join, resolve, win32 } from 'node:path';
|
|
5
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
6
|
+
import { backendStatus } from './managed-runtime.mjs';
|
|
7
|
+
import { directoryIdentity, readManagedJson } from './managed-state.mjs';
|
|
8
|
+
import { PoolingFiles, hasPoolingTransaction } from './pooling-files.mjs';
|
|
9
|
+
import { canonicalPort } from './cli-selection.mjs';
|
|
10
|
+
import { eligibleTaskOwner, WindowsLegacyTaskAdapter } from './legacy-task.mjs';
|
|
11
|
+
import { assertDurableRoot } from './service-control.mjs';
|
|
12
|
+
|
|
13
|
+
const approved = new WeakMap();
|
|
14
|
+
const inventory = readManagedJson(new URL('./legacy-1.3.json', import.meta.url));
|
|
15
|
+
const hash = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
16
|
+
const ACCESS_PROBLEMS = {
|
|
17
|
+
'task-write': report => `Task ${report.task.path} (owner ${report.task.ownerName}): your account can only read it.`,
|
|
18
|
+
'cli-owner': report => `${report.cli.path} (owner ${report.cli.ownerName}): your account cannot copy it with its permissions.`,
|
|
19
|
+
'cli-write': report => `${report.cli.path}: your account cannot write it.`,
|
|
20
|
+
'cli-delete': report => `${report.cli.path}: your account cannot rename it.`,
|
|
21
|
+
'bin-add-file': report => `${report.cli.directory}: your account cannot create files in it.`,
|
|
22
|
+
};
|
|
23
|
+
export const ACCESS_FIXES = {
|
|
24
|
+
'task-write': report => `give your account full control of task ${report.task.path}`,
|
|
25
|
+
'cli-owner': report => `make your account the owner of ${report.cli.path}`,
|
|
26
|
+
};
|
|
27
|
+
export const LEGACY_WARNING = [
|
|
28
|
+
'Upgrade this 1.3 installation? Its supervisor, its bridge and the processes the bridge started will be stopped.',
|
|
29
|
+
'Open sessions and calls in progress end, and clients reconnect to the new version. No call is retried.',
|
|
30
|
+
'servers.json, every server setting in it and your client setup are kept as they are.',
|
|
31
|
+
].join('\n');
|
|
32
|
+
|
|
33
|
+
export function legacyPlanDigest(plan) {
|
|
34
|
+
return hash(JSON.stringify({
|
|
35
|
+
contract: 'legacy-restart-observed-set-v1', to: plan.to, registry: plan.registry,
|
|
36
|
+
instance: plan.instance, legacy: plan.legacy,
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function confirmLegacyUpgrade(plan, { yes = false, interactive = false, confirm } = {}) {
|
|
41
|
+
if (!plan.legacy) throw new Error('Expected a legacy restart plan.');
|
|
42
|
+
const digest = legacyPlanDigest(plan);
|
|
43
|
+
if (!yes) {
|
|
44
|
+
if (!interactive) throw new Error('Legacy migration needs confirmation. Run upgrade --to <version> in an interactive terminal, or add --yes to confirm the plan shown.');
|
|
45
|
+
const answer = await confirm(`${LEGACY_WARNING}\nPlan: ${digest}`);
|
|
46
|
+
if (answer !== true) return false;
|
|
47
|
+
}
|
|
48
|
+
if (legacyPlanDigest(plan) !== digest) throw new Error('Legacy plan changed during confirmation.');
|
|
49
|
+
approved.set(plan, digest);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function requireLegacyApproval(plan) {
|
|
54
|
+
if (approved.get(plan) !== legacyPlanDigest(plan)) throw new Error('This legacy plan has no matching confirmation.');
|
|
55
|
+
approved.delete(plan);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function verifyLegacyPackage(root, { cliPath = join(root, 'bin', 'cli.mjs') } = {}) {
|
|
59
|
+
assertDurableRoot(root);
|
|
60
|
+
if (realpathSync.native(root) !== root) throw new Error('Legacy package root is not canonical.');
|
|
61
|
+
const identity = directoryIdentity(root);
|
|
62
|
+
for (const [relative, expected] of Object.entries(inventory.files)) {
|
|
63
|
+
const path = relative === 'bin/cli.mjs' ? cliPath : join(root, relative);
|
|
64
|
+
const stat = lstatSync(path);
|
|
65
|
+
if (!stat.isFile() ||
|
|
66
|
+
stat.isSymbolicLink() ||
|
|
67
|
+
stat.nlink !== 1 ||
|
|
68
|
+
stat.size > 4 * 1024 * 1024 ||
|
|
69
|
+
realpathSync.native(path) !== path) throw new Error('Legacy package contains linked or unsupported files.');
|
|
70
|
+
const bytes = readFileSync(path);
|
|
71
|
+
const normalized = relative.endsWith('.exe') ? bytes : Buffer.from(bytes.toString('utf8').replaceAll('\r\n', '\n'));
|
|
72
|
+
if (hash(normalized) !== expected) throw new Error(`Unsupported or changed legacy 1.3 package file: ${relative}. Nothing was stopped.`);
|
|
73
|
+
}
|
|
74
|
+
return identity;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function parseLegacyRegistration(records, port) {
|
|
78
|
+
if (!Array.isArray(records) ||
|
|
79
|
+
records.length !== 1) throw new Error('Exactly one known legacy Windows registration must match the selected installation.');
|
|
80
|
+
const record = records[0];
|
|
81
|
+
const security = record.security;
|
|
82
|
+
const completeSecurity = security?.protocol === 1 &&
|
|
83
|
+
security.information === 31 &&
|
|
84
|
+
security.complete === true &&
|
|
85
|
+
eligibleTaskOwner(security.ownerSid, record.currentUserSid) &&
|
|
86
|
+
typeof security.groupSid === 'string' &&
|
|
87
|
+
security.groupSid.startsWith('S-1-') &&
|
|
88
|
+
Number.isInteger(security.controlFlags) &&
|
|
89
|
+
typeof security.sddl === 'string' &&
|
|
90
|
+
security.sddl.length > 0 &&
|
|
91
|
+
security.sddl.length <= 65536 &&
|
|
92
|
+
hash(security.sddl) === security.sha256;
|
|
93
|
+
if (!completeSecurity) throw new Error('TASK_SECURITY_UNVERIFIED: Full task security and a supported runtime-user or builtin Administrators owner are required.');
|
|
94
|
+
const action = record.actions?.[0];
|
|
95
|
+
const settings = record.settings;
|
|
96
|
+
const expectedSettings = {
|
|
97
|
+
multipleInstances: 2, restartCount: 3, restartInterval: 'PT1M', executionTimeLimit: 'PT0S',
|
|
98
|
+
startWhenAvailable: true, disallowStartIfOnBatteries: false, stopIfGoingOnBatteries: false,
|
|
99
|
+
};
|
|
100
|
+
const triggers = record.triggers ?? [];
|
|
101
|
+
const exactTriggers = triggers.length === 2 &&
|
|
102
|
+
triggers.every(trigger => trigger.userSid === record.currentUserSid && trigger.enabled === true) &&
|
|
103
|
+
triggers.some(trigger => trigger.type === 9 && trigger.stateChange === 0) &&
|
|
104
|
+
triggers.some(trigger => trigger.type === 11 && trigger.stateChange === 8);
|
|
105
|
+
const valid = [`McpPacemaker-${port}`, 'McpPacemaker'].includes(record.name) &&
|
|
106
|
+
record.path === `\\${record.name}` &&
|
|
107
|
+
record.enabled === true &&
|
|
108
|
+
record.userSid === record.currentUserSid &&
|
|
109
|
+
typeof record.userSid === 'string' &&
|
|
110
|
+
record.userSid.startsWith('S-1-') &&
|
|
111
|
+
record.logonType === 3 &&
|
|
112
|
+
record.runLevel === 0 &&
|
|
113
|
+
record.actions?.length === 1 &&
|
|
114
|
+
action.type === 0 &&
|
|
115
|
+
['wscript.exe', `${process.env.SystemRoot}\\System32\\wscript.exe`.toLowerCase()].includes(action.path.toLowerCase()) &&
|
|
116
|
+
action.workingDirectory === '' &&
|
|
117
|
+
isDeepStrictEqual(settings, expectedSettings) &&
|
|
118
|
+
exactTriggers &&
|
|
119
|
+
typeof record.xml === 'string' &&
|
|
120
|
+
record.xml.length <= 524288 &&
|
|
121
|
+
hash(record.xml) === record.xmlSha256;
|
|
122
|
+
if (!valid) throw new Error('Unknown or changed legacy registration shape/run-as identity; no task or process was changed.');
|
|
123
|
+
const suffix = `" ${port}`;
|
|
124
|
+
const args = action.arguments;
|
|
125
|
+
if (typeof args !== 'string' ||
|
|
126
|
+
args.length > 4096 ||
|
|
127
|
+
!args.startsWith('"') ||
|
|
128
|
+
!args.endsWith(suffix)) throw new Error('Unsupported legacy launcher arguments.');
|
|
129
|
+
const launcher = args.slice(1, -suffix.length);
|
|
130
|
+
if (launcher.includes('"') ||
|
|
131
|
+
launcher.includes('\n') ||
|
|
132
|
+
launcher.includes('\r') ||
|
|
133
|
+
!win32.isAbsolute(launcher)) throw new Error('Unsupported legacy launcher path.');
|
|
134
|
+
const canonical = realpathSync.native(launcher);
|
|
135
|
+
const root = dirname(dirname(dirname(canonical)));
|
|
136
|
+
if (canonical !== join(root, 'autostart', 'windows', 'launcher.vbs')) throw new Error('Unexpected legacy launcher location.');
|
|
137
|
+
return { root, registration: record };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function legacySessionState(config) {
|
|
141
|
+
const path = join(dirname(config), 'sessions.json');
|
|
142
|
+
return existsSync(path) ? new PoolingFiles(config).inspect(path) : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function legacyStatePort(home) {
|
|
146
|
+
const statePath = join(home, 'state.json');
|
|
147
|
+
if (!existsSync(statePath)) throw new Error('No stable managed installation or exact legacy install state was found. Nothing was changed.');
|
|
148
|
+
const state = readManagedJson(statePath);
|
|
149
|
+
const ports = [...new Set((state.hosts ?? []).map(host => canonicalPort(host.port)))];
|
|
150
|
+
if (ports.length !== 1) throw new Error('Legacy migration requires exactly one selected port in existing install state.');
|
|
151
|
+
return { statePath, port: ports[0] };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function legacyAccessReport(report, registration) {
|
|
155
|
+
const bound = report?.protocol === 1 &&
|
|
156
|
+
report.task?.path === registration.path &&
|
|
157
|
+
report.expected?.xmlSha256 === registration.xmlSha256 &&
|
|
158
|
+
Array.isArray(report.missing) &&
|
|
159
|
+
Array.isArray(report.fixable) &&
|
|
160
|
+
report.missing.every(id => Object.hasOwn(ACCESS_PROBLEMS, id)) &&
|
|
161
|
+
report.fixable.every(id => Object.hasOwn(ACCESS_FIXES, id) && report.missing.includes(id));
|
|
162
|
+
if (!bound) throw new Error('Legacy access check did not match the selected registration. Nothing was changed.');
|
|
163
|
+
return report;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function legacyAccessProblems(report) {
|
|
167
|
+
return report.missing.map(id => ACCESS_PROBLEMS[id](report));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// A certain gap here would otherwise fail after the journal is published (task hold) or after staging (CLI copy).
|
|
171
|
+
export function assertLegacyAccess(report, registration) {
|
|
172
|
+
legacyAccessReport(report, registration);
|
|
173
|
+
if (!report.missing.length) return;
|
|
174
|
+
const lines = [
|
|
175
|
+
'Your account cannot make the changes this upgrade needs. Nothing was changed.',
|
|
176
|
+
...legacyAccessProblems(report).map(problem => ` - ${problem}`),
|
|
177
|
+
];
|
|
178
|
+
if (report.fixable.length) {
|
|
179
|
+
lines.push(
|
|
180
|
+
'This usually means 1.3 was set up from an administrator terminal.',
|
|
181
|
+
`Run this once from ${report.elevated ? 'this' : 'an'} administrator terminal. It will only ${report.fixable.map(id => ACCESS_FIXES[id](report)).join(' and ')}:`,
|
|
182
|
+
' mcp-pacemaker upgrade --fix-legacy-access',
|
|
183
|
+
'Then run this upgrade again from a normal (non-administrator) terminal.',
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
if (report.missing.some(id => !report.fixable.includes(id))) {
|
|
187
|
+
lines.push('--fix-legacy-access does not change the other permissions listed; an administrator must grant them.');
|
|
188
|
+
}
|
|
189
|
+
throw Object.assign(new Error(lines.join('\n')), { code: 'LEGACY_ACCESS_MISSING' });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 1.3 rewrites sessions.json whenever a session starts or ends, and an upgrade may end
|
|
193
|
+
// sessions, so only the config is compared.
|
|
194
|
+
export function verifyLegacyConfig(config, legacy) {
|
|
195
|
+
new PoolingFiles(config).verify(config, legacy.authority);
|
|
196
|
+
if (hasPoolingTransaction(config)) throw new Error('Legacy configuration has an unresolved transaction.');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function nonceChallenge(port, nonce) {
|
|
200
|
+
return new Promise((resolveChallenge, reject) => {
|
|
201
|
+
const request = get({
|
|
202
|
+
hostname: '127.0.0.1', port, path: '/admin/upgrade-identity-check',
|
|
203
|
+
headers: { 'x-mcp-nonce': nonce }, agent: false,
|
|
204
|
+
}, response => {
|
|
205
|
+
let body = '';
|
|
206
|
+
response.on('data', data => {
|
|
207
|
+
body += data;
|
|
208
|
+
if (body.length > 1024) request.destroy(new Error('Oversized legacy identity response.'));
|
|
209
|
+
});
|
|
210
|
+
response.on('end', () => {
|
|
211
|
+
if (response.statusCode !== 404 ||
|
|
212
|
+
body !== 'unknown admin action') reject(new Error('Legacy listener does not match the selected config nonce.'));
|
|
213
|
+
else resolveChallenge();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
request.on('error', reject);
|
|
217
|
+
request.setTimeout(2000, () => request.destroy(new Error('Legacy identity challenge timed out.')));
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function verifyLegacyListener(instance, legacy, { sameGeneration = true } = {}) {
|
|
222
|
+
verifyLegacyConfig(instance.config, legacy);
|
|
223
|
+
const files = new PoolingFiles(instance.config);
|
|
224
|
+
const noncePath = join(dirname(instance.config), 'admin.nonce');
|
|
225
|
+
const authority = files.inspect(noncePath);
|
|
226
|
+
if (sameGeneration &&
|
|
227
|
+
!isDeepStrictEqual(authority, legacy.nonceAuthority)) throw new Error('Legacy nonce identity changed.');
|
|
228
|
+
const nonce = readFileSync(noncePath, 'utf8').trim();
|
|
229
|
+
if (nonce.length !== 36) throw new Error('Invalid legacy nonce.');
|
|
230
|
+
const status = await backendStatus(instance.port);
|
|
231
|
+
if (status.service !== 'mcp-pacemaker' ||
|
|
232
|
+
status.version !== '1.3.0' ||
|
|
233
|
+
status.port !== instance.port ||
|
|
234
|
+
typeof status.instanceId !== 'string' ||
|
|
235
|
+
(sameGeneration && status.instanceId !== legacy.backendInstanceId)) throw new Error('Legacy backend generation changed.');
|
|
236
|
+
await nonceChallenge(instance.port, nonce);
|
|
237
|
+
files.verify(noncePath, authority);
|
|
238
|
+
return { status, nonceAuthority: authority };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function prepareLegacyPlan(options, { home, task = new WindowsLegacyTaskAdapter(), processes } = {}) {
|
|
242
|
+
if (process.platform !== 'win32') throw new Error('Legacy restart migration has no supported adapter on this OS. Nothing was changed.');
|
|
243
|
+
if (options.sxs) throw new Error('Legacy-source SxS is not supported; no existing service was changed.');
|
|
244
|
+
const { statePath, port } = legacyStatePort(home);
|
|
245
|
+
const records = task.inspectLegacySource ? await task.inspectLegacySource(port) : await task.inspect(port);
|
|
246
|
+
const { root, registration } = parseLegacyRegistration(records, port);
|
|
247
|
+
const rootIdentity = verifyLegacyPackage(root);
|
|
248
|
+
if (typeof task.access === 'function') assertLegacyAccess(await task.access(port, join(root, 'bin', 'cli.mjs')), registration);
|
|
249
|
+
const config = realpathSync.native(join(home, 'servers.json'));
|
|
250
|
+
const files = new PoolingFiles(config);
|
|
251
|
+
// 1.3 rewrites sessions.json on every session start and end, so it is read only after the
|
|
252
|
+
// old bridge has stopped.
|
|
253
|
+
const legacy = {
|
|
254
|
+
protocol: 1, root, rootIdentity, registration,
|
|
255
|
+
stateAuthority: files.inspect(statePath),
|
|
256
|
+
authority: files.inspect(config), sessions: null,
|
|
257
|
+
};
|
|
258
|
+
const instance = {
|
|
259
|
+
id: randomUUID(), directory: join(home, 'managed', `legacy-${port}-${randomUUID()}`),
|
|
260
|
+
config, cwd: dirname(config), port, node: realpathSync.native(process.execPath),
|
|
261
|
+
};
|
|
262
|
+
const api = processes ?? await import('./windows-legacy-process.mjs');
|
|
263
|
+
const session = await api.prepareLegacyProcesses({ port, root });
|
|
264
|
+
try {
|
|
265
|
+
legacy.processes = session.plan;
|
|
266
|
+
const listener = await verifyLegacyListener(instance, legacy, { sameGeneration: false });
|
|
267
|
+
legacy.backendInstanceId = listener.status.instanceId;
|
|
268
|
+
legacy.nonceAuthority = listener.nonceAuthority;
|
|
269
|
+
if (session.plan.roots.supervisor.ownerSid !== registration.currentUserSid ||
|
|
270
|
+
session.plan.roots.bridge.ownerSid !== registration.currentUserSid) throw new Error('Task and process owners do not match.');
|
|
271
|
+
} finally { await session.close(); }
|
|
272
|
+
return { instance, legacy, task, processes: api };
|
|
273
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { readAutostart } from './upgrade-autostart.mjs';
|
|
6
|
+
import { readManagedJson } from './managed-state.mjs';
|
|
7
|
+
|
|
8
|
+
const ownerPolicy = readManagedJson(new URL('./legacy-1.3.json', import.meta.url)).taskOwnerPolicy;
|
|
9
|
+
|
|
10
|
+
export function eligibleTaskOwner(ownerSid, runtimeSid) {
|
|
11
|
+
if (ownerPolicy?.protocol !== 1 ||
|
|
12
|
+
ownerPolicy.allowRuntimeUser !== true ||
|
|
13
|
+
!Array.isArray(ownerPolicy.allowedOwnerSids)) throw new Error('Unsupported task owner policy.');
|
|
14
|
+
return typeof ownerSid === 'string' &&
|
|
15
|
+
(ownerSid === runtimeSid || ownerPolicy.allowedOwnerSids.includes(ownerSid));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class WindowsLegacyTaskAdapter {
|
|
19
|
+
call(operation, port, input) {
|
|
20
|
+
if (process.platform !== 'win32') throw new Error('Legacy restart migration is supported only for the known Windows 1.3 registration.');
|
|
21
|
+
const script = fileURLToPath(new URL('../autostart/windows/legacy-task.ps1', import.meta.url));
|
|
22
|
+
const output = execFileSync('pwsh', [
|
|
23
|
+
'-NoProfile', '-NonInteractive', '-File', script, '-Operation', operation, '-Port', String(port),
|
|
24
|
+
], {
|
|
25
|
+
input: input ? JSON.stringify(input) : '', encoding: 'utf8',
|
|
26
|
+
windowsHide: true, timeout: 15000, maxBuffer: 1024 * 1024,
|
|
27
|
+
});
|
|
28
|
+
return JSON.parse(output);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
inspect(port) { return this.call('inspect', port); }
|
|
32
|
+
hold(port, expected) { return this.call('hold', port, { expected }); }
|
|
33
|
+
repoint(port, expected, launcher) { return this.call('repoint', port, { expected, launcher }); }
|
|
34
|
+
enable(port, expected) { return this.call('enable', port, { expected }); }
|
|
35
|
+
restore(port, expected, original) { return this.call('restore', port, { expected, original }); }
|
|
36
|
+
|
|
37
|
+
callAccess(operation, port, cliPath, input) {
|
|
38
|
+
if (process.platform !== 'win32') throw new Error('Legacy access checks are supported only for the known Windows 1.3 registration.');
|
|
39
|
+
const script = fileURLToPath(new URL('../autostart/windows/legacy-access.ps1', import.meta.url));
|
|
40
|
+
let output;
|
|
41
|
+
try {
|
|
42
|
+
output = execFileSync('pwsh', [
|
|
43
|
+
'-NoProfile', '-NonInteractive', '-File', script, '-Operation', operation, '-Port', String(port), '-CliPath', cliPath,
|
|
44
|
+
], {
|
|
45
|
+
input: input ? JSON.stringify(input) : '', encoding: 'utf8',
|
|
46
|
+
windowsHide: true, timeout: 30000, maxBuffer: 1024 * 1024,
|
|
47
|
+
});
|
|
48
|
+
} catch (error) {
|
|
49
|
+
let reply;
|
|
50
|
+
try { reply = JSON.parse(String(error.stdout ?? '')); } catch { throw error; }
|
|
51
|
+
throw Object.assign(new Error(String(reply.error)), { changed: Array.isArray(reply.changed) ? reply.changed : [] });
|
|
52
|
+
}
|
|
53
|
+
return JSON.parse(output);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Read-only: what the ordinary runtime user cannot do to the task and bin/cli.mjs.
|
|
57
|
+
access(port, cliPath) { return this.callAccess('inspect', port, cliPath); }
|
|
58
|
+
// Elevated only: fixes the gaps reported by access(), bound to its returned descriptors.
|
|
59
|
+
grantAccess(port, cliPath, expected) { return this.callAccess('grant', port, cliPath, { expected }); }
|
|
60
|
+
assertRestored(port, actual, original) {
|
|
61
|
+
const { xml: actualXml, xmlSha256: actualHash, ...actualFields } = actual;
|
|
62
|
+
const { xml: originalXml, xmlSha256: originalHash, ...originalFields } = original;
|
|
63
|
+
if (!isDeepStrictEqual(actualFields, originalFields) ||
|
|
64
|
+
typeof actualXml !== 'string' ||
|
|
65
|
+
typeof originalXml !== 'string' ||
|
|
66
|
+
createHash('sha256').update(actualXml).digest('hex') !== actualHash ||
|
|
67
|
+
createHash('sha256').update(originalXml).digest('hex') !== originalHash) {
|
|
68
|
+
throw new Error('Restored legacy registration fields, security or raw XML identity differ.');
|
|
69
|
+
}
|
|
70
|
+
if (actualXml === originalXml) return;
|
|
71
|
+
const proof = this.call('compare-restored', port, { actual, original });
|
|
72
|
+
if (proof.equivalent !== true) throw new Error('Legacy restoration equivalence was not verified.');
|
|
73
|
+
}
|
|
74
|
+
start(port, expected) { return this.call('start', port, { expected }); }
|
|
75
|
+
stableRecords(port) { return readAutostart(port); }
|
|
76
|
+
}
|