mcp-pacemaker 2.1.0 → 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.
Files changed (37) hide show
  1. package/CHANGELOG.md +98 -2
  2. package/CONTRIBUTING.md +10 -3
  3. package/README.md +98 -760
  4. package/autostart/windows/legacy-access.ps1 +359 -0
  5. package/bin/account-upgrade-controller.mjs +1 -1
  6. package/bin/account-upgrade-worker.mjs +7 -0
  7. package/bin/cli-main.mjs +61 -5
  8. package/bin/dependency-lock.json +642 -0
  9. package/bin/legacy-access.mjs +73 -0
  10. package/bin/legacy-installation.mjs +75 -19
  11. package/bin/legacy-task.mjs +24 -0
  12. package/bin/legacy-upgrade.mjs +66 -10
  13. package/bin/managed-upgrade.mjs +102 -12
  14. package/bin/mcp-bridge.mjs +38 -20
  15. package/bin/prewarm.mjs +28 -6
  16. package/bin/service-control.mjs +10 -5
  17. package/bin/upgrade-capability.json +2 -1
  18. package/bin/upgrade-dependencies.mjs +67 -6
  19. package/bin/upgrade-package.mjs +2 -0
  20. package/bin/windows-legacy/LegacyProcessBroker.build.json +3 -3
  21. package/bin/windows-legacy/LegacyProcessBroker.exe +0 -0
  22. package/bin/windows-legacy/src/LegacyNative.cs +60 -11
  23. package/bin/windows-legacy/src/LegacyProcessBroker.cs +317 -197
  24. package/bin/windows-legacy-process.mjs +12 -5
  25. package/docs/account-upgrades.md +17 -4
  26. package/docs/compatibility.md +39 -0
  27. package/docs/configuration.md +146 -0
  28. package/docs/dashboards.md +125 -0
  29. package/docs/execution-context.md +11 -5
  30. package/docs/http-api.md +47 -0
  31. package/docs/install.md +52 -0
  32. package/docs/operations.md +99 -0
  33. package/docs/upgrades.md +324 -0
  34. package/package.json +10 -3
  35. package/tools/windows-legacy/README.md +118 -92
  36. package/tools/windows-legacy/inventory.mjs +8 -6
  37. package/ui/dist/third-party-manifest.json +1 -1
@@ -0,0 +1,359 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Checks, or grants, the runtime user's access to one known legacy 1.3 registration and CLI file.
4
+ .DESCRIPTION
5
+ inspect is read-only. It reports whether the ordinary runtime user can make the task writes
6
+ and the bin\cli.mjs stage/move operations that legacy migration needs. Only allow ACEs that
7
+ apply to that user are counted, so a reported gap is certain; deny ACEs can still refuse later.
8
+ A non-elevated caller is evaluated with its own token. An elevated caller is evaluated as its
9
+ UAC-filtered ordinary context: its user SID and groups, without administrator groups.
10
+
11
+ grant requires an elevated administrator and the descriptors returned by inspect. It gives the
12
+ task's run-as user full control of that task and makes that user the owner of bin\cli.mjs,
13
+ only where inspect reports that gap. Task XML, run-as account, task owner/group, other ACEs,
14
+ audit/label data, file bytes and the file's DACL are unchanged and verified afterwards.
15
+ .PARAMETER Operation
16
+ inspect or grant.
17
+ .PARAMETER Port
18
+ The selected Pacemaker port.
19
+ .PARAMETER CliPath
20
+ The selected legacy package's bin\cli.mjs. It must belong to the selected task's launcher root.
21
+ .PARAMETER FunctionsOnly
22
+ Loads the access functions without connecting to Task Scheduler.
23
+ .OUTPUTS
24
+ JSON access report. grant reads {"expected": {...}} from standard input.
25
+ .EXAMPLE
26
+ .\legacy-access.ps1 -Operation inspect -Port 8791 -CliPath C:\src\mcp-pacemaker\bin\cli.mjs
27
+ #>
28
+ [CmdletBinding()]
29
+ param(
30
+ [Parameter(Mandatory)]
31
+ [ValidateSet('inspect', 'grant')]
32
+ [string]$Operation,
33
+ [Parameter(Mandatory)]
34
+ [ValidateRange(1, 65535)]
35
+ [int]$Port,
36
+ [Parameter(Mandatory)]
37
+ [string]$CliPath,
38
+ [switch]$FunctionsOnly
39
+ )
40
+
41
+ $ErrorActionPreference = 'Stop'
42
+ # legacy-task.ps1 binds its own parameters in this scope when dot-sourced.
43
+ $accessOperation = $Operation
44
+ $accessPort = $Port
45
+ $accessCliPath = $CliPath
46
+ $accessFunctionsOnly = [bool]$FunctionsOnly
47
+ . (Join-Path $PSScriptRoot 'legacy-task.ps1') -Operation inspect -Port $accessPort -FunctionsOnly
48
+
49
+ $fileReadData = 0x1
50
+ # FILE_WRITE_DATA on a file is FILE_ADD_FILE on a directory.
51
+ $fileWriteData = 0x2
52
+ $fileDeleteChild = 0x40
53
+ $deleteAccess = 0x10000
54
+ $readControl = 0x20000
55
+ $writeDac = 0x40000
56
+ $fullControl = 0x1F01FF
57
+ # PoolingSecurityHelper stage opens the source with READ_CONTROL | FILE_READ_DATA | FILE_WRITE_DATA.
58
+ $stageSource = $readControl -bor $fileReadData -bor $fileWriteData
59
+ $fixable = @('task-write', 'cli-owner')
60
+ $uacFilteredSids = @('S-1-5-32-544', 'S-1-5-32-547', 'S-1-5-32-548', 'S-1-5-32-549', 'S-1-5-32-550',
61
+ 'S-1-5-32-551', 'S-1-5-32-552', 'S-1-5-32-556')
62
+ $uacFilteredDomainRids = @('-498', '-512', '-516', '-517', '-518', '-519', '-520', '-521')
63
+
64
+ $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
65
+ $principal = [Security.Principal.WindowsPrincipal]::new($identity)
66
+ $elevated = $principal.IsInRole([Security.Principal.SecurityIdentifier]::new('S-1-5-32-544'))
67
+
68
+ function Test-UacFiltered {
69
+ param([string]$Sid)
70
+ if ($Sid -cin $uacFilteredSids) { return $true }
71
+ if (-not $Sid.StartsWith('S-1-5-21-', [StringComparison]::Ordinal)) { return $false }
72
+ foreach ($rid in $uacFilteredDomainRids) {
73
+ if ($Sid.EndsWith($rid, [StringComparison]::Ordinal)) { return $true }
74
+ }
75
+ return $false
76
+ }
77
+
78
+ $ordinarySids = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
79
+ $null = $ordinarySids.Add($identity.User.Value)
80
+ foreach ($group in $identity.Groups) {
81
+ if (-not (Test-UacFiltered -Sid $group.Value)) { $null = $ordinarySids.Add($group.Value) }
82
+ }
83
+
84
+ function Test-CallerSid {
85
+ param([string]$Sid)
86
+ if ($elevated) { return $ordinarySids.Contains($Sid) }
87
+ if ($Sid -ceq $identity.User.Value) { return $true }
88
+ return $principal.IsInRole([Security.Principal.SecurityIdentifier]::new($Sid))
89
+ }
90
+
91
+ function ConvertTo-FileRights {
92
+ param([int]$Mask)
93
+ [int64]$value = [BitConverter]::ToUInt32([BitConverter]::GetBytes($Mask), 0)
94
+ [int64]$rights = $value -band 0x01FFFFFF
95
+ # GENERIC_READ, GENERIC_WRITE, GENERIC_EXECUTE and GENERIC_ALL use the file mapping.
96
+ if ($value -band 2147483648) { $rights = $rights -bor 0x120089 }
97
+ if ($value -band 1073741824) { $rights = $rights -bor 0x120116 }
98
+ if ($value -band 536870912) { $rights = $rights -bor 0x1200A0 }
99
+ if ($value -band 268435456) { $rights = $rights -bor $fullControl }
100
+ return $rights
101
+ }
102
+
103
+ function Get-AllowedRights {
104
+ param([string]$Sddl)
105
+ $descriptor = [Security.AccessControl.RawSecurityDescriptor]::new($Sddl)
106
+ $ownerApplies = Test-CallerSid -Sid $descriptor.Owner.Value
107
+ if ($null -eq $descriptor.DiscretionaryAcl) {
108
+ return [pscustomobject]@{ Allowed = [int64]$fullControl; OwnerSid = $descriptor.Owner.Value }
109
+ }
110
+ [int64]$allowed = 0
111
+ $ownerRightsAce = $false
112
+ foreach ($ace in $descriptor.DiscretionaryAcl) {
113
+ if ($ace.AceFlags -band [Security.AccessControl.AceFlags]::InheritOnly) { continue }
114
+ $common = $ace -as [Security.AccessControl.CommonAce]
115
+ if ($null -eq $common) { continue }
116
+ $sid = $common.SecurityIdentifier.Value
117
+ $ownerRights = $sid -ceq 'S-1-3-4'
118
+ if ($ownerRights) { $ownerRightsAce = $true }
119
+ $applies = if ($ownerRights) { $ownerApplies } else { Test-CallerSid -Sid $sid }
120
+ $grants = $applies -and
121
+ -not $common.IsCallback -and
122
+ $common.AceQualifier -eq [Security.AccessControl.AceQualifier]::AccessAllowed
123
+ if ($grants) { $allowed = $allowed -bor (ConvertTo-FileRights -Mask $common.AccessMask) }
124
+ }
125
+ # Owners implicitly hold READ_CONTROL and WRITE_DAC unless an OWNER RIGHTS ACE replaces them.
126
+ if ($ownerApplies -and
127
+ -not $ownerRightsAce) { $allowed = $allowed -bor $readControl -bor $writeDac }
128
+ return [pscustomobject]@{ Allowed = $allowed; OwnerSid = $descriptor.Owner.Value }
129
+ }
130
+
131
+ function Get-AccountName {
132
+ param([string]$Sid)
133
+ try { return ([Security.Principal.SecurityIdentifier]::new($Sid)).Translate([Security.Principal.NTAccount]).Value }
134
+ catch { return $Sid }
135
+ }
136
+
137
+ function Get-LegacyCliPath {
138
+ param($Record)
139
+ $arguments = [string]$Record.actions[0].arguments
140
+ $suffix = "`" $accessPort"
141
+ if (-not $arguments.StartsWith('"') -or
142
+ -not $arguments.EndsWith($suffix)) { throw 'Unsupported legacy launcher arguments.' }
143
+ $launcher = [IO.Path]::GetFullPath($arguments.Substring(1, $arguments.Length - 1 - $suffix.Length))
144
+ $root = [IO.Path]::GetDirectoryName([IO.Path]::GetDirectoryName([IO.Path]::GetDirectoryName($launcher)))
145
+ return [IO.Path]::Combine($root, 'bin', 'cli.mjs')
146
+ }
147
+
148
+ function Get-FileSddl {
149
+ param([IO.FileSystemInfo]$Item)
150
+ $sections = [Security.AccessControl.AccessControlSections]'Owner, Group, Access'
151
+ if ($Item -is [IO.DirectoryInfo]) {
152
+ return [IO.FileSystemAclExtensions]::GetAccessControl([IO.DirectoryInfo]$Item).GetSecurityDescriptorSddlForm($sections)
153
+ }
154
+ return [IO.FileSystemAclExtensions]::GetAccessControl([IO.FileInfo]$Item).GetSecurityDescriptorSddlForm($sections)
155
+ }
156
+
157
+ function Get-AccessReport {
158
+ param($Task, [string]$Path)
159
+ $record = Get-TaskRecord -Task $Task
160
+ $expectedCli = Get-LegacyCliPath -Record $record
161
+ $file = [IO.FileInfo]::new($Path)
162
+ if (-not [string]::Equals($expectedCli, $file.FullName, [StringComparison]::OrdinalIgnoreCase)) {
163
+ throw 'The CLI file does not belong to the selected legacy registration.'
164
+ }
165
+ # PowerShell reports LinkType HardLink when a file has more than one link.
166
+ $linkType = if ($file.Exists) { (Get-Item -LiteralPath $file.FullName -Force).LinkType } else { $null }
167
+ $regular = $file.Exists -and
168
+ -not ($file.Attributes -band [IO.FileAttributes]::ReparsePoint) -and
169
+ [string]::IsNullOrEmpty($linkType)
170
+ if (-not $regular) { throw 'The legacy CLI file must be one regular file without links.' }
171
+ $cliSddl = Get-FileSddl -Item $file
172
+ $binSddl = Get-FileSddl -Item $file.Directory
173
+ $taskRights = Get-AllowedRights -Sddl $record.security.sddl
174
+ $cliRights = Get-AllowedRights -Sddl $cliSddl
175
+ $binRights = Get-AllowedRights -Sddl $binSddl
176
+ $runtimeSid = $identity.User.Value
177
+ # Staging keeps the owner when it is the caller's user SID or (non-elevated) default owner.
178
+ $ownerRetained = $cliRights.OwnerSid -ceq $runtimeSid -or
179
+ (-not $elevated -and $cliRights.OwnerSid -ceq $identity.Owner.Value)
180
+ $canDelete = ($cliRights.Allowed -band $deleteAccess) -ne 0 -or
181
+ ($binRights.Allowed -band $fileDeleteChild) -ne 0
182
+ $missing = [Collections.Generic.List[string]]::new()
183
+ if (($taskRights.Allowed -band $fileWriteData) -eq 0) { $missing.Add('task-write') }
184
+ if (-not $ownerRetained -and
185
+ ($cliRights.Allowed -band $writeDac) -eq 0) { $missing.Add('cli-owner') }
186
+ if (($cliRights.Allowed -band $stageSource) -ne $stageSource) { $missing.Add('cli-write') }
187
+ if (-not $canDelete) { $missing.Add('cli-delete') }
188
+ if (($binRights.Allowed -band $fileWriteData) -eq 0) { $missing.Add('bin-add-file') }
189
+ return [ordered]@{
190
+ protocol = 1
191
+ elevated = $elevated
192
+ evaluated = if ($elevated) { 'ordinary-context-of-elevated-token' } else { 'current-token' }
193
+ runtimeSidMatches = $record.userSid -ceq $runtimeSid
194
+ task = [ordered]@{
195
+ name = $record.name
196
+ path = $record.path
197
+ ownerName = Get-AccountName -Sid ([Security.AccessControl.RawSecurityDescriptor]::new($record.security.sddl).Owner.Value)
198
+ }
199
+ cli = [ordered]@{
200
+ path = $file.FullName
201
+ directory = $file.DirectoryName
202
+ ownerName = Get-AccountName -Sid $cliRights.OwnerSid
203
+ }
204
+ missing = @($missing)
205
+ fixable = @($missing | Where-Object { $_ -cin $fixable })
206
+ expected = [ordered]@{ taskSddl = $record.security.sddl; xmlSha256 = $record.xmlSha256; cliSddl = $cliSddl }
207
+ }
208
+ }
209
+
210
+ function Get-AceKeys {
211
+ param([string]$Sddl, [string]$RuntimeSid)
212
+ $descriptor = [Security.AccessControl.RawSecurityDescriptor]::new($Sddl)
213
+ if ($null -eq $descriptor.DiscretionaryAcl) { return '' }
214
+ $keys = foreach ($ace in $descriptor.DiscretionaryAcl) {
215
+ $common = $ace -as [Security.AccessControl.CommonAce]
216
+ $explicitRuntime = $null -ne $common -and
217
+ ($ace.AceFlags -band [Security.AccessControl.AceFlags]::Inherited) -eq 0 -and
218
+ $common.SecurityIdentifier.Value -ceq $RuntimeSid
219
+ if ($explicitRuntime) { continue }
220
+ $bytes = [byte[]]::new($ace.BinaryLength)
221
+ $ace.GetBinaryForm($bytes, 0)
222
+ [Convert]::ToBase64String($bytes)
223
+ }
224
+ return (@($keys) | Sort-Object) -join '|'
225
+ }
226
+
227
+ function Assert-TaskAccessChange {
228
+ param([string]$Before, [string]$After, [string]$RuntimeSid)
229
+ $old = [Security.AccessControl.RawSecurityDescriptor]::new($Before)
230
+ $new = [Security.AccessControl.RawSecurityDescriptor]::new($After)
231
+ $sections = [Security.AccessControl.AccessControlSections]::Audit
232
+ $unchanged = $old.Owner -eq $new.Owner -and
233
+ $old.Group -eq $new.Group -and
234
+ $old.GetSddlForm($sections) -ceq $new.GetSddlForm($sections) -and
235
+ (Get-AceKeys -Sddl $Before -RuntimeSid $RuntimeSid) -ceq (Get-AceKeys -Sddl $After -RuntimeSid $RuntimeSid)
236
+ if (-not $unchanged) {
237
+ throw 'LEGACY_ACCESS_DRIFT: Task security changed beyond the runtime user entry. Inspect it before retrying.'
238
+ }
239
+ }
240
+
241
+ function Grant-TaskWrite {
242
+ param($Task, [string]$RuntimeSid)
243
+ $descriptor = [Security.AccessControl.RawSecurityDescriptor]::new($Task.GetSecurityDescriptor(7))
244
+ $acl = $descriptor.DiscretionaryAcl
245
+ if ($null -eq $acl) { throw 'Task has no discretionary ACL; nothing was changed.' }
246
+ $sid = [Security.Principal.SecurityIdentifier]::new($RuntimeSid)
247
+ $replacement = [Security.AccessControl.CommonAce]::new([Security.AccessControl.AceFlags]::None,
248
+ [Security.AccessControl.AceQualifier]::AccessAllowed, $fullControl, $sid, $false, $null)
249
+ $explicitEnd = $acl.Count
250
+ for ($index = 0; $index -lt $acl.Count; $index++) {
251
+ if ($acl[$index].AceFlags -band [Security.AccessControl.AceFlags]::Inherited) { $explicitEnd = $index; break }
252
+ }
253
+ # Task Scheduler can return the principal's explicit entry after inherited ones, so search them all.
254
+ $updated = $false
255
+ for ($index = 0; $index -lt $acl.Count; $index++) {
256
+ $common = $acl[$index] -as [Security.AccessControl.CommonAce]
257
+ $runtimeAllow = $null -ne $common -and
258
+ -not $common.IsCallback -and
259
+ $common.AceQualifier -eq [Security.AccessControl.AceQualifier]::AccessAllowed -and
260
+ $common.AceFlags -eq [Security.AccessControl.AceFlags]::None -and
261
+ $common.SecurityIdentifier -eq $sid
262
+ if ($runtimeAllow) {
263
+ $acl.RemoveAce($index)
264
+ $acl.InsertAce($index, $replacement)
265
+ $updated = $true
266
+ break
267
+ }
268
+ }
269
+ # A new entry goes before inherited ones; Windows keeps explicit entries first.
270
+ if (-not $updated) { $acl.InsertAce($explicitEnd, $replacement) }
271
+ $sddl = $descriptor.GetSddlForm([Security.AccessControl.AccessControlSections]'Owner, Group, Access')
272
+ # 0x10 is TASK_DONT_ADD_PRINCIPAL_ACE.
273
+ $Task.SetSecurityDescriptor($sddl, 0x10)
274
+ }
275
+
276
+ function Set-CliOwner {
277
+ param([string]$Path, [string]$RuntimeSid)
278
+ # A new FileSecurity persists only the sections that were set: here, the owner.
279
+ $security = [Security.AccessControl.FileSecurity]::new()
280
+ $security.SetOwner([Security.Principal.SecurityIdentifier]::new($RuntimeSid))
281
+ [IO.FileSystemAclExtensions]::SetAccessControl([IO.FileInfo]::new($Path), $security)
282
+ }
283
+
284
+ function Get-FileDigest {
285
+ param([string]$Path)
286
+ return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
287
+ }
288
+
289
+ if ($accessFunctionsOnly) { return }
290
+ $script:accessChanged = [Collections.Generic.List[string]]::new()
291
+
292
+ function Invoke-LegacyAccess {
293
+ $service = New-Object -ComObject Schedule.Service
294
+ $service.Connect()
295
+ $folder = $service.GetFolder('\')
296
+ $script:folderCount = 0
297
+ $tasks = @(Get-MatchingTasks -TaskFolder $folder -Depth 0)
298
+ if ($tasks.Count -ne 1) { throw 'Exactly one selected legacy registration is required.' }
299
+ $task = $tasks[0]
300
+ $report = Get-AccessReport -Task $task -Path $accessCliPath
301
+ if ($accessOperation -eq 'inspect') { return $report }
302
+
303
+ if (-not $elevated) { throw 'LEGACY_ACCESS_NOT_ELEVATED: Run --fix-legacy-access from an administrator terminal. Nothing was changed.' }
304
+ $text = [Console]::In.ReadToEnd()
305
+ if ($text.Length -gt 1048576) { throw 'Access request exceeds the supported bound.' }
306
+ $request = $text | ConvertFrom-Json
307
+ $record = Get-TaskRecord -Task $task
308
+ $planned = $report.expected.taskSddl -ceq $request.expected.taskSddl -and
309
+ $report.expected.xmlSha256 -ceq $request.expected.xmlSha256 -and
310
+ $report.expected.cliSddl -ceq $request.expected.cliSddl
311
+ if (-not $planned) { throw 'LEGACY_ACCESS_CHANGED: The task or CLI file changed after the plan was shown. Nothing was changed.' }
312
+ $selected = $report.runtimeSidMatches -and
313
+ $record.logonType -eq 3 -and
314
+ $record.runLevel -eq 0
315
+ if (-not $selected) { throw 'Selected task is not a non-elevated InteractiveToken task for this account. Nothing was changed.' }
316
+ $manual = @($report.missing | Where-Object { $_ -cnotin $fixable })
317
+ if ($manual.Count) { throw "LEGACY_ACCESS_MANUAL: These need a manual permission change: $($manual -join ', '). Nothing was changed." }
318
+ $runtimeSid = $identity.User.Value
319
+ $cliDigest = Get-FileDigest -Path $accessCliPath
320
+ if ($report.missing -ccontains 'task-write') {
321
+ Grant-TaskWrite -Task $task -RuntimeSid $runtimeSid
322
+ $script:accessChanged.Add('task-write')
323
+ }
324
+ if ($report.missing -ccontains 'cli-owner') {
325
+ Set-CliOwner -Path $accessCliPath -RuntimeSid $runtimeSid
326
+ $script:accessChanged.Add('cli-owner')
327
+ }
328
+ $updatedTask = $folder.GetTask($task.Name)
329
+ $after = Get-AccessReport -Task $updatedTask -Path $accessCliPath
330
+ $updatedRecord = Get-TaskRecord -Task $updatedTask
331
+ Assert-TaskAccessChange -Before $report.expected.taskSddl -After $after.expected.taskSddl -RuntimeSid $runtimeSid
332
+ $beforeCli = [Security.AccessControl.RawSecurityDescriptor]::new($report.expected.cliSddl)
333
+ $afterCli = [Security.AccessControl.RawSecurityDescriptor]::new($after.expected.cliSddl)
334
+ $accessSection = [Security.AccessControl.AccessControlSections]::Access
335
+ $verified = $updatedRecord.xmlSha256 -ceq $record.xmlSha256 -and
336
+ $updatedRecord.enabled -eq $record.enabled -and
337
+ $updatedRecord.userSid -ceq $record.userSid -and
338
+ $beforeCli.Group -eq $afterCli.Group -and
339
+ $beforeCli.GetSddlForm($accessSection) -ceq $afterCli.GetSddlForm($accessSection) -and
340
+ (Get-FileDigest -Path $accessCliPath) -ceq $cliDigest -and
341
+ -not ($after.missing | Where-Object { $_ -cin $fixable })
342
+ if (-not $verified) { throw 'LEGACY_ACCESS_UNVERIFIED: The access change could not be verified. Inspect the task and CLI file.' }
343
+ return [ordered]@{
344
+ protocol = 1
345
+ changed = @($script:accessChanged)
346
+ before = $report.expected
347
+ after = $after.expected
348
+ missing = $after.missing
349
+ }
350
+ }
351
+
352
+ try {
353
+ Invoke-LegacyAccess | ConvertTo-Json -Depth 6 -Compress
354
+ }
355
+ catch {
356
+ # Callers read one JSON document; changed lists any write that completed before the failure.
357
+ [ordered]@{ protocol = 1; error = $_.Exception.Message; changed = @($script:accessChanged) } | ConvertTo-Json -Compress
358
+ exit 1
359
+ }
@@ -40,7 +40,7 @@ export function accountUpgradeOptions(args) {
40
40
  (result.plan || result.registry !== undefined)) throw new Error('Explicit account recovery cannot combine --plan or --registry; it uses the recorded operation.');
41
41
  if (result.to) exactVersion(result.to);
42
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.');
43
+ if (result.yes && !result.plan) throw new Error('Original-account task bootstrap and restart require interactive confirmation; --yes cannot replace it.');
44
44
  return result;
45
45
  }
46
46
 
@@ -47,6 +47,13 @@ export async function executeAccountUpgrade(session) {
47
47
  const current = await session.repoint(binding.port, session.logical, binding.launcher);
48
48
  await session.enable(binding.port, current);
49
49
  },
50
+ // A refused drain resumed the current version with nothing stopped, so the task held for the
51
+ // upgrade is restored before the runtime journal settles.
52
+ afterRefusal: async () => {
53
+ if (taskRevision(session.logical) === taskRevision(session.original)) return;
54
+ try { await session.restore(binding.port, session.logical, session.original); }
55
+ catch (error) { throw new Error(`The original task could not be restored (${error.message}).`, { cause: error }); }
56
+ },
50
57
  });
51
58
  if (binding.recover) {
52
59
  const { instance, journal } = readRecoveryContext(binding.destination);
package/bin/cli-main.mjs CHANGED
@@ -31,6 +31,7 @@ import { registerDefaultCli } from './cli-dispatch.mjs';
31
31
  import { randomUUID } from 'node:crypto';
32
32
  import { canonicalPort, validateSelectedCommand } from './cli-selection.mjs';
33
33
  import { requireUserRuntimeContext } from './runtime-context.mjs';
34
+ import { POOLING_BUDGET_MS } from './pooling-execution.mjs';
34
35
 
35
36
  const __dirname = dirname(fileURLToPath(import.meta.url));
36
37
  const ROOT = resolve(__dirname, '..');
@@ -414,7 +415,44 @@ async function cmdInit(args) {
414
415
  }
415
416
 
416
417
  /* -------------------------------- upgrade ----------------------------------- */
418
+ async function requireOrdinaryUpgradeCaller() {
419
+ try { await requireUserRuntimeContext(); }
420
+ catch (error) {
421
+ let hint = '';
422
+ try {
423
+ // Only a plain legacy install can need the access fix; skip the Task Scheduler probe otherwise.
424
+ const { discoverInstances } = await import('./managed-upgrade.mjs');
425
+ if (!discoverInstances(HOME).length) {
426
+ const { inspectLegacyAccess } = await import('./legacy-access.mjs');
427
+ const { report } = await inspectLegacyAccess(HOME);
428
+ if (report.fixable.length) {
429
+ hint = '\nThis 1.3 install also needs a one-time access fix first: run mcp-pacemaker upgrade --fix-legacy-access in this administrator terminal.';
430
+ }
431
+ }
432
+ } catch { /* Not an inspectable legacy install: the general guidance below applies. */ }
433
+ throw new Error(`${error.message}\nRun upgrades from a normal (non-administrator) terminal; the new bridge must run as you.${hint}`, { cause: error });
434
+ }
435
+ }
436
+
417
437
  async function cmdUpgrade(args) {
438
+ if (args.fixLegacyAccess) {
439
+ const conflicting = args.to !== undefined ||
440
+ args.sxs ||
441
+ args.recover ||
442
+ args.instance ||
443
+ args.port !== undefined ||
444
+ args.self ||
445
+ args.registry !== undefined ||
446
+ args.controllerRoot !== undefined;
447
+ if (conflicting) throw new Error('--fix-legacy-access can only be combined with --plan or --yes.');
448
+ const { fixLegacyAccess } = await import('./legacy-access.mjs');
449
+ const result = await fixLegacyAccess({
450
+ home: HOME, plan: Boolean(args.plan), yes: Boolean(args.yes),
451
+ interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY), confirm: message => p.confirm({ message }),
452
+ });
453
+ console.log(JSON.stringify(result, null, 2));
454
+ return;
455
+ }
418
456
  if (selectedInstance &&
419
457
  (args.to || args.recover) &&
420
458
  !args.instance) args.instance = selectedInstance.directory;
@@ -434,6 +472,8 @@ async function cmdUpgrade(args) {
434
472
  console.log(JSON.stringify(await upgrader.recover(options.instance), null, 2));
435
473
  return;
436
474
  }
475
+ // Refuse a privileged caller before planning and before any confirmation prompt.
476
+ if (!args.plan) await requireOrdinaryUpgradeCaller();
437
477
  const plan = await planUpgrade(args, { home: HOME });
438
478
  console.log(describeUpgrade(plan));
439
479
  if (args.plan) return;
@@ -443,10 +483,17 @@ async function cmdUpgrade(args) {
443
483
  yes: Boolean(args.yes), interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
444
484
  confirm: message => p.confirm({ message }),
445
485
  });
446
- if (!accepted) return;
486
+ if (!accepted) {
487
+ p.cancel('Cancelled. Nothing was changed.');
488
+ return;
489
+ }
447
490
  } else if (!args.yes) {
448
491
  const approved = await p.confirm({ message: 'Apply this local managed upgrade plan?' });
449
- if (p.isCancel(approved) || approved !== true) return;
492
+ if (p.isCancel(approved) ||
493
+ approved !== true) {
494
+ p.cancel('Cancelled. Nothing was changed.');
495
+ return;
496
+ }
450
497
  }
451
498
  console.log(JSON.stringify(await upgrader.execute(plan), null, 2));
452
499
  return;
@@ -659,11 +706,15 @@ async function reportBackend(port, action) {
659
706
  info(`:${port}: ${backendDescription(pkgVersion, backend, action)}`);
660
707
  }
661
708
 
709
+ // A slow answer is not a stopped bridge: a refused connection still fails at once, and a reload
710
+ // may first spend the bridge's whole pooling budget settling a pending batch.
711
+ const STATUS_TIMEOUT_MS = 10_000;
712
+ const RELOAD_TIMEOUT_MS = POOLING_BUDGET_MS + 20_000;
662
713
  function httpGet(url) {
663
714
  return new Promise((res) => {
664
715
  const r = http.get(url, (resp) => { let d = ''; resp.on('data', (c) => (d += c)); resp.on('end', () => res({ status: resp.statusCode, body: d })); });
665
716
  r.on('error', () => res(null));
666
- r.setTimeout(2000, () => { r.destroy(); res(null); });
717
+ r.setTimeout(STATUS_TIMEOUT_MS, () => { r.destroy(); res(null); });
667
718
  });
668
719
  }
669
720
  async function cmdStatus() {
@@ -708,7 +759,7 @@ function httpPost(url, nonce) {
708
759
  const r = http.request({ hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST', headers: { 'x-mcp-nonce': nonce } },
709
760
  (resp) => { let d = ''; resp.on('data', (c) => (d += c)); resp.on('end', () => res({ status: resp.statusCode, body: d })); });
710
761
  r.on('error', () => res(null));
711
- r.setTimeout(5000, () => { r.destroy(); res(null); });
762
+ r.setTimeout(RELOAD_TIMEOUT_MS, () => { res({ timedOut: true }); r.destroy(); });
712
763
  r.end();
713
764
  });
714
765
  }
@@ -723,6 +774,10 @@ async function cmdReload(opts) {
723
774
 
724
775
  const r = await httpPost(`http://127.0.0.1:${port}/admin/reload`, nonce);
725
776
  if (!r) { err(`bridge DOWN on :${port} (start with "mcp-pacemaker start")`); process.exit(1); }
777
+ if (r.timedOut) {
778
+ err(`bridge on :${port} did not answer the reload within ${RELOAD_TIMEOUT_MS / 1000}s; it may still apply it (check "mcp-pacemaker status")`);
779
+ process.exit(1);
780
+ }
726
781
  let j = null; try { j = JSON.parse(r.body); } catch { /* noop */ }
727
782
  if (r.status !== 200 || !j || !j.ok) { err(`reload rejected: ${(j && j.error) || r.body || r.status} — the bridge kept its previous config`); process.exit(1); }
728
783
  if (j.unchanged) { ok(`${CONFIG} re-read on :${port} — no changes`); return; }
@@ -959,7 +1014,7 @@ program.command('emit')
959
1014
 
960
1015
  program.command('upgrade')
961
1016
  .description('Replace a managed version with --to; bare upgrade retains legacy host repair')
962
- .addHelpText('after', '\nDefault scope is the current user, including elevated callers. Explicit original-account management uses --user <canonical SID> plus --task <exact task path> or --instance <directory>. --all-users is not implemented. Legacy restart requires interactive acknowledgement of uncertain HTTP outcomes and partial descendant coverage; --yes cannot replace it.')
1017
+ .addHelpText('after', '\nDefault scope is the current user, including elevated callers. Explicit original-account management uses --user <canonical SID> plus --task <exact task path> or --instance <directory>. --all-users is not implemented. An upgrade may end open sessions and calls in progress; it never changes servers.json or your client setup. The 1.3 restart asks for confirmation; --yes confirms the plan shown. Explicit-account (--user) upgrades still need interactive confirmation.')
963
1018
  .option('--to <version>', 'exact stable version to stage and activate')
964
1019
  .option('--sxs', 'keep the old instance; create an isolated new instance without rewiring hosts')
965
1020
  .option('--port <n>', 'SxS only: exact port; omitted lets the actual listener use OS-assigned port 0', canonicalPort)
@@ -970,6 +1025,7 @@ program.command('upgrade')
970
1025
  .option('--self', 'resolve the same-major channel and print exact-version guidance (no update)')
971
1026
  .option('--registry <url>', 'approved npm registry override (otherwise use existing npm configuration)')
972
1027
  .option('--controller-root <directory>', 'existing protected operation store for explicit account management')
1028
+ .option('--fix-legacy-access', 'Windows, administrator terminal: give your account control of a 1.3 task and CLI file that were set up as administrator')
973
1029
  .action(cmdUpgrade);
974
1030
 
975
1031
  program.command('update-check')