diff --git a/agents/windows/README.md b/agents/windows/README.md index e63fd5e..c0caedf 100644 --- a/agents/windows/README.md +++ b/agents/windows/README.md @@ -197,6 +197,20 @@ and other runtime data. Existing Scheduled Task definitions are authoritative and are not rewritten. Missing tasks are created for a fresh or incomplete installation without hard-coded user names. +The per-user `MinerOpsAgentTray` task is optional UI and is not credential or +core Agent authority. During an explicit filesystem trust reset, runtime +publication may run noninteractively as `NT AUTHORITY\SYSTEM`, where no safe +interactive user principal can be inferred. The installer still publishes +`MinerOpsAgentTray.ps1`, but deliberately defers creation of a missing tray task +until a later interactive maintenance install. It never creates the tray task +as SYSTEM or invents a username. An existing tray task remains authoritative +and is preserved unchanged rather than silently repaired under SYSTEM; inspect +and remediate a suspected malformed definition separately. The core +`MinerOpsAgent` task remains mandatory and strictly verified by credential +recovery. `MinerOpsApi3Enrichment` and `MinerOpsPoolPoller` are non-interactive +background tasks with explicit SYSTEM principals, so missing instances continue +to be created during trust-reset publication. + If MinerOpsAgent was running before an upgrade, the installer stops only that Scheduled Task, waits for it to stop, replaces the managed files, and starts it again. A previously stopped Agent remains stopped. Before replacing runtime diff --git a/agents/windows/install.ps1 b/agents/windows/install.ps1 index 221bb0d..b24f58a 100644 --- a/agents/windows/install.ps1 +++ b/agents/windows/install.ps1 @@ -953,6 +953,19 @@ function Get-MinerOpsScheduledTask { return $tasks[0] } +function Test-MinerOpsInstallerShouldCreateMissingTask { + param( + [Parameter(Mandatory)]$Definition, + [Parameter(Mandatory)][bool]$TrustResetMode + ) + + if (-not $TrustResetMode) { return $true } + if ($null -eq $Definition.CreateWhenMissingDuringTrustReset) { + throw "Scheduled Task trust-reset creation policy is missing." + } + return [bool]$Definition.CreateWhenMissingDuringTrustReset +} + function Stop-MinerOpsScheduledTaskForUpgrade { param( [Parameter(Mandatory)][string]$Name, @@ -1101,22 +1114,26 @@ try { Name = "MinerOpsAgent"; Script = "MinerOpsAgentService.ps1" Trigger = $startup; Principal = $system; Settings = $agentSettings ExtraArguments = "" + CreateWhenMissingDuringTrustReset = $true }, @{ Name = "MinerOpsAgentTray"; Script = "MinerOpsAgentTray.ps1" Trigger = (New-ScheduledTaskTrigger -AtLogOn); Principal = $null Settings = $commonSettings; ExtraArguments = "-WindowStyle Hidden" + CreateWhenMissingDuringTrustReset = $false }, @{ Name = "MinerOpsApi3Enrichment" Script = "MinerOpsApi3EnrichmentPoller.ps1" Trigger = $fiveMinutes; Principal = $system; Settings = $commonSettings ExtraArguments = "-Limit 0 -TimeoutMs 8000" + CreateWhenMissingDuringTrustReset = $true }, @{ Name = "MinerOpsPoolPoller"; Script = "MinerOpsPoolPollerTask.ps1" Trigger = $fiveMinutes; Principal = $system; Settings = $commonSettings ExtraArguments = "" + CreateWhenMissingDuringTrustReset = $true } ) foreach ($definition in $definitions) { @@ -1129,6 +1146,16 @@ try { continue } + if (-not (Test-MinerOpsInstallerShouldCreateMissingTask ` + -Definition $definition -TrustResetMode $trustResetMode)) { + # Trust-reset publication can run noninteractively as SYSTEM. A + # missing per-user tray has no safe interactive principal in that + # context. Its runtime file is already published; task creation is + # intentionally deferred to a later interactive maintenance run. + Write-Host "Deferring missing per-user Scheduled Task $($definition.Name) until an interactive install." + continue + } + $arguments = '-NoProfile -ExecutionPolicy Bypass -File "{0}"' -f ` (Join-Path $Target $definition.Script) if ($definition.ExtraArguments) { diff --git a/agents/windows/tests/Test-MinerOpsInstaller.ps1 b/agents/windows/tests/Test-MinerOpsInstaller.ps1 index 1ebabc1..3614d9d 100644 --- a/agents/windows/tests/Test-MinerOpsInstaller.ps1 +++ b/agents/windows/tests/Test-MinerOpsInstaller.ps1 @@ -37,6 +37,10 @@ Assert-SourceContains '\$existingTask\s*=\s*Get-MinerOpsScheduledTask' ` "Existing task preservation guard is missing." Assert-SourceContains 'Preserving existing Scheduled Task' ` "Existing task preservation is not explicit." +Assert-SourceContains 'Test-MinerOpsInstallerShouldCreateMissingTask' ` + "Missing-task trust-reset policy helper is absent." +Assert-SourceContains 'Deferring missing per-user Scheduled Task' ` + "Missing per-user task deferral is not explicit." Assert-SourceContains 'ExtraArguments\s*=\s*"-Limit 0 -TimeoutMs 8000"' ` "Fresh enrichment task arguments are missing." Assert-SourceContains 'Stop-MinerOpsScheduledTaskForUpgrade' ` @@ -52,6 +56,98 @@ Assert-SourceContains 'could not restart restored MinerOpsAgent' ` Assert-SourceContains 'Assert-MinerOpsInstallerTrustedAncestors' ` "Installer does not validate ancestor namespace protection." +$taskDefinitionsStart = $source.IndexOf('$definitions = @(') +$taskLoopStart = $source.IndexOf( + 'foreach ($definition in $definitions)', $taskDefinitionsStart +) +if ($taskDefinitionsStart -lt 0 -or $taskLoopStart -le $taskDefinitionsStart) { + throw "Scheduled Task definitions or reconciliation loop were not found." +} +$taskDefinitions = $source.Substring( + $taskDefinitionsStart, $taskLoopStart - $taskDefinitionsStart +) +foreach ($policy in @( + @{ Name = "MinerOpsAgent"; Expected = '\$true' }, + @{ Name = "MinerOpsAgentTray"; Expected = '\$false' }, + @{ Name = "MinerOpsApi3Enrichment"; Expected = '\$true' }, + @{ Name = "MinerOpsPoolPoller"; Expected = '\$true' } +)) { + $definition = [regex]::Match( + $taskDefinitions, + ('(?s)@\{{.*?Name\s*=\s*"{0}".*?\}}' -f + [regex]::Escape($policy.Name)) + ).Value + if ([string]::IsNullOrWhiteSpace($definition) -or + $definition -notmatch ( + 'CreateWhenMissingDuringTrustReset\s*=\s*{0}' -f + $policy.Expected + )) { + throw "Scheduled Task $($policy.Name) has the wrong trust-reset creation policy." + } +} + +$taskLoopEnd = $source.IndexOf( + 'if ($mainTaskWasRunning', $taskLoopStart +) +if ($taskLoopEnd -le $taskLoopStart) { + throw "Scheduled Task reconciliation loop end was not found." +} +$taskLoop = $source.Substring($taskLoopStart, $taskLoopEnd - $taskLoopStart) +$preserveIndex = $taskLoop.IndexOf('$null -ne $existingTask') +$deferIndex = $taskLoop.IndexOf( + 'Test-MinerOpsInstallerShouldCreateMissingTask' +) +$actionIndex = $taskLoop.IndexOf('New-ScheduledTaskAction') +if ( + $preserveIndex -lt 0 -or + $deferIndex -le $preserveIndex -or + $actionIndex -le $deferIndex +) { + throw "Existing-task preservation and missing-task deferral are ordered unsafely." +} +if ($taskLoop -match '80070534|No mapping between account names') { + throw "Missing per-user task handling must be policy-based, not HRESULT-based." +} + +$taskPolicyFunction = [regex]::Match( + $source, + '(?s)function Test-MinerOpsInstallerShouldCreateMissingTask \{.*?\n\}' +).Value +if ([string]::IsNullOrWhiteSpace($taskPolicyFunction)) { + throw "Missing-task trust-reset policy function source was not found." +} +Invoke-Expression $taskPolicyFunction +$trayPolicy = [pscustomobject]@{ + CreateWhenMissingDuringTrustReset = $false +} +$systemTaskPolicy = [pscustomobject]@{ + CreateWhenMissingDuringTrustReset = $true +} +if (Test-MinerOpsInstallerShouldCreateMissingTask ` + -Definition $trayPolicy -TrustResetMode $true) { + throw "Trust reset would create a missing per-user tray task." +} +if (-not (Test-MinerOpsInstallerShouldCreateMissingTask ` + -Definition $trayPolicy -TrustResetMode $false)) { + throw "Normal interactive installation would not repair a missing tray task." +} +if (-not (Test-MinerOpsInstallerShouldCreateMissingTask ` + -Definition $systemTaskPolicy -TrustResetMode $true)) { + throw "Trust reset would skip a missing explicit-SYSTEM task." +} +$missingPolicyRejected = $false +try { + [void](Test-MinerOpsInstallerShouldCreateMissingTask ` + -Definition ([pscustomobject]@{}) -TrustResetMode $true) +} +catch { + $missingPolicyRejected = $_.Exception.Message -match + 'trust-reset creation policy is missing' +} +if (-not $missingPolicyRejected) { + throw "Trust reset accepted a task definition without explicit policy." +} + foreach ($functionName in @( "Assert-MinerOpsInstallerNoReparsePath", "Assert-MinerOpsInstallerTrustedAncestors"