mcp-pacemaker 2.0.2 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +171 -1
  2. package/CONTRIBUTING.md +46 -13
  3. package/README.md +109 -524
  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-access.ps1 +359 -0
  9. package/autostart/windows/legacy-task.ps1 +301 -0
  10. package/autostart/windows/register-task.ps1 +11 -2
  11. package/bin/account-task-adapter.mjs +47 -0
  12. package/bin/account-upgrade-controller.mjs +198 -0
  13. package/bin/account-upgrade-worker.mjs +141 -0
  14. package/bin/account-worker-session.mjs +215 -0
  15. package/bin/cli-dispatch.mjs +154 -0
  16. package/bin/cli-main.mjs +1063 -0
  17. package/bin/cli-selection.mjs +43 -0
  18. package/bin/cli.mjs +7 -876
  19. package/bin/dependency-lock.json +642 -0
  20. package/bin/legacy-1.3.json +32 -0
  21. package/bin/legacy-access.mjs +73 -0
  22. package/bin/legacy-installation.mjs +273 -0
  23. package/bin/legacy-task.mjs +76 -0
  24. package/bin/legacy-upgrade.mjs +527 -0
  25. package/bin/managed-runtime.mjs +170 -0
  26. package/bin/managed-state.mjs +257 -0
  27. package/bin/managed-upgrade.mjs +581 -0
  28. package/bin/mcp-bridge.mjs +150 -4
  29. package/bin/pooling-batches.mjs +13 -0
  30. package/bin/pooling-writer.mjs +4 -0
  31. package/bin/prewarm.mjs +59 -25
  32. package/bin/runtime-context.mjs +100 -0
  33. package/bin/service-control.mjs +32 -4
  34. package/bin/stable-launcher.mjs +54 -0
  35. package/bin/task-only-controller.mjs +226 -0
  36. package/bin/task-scope.mjs +65 -0
  37. package/bin/task-transaction-protocol.mjs +173 -0
  38. package/bin/top.mjs +22 -10
  39. package/bin/update-check.mjs +13 -2
  40. package/bin/upgrade-autostart.mjs +47 -0
  41. package/bin/upgrade-capability.json +9 -0
  42. package/bin/upgrade-dependencies.mjs +183 -0
  43. package/bin/upgrade-package.mjs +205 -0
  44. package/bin/windows-legacy/LegacyProcessBroker.build.json +42 -0
  45. package/bin/windows-legacy/LegacyProcessBroker.exe +0 -0
  46. package/bin/windows-legacy/src/AssemblyInfo.cs +8 -0
  47. package/bin/windows-legacy/src/LegacyNative.cs +832 -0
  48. package/bin/windows-legacy/src/LegacyProcessBroker.cs +961 -0
  49. package/bin/windows-legacy-process.mjs +248 -0
  50. package/bin/windows-lifetime/ProcessLifetimeHelper.build.json +38 -0
  51. package/bin/windows-lifetime/ProcessLifetimeHelper.exe +0 -0
  52. package/bin/windows-lifetime/src/AssemblyInfo.cs +8 -0
  53. package/bin/windows-lifetime/src/ProcessLifetimeHelper.cs +468 -0
  54. package/bin/windows-process-lifetime.mjs +218 -0
  55. package/bin/windows-task-channel/TaskChannelGuard.build.json +45 -0
  56. package/bin/windows-task-channel/TaskChannelGuard.exe +0 -0
  57. package/bin/windows-task-channel/src/AssemblyInfo.cs +8 -0
  58. package/bin/windows-task-channel/src/ChannelCliContext.cs +210 -0
  59. package/bin/windows-task-channel/src/ChannelFiles.cs +231 -0
  60. package/bin/windows-task-channel/src/ChannelNative.cs +477 -0
  61. package/bin/windows-task-channel/src/ChannelSession.cs +203 -0
  62. package/bin/windows-task-channel/src/TaskChannelGuard.cs +686 -0
  63. package/bin/windows-task-channel.mjs +335 -0
  64. package/docs/account-upgrades.md +218 -0
  65. package/docs/compatibility.md +39 -0
  66. package/docs/configuration.md +146 -0
  67. package/docs/dashboards.md +125 -0
  68. package/docs/execution-context.md +119 -0
  69. package/docs/http-api.md +47 -0
  70. package/docs/install.md +52 -0
  71. package/docs/operations.md +99 -0
  72. package/docs/upgrades.md +324 -0
  73. package/package.json +19 -5
  74. package/supervisor/supervise.mjs +226 -40
  75. package/tools/account-upgrade/qualify-two-account.ps1 +66 -0
  76. package/tools/windows-legacy/README.md +251 -0
  77. package/tools/windows-legacy/build.ps1 +122 -0
  78. package/tools/windows-legacy/inventory.mjs +92 -0
  79. package/tools/windows-process-lifetime/README.md +119 -0
  80. package/tools/windows-process-lifetime/build.ps1 +122 -0
  81. package/tools/windows-process-lifetime/inventory.mjs +86 -0
  82. package/tools/windows-task-channel/README.md +329 -0
  83. package/tools/windows-task-channel/build.ps1 +107 -0
  84. package/tools/windows-task-channel/inventory.mjs +117 -0
  85. package/ui/dist/THIRD_PARTY_NOTICES.txt +1 -28
  86. package/ui/dist/assets/{index-CgPruWYi.js → index-CcwMo2sI.js} +1 -1
  87. package/ui/dist/assets/index-PVcuDNrN.css +1 -0
  88. package/ui/dist/index.html +2 -2
  89. package/ui/dist/third-party-manifest.json +37 -25
  90. package/ui/dist/assets/index-CaLPg0c-.css +0 -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
+ }
@@ -0,0 +1,301 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Inspects or updates one identity-bound per-user Pacemaker registration.
4
+ .DESCRIPTION
5
+ Reads a bounded JSON request from standard input. Mutations require the exact
6
+ task XML and full owner/group/DACL/SACL/integrity descriptor. Does not enable
7
+ privileges, repair permissions or stop processes.
8
+ .PARAMETER Operation
9
+ The scoped task operation to perform.
10
+ .PARAMETER Port
11
+ The selected Pacemaker port.
12
+ .PARAMETER FunctionsOnly
13
+ Loads shared task inspection/security functions without connecting to Task Scheduler.
14
+ .OUTPUTS
15
+ JSON task identity including its XML for protected backup and scoped restoration.
16
+ .EXAMPLE
17
+ .\legacy-task.ps1 -Operation inspect -Port 8791
18
+ #>
19
+ [CmdletBinding()]
20
+ param(
21
+ [Parameter(Mandatory)]
22
+ [ValidateSet('inspect', 'hold', 'repoint', 'enable', 'restore', 'start', 'compare-restored')]
23
+ [string]$Operation,
24
+ [Parameter(Mandatory)]
25
+ [ValidateRange(1, 65535)]
26
+ [int]$Port,
27
+ [switch]$FunctionsOnly
28
+ )
29
+
30
+ $ErrorActionPreference = 'Stop'
31
+ $currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
32
+ $ownerPolicy = (Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot '..\..\bin\legacy-1.3.json') | ConvertFrom-Json).taskOwnerPolicy
33
+ if ($ownerPolicy.protocol -ne 1 -or
34
+ $ownerPolicy.allowRuntimeUser -ne $true -or
35
+ $null -eq $ownerPolicy.allowedOwnerSids) { throw 'Unsupported task owner policy.' }
36
+
37
+ function Get-UserSid {
38
+ param([string]$User)
39
+ if ($User.StartsWith('S-1-', [StringComparison]::Ordinal)) { return $User }
40
+ $account = [Security.Principal.NTAccount]::new($User)
41
+ return $account.Translate([Security.Principal.SecurityIdentifier]).Value
42
+ }
43
+
44
+ function Get-XmlHash {
45
+ param([string]$Xml)
46
+ $algorithm = [Security.Cryptography.SHA256]::Create()
47
+ try {
48
+ return [Convert]::ToHexString($algorithm.ComputeHash([Text.Encoding]::UTF8.GetBytes($Xml))).ToLowerInvariant()
49
+ }
50
+ finally { $algorithm.Dispose() }
51
+ }
52
+
53
+ function ConvertTo-TaskSecurity {
54
+ param([string]$Sddl)
55
+ try {
56
+ if ([string]::IsNullOrEmpty($Sddl) -or
57
+ $Sddl.Length -gt 65536) { throw 'Unsupported descriptor length.' }
58
+ $descriptor = [Security.AccessControl.RawSecurityDescriptor]::new($Sddl)
59
+ if ($null -eq $descriptor.Owner -or
60
+ $null -eq $descriptor.Group) { throw 'Missing resource owner/group.' }
61
+ return @{
62
+ protocol = 1
63
+ information = 31
64
+ complete = $true
65
+ ownerSid = $descriptor.Owner.Value
66
+ groupSid = $descriptor.Group.Value
67
+ controlFlags = [int]$descriptor.ControlFlags
68
+ # AccessControlSections.All omits the LABEL flag when reserializing SDDL.
69
+ sddl = $Sddl
70
+ sha256 = Get-XmlHash -Xml $Sddl
71
+ }
72
+ }
73
+ catch { throw 'TASK_SECURITY_UNVERIFIED: Full task security descriptor could not be represented. No security fallback is allowed.' }
74
+ }
75
+
76
+ function Get-TaskSecurity {
77
+ param($Task)
78
+ try {
79
+ # OWNER | GROUP | DACL | SACL | LABEL; omitted audit data is not an empty SACL.
80
+ $sddl = $Task.GetSecurityDescriptor(31)
81
+ }
82
+ catch {
83
+ throw 'TASK_SECURITY_UNVERIFIED: Owner/group/DACL/SACL/integrity inspection was denied or unsupported. Further operations are refused; this adapter does not enable security privilege or elevate.'
84
+ }
85
+ return ConvertTo-TaskSecurity -Sddl $sddl
86
+ }
87
+
88
+ function Assert-TaskSecurity {
89
+ param($Actual, $Expected, [string]$RuntimeSid = $currentSid)
90
+ $recorded = ConvertTo-TaskSecurity -Sddl $Expected.sddl
91
+ if ($Expected.protocol -ne 1 -or
92
+ $Expected.information -ne 31 -or
93
+ $Expected.complete -ne $true -or
94
+ $Expected.sha256 -cne $recorded.sha256 -or
95
+ $Expected.ownerSid -cne $recorded.ownerSid -or
96
+ $Expected.groupSid -cne $recorded.groupSid -or
97
+ $Expected.controlFlags -ne $recorded.controlFlags -or
98
+ $Actual.sha256 -cne $recorded.sha256 -or
99
+ -not ($Actual.ownerSid -ceq $RuntimeSid -or
100
+ $Actual.ownerSid -cin $ownerPolicy.allowedOwnerSids)) {
101
+ throw 'TASK_SECURITY_CHANGED: Resource owner, permissions, audit or integrity no longer matches the approved task. Further operations are refused.'
102
+ }
103
+ }
104
+
105
+ function Get-TaskRecord {
106
+ param($Task)
107
+ $definition = $Task.Definition
108
+ $actions = @($definition.Actions | ForEach-Object {
109
+ @{
110
+ type = $_.Type
111
+ path = $_.Path
112
+ arguments = $_.Arguments
113
+ workingDirectory = [string]$_.WorkingDirectory
114
+ }
115
+ })
116
+ $triggers = @($definition.Triggers | ForEach-Object {
117
+ @{
118
+ type = $_.Type
119
+ userSid = Get-UserSid -User $_.UserId
120
+ enabled = $_.Enabled
121
+ stateChange = if ($_.Type -eq 11) { $_.StateChange } else { 0 }
122
+ }
123
+ })
124
+ $xml = $Task.Xml
125
+ if ($xml.Length -gt 524288) { throw 'Selected task XML exceeds the supported bound.' }
126
+ return @{
127
+ name = $Task.Name
128
+ path = $Task.Path
129
+ enabled = $Task.Enabled
130
+ xml = $xml
131
+ xmlSha256 = Get-XmlHash -Xml $xml
132
+ security = Get-TaskSecurity -Task $Task
133
+ currentUserSid = $currentSid
134
+ userSid = Get-UserSid -User $definition.Principal.UserId
135
+ logonType = $definition.Principal.LogonType
136
+ runLevel = $definition.Principal.RunLevel
137
+ actions = $actions
138
+ triggers = $triggers
139
+ settings = @{
140
+ multipleInstances = $definition.Settings.MultipleInstances
141
+ restartCount = $definition.Settings.RestartCount
142
+ restartInterval = $definition.Settings.RestartInterval
143
+ executionTimeLimit = $definition.Settings.ExecutionTimeLimit
144
+ startWhenAvailable = $definition.Settings.StartWhenAvailable
145
+ disallowStartIfOnBatteries = $definition.Settings.DisallowStartIfOnBatteries
146
+ stopIfGoingOnBatteries = $definition.Settings.StopIfGoingOnBatteries
147
+ }
148
+ }
149
+ }
150
+
151
+ function Get-RestorationXml {
152
+ param([string]$Xml, $TaskService)
153
+ if ([string]::IsNullOrWhiteSpace($Xml) -or
154
+ $Xml.Length -gt 524288) { throw 'TASK_RESTORATION_XML_UNVERIFIED: Invalid XML bounds.' }
155
+ $settings = [Xml.XmlReaderSettings]::new()
156
+ $settings.DtdProcessing = [Xml.DtdProcessing]::Prohibit
157
+ $settings.XmlResolver = $null
158
+ $settings.MaxCharactersInDocument = 524288
159
+ $text = [IO.StringReader]::new($Xml)
160
+ $reader = [Xml.XmlReader]::Create($text, $settings)
161
+ $document = [Xml.XmlDocument]::new()
162
+ $document.XmlResolver = $null
163
+ try { $document.Load($reader) }
164
+ finally { $reader.Dispose(); $text.Dispose() }
165
+ $namespace = 'http://schemas.microsoft.com/windows/2004/02/mit/task'
166
+ $root = $document.DocumentElement
167
+ if ($root.LocalName -cne 'Task' -or
168
+ $root.NamespaceURI -cne $namespace) { throw 'TASK_RESTORATION_XML_UNVERIFIED: Unexpected task root.' }
169
+ $registrations = @($document.SelectNodes("//*[local-name()='RegistrationInfo']"))
170
+ $descriptors = @($document.SelectNodes("//*[local-name()='SecurityDescriptor']"))
171
+ if ($registrations.Count -gt 1 -or
172
+ $descriptors.Count -gt 1) { throw 'TASK_RESTORATION_XML_UNVERIFIED: Duplicate registration metadata.' }
173
+ foreach ($registration in $registrations) {
174
+ if ($registration.NamespaceURI -cne $namespace -or
175
+ $registration.ParentNode -ne $root) { throw 'TASK_RESTORATION_XML_UNVERIFIED: Unexpected registration metadata.' }
176
+ }
177
+ foreach ($descriptor in $descriptors) {
178
+ if ($descriptor.NamespaceURI -cne $namespace -or
179
+ $descriptor.ParentNode.LocalName -cne 'RegistrationInfo' -or
180
+ $descriptor.ParentNode.NamespaceURI -cne $namespace -or
181
+ $descriptor.ParentNode.ParentNode -ne $root) { throw 'TASK_RESTORATION_XML_UNVERIFIED: Unexpected embedded descriptor.' }
182
+ }
183
+ $definition = $TaskService.NewTask(0)
184
+ $definition.XmlText = $Xml
185
+ $definition.RegistrationInfo.SecurityDescriptor = ''
186
+ return $definition.XmlText
187
+ }
188
+
189
+ function Get-MatchingTasks {
190
+ param($TaskFolder, [int]$Depth)
191
+ if ($Depth -gt 8) { throw 'Task folder depth exceeds the inspection bound.' }
192
+ $script:folderCount++
193
+ if ($script:folderCount -gt 512) { throw 'Task folder count exceeds the inspection bound.' }
194
+ foreach ($candidate in $TaskFolder.GetTasks(1)) {
195
+ $matching = $candidate.Name -in @("McpPacemaker-$Port", 'McpPacemaker')
196
+ foreach ($action in $candidate.Definition.Actions) {
197
+ if ($action.Type -eq 0 -and
198
+ $null -ne $action.Arguments -and
199
+ $action.Arguments.EndsWith("\autostart\windows\launcher.vbs`" $Port", [StringComparison]::OrdinalIgnoreCase)) {
200
+ $matching = $true
201
+ }
202
+ }
203
+ if ($matching) { $candidate }
204
+ }
205
+ foreach ($childFolder in $TaskFolder.GetFolders(0)) {
206
+ Get-MatchingTasks -TaskFolder $childFolder -Depth ($Depth + 1)
207
+ }
208
+ }
209
+ if ($FunctionsOnly) { return }
210
+ $service = New-Object -ComObject Schedule.Service
211
+ $service.Connect()
212
+ if ($Operation -eq 'compare-restored') {
213
+ $text = [Console]::In.ReadToEnd()
214
+ if ($text.Length -gt 1048576) { throw 'Task operation input exceeds the supported bound.' }
215
+ $request = $text | ConvertFrom-Json
216
+ Assert-TaskSecurity -Actual $request.actual.security -Expected $request.original.security -RuntimeSid $request.original.userSid
217
+ $actualXml = Get-RestorationXml -Xml $request.actual.xml -TaskService $service
218
+ $originalXml = Get-RestorationXml -Xml $request.original.xml -TaskService $service
219
+ if ((Get-XmlHash -Xml $actualXml) -cne (Get-XmlHash -Xml $originalXml)) {
220
+ throw 'Restored legacy registration XML differs beyond its embedded descriptor representation.'
221
+ }
222
+ @{ equivalent = $true } | ConvertTo-Json -Compress
223
+ return
224
+ }
225
+ $folder = $service.GetFolder('\')
226
+ $script:folderCount = 0
227
+ $tasks = @(Get-MatchingTasks -TaskFolder $folder -Depth 0)
228
+ if ($tasks.Count -gt 16) { throw 'Too many matching Pacemaker registrations.' }
229
+ if ($Operation -eq 'inspect') {
230
+ ConvertTo-Json -InputObject @($tasks | ForEach-Object { Get-TaskRecord -Task $_ }) -Depth 8 -Compress
231
+ return
232
+ }
233
+
234
+ $text = [Console]::In.ReadToEnd()
235
+ if ($text.Length -gt 1048576) { throw 'Task operation input exceeds the supported bound.' }
236
+ $request = $text | ConvertFrom-Json
237
+ if ($tasks.Count -ne 1) { throw 'Exactly one selected registration is required before mutation.' }
238
+ $task = $tasks[0]
239
+ $record = Get-TaskRecord -Task $task
240
+ Assert-TaskSecurity -Actual $record.security -Expected $request.expected.security
241
+ if ($record.name -ne $request.expected.name -or
242
+ $record.path -ne "\$($record.name)" -or
243
+ $record.xmlSha256 -cne $request.expected.xmlSha256 -or
244
+ $record.userSid -cne $currentSid -or
245
+ $record.logonType -ne 3 -or
246
+ $record.runLevel -ne 0) {
247
+ throw 'Selected task identity changed; no operation was performed.'
248
+ }
249
+ switch ($Operation) {
250
+ 'hold' { $task.Enabled = $false }
251
+ 'enable' { $task.Enabled = $true }
252
+ 'repoint' {
253
+ if ($task.Enabled) { throw 'Registration must be held before replacing its launcher.' }
254
+ $launcher = [IO.Path]::GetFullPath($request.launcher)
255
+ if ($launcher.IndexOfAny([char[]]"`"`r`n") -ge 0 -or
256
+ -not [IO.File]::Exists($launcher)) { throw 'Invalid stable launcher path.' }
257
+ $definition = $task.Definition
258
+ if ($definition.Actions.Count -ne 1) { throw 'Unexpected task actions.' }
259
+ $definition.Actions.Item(1).Arguments = "`"$launcher`""
260
+ # Update only, do not alter principal ACEs, and suppress registration-trigger execution.
261
+ $definition.RegistrationInfo.SecurityDescriptor = ''
262
+ $folder.RegisterTaskDefinition($task.Name, $definition, 52, $definition.Principal.UserId, $null, 3, $null) | Out-Null
263
+ }
264
+ 'restore' {
265
+ if ($task.Enabled) { throw 'Registration must be held before restoration.' }
266
+ Assert-TaskSecurity -Actual $record.security -Expected $request.original.security
267
+ $definition = $service.NewTask(0)
268
+ $definition.XmlText = $request.original.xml
269
+ if ((Get-XmlHash -Xml $request.original.xml) -cne $request.original.xmlSha256 -or
270
+ (Get-UserSid -User $definition.Principal.UserId) -cne $currentSid -or
271
+ $definition.Principal.LogonType -ne 3 -or
272
+ $definition.Principal.RunLevel -ne 0 -or
273
+ $request.original.name -ne $task.Name) { throw 'Invalid original task identity.' }
274
+ $definition.RegistrationInfo.SecurityDescriptor = ''
275
+ $folder.RegisterTaskDefinition($task.Name, $definition, 52, $definition.Principal.UserId, $null, 3, $null) | Out-Null
276
+ }
277
+ 'start' { $task.Run($null) | Out-Null }
278
+ }
279
+ $updated = Get-TaskRecord -Task ($folder.GetTask($task.Name))
280
+ Assert-TaskSecurity -Actual $updated.security -Expected $record.security
281
+ $expectedDefinition = $service.NewTask(0)
282
+ $expectedDefinition.XmlText = $record.xml
283
+ switch ($Operation) {
284
+ 'hold' { $expectedDefinition.Settings.Enabled = $false }
285
+ 'enable' { $expectedDefinition.Settings.Enabled = $true }
286
+ 'repoint' { $expectedDefinition.Actions.Item(1).Arguments = "`"$launcher`"" }
287
+ 'restore' { $expectedDefinition.XmlText = $request.original.xml }
288
+ }
289
+ $updatedDefinition = $service.NewTask(0)
290
+ $updatedDefinition.XmlText = $updated.xml
291
+ if ($Operation -in @('repoint', 'restore')) {
292
+ $expectedDefinition.RegistrationInfo.SecurityDescriptor = ''
293
+ if (-not [string]::IsNullOrEmpty($updatedDefinition.RegistrationInfo.SecurityDescriptor)) {
294
+ throw 'Registration changed beyond the requested scoped edit; do not stop or activate a backend.'
295
+ }
296
+ $updatedDefinition.RegistrationInfo.SecurityDescriptor = ''
297
+ }
298
+ if ((Get-XmlHash -Xml $expectedDefinition.XmlText) -cne (Get-XmlHash -Xml $updatedDefinition.XmlText)) {
299
+ throw 'Registration changed beyond the requested scoped edit; do not stop or activate a backend.'
300
+ }
301
+ $updated | ConvertTo-Json -Depth 8 -Compress