Private/Invoke-Win32ToolkitGuestScheduledTask.ps1
|
function Invoke-Win32ToolkitGuestScheduledTask { <# .SYNOPSIS Runs a command in the guest via a one-shot scheduled task, as SYSTEM or as an interactive user, and returns its exit code. .DESCRIPTION Shared execution primitive for the Hyper-V backend. Running through a scheduled task lets us pick the security context: - RunAs 'System' -> NT AUTHORITY\SYSTEM (ServiceAccount, session 0, non-interactive). This is the SAME context Intune deploys Win32 apps under, so silent/automation runs match production. - RunAs '<user>' -> the logged-on user (Interactive), RunLevel Highest, so a GUI renders on the console (for hands-on PSADT testing). SYSTEM cannot show a GUI (session-0 isolation). Untrusted values are passed as scriptblock ARGUMENTS, never spliced into code. HOST-ONLY. .PARAMETER Session An open PowerShell Direct PSSession. .PARAMETER Command A 5.1-safe PowerShell command string to run in the guest. .PARAMETER RunAs 'System' (default) or a SAM username to run the task as. .PARAMETER Label Short label for progress output. .PARAMETER TimeoutMinutes How long to wait for the task to finish (default 30). .OUTPUTS [int] the task's LastTaskResult (0 = success). #> [CmdletBinding()] [OutputType([int])] param( [Parameter(Mandatory)] $Session, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Command, [string]$RunAs = 'System', [string]$Label = 'phase', [ValidateRange(1, 240)] [int]$TimeoutMinutes = 30 ) $ctx = if ($RunAs -eq 'System') { 'SYSTEM' } else { "$RunAs (interactive)" } Write-Verbose " [guest:$ctx] $Label" $result = Invoke-Command -Session $Session -ScriptBlock { param($cmd, $runAs, $timeoutMin) $taskName = 'Win32ToolkitPhase' Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue # -WindowStyle Hidden so the raw powershell.exe console never shows — only PSADT's own GUI does. $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -Command `"$cmd`"" $principal = if ($runAs -eq 'System') { New-ScheduledTaskPrincipal -UserId 'NT AUTHORITY\SYSTEM' -LogonType ServiceAccount -RunLevel Highest } else { New-ScheduledTaskPrincipal -UserId $runAs -LogonType Interactive -RunLevel Highest } Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Force | Out-Null # Snapshot the task info BEFORE starting. Task State is AMBIGUOUS: 'Ready' means both # "not started yet" and "already finished", and Start-ScheduledTask launches ASYNCHRONOUSLY — # a fast first poll can sample 'Ready' before the scheduler transitions to 'Running' and would # declare the phase complete while the installer is still launching (racing the UNINSTALL phase # against a running install). Completion is therefore keyed on a task-info TRANSITION # (LastRunTime / LastTaskResult changed vs this stamp) AND a non-Running state — which is what # lets the poll run at 1 s (guest-local, free) instead of the old blind 3 s pre-sleep per phase. $preInfo = Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction SilentlyContinue Start-ScheduledTask -TaskName $taskName $deadline = (Get-Date).AddMinutes($timeoutMin) if ($preInfo) { # A task that NEVER starts (scheduler refuses it, principal problem) must fail fast, not # burn the full phase timeout: if nothing has transitioned and it was never seen Running # within 120 s of Start-ScheduledTask, bail — the stale LastTaskResult (267011, # "has not yet run") is returned and surfaces as a failed phase, like the old fast path. $startupDeadline = (Get-Date).AddSeconds(120) $sawRunning = $false do { $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State if ($state -eq 'Running') { $sawRunning = $true } $info = Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction SilentlyContinue $ran = $info -and (($info.LastRunTime -ne $preInfo.LastRunTime) -or ($info.LastTaskResult -ne $preInfo.LastTaskResult)) if ($ran -and $state -ne 'Running') { break } if (-not $ran -and -not $sawRunning -and (Get-Date) -gt $startupDeadline) { break } Start-Sleep -Seconds 1 } until ((Get-Date) -gt $deadline) } else { # No pre-start stamp (abnormal): the transition signal is unavailable, so fall back to the # legacy shape — a blind first sleep long enough to outlive the async launch, then state-only. do { Start-Sleep -Seconds 3 $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State } until ($state -ne 'Running' -or (Get-Date) -gt $deadline) } $rc = (Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction SilentlyContinue).LastTaskResult Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue if ($null -ne $rc) { $rc } else { 0 } } -ArgumentList $Command, $RunAs, $TimeoutMinutes return [int](@($result) | Select-Object -Last 1) } |