mcp-pacemaker 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +105 -1
  2. package/CONTRIBUTING.md +49 -13
  3. package/README.md +361 -60
  4. package/THIRD_PARTY_NOTICES.txt +1 -28
  5. package/autostart/windows/account-task.ps1 +167 -0
  6. package/autostart/windows/inspect-task.ps1 +42 -0
  7. package/autostart/windows/inspect-worker-process-sessions.ps1 +155 -0
  8. package/autostart/windows/legacy-task.ps1 +301 -0
  9. package/autostart/windows/register-task.ps1 +11 -2
  10. package/bin/account-task-adapter.mjs +47 -0
  11. package/bin/account-upgrade-controller.mjs +198 -0
  12. package/bin/account-upgrade-worker.mjs +134 -0
  13. package/bin/account-worker-session.mjs +215 -0
  14. package/bin/cli-dispatch.mjs +154 -0
  15. package/bin/cli-main.mjs +1007 -0
  16. package/bin/cli-selection.mjs +43 -0
  17. package/bin/cli.mjs +7 -876
  18. package/bin/legacy-1.3.json +32 -0
  19. package/bin/legacy-installation.mjs +217 -0
  20. package/bin/legacy-task.mjs +52 -0
  21. package/bin/legacy-upgrade.mjs +471 -0
  22. package/bin/managed-runtime.mjs +170 -0
  23. package/bin/managed-state.mjs +257 -0
  24. package/bin/managed-upgrade.mjs +491 -0
  25. package/bin/mcp-bridge.mjs +132 -4
  26. package/bin/pooling-batches.mjs +13 -0
  27. package/bin/pooling-writer.mjs +4 -0
  28. package/bin/prewarm.mjs +33 -21
  29. package/bin/runtime-context.mjs +100 -0
  30. package/bin/service-control.mjs +27 -4
  31. package/bin/stable-launcher.mjs +54 -0
  32. package/bin/task-only-controller.mjs +226 -0
  33. package/bin/task-scope.mjs +65 -0
  34. package/bin/task-transaction-protocol.mjs +173 -0
  35. package/bin/top.mjs +22 -10
  36. package/bin/update-check.mjs +13 -2
  37. package/bin/upgrade-autostart.mjs +47 -0
  38. package/bin/upgrade-capability.json +8 -0
  39. package/bin/upgrade-dependencies.mjs +122 -0
  40. package/bin/upgrade-package.mjs +203 -0
  41. package/bin/windows-legacy/LegacyProcessBroker.build.json +42 -0
  42. package/bin/windows-legacy/LegacyProcessBroker.exe +0 -0
  43. package/bin/windows-legacy/src/AssemblyInfo.cs +8 -0
  44. package/bin/windows-legacy/src/LegacyNative.cs +783 -0
  45. package/bin/windows-legacy/src/LegacyProcessBroker.cs +841 -0
  46. package/bin/windows-legacy-process.mjs +241 -0
  47. package/bin/windows-lifetime/ProcessLifetimeHelper.build.json +38 -0
  48. package/bin/windows-lifetime/ProcessLifetimeHelper.exe +0 -0
  49. package/bin/windows-lifetime/src/AssemblyInfo.cs +8 -0
  50. package/bin/windows-lifetime/src/ProcessLifetimeHelper.cs +468 -0
  51. package/bin/windows-process-lifetime.mjs +218 -0
  52. package/bin/windows-task-channel/TaskChannelGuard.build.json +45 -0
  53. package/bin/windows-task-channel/TaskChannelGuard.exe +0 -0
  54. package/bin/windows-task-channel/src/AssemblyInfo.cs +8 -0
  55. package/bin/windows-task-channel/src/ChannelCliContext.cs +210 -0
  56. package/bin/windows-task-channel/src/ChannelFiles.cs +231 -0
  57. package/bin/windows-task-channel/src/ChannelNative.cs +477 -0
  58. package/bin/windows-task-channel/src/ChannelSession.cs +203 -0
  59. package/bin/windows-task-channel/src/TaskChannelGuard.cs +686 -0
  60. package/bin/windows-task-channel.mjs +335 -0
  61. package/docs/account-upgrades.md +205 -0
  62. package/docs/execution-context.md +113 -0
  63. package/package.json +12 -5
  64. package/supervisor/supervise.mjs +226 -40
  65. package/tools/account-upgrade/qualify-two-account.ps1 +66 -0
  66. package/tools/windows-legacy/README.md +225 -0
  67. package/tools/windows-legacy/build.ps1 +122 -0
  68. package/tools/windows-legacy/inventory.mjs +90 -0
  69. package/tools/windows-process-lifetime/README.md +119 -0
  70. package/tools/windows-process-lifetime/build.ps1 +122 -0
  71. package/tools/windows-process-lifetime/inventory.mjs +86 -0
  72. package/tools/windows-task-channel/README.md +329 -0
  73. package/tools/windows-task-channel/build.ps1 +107 -0
  74. package/tools/windows-task-channel/inventory.mjs +117 -0
  75. package/ui/dist/THIRD_PARTY_NOTICES.txt +1 -28
  76. package/ui/dist/assets/{index-CgPruWYi.js → index-CcwMo2sI.js} +1 -1
  77. package/ui/dist/assets/index-PVcuDNrN.css +1 -0
  78. package/ui/dist/index.html +2 -2
  79. package/ui/dist/third-party-manifest.json +37 -25
  80. package/ui/dist/assets/index-CaLPg0c-.css +0 -1
@@ -0,0 +1,167 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Performs fixed controller task operations while preserving the registered account.
4
+ .DESCRIPTION
5
+ Reads a bounded controller request on stdin. This adapter changes only the exact
6
+ selected registration, preserving principal, logon type, run level and full security.
7
+ It never stops processes, edits configuration, enables privileges or changes ACLs.
8
+ .PARAMETER Action
9
+ Exact task operation chosen by the trusted controller.
10
+ .OUTPUTS
11
+ Bounded JSON task/profile/session evidence. A successful launch call is not readiness.
12
+ .EXAMPLE
13
+ .\account-task.ps1 -Action inspect
14
+ #>
15
+ [CmdletBinding()]
16
+ param(
17
+ [Parameter(Mandatory)]
18
+ [ValidateSet('inspect', 'session', 'bootstrap', 'hold', 'repoint', 'release', 'restore', 'launch')]
19
+ [string]$Action
20
+ )
21
+ $ErrorActionPreference = 'Stop'
22
+ . (Join-Path $PSScriptRoot 'legacy-task.ps1') -Operation inspect -Port 1 -FunctionsOnly
23
+ $text = [Console]::In.ReadToEnd()
24
+ if ($text.Length -gt 65536) { throw 'Controller request exceeds its bound.' }
25
+ $request = $text | ConvertFrom-Json
26
+ $targetSid = $request.targetSid
27
+ if ($targetSid -cnotmatch '^S-1-[0-9]+(?:-[0-9]+)+$' -or
28
+ ([Security.Principal.SecurityIdentifier]::new($targetSid)).Value -cne $targetSid) {
29
+ throw 'A canonical target SID is required.'
30
+ }
31
+ $impersonated = [Security.Principal.WindowsIdentity]::GetCurrent($true)
32
+ if ($null -ne $impersonated) {
33
+ $impersonated.Dispose()
34
+ throw 'Impersonating task-adapter contexts are unsupported.'
35
+ }
36
+ $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
37
+ $principal = [Security.Principal.WindowsPrincipal]::new($identity)
38
+ if ($targetSid -cne $currentSid -and
39
+ -not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
40
+ throw 'The current task-adapter token is not an enabled administrator.'
41
+ }
42
+ $path = $request.taskPath
43
+ if ([string]::IsNullOrEmpty($path) -or
44
+ $path.Length -gt 1024 -or
45
+ -not $path.StartsWith('\') -or
46
+ $path.Contains('/') -or
47
+ $path.Split('\') -contains '..') { throw 'An exact task path is required.' }
48
+ $separator = $path.LastIndexOf('\')
49
+ $folderPath = if ($separator -eq 0) { '\' } else { $path.Substring(0, $separator) }
50
+ $name = $path.Substring($separator + 1)
51
+ $service = New-Object -ComObject Schedule.Service
52
+ $service.Connect()
53
+ $folder = $service.GetFolder($folderPath)
54
+ $task = $folder.GetTask($name)
55
+ $record = Get-TaskRecord -Task $task
56
+ $record.runAsName = $task.Definition.Principal.UserId
57
+ if ($record.path -cne $path -or
58
+ $record.userSid -cne $targetSid -or
59
+ $record.logonType -ne 3 -or
60
+ $record.runLevel -ne 0) { throw 'Selected task does not preserve the requested interactive account.' }
61
+ Assert-TaskSecurity -Actual $record.security -Expected $record.security -RuntimeSid $targetSid
62
+ if ($Action -eq 'inspect') {
63
+ $profiles = @(Get-CimInstance -ClassName Win32_UserProfile -Filter "SID='$targetSid'" -Property SID, LocalPath, Loaded -OperationTimeoutSec 10)
64
+ if ($profiles.Count -ne 1 -or
65
+ -not $profiles[0].Loaded) { throw 'The selected user profile is not loaded in an existing interactive session.' }
66
+ @{
67
+ record = $record
68
+ profilePath = [IO.Path]::GetFullPath($profiles[0].LocalPath)
69
+ allowDemandStart = $task.Definition.Settings.AllowDemandStart
70
+ } | ConvertTo-Json -Depth 12 -Compress
71
+ return
72
+ }
73
+ if ($record.xmlSha256 -cne $request.expected.xmlSha256 -or
74
+ $record.userSid -cne $request.expected.userSid) { throw 'Task definition changed after the approved plan.' }
75
+ Assert-TaskSecurity -Actual $record.security -Expected $request.expected.security -RuntimeSid $targetSid
76
+ if ($Action -eq 'session') {
77
+ $port = [int]$request.port
78
+ if ($port -lt 1 -or $port -gt 65535) { throw 'Invalid selected port.' }
79
+ $listeners = @(Get-CimInstance -Namespace 'root/StandardCimv2' -ClassName MSFT_NetTCPConnection `
80
+ -Filter "LocalAddress='127.0.0.1' AND LocalPort=$port AND State=2" -OperationTimeoutSec 10)
81
+ if ($listeners.Count -ne 1) { throw 'Selected listener is absent or ambiguous.' }
82
+ $processId = [int]$listeners[0].OwningProcess
83
+ $process = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$processId" -Property ProcessId, ParentProcessId, SessionId, CreationDate, CommandLine, ExecutablePath -OperationTimeoutSec 10
84
+ $owner = Invoke-CimMethod -InputObject $process -MethodName GetOwnerSid -ErrorAction Stop
85
+ $scriptPath = [IO.Path]::GetFullPath((Join-Path $request.root 'bin\mcp-bridge.mjs'))
86
+ $commands = @(
87
+ "`"$($process.ExecutablePath)`" `"$scriptPath`" --port $port",
88
+ "$($process.ExecutablePath) `"$scriptPath`" --port $port"
89
+ )
90
+ if ($null -ne $request.managed) {
91
+ $parts = @((Join-Path $request.root 'supervisor\bridge-child.mjs'), '--port', "$port", '--config', $request.managed.config, '--cwd', $request.managed.cwd)
92
+ $arguments = ($parts | ForEach-Object {
93
+ if ($_ -match '\s') { "`"$_`"" } else { $_ }
94
+ }) -join ' '
95
+ $commands = @("`"$($process.ExecutablePath)`" $arguments", "$($process.ExecutablePath) $arguments")
96
+ }
97
+ if ($owner.ReturnValue -ne 0 -or
98
+ $owner.Sid -cne $targetSid -or
99
+ $process.SessionId -le 0 -or
100
+ $process.CommandLine -notin $commands) {
101
+ throw 'Selected listener account/session/absolute legacy command could not be verified.'
102
+ }
103
+ $parentId = [int]$process.ParentProcessId
104
+ $parent = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$parentId" -Property ProcessId, ExecutablePath, SessionId, CreationDate -OperationTimeoutSec 10
105
+ $parentOwner = Invoke-CimMethod -InputObject $parent -MethodName GetOwnerSid -ErrorAction Stop
106
+ if ($parentOwner.ReturnValue -ne 0 -or
107
+ $parentOwner.Sid -cne $targetSid -or
108
+ $parent.SessionId -ne $process.SessionId -or
109
+ $parent.CreationDate -gt $process.CreationDate) { throw 'Selected supervisor account/session/generation is ambiguous.' }
110
+ @{ ownerSid = $owner.Sid; sessionId = $process.SessionId; pid = $processId
111
+ createdUtc = $process.CreationDate.ToUniversalTime().ToString('o')
112
+ supervisorImage = @{ path = $parent.ExecutablePath; sha256 = (Get-FileHash -LiteralPath $parent.ExecutablePath -Algorithm SHA256).Hash.ToLowerInvariant() }
113
+ } | ConvertTo-Json -Depth 4 -Compress
114
+ return
115
+ }
116
+ if ($Action -eq 'launch') {
117
+ if (-not $task.Enabled -or
118
+ -not $task.Definition.Settings.AllowDemandStart -or
119
+ $request.sessionId -le 0) { throw 'The selected task cannot demand-start in the requested session.' }
120
+ $running = $task.RunEx($null, 12, [int]$request.sessionId, $targetSid)
121
+ @{ requested = $true; instanceGuid = $running.InstanceGuid; readinessProven = $false } | ConvertTo-Json -Compress
122
+ return
123
+ }
124
+ $definition = $task.Definition
125
+ switch ($Action) {
126
+ 'bootstrap' {
127
+ if (-not $task.Enabled -or
128
+ -not $definition.Settings.AllowDemandStart) { throw 'Bootstrap requires an enabled demand-start task.' }
129
+ if ($definition.Actions.Count -ne 1) { throw 'Unsupported task actions.' }
130
+ $definition.Actions.Item(1).Path = $request.bootstrap.nodePath
131
+ $definition.Actions.Item(1).Arguments = $request.bootstrap.arguments
132
+ $definition.Actions.Item(1).WorkingDirectory = $request.bootstrap.cwd
133
+ foreach ($trigger in $definition.Triggers) { $trigger.Enabled = $false }
134
+ $definition.Settings.RestartCount = 0
135
+ }
136
+ 'repoint' {
137
+ if ($definition.Actions.Count -ne 1) { throw 'Unsupported task actions.' }
138
+ $definition.Actions.Item(1).Path = 'wscript.exe'
139
+ $definition.Actions.Item(1).Arguments = "`"$($request.launcher)`""
140
+ $definition.Actions.Item(1).WorkingDirectory = ''
141
+ }
142
+ 'hold' {
143
+ foreach ($trigger in $definition.Triggers) { $trigger.Enabled = $false }
144
+ $definition.Settings.RestartCount = 0
145
+ }
146
+ 'release' {
147
+ $original = $service.NewTask(0)
148
+ $original.XmlText = $request.original.xml
149
+ if ($original.Triggers.Count -ne $definition.Triggers.Count) { throw 'Task trigger identity changed.' }
150
+ for ($index = 1; $index -le $definition.Triggers.Count; $index++) {
151
+ $definition.Triggers.Item($index).Enabled = $original.Triggers.Item($index).Enabled
152
+ }
153
+ $definition.Settings.RestartCount = $original.Settings.RestartCount
154
+ }
155
+ 'restore' { $definition.XmlText = $request.original.xml }
156
+ }
157
+ Assert-TaskSecurity -Actual $record.security -Expected $request.original.security -RuntimeSid $targetSid
158
+ if ((Get-UserSid -User $definition.Principal.UserId) -cne $targetSid -or
159
+ $definition.Principal.LogonType -ne $record.logonType -or
160
+ $definition.Principal.RunLevel -ne $record.runLevel) { throw 'Task account semantics changed.' }
161
+ $expectedXml = $definition.XmlText
162
+ $folder.RegisterTaskDefinition($name, $definition, 52, $definition.Principal.UserId, $null, 3, $record.security.sddl) | Out-Null
163
+ $updated = Get-TaskRecord -Task ($folder.GetTask($name))
164
+ $updated.runAsName = $definition.Principal.UserId
165
+ Assert-TaskSecurity -Actual $updated.security -Expected $record.security -RuntimeSid $targetSid
166
+ if ($updated.xmlSha256 -cne (Get-XmlHash -Xml $expectedXml)) { throw 'Task update outcome differs from the exact requested definition.' }
167
+ $updated | ConvertTo-Json -Depth 12 -Compress
@@ -0,0 +1,42 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Reads the selected per-user Pacemaker task and its legacy task identity.
4
+ .DESCRIPTION
5
+ Returns task actions and principal data without changing tasks or processes.
6
+ .PARAMETER Port
7
+ Selected service port.
8
+ .OUTPUTS
9
+ JSON array of existing task identities.
10
+ .EXAMPLE
11
+ .\inspect-task.ps1 -Port 8791
12
+ #>
13
+ [CmdletBinding()]
14
+ param([ValidateRange(1, 65535)][int]$Port)
15
+
16
+ $ErrorActionPreference = 'Stop'
17
+ $service = New-Object -ComObject Schedule.Service
18
+ $service.Connect()
19
+ $folder = $service.GetFolder('\')
20
+ $names = @("McpPacemaker-$Port", 'McpPacemaker')
21
+ $records = @()
22
+ foreach ($task in $folder.GetTasks(1)) {
23
+ if ($task.Name -notin $names) { continue }
24
+ $actions = @()
25
+ foreach ($action in $task.Definition.Actions) {
26
+ $actions += @{
27
+ type = $action.Type
28
+ path = $action.Path
29
+ arguments = $action.Arguments
30
+ workingDirectory = $action.WorkingDirectory
31
+ }
32
+ }
33
+ $records += @{
34
+ name = $task.Name
35
+ enabled = $task.Enabled
36
+ user = $task.Definition.Principal.UserId
37
+ logonType = $task.Definition.Principal.LogonType
38
+ runLevel = $task.Definition.Principal.RunLevel
39
+ actions = $actions
40
+ }
41
+ }
42
+ ConvertTo-Json -InputObject $records -Depth 5 -Compress
@@ -0,0 +1,155 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Checks session facts for the original-user broker's bounded captured identities.
4
+ .DESCRIPTION
5
+ Reads a same-user request on stdin. Queries only its listed process identities,
6
+ or the selected managed loopback listener, and performs no process or security
7
+ mutation. Native held-generation revalidation remains the stop authority.
8
+ .OUTPUTS
9
+ A verification result without command lines or account identifiers.
10
+ .EXAMPLE
11
+ .\inspect-worker-process-sessions.ps1
12
+ #>
13
+ [CmdletBinding()]
14
+ param()
15
+ $ErrorActionPreference = 'Stop'
16
+ $watch = [Diagnostics.Stopwatch]::StartNew()
17
+ $stream = [Console]::OpenStandardInput()
18
+ $bytes = [byte[]]::new(131073)
19
+ $length = 0
20
+ while ($length -lt $bytes.Length) {
21
+ $read = $stream.Read($bytes, $length, $bytes.Length - $length)
22
+ if ($read -eq 0) { break }
23
+ $length += $read
24
+ }
25
+ if ($length -gt 131072) { throw 'Worker identity request exceeds its bound.' }
26
+ $text = [Text.UTF8Encoding]::new($false, $true).GetString($bytes, 0, $length)
27
+ $request = $text | ConvertFrom-Json
28
+ $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
29
+ $hasSessionType = $request.sessionId -is [long] -or $request.sessionId -is [int]
30
+ if ($request.ownerSid -isnot [string] -or
31
+ $request.ownerSid -cne $sid -or
32
+ $request.ownerSid.Length -gt 184 -or
33
+ -not $hasSessionType -or
34
+ $request.sessionId -ne [Diagnostics.Process]::GetCurrentProcess().SessionId) {
35
+ throw 'Worker process/session scope is not the actual current account.'
36
+ }
37
+ if ($null -ne $request.port) {
38
+ if ((($request.PSObject.Properties.Name | Sort-Object) -join ',') -cne 'ownerSid,port,sessionId') {
39
+ throw 'Invalid managed listener request shape.'
40
+ }
41
+ $port = [int]$request.port
42
+ if ($port -lt 1 -or
43
+ $port -gt 65535 -or
44
+ $null -ne $request.identities) { throw 'Invalid managed listener scope.' }
45
+ $listeners = @(Get-CimInstance -Namespace 'root/StandardCimv2' -ClassName MSFT_NetTCPConnection `
46
+ -Filter "LocalAddress='127.0.0.1' AND LocalPort=$port AND State=2" -OperationTimeoutSec 10)
47
+ # A verified stopped/failed-start generation has no listener; its native proof is checked by the caller.
48
+ if ($listeners.Count -eq 0) {
49
+ '{"verified":true}'
50
+ return
51
+ }
52
+ if ($listeners.Count -ne 1) { throw 'Managed listener is ambiguous.' }
53
+ $processId = [int]$listeners[0].OwningProcess
54
+ $row = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$processId" -OperationTimeoutSec 10
55
+ $owner = Invoke-CimMethod -InputObject $row -MethodName GetOwnerSid -ErrorAction Stop
56
+ if ($null -eq $row -or
57
+ $row.SessionId -ne $request.sessionId -or
58
+ $owner.ReturnValue -ne 0 -or
59
+ $owner.Sid -cne $sid) { throw 'Managed listener account/session changed; replan before stop.' }
60
+ '{"verified":true}'
61
+ return
62
+ }
63
+ if ((($request.PSObject.Properties.Name | Sort-Object) -join ',') -cne 'identities,ownerSid,sessionId' -or
64
+ $request.identities -isnot [Array]) { throw 'Invalid captured identity request shape.' }
65
+ $identities = @($request.identities)
66
+ if ($identities.Count -lt 2 -or
67
+ $identities.Count -gt 512) { throw 'Captured identity count is unsupported.' }
68
+ $seen = @{}
69
+ foreach ($identity in $identities) {
70
+ if ((($identity.PSObject.Properties.Name | Sort-Object) -join ',') -cne 'creationTime,ownerSid,pid' -or
71
+ ($identity.pid -isnot [long] -and $identity.pid -isnot [int]) -or
72
+ $identity.pid -le 0 -or
73
+ $identity.pid -gt [int]::MaxValue -or
74
+ $identity.ownerSid -isnot [string] -or
75
+ $identity.creationTime -isnot [string]) { throw 'Invalid captured identity shape.' }
76
+ $number = [int]$identity.pid
77
+ if ($seen.ContainsKey($number) -or
78
+ $identity.ownerSid -cne $sid -or
79
+ $identity.creationTime -cnotmatch '^[1-9][0-9]{0,18}$') {
80
+ throw 'Invalid original-user captured identity.'
81
+ }
82
+ $null = [DateTime]::FromFileTimeUtc([long]$identity.creationTime)
83
+ $seen[$number] = $true
84
+ }
85
+ $query = {
86
+ param([string]$Filter)
87
+ $ErrorActionPreference = 'Stop'
88
+ Import-Module CimCmdlets -ErrorAction Stop
89
+ $session = New-CimSession -ErrorAction Stop
90
+ try {
91
+ foreach ($row in Get-CimInstance -CimSession $session -ClassName Win32_Process -Filter $Filter `
92
+ -Property ProcessId, SessionId, CreationDate -OperationTimeoutSec 2) {
93
+ [pscustomobject]@{
94
+ pid = [int]$row.ProcessId
95
+ sessionId = [int]$row.SessionId
96
+ creationTicks = $row.CreationDate.ToUniversalTime().Ticks
97
+ }
98
+ }
99
+ } finally { Remove-CimSession -CimSession $session }
100
+ }
101
+ $observed = @{}
102
+ $pool = [RunspaceFactory]::CreateRunspacePool(1, 4)
103
+ $pool.Open()
104
+ try {
105
+ for ($offset = 0; $offset -lt $identities.Count; $offset += 128) {
106
+ $pending = @()
107
+ try {
108
+ for ($index = $offset; $index -lt [Math]::Min($offset + 128, $identities.Count); $index += 32) {
109
+ if ($watch.ElapsedMilliseconds -ge 13000) { throw 'Worker session query deadline.' }
110
+ $batch = @($identities[$index..([Math]::Min($index + 31, $identities.Count - 1))])
111
+ $expected = @{}
112
+ $clauses = foreach ($identity in $batch) {
113
+ $expected[[int]$identity.pid] = $true
114
+ 'ProcessId=' + ([int]$identity.pid).ToString([Globalization.CultureInfo]::InvariantCulture)
115
+ }
116
+ $pipeline = [PowerShell]::Create()
117
+ $pipeline.RunspacePool = $pool
118
+ $null = $pipeline.AddScript($query.ToString()).AddArgument(($clauses -join ' OR '))
119
+ $pending += @{ Pipeline = $pipeline; Expected = $expected; Result = $pipeline.BeginInvoke() }
120
+ }
121
+ foreach ($item in $pending) {
122
+ while (-not $item.Result.IsCompleted) {
123
+ if ($watch.ElapsedMilliseconds -ge 13000) { throw 'Worker session query deadline.' }
124
+ Start-Sleep -Milliseconds 10
125
+ }
126
+ $rows = @($item.Pipeline.EndInvoke($item.Result))
127
+ if ($item.Pipeline.HadErrors -or
128
+ $rows.Count -ne $item.Expected.Count) { throw 'Worker session query did not return its exact captured set.' }
129
+ foreach ($row in $rows) {
130
+ $number = [int]$row.pid
131
+ if (-not $item.Expected.ContainsKey($number) -or
132
+ $observed.ContainsKey($number)) { throw 'Worker session query returned an unexpected or duplicate identity.' }
133
+ $observed[$number] = $row
134
+ }
135
+ }
136
+ } finally {
137
+ foreach ($item in $pending) {
138
+ if (-not $item.Result.IsCompleted) { $item.Pipeline.Stop() }
139
+ $item.Pipeline.Dispose()
140
+ }
141
+ }
142
+ }
143
+ } finally { $pool.Close(); $pool.Dispose() }
144
+ foreach ($identity in $identities) {
145
+ $matching = $observed[[int]$identity.pid]
146
+ if ($null -eq $matching -or
147
+ $matching.sessionId -ne $request.sessionId) { throw 'Captured generation/session changed; replan before stop.' }
148
+ $actual = $matching.creationTicks
149
+ $expected = [DateTime]::FromFileTimeUtc([long]$identity.creationTime).Ticks
150
+ # CIM creation dates have microsecond precision; this read never authorizes termination.
151
+ if ([decimal]::Truncate([decimal]$actual / 10) -ne [decimal]::Truncate([decimal]$expected / 10)) {
152
+ throw 'Captured process birth changed; replan before stop.'
153
+ }
154
+ }
155
+ '{"verified":true}'
@@ -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