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
@@ -6,13 +6,18 @@
6
6
  logon AND on workstation unlock (unlock covers sleep/wake, where logon never fires).
7
7
  .PARAMETER Port Port to pass through to the supervisor (default 8791).
8
8
  .PARAMETER TaskName Selected per-user task name (default McpPacemaker-<port>).
9
+ .PARAMETER LauncherPath Stable per-instance launcher, when using managed upgrades.
9
10
  .OUTPUTS
10
11
  Registration result.
11
12
  .EXAMPLE
12
13
  .\register-task.ps1 -Port 8791
13
14
  #>
14
15
  [CmdletBinding()]
15
- param([ValidateRange(1, 65535)][int]$Port = 8791, [string]$TaskName = "McpPacemaker-$Port")
16
+ param(
17
+ [ValidateRange(1, 65535)][int]$Port = 8791,
18
+ [string]$TaskName = "McpPacemaker-$Port",
19
+ [string]$LauncherPath
20
+ )
16
21
 
17
22
  $ErrorActionPreference = 'Stop'
18
23
  try {
@@ -21,6 +26,10 @@ try {
21
26
  throw 'Cache-backed npx roots cannot host autostart. Install in a durable root first.'
22
27
  }
23
28
  $vbs = Join-Path $PSScriptRoot 'launcher.vbs'
29
+ if ($LauncherPath) {
30
+ $vbs = (Resolve-Path -LiteralPath $LauncherPath).Path
31
+ if ($vbs -match '["\r\n]') { throw 'Unsupported launcher path.' }
32
+ }
24
33
  $user = "$env:USERDOMAIN\$env:USERNAME"
25
34
 
26
35
  $svc = New-Object -ComObject Schedule.Service
@@ -48,7 +57,7 @@ try {
48
57
 
49
58
  $action = $def.Actions.Create(0) # TASK_ACTION_EXEC
50
59
  $action.Path = 'wscript.exe'
51
- $action.Arguments = "`"$vbs`" $Port"
60
+ $action.Arguments = if ($LauncherPath) { "`"$vbs`"" } else { "`"$vbs`" $Port" }
52
61
 
53
62
  $def.Principal.UserId = $user
54
63
  $def.Principal.LogonType = 3 # interactive token (no stored password)
@@ -0,0 +1,47 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ export function windowsArgument(value) {
7
+ return `"${String(value).replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1')}"`;
8
+ }
9
+
10
+ export class AccountTaskAdapter {
11
+ constructor({ taskPath, targetSid, powershell, run = execFileSync }) {
12
+ this.taskPath = taskPath;
13
+ this.targetSid = targetSid;
14
+ this.powershell = powershell;
15
+ this.run = run;
16
+ }
17
+
18
+ call(action, input = {}) {
19
+ if (!this.powershell || !existsSync(this.powershell)) throw new Error('A verified absolute PowerShell toolchain is required.');
20
+ const script = fileURLToPath(new URL('../autostart/windows/account-task.ps1', import.meta.url));
21
+ const env = { ...process.env };
22
+ const excluded = ['node_options', 'node_path', 'npm_execpath', 'mcp_pacemaker_cli_context', 'path', 'psmodulepath',
23
+ 'dotnet_startup_hooks', 'dotnet_additional_deps', 'dotnet_shared_store', 'complus_profapi_profilercompatibilitysetting'];
24
+ for (const key of Object.keys(env)) {
25
+ const lower = key.toLowerCase();
26
+ if (excluded.includes(lower) ||
27
+ /^(cor_|coreclr_|complus_|dotnet_)/.test(lower)) delete env[key];
28
+ }
29
+ env.PATH = dirname(this.powershell);
30
+ env.PSModulePath = join(dirname(this.powershell), 'Modules');
31
+ const output = this.run(this.powershell, ['-NoProfile', '-NonInteractive', '-File', script, '-Action', action], {
32
+ input: JSON.stringify({ ...input, taskPath: this.taskPath, targetSid: this.targetSid }),
33
+ cwd: dirname(this.powershell), env, encoding: 'utf8', windowsHide: true,
34
+ timeout: 20000, maxBuffer: 65536,
35
+ });
36
+ return JSON.parse(output);
37
+ }
38
+
39
+ inspect() { return this.call('inspect'); }
40
+ session(expected, port, root, managed) { return this.call('session', { expected, port, root, managed }); }
41
+ bootstrap(expected, original, bootstrap) { return this.call('bootstrap', { expected, original, bootstrap }); }
42
+ hold(expected, original) { return this.call('hold', { expected, original }); }
43
+ repoint(expected, original, launcher) { return this.call('repoint', { expected, original, launcher }); }
44
+ release(expected, original) { return this.call('release', { expected, original }); }
45
+ restore(expected, original) { return this.call('restore', { expected, original }); }
46
+ launch(expected, sessionId) { return this.call('launch', { expected, sessionId }); }
47
+ }
@@ -0,0 +1,198 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { readFileSync, realpathSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { AccountTaskAdapter, windowsArgument } from './account-task-adapter.mjs';
7
+ import { authorizeTaskActor } from './task-scope.mjs';
8
+ import { accountOperationReference, accountRecoveryTerminalPhases, ControllerSignatures, operationDigest, taskRevision, canonicalJson } from './task-transaction-protocol.mjs';
9
+ import { TaskOnlyController, workerTaskRecord } from './task-only-controller.mjs';
10
+ import { readInstanceState, readManagedJson, readRecoveryContext } from './managed-state.mjs';
11
+ import { parseLegacyRegistration, verifyLegacyPackage, LEGACY_WARNING } from './legacy-installation.mjs';
12
+ import { exactVersion } from './upgrade-package.mjs';
13
+ import { canonicalPort } from './cli-selection.mjs';
14
+ import { validateRegistry } from './update-check.mjs';
15
+
16
+ const hash = path => createHash('sha256').update(readFileSync(path)).digest('hex');
17
+
18
+ export function accountUpgradeOptions(args) {
19
+ const result = {};
20
+ let command = false;
21
+ for (let index = 0; index < args.length; index++) {
22
+ const value = args[index];
23
+ if (value === 'upgrade' && !command) { command = true; continue; }
24
+ if (['--plan', '--recover', '--yes'].includes(value)) {
25
+ if (result[value.slice(2)]) throw new Error('Repeated account operation flag.');
26
+ result[value.slice(2)] = true;
27
+ continue;
28
+ }
29
+ const key = ['--to', '--registry', '--instance', '--controller-root'].find(option => value === option || value.startsWith(`${option}=`));
30
+ if (key) {
31
+ const argument = value === key ? args[++index] : value.slice(key.length + 1);
32
+ if (!argument || argument.startsWith('-') || result[key.slice(2)]) throw new Error('Invalid account operation option.');
33
+ result[key.slice(2)] = argument;
34
+ continue;
35
+ }
36
+ throw new Error(`Unsupported account-management option: ${value}`);
37
+ }
38
+ if (!command || (result.recover ? Boolean(result.to) : !result.to)) throw new Error('Select an exact --to version or --recover.');
39
+ if (result.recover &&
40
+ (result.plan || result.registry !== undefined)) throw new Error('Explicit account recovery cannot combine --plan or --registry; it uses the recorded operation.');
41
+ if (result.to) exactVersion(result.to);
42
+ if (result.registry) result.registry = validateRegistry(result.registry);
43
+ if (result.yes && !result.plan) throw new Error('Original-account task bootstrap and restart require interactive confirmation; --yes cannot attest paused callers.');
44
+ return result;
45
+ }
46
+
47
+ export async function runAccountController(root, scope, {
48
+ channelApi, task, confirm, report = console.log, powershell,
49
+ } = {}) {
50
+ if (process.platform !== 'win32') throw new Error('Explicit account management is Windows-only; --all-users is unsupported.');
51
+ const options = accountUpgradeOptions(scope.args);
52
+ if (options.instance !== undefined &&
53
+ options.instance !== scope.instance) throw new Error('Account instance selector disagrees with the parsed scope.');
54
+ if (options.recover &&
55
+ !scope.instance) throw new Error('Explicit account recovery requires --instance, not --task.');
56
+ const api = channelApi ?? await import('./windows-task-channel.mjs');
57
+ const { actorFacts: actor } = await api.queryTaskChannelCaller();
58
+ authorizeTaskActor(actor, scope.targetSid);
59
+ if (actor.elevated || actor.ownerSid !== scope.targetSid) await api.inspectTrustedCodeRoot(root);
60
+ const ps = powershell ?? join(process.env.ProgramFiles, 'PowerShell', '7', 'pwsh.exe');
61
+ if (!task && (actor.elevated || actor.ownerSid !== scope.targetSid)) await api.inspectTrustedCodeRoot(dirname(ps));
62
+ let selected;
63
+ let taskPath = scope.taskPath;
64
+ if (scope.instance) {
65
+ selected = readInstanceState(resolve(scope.instance));
66
+ if (options.recover && selected.controllerOperation?.targetSid === scope.targetSid) {
67
+ taskPath = selected.controllerOperation.taskPath;
68
+ } else {
69
+ const records = selected.autostart?.records;
70
+ if (selected.autostart?.kind !== 'stable' ||
71
+ records?.length !== 1 ||
72
+ typeof records[0].name !== 'string') throw new Error('Explicit account management requires one verified existing per-user registration.');
73
+ taskPath = `\\${records[0].name}`;
74
+ }
75
+ }
76
+ const adapter = task ?? new AccountTaskAdapter({ taskPath, targetSid: scope.targetSid, powershell: ps });
77
+ const context = await adapter.inspect();
78
+ const actual = context.record;
79
+ if (actual.path !== taskPath ||
80
+ actual.userSid !== scope.targetSid ||
81
+ actual.logonType !== 3 ||
82
+ actual.runLevel !== 0 ||
83
+ !actual.enabled ||
84
+ context.allowDemandStart !== true) throw new Error('The exact original interactive account cannot demand-start this registration.');
85
+ if (Buffer.byteLength(canonicalJson(actual)) > 8192) throw new Error('Task record exceeds the bounded account-channel contract.');
86
+ const home = selected ? dirname(selected.config) : join(realpathSync.native(context.profilePath), '.mcp-pacemaker');
87
+ let sourceRoot;
88
+ let port;
89
+ let from;
90
+ let original = actual;
91
+ let prior;
92
+ if (options.recover) {
93
+ if (!selected?.controllerOperation?.manifestPath) throw new Error('Recovery requires --instance with a recorded protected controller operation.');
94
+ const manifestPath = selected.controllerOperation.manifestPath;
95
+ await api.inspectTrustedCodeRoot(dirname(manifestPath));
96
+ const manifest = readManagedJson(manifestPath);
97
+ prior = readManagedJson(join(dirname(manifestPath), 'controller-journal.json'));
98
+ if (canonicalJson(selected.controllerOperation) !== canonicalJson(accountOperationReference(prior.binding)) ||
99
+ canonicalJson(manifest.document?.binding) !== canonicalJson(prior.binding) ||
100
+ manifest.document.digest !== operationDigest(prior.binding) ||
101
+ prior.binding.operationId !== manifest.operationId ||
102
+ manifest.targetSid !== scope.targetSid ||
103
+ manifest.sessionId !== prior.binding.sessionId ||
104
+ prior.binding.targetSid !== scope.targetSid ||
105
+ prior.binding.destination !== selected.directory ||
106
+ prior.binding.config !== selected.config ||
107
+ prior.binding.port !== selected.port ||
108
+ prior.binding.taskPath !== taskPath ||
109
+ taskRevision(prior.current) !== taskRevision(actual)) throw new Error('Protected controller recovery state disagrees with the selected task.');
110
+ if (manifest.document.initialTask &&
111
+ (taskRevision(manifest.document.initialTask) !== prior.binding.initialTaskRevision ||
112
+ taskRevision(prior.initial) !== prior.binding.initialTaskRevision)) {
113
+ throw new Error('Protected pre-bootstrap task revision disagrees with its operation binding.');
114
+ }
115
+ original = prior.original;
116
+ sourceRoot = prior.binding.root;
117
+ port = prior.binding.port;
118
+ from = prior.binding.from;
119
+ const recovery = readRecoveryContext(selected.directory);
120
+ if (accountRecoveryTerminalPhases.includes(recovery.journal.phase)) {
121
+ if (!['completed', 'failed-restored', 'restored'].includes(prior.phase)) {
122
+ throw new Error('Runtime journal is terminal but task outcome is unresolved; no bootstrap or automatic task rewrite was attempted.');
123
+ }
124
+ report('This runtime and controller transaction is already settled; no task or runtime change was made.');
125
+ return { status: recovery.journal.phase, directory: selected.directory, port: selected.port };
126
+ }
127
+ } else if (selected) {
128
+ sourceRoot = selected.active.root;
129
+ port = selected.port;
130
+ from = selected.active.version;
131
+ if (actual.actions.length !== 1 ||
132
+ actual.actions[0].arguments !== `"${join(selected.directory, 'launcher.vbs')}"`) throw new Error('The selected task is not bound to the managed launcher.');
133
+ } else {
134
+ const state = readManagedJson(join(home, 'state.json'));
135
+ const ports = [...new Set((state.hosts ?? []).map(host => canonicalPort(host.port)))];
136
+ if (ports.length !== 1) throw new Error('Account migration requires exactly one existing legacy port.');
137
+ port = ports[0];
138
+ sourceRoot = parseLegacyRegistration([workerTaskRecord(actual, scope.targetSid)], port).root;
139
+ verifyLegacyPackage(sourceRoot);
140
+ from = '1.3.0';
141
+ }
142
+ const session = options.recover
143
+ ? { sessionId: prior.binding.sessionId, supervisorImage: prior.binding.legacySupervisorImage }
144
+ : await adapter.session(actual, port, sourceRoot, selected ? { config: selected.config, cwd: selected.cwd } : undefined);
145
+ if (!Number.isInteger(session.sessionId) || session.sessionId <= 0) throw new Error('No verified original interactive session is available.');
146
+ const operationId = randomUUID();
147
+ const destination = selected?.directory ?? join(home, 'managed', `legacy-${port}-${operationId}`);
148
+ const parent = realpathSync.native(options['controller-root'] ?? process.env.LOCALAPPDATA ?? homedir());
149
+ const manifestPath = join(parent, operationId, 'manifest.json');
150
+ const scriptPath = fileURLToPath(new URL('./account-upgrade-worker.mjs', import.meta.url));
151
+ const nodePath = realpathSync.native(process.execPath);
152
+ const workerArgs = [scriptPath, '--manifest', manifestPath, '--operation', operationId, '--controller-root', parent];
153
+ const binding = {
154
+ protocol: 1, operationId, manifestPath, taskPath, targetSid: scope.targetSid, sessionId: session.sessionId,
155
+ kind: prior?.binding.kind ?? (selected ? 'managed' : 'legacy'), recover: Boolean(options.recover),
156
+ root: sourceRoot, home, config: selected?.config ?? join(home, 'servers.json'), port,
157
+ from, to: options.to ?? prior.binding.to, registry: options.registry ?? prior?.binding.registry ?? null,
158
+ destination, launcher: join(destination, 'launcher.vbs'),
159
+ originalTaskRevision: taskRevision(original), initialTaskRevision: taskRevision(actual),
160
+ legacySupervisorImage: session.supervisorImage ?? null,
161
+ previousControllerOperation: selected?.controllerOperation ?? null,
162
+ processQueryImage: { path: ps, sha256: hash(ps) },
163
+ bootstrapTask: { nodePath, arguments: workerArgs.map(windowsArgument).join(' '), cwd: root },
164
+ };
165
+ report(`Account scope: ${actor.ownerSid === scope.targetSid ? 'current user' : 'explicit other user'}; per-user installation only.\nTask: ${taskPath}\nPort: ${port}\nOriginal account/session preserved. No all-users conversion.\n${binding.kind === 'legacy' ? LEGACY_WARNING : 'Managed admission and job lifetime proof remain required.'}`);
166
+ if (options.plan) {
167
+ report('Read-only plan. Native logged-on session eligibility is checked at execution before bootstrap. Original-user dependency policy, staging and executable preflight will be verified before stop. No task was changed.');
168
+ return { status: 'planned', targetSid: scope.targetSid, taskPath, port };
169
+ }
170
+ if (!confirm) throw new Error('Interactive bootstrap confirmation is required.');
171
+ if (await confirm('Temporarily hold automatic starts and launch the trusted preparation worker under this exact original account? The current backend is not stopped by bootstrap.') !== true) {
172
+ return { status: 'cancelled' };
173
+ }
174
+ const signatures = new ControllerSignatures();
175
+ const channel = await api.createTaskChannel({
176
+ operationId, targetSid: scope.targetSid, sessionId: session.sessionId,
177
+ bootstrap: { nodePath, scriptPath, nodeSha256: hash(nodePath), scriptSha256: hash(scriptPath), argv: workerArgs },
178
+ manifestParent: parent,
179
+ manifest: {
180
+ binding, digest: operationDigest(binding), publicKey: signatures.publicKey,
181
+ initialTask: actual,
182
+ authorizedTaskRevisions: [
183
+ taskRevision(workerTaskRecord(original, scope.targetSid)),
184
+ taskRevision(workerTaskRecord(actual, scope.targetSid)),
185
+ ],
186
+ },
187
+ });
188
+ if (channel.manifestPath !== manifestPath) {
189
+ await channel.close();
190
+ throw new Error('Protected operation path differs from the sealed bootstrap.');
191
+ }
192
+ const controller = new TaskOnlyController({
193
+ binding, original, initial: actual, task: adapter, channel, signatures, confirm,
194
+ journalPath: join(dirname(manifestPath), 'controller-journal.json'),
195
+ });
196
+ report(`Recovery evidence: ${dirname(manifestPath)}`);
197
+ return controller.run();
198
+ }
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+ import { fileURLToPath } from 'node:url';
3
+ import { createHash } from 'node:crypto';
4
+ import { resolve } from 'node:path';
5
+ import { AccountWorkerSession } from './account-worker-session.mjs';
6
+ import { readProtectedManifest } from './windows-task-channel.mjs';
7
+ import { confirmLegacyUpgrade, legacyPlanDigest } from './legacy-installation.mjs';
8
+ import { ManagedUpgrader, describeUpgrade, planUpgrade } from './managed-upgrade.mjs';
9
+ import { readRecoveryContext } from './managed-state.mjs';
10
+ import { accountOperationReference, accountRecoveryTerminalPhases, operationDigest, taskRevision, verifyTargetSession } from './task-transaction-protocol.mjs';
11
+
12
+ export async function executeAccountUpgrade(session) {
13
+ const binding = session.binding;
14
+ process.chdir(binding.home);
15
+ const native = await import('./windows-legacy-process.mjs');
16
+ const processes = {
17
+ ...native,
18
+ prepareLegacyProcesses: async options => {
19
+ const held = await native.prepareLegacyProcesses(options);
20
+ try { session.verifyProcessSessions(held.plan); }
21
+ catch (error) { await held.close(); throw error; }
22
+ return {
23
+ ...held,
24
+ stop: async () => {
25
+ await session.call('INSPECT');
26
+ session.verifyProcessSessions(held.plan);
27
+ return held.stop();
28
+ },
29
+ };
30
+ },
31
+ };
32
+ const upgrader = new ManagedUpgrader({
33
+ legacy: { task: session, processes },
34
+ autostart: instance => session.inspectAutostart(instance),
35
+ beforeInterruption: async () => {
36
+ await session.hold(binding.port, session.logical);
37
+ },
38
+ beforeStop: async () => {
39
+ await session.call('INSPECT');
40
+ session.verifyManagedProcessSession();
41
+ },
42
+ beforeAdmission: async (instance, journal) => {
43
+ if (journal.rollback) {
44
+ await session.restore(binding.port, session.logical, session.original);
45
+ return;
46
+ }
47
+ const current = await session.repoint(binding.port, session.logical, binding.launcher);
48
+ await session.enable(binding.port, current);
49
+ },
50
+ });
51
+ if (binding.recover) {
52
+ const { instance, journal } = readRecoveryContext(binding.destination);
53
+ if (instance.config !== binding.config ||
54
+ instance.port !== binding.port ||
55
+ (journal.legacy && journal.legacy.root !== binding.root)) throw new Error('Recovery is outside the protected account scope.');
56
+ if (accountRecoveryTerminalPhases.includes(journal.phase)) {
57
+ throw new Error('Runtime recovery became terminal before worker approval; retry the read-only controller check.');
58
+ }
59
+ const approved = await session.call('PLAN', {
60
+ digest: operationDigest({ instance: instance.id, phase: journal.phase, target: {
61
+ root: journal.target.root, version: journal.target.version, directoryIdentity: journal.target.directoryIdentity,
62
+ inventorySha256: createHash('sha256').update(JSON.stringify(journal.target.files)).digest('hex'),
63
+ } }),
64
+ summary: 'Recover only this original-account instance. Uncertain task/traffic outcomes remain a refusal; no calls are replayed.',
65
+ });
66
+ if (!approved.approved) return { status: 'cancelled', directory: binding.destination, port: binding.port };
67
+ await session.hold(binding.port, session.logical);
68
+ const result = await upgrader.recover(binding.destination);
69
+ if (accountRecoveryTerminalPhases.includes(result.status) &&
70
+ result.status !== 'committed' &&
71
+ taskRevision(session.logical) !== taskRevision(session.original)) {
72
+ await session.restore(binding.port, session.logical, session.original);
73
+ }
74
+ return result;
75
+ }
76
+ const options = { to: binding.to, registry: binding.registry ?? undefined,
77
+ ...(binding.kind === 'managed' ? { instance: binding.destination } : {}) };
78
+ const plan = await planUpgrade(options, {
79
+ home: binding.home, legacy: { task: session, processes },
80
+ autostart: instance => session.inspectAutostart(instance),
81
+ });
82
+ if (plan.instance.config !== binding.config ||
83
+ plan.instance.port !== binding.port ||
84
+ (plan.legacy && plan.legacy.root !== binding.root)) throw new Error('Original-account discovery disagrees with the approved installation.');
85
+ plan.instance.directory = binding.destination;
86
+ if (plan.legacy) {
87
+ plan.instance.controllerOperation = accountOperationReference(binding);
88
+ const accepted = await confirmLegacyUpgrade(plan, {
89
+ interactive: true,
90
+ confirm: async message => {
91
+ const result = await session.call('PLAN', { digest: legacyPlanDigest(plan), summary: message });
92
+ return result.approved === true && result.digest === legacyPlanDigest(plan);
93
+ },
94
+ });
95
+ if (!accepted) return { status: 'cancelled', directory: binding.destination, port: binding.port };
96
+ } else {
97
+ const result = await session.call('PLAN', {
98
+ digest: operationDigest({ instance: plan.instance.id, to: plan.to, port: plan.port }),
99
+ summary: describeUpgrade(plan),
100
+ });
101
+ if (!result.approved) return { status: 'cancelled', directory: binding.destination, port: binding.port };
102
+ }
103
+ return upgrader.execute(plan);
104
+ }
105
+
106
+ export async function runAccountWorker(args = process.argv.slice(2), { execute = executeAccountUpgrade } = {}) {
107
+ if (args.length !== 6 ||
108
+ args[0] !== '--manifest' ||
109
+ args[2] !== '--operation' ||
110
+ args[4] !== '--controller-root') throw new Error('Invalid original-account bootstrap arguments.');
111
+ const protectedInput = await readProtectedManifest(args[1], { operationId: args[3], manifestParent: args[5] });
112
+ if (protectedInput.document.document.binding.manifestPath !== args[1]) throw new Error('Worker manifest path does not match the protected scope.');
113
+ verifyTargetSession(protectedInput.targetSession, protectedInput.document.targetSession);
114
+ const session = await new AccountWorkerSession(protectedInput.document, protectedInput.readerIdentity).open();
115
+ try {
116
+ const result = await execute(session);
117
+ if (result.status === 'cancelled') return result;
118
+ await session.call('DONE', {
119
+ status: result.status, directory: result.directory, port: result.port,
120
+ version: result.version ?? session.binding.from,
121
+ });
122
+ return result;
123
+ } catch (error) {
124
+ if (!session.failure) {
125
+ await session.call('FAILED', { message: 'The original-account operation did not complete; retain both journals.' }).catch(() => {});
126
+ }
127
+ throw error;
128
+ } finally { session.close(); }
129
+ }
130
+
131
+ if (process.argv[1] &&
132
+ resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
133
+ runAccountWorker().catch(error => { console.error(error.message); process.exitCode = 1; });
134
+ }
@@ -0,0 +1,215 @@
1
+ import { connect } from 'node:net';
2
+ import { createHash } from 'node:crypto';
3
+ import { execFileSync, spawn } from 'node:child_process';
4
+ import { closeSync, openSync, readFileSync } from 'node:fs';
5
+ import { dirname, join } from 'node:path';
6
+ import { isDeepStrictEqual } from 'node:util';
7
+ import { accountOperationReference, operationDigest, taskRevision, WorkerSignatures } from './task-transaction-protocol.mjs';
8
+ import { minimalTaskRecord } from './task-only-controller.mjs';
9
+ import { durableJson, readManagedJson } from './managed-state.mjs';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ export class AccountWorkerSession {
13
+ constructor(publicManifest, identity) {
14
+ this.manifest = publicManifest;
15
+ this.binding = publicManifest.document.binding;
16
+ if (this.binding.operationId !== publicManifest.operationId ||
17
+ this.binding.targetSid !== identity.ownerSid ||
18
+ this.binding.sessionId !== identity.sessionId ||
19
+ publicManifest.targetSid !== identity.ownerSid ||
20
+ publicManifest.sessionId !== identity.sessionId ||
21
+ operationDigest(this.binding) !== publicManifest.document.digest) throw new Error('Protected operation scope does not match the actual worker.');
22
+ this.signatures = new WorkerSignatures({
23
+ publicKey: publicManifest.document.publicKey, operationId: publicManifest.operationId,
24
+ digest: publicManifest.document.digest, identity, targetSession: publicManifest.targetSession,
25
+ });
26
+ this.queue = [];
27
+ this.waiters = [];
28
+ this.holdKind = 'automatic-triggers';
29
+ this.destination = this.binding.destination;
30
+ this.allowed = new Set(publicManifest.document.authorizedTaskRevisions ?? []);
31
+ }
32
+
33
+ async open() {
34
+ this.socket = connect(this.manifest.endpoint);
35
+ this.socket.setEncoding('utf8');
36
+ let pending = '';
37
+ const fail = error => {
38
+ this.failure = error;
39
+ for (const waiter of this.waiters.splice(0)) waiter.reject(error);
40
+ };
41
+ this.socket.on('error', fail);
42
+ this.socket.on('close', () => fail(new Error('Controller channel closed; no reconnect or privileged fallback.')));
43
+ this.socket.on('data', chunk => {
44
+ pending += chunk;
45
+ if (Buffer.byteLength(pending) > 16384) { this.socket.destroy(new Error('Oversized controller response.')); return; }
46
+ const newline = pending.indexOf('\n');
47
+ if (newline < 0) return;
48
+ const text = pending.slice(0, newline);
49
+ pending = pending.slice(newline + 1);
50
+ try {
51
+ const response = JSON.parse(text);
52
+ const waiter = this.waiters.shift();
53
+ if (waiter) waiter.resolve(response);
54
+ else this.queue.push(response);
55
+ if (this.queue.length > 1 || pending.length) this.socket.destroy(new Error('Unsolicited controller frames.'));
56
+ } catch (error) { this.socket.destroy(error); }
57
+ });
58
+ await new Promise((resolveConnected, reject) => {
59
+ this.socket.once('connect', resolveConnected);
60
+ this.socket.once('error', reject);
61
+ });
62
+ const hello = await this.call('HELLO', { challenge: this.signatures.challenge, identity: this.signatures.identity });
63
+ this.original = hello.original;
64
+ this.logical = this.original;
65
+ this.allowed.add(taskRevision(this.original));
66
+ return this;
67
+ }
68
+
69
+ async call(verb, body = null) {
70
+ if (this.failure) throw this.failure;
71
+ if (this.busy) throw new Error('Only one original-account task request may be outstanding.');
72
+ this.busy = true;
73
+ let timer;
74
+ try {
75
+ const request = this.signatures.request(verb, body);
76
+ const response = new Promise((resolveResponse, reject) => {
77
+ timer = setTimeout(() => reject(new Error('Controller response timed out; task outcome is uncertain.')), verb === 'PLAN' ? 300000 : 30000);
78
+ if (this.queue.length) resolveResponse(this.queue.shift());
79
+ else this.waiters.push({ resolve: resolveResponse, reject });
80
+ });
81
+ this.socket.write(`${JSON.stringify(request)}\n`);
82
+ return this.signatures.accept(await response, request);
83
+ } catch (error) {
84
+ this.failure = error;
85
+ this.socket.destroy();
86
+ throw error;
87
+ } finally { clearTimeout(timer); this.busy = false; }
88
+ }
89
+
90
+ async inspectLegacySource() {
91
+ await this.call('INSPECT');
92
+ return [this.original];
93
+ }
94
+
95
+ async verifyBinding(instance, expected) {
96
+ if (instance.port !== this.binding.port ||
97
+ instance.config !== this.binding.config ||
98
+ !this.allowed.has(taskRevision(expected))) throw new Error('Worker task binding is outside the protected operation.');
99
+ await this.call('INSPECT');
100
+ return expected;
101
+ }
102
+
103
+ async taskCall(verb, expected) {
104
+ if (!this.allowed.has(taskRevision(expected))) throw new Error('Unknown worker task revision.');
105
+ const result = await this.call(verb);
106
+ this.logical = result.record;
107
+ this.allowed.add(taskRevision(result.record));
108
+ return result.record;
109
+ }
110
+
111
+ async hold(port, expected) {
112
+ if (port !== this.binding.port) throw new Error('Task hold is outside the original account port.');
113
+ const path = join(this.binding.destination, 'instance.json');
114
+ const instance = readManagedJson(path);
115
+ if (instance.config !== this.binding.config ||
116
+ instance.port !== port) throw new Error('Worker instance differs from its protected operation.');
117
+ const reference = accountOperationReference(this.binding);
118
+ if (!isDeepStrictEqual(instance.controllerOperation, reference)) {
119
+ if ((this.binding.kind === 'legacy' && !this.binding.recover) ||
120
+ !isDeepStrictEqual(instance.controllerOperation ?? null, this.binding.previousControllerOperation ?? null)) {
121
+ throw new Error('Published controller operation is missing or changed; no task hold was requested.');
122
+ }
123
+ durableJson(path, { ...instance, controllerOperation: reference });
124
+ }
125
+ return this.taskCall('HOLD', expected);
126
+ }
127
+ repoint(port, expected, launcher) {
128
+ if (launcher !== this.binding.launcher) throw new Error('Worker cannot choose the elevated task launcher.');
129
+ return this.taskCall('REPOINT', expected);
130
+ }
131
+ enable(port, expected) { return this.taskCall('ENABLE', expected); }
132
+ restore(port, expected, original) {
133
+ if (taskRevision(original) !== taskRevision(this.original)) throw new Error('Worker cannot supply a replacement task definition.');
134
+ return this.taskCall('RESTORE', expected);
135
+ }
136
+ async stableRecords() { return (await this.call('RECORDS')).records; }
137
+
138
+ async start(port, expected) {
139
+ if (port !== this.binding.port ||
140
+ taskRevision(expected) !== taskRevision(this.original)) throw new Error('Rollback is outside the original-account binding.');
141
+ const image = this.binding.legacySupervisorImage;
142
+ if (!image ||
143
+ createHash('sha256').update(readFileSync(image.path)).digest('hex') !== image.sha256) throw new Error('Original supervisor image changed before rollback.');
144
+ const log = openSync(join(this.binding.destination, 'rollback-launcher.log'), 'a', 0o600);
145
+ let child;
146
+ try {
147
+ child = spawn(image.path, [
148
+ '-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden',
149
+ '-File', join(this.binding.root, 'supervisor', 'supervise.ps1'), '-Port', String(port),
150
+ ], { cwd: dirname(this.binding.config), env: { ...process.env }, windowsHide: true,
151
+ stdio: ['ignore', log, log] });
152
+ } finally { closeSync(log); }
153
+ await new Promise((resolveStart, reject) => {
154
+ child.once('spawn', resolveStart);
155
+ child.once('error', reject);
156
+ });
157
+ child.once('exit', (code, signal) => {
158
+ durableJson(join(this.binding.destination, 'rollback-launch-exit.json'), { pid: child.pid, code, signal, imagePath: image.path });
159
+ });
160
+ child.unref();
161
+ }
162
+
163
+ verifyProcessSessions(plan) {
164
+ if (plan.ownerSid !== this.binding.targetSid ||
165
+ plan.root !== this.binding.root ||
166
+ plan.port !== this.binding.port) throw new Error('Legacy process scope differs from the protected operation.');
167
+ const identities = [plan.roots.supervisor, plan.roots.bridge, ...plan.observed]
168
+ .map(({ pid, creationTime, ownerSid }) => ({ pid, creationTime, ownerSid }));
169
+ if (identities.length < 2 ||
170
+ identities.length > 512 ||
171
+ new Set(identities.map(identity => identity.pid)).size !== identities.length) {
172
+ throw new Error('Unsupported captured identity count or duplicate process.');
173
+ }
174
+ for (const identity of identities) {
175
+ if (!Number.isInteger(identity.pid) ||
176
+ identity.pid <= 0 ||
177
+ identity.pid > 2147483647 ||
178
+ identity.ownerSid !== this.binding.targetSid ||
179
+ typeof identity.creationTime !== 'string' ||
180
+ !/^[1-9][0-9]{0,18}$/.test(identity.creationTime)) {
181
+ throw new Error('Invalid captured original-user identity.');
182
+ }
183
+ }
184
+ this.queryProcessSessions({ identities });
185
+ }
186
+
187
+ verifyManagedProcessSession() {
188
+ this.queryProcessSessions({ port: this.binding.port });
189
+ }
190
+
191
+ queryProcessSessions(selection) {
192
+ const image = this.binding.processQueryImage;
193
+ if (!image ||
194
+ createHash('sha256').update(readFileSync(image.path)).digest('hex') !== image.sha256) throw new Error('Original-user query host changed.');
195
+ const input = JSON.stringify({ ownerSid: this.binding.targetSid, sessionId: this.binding.sessionId, ...selection });
196
+ if (Buffer.byteLength(input, 'utf8') > 131072) throw new Error('Worker identity request exceeds its bound.');
197
+ const output = execFileSync(image.path, ['-NoProfile', '-NonInteractive', '-File',
198
+ fileURLToPath(new URL('../autostart/windows/inspect-worker-process-sessions.ps1', import.meta.url)),
199
+ ], {
200
+ input,
201
+ encoding: 'utf8', windowsHide: true, timeout: 15000, maxBuffer: 16384,
202
+ });
203
+ if (JSON.parse(output).verified !== true) throw new Error('Original-user process session inspection did not verify.');
204
+ }
205
+
206
+ async inspectAutostart(instance) {
207
+ if (instance.directory !== this.binding.destination ||
208
+ instance.autostart.kind !== 'stable' ||
209
+ !isDeepStrictEqual(instance.autostart.records, [minimalTaskRecord(this.original)])) throw new Error('Managed autostart does not match the selected original registration.');
210
+ await this.call('INSPECT');
211
+ return { kind: 'stable', launcher: this.binding.launcher };
212
+ }
213
+
214
+ close() { this.socket?.end(); }
215
+ }