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.
- package/CHANGELOG.md +98 -2
- package/CONTRIBUTING.md +10 -3
- package/README.md +98 -760
- package/autostart/windows/legacy-access.ps1 +359 -0
- package/bin/account-upgrade-controller.mjs +1 -1
- package/bin/account-upgrade-worker.mjs +7 -0
- package/bin/cli-main.mjs +61 -5
- package/bin/dependency-lock.json +642 -0
- package/bin/legacy-access.mjs +73 -0
- package/bin/legacy-installation.mjs +75 -19
- package/bin/legacy-task.mjs +24 -0
- package/bin/legacy-upgrade.mjs +66 -10
- package/bin/managed-upgrade.mjs +102 -12
- package/bin/mcp-bridge.mjs +38 -20
- package/bin/prewarm.mjs +28 -6
- package/bin/service-control.mjs +10 -5
- package/bin/upgrade-capability.json +2 -1
- package/bin/upgrade-dependencies.mjs +67 -6
- package/bin/upgrade-package.mjs +2 -0
- package/bin/windows-legacy/LegacyProcessBroker.build.json +3 -3
- package/bin/windows-legacy/LegacyProcessBroker.exe +0 -0
- package/bin/windows-legacy/src/LegacyNative.cs +60 -11
- package/bin/windows-legacy/src/LegacyProcessBroker.cs +317 -197
- package/bin/windows-legacy-process.mjs +12 -5
- package/docs/account-upgrades.md +17 -4
- package/docs/compatibility.md +39 -0
- package/docs/configuration.md +146 -0
- package/docs/dashboards.md +125 -0
- package/docs/execution-context.md +11 -5
- 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 +10 -3
- package/tools/windows-legacy/README.md +118 -92
- package/tools/windows-legacy/inventory.mjs +8 -6
- 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
|
|
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)
|
|
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) ||
|
|
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(
|
|
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(
|
|
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.
|
|
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')
|