param( [Parameter(Mandatory = $true)] [string]$InlineCommand, [int]$TimeoutSeconds = 120, [string]$TaskNamePrefix = 'HyperTwist-InteractiveDesktop', [string]$ResultPath = '', [switch]$KeepArtifacts ) $ErrorActionPreference = 'Stop' function Write-Utf8JsonFile { param( [Parameter(Mandatory = $true)] [string]$Path, [Parameter(Mandatory = $true)] [object]$Value ) $ParentPath = Split-Path -Parent $Path if (-not [string]::IsNullOrWhiteSpace($ParentPath)) { New-Item -ItemType Directory -Force -Path $ParentPath | Out-Null } $Json = $Value | ConvertTo-Json -Depth 12 $Utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($Path, $Json, $Utf8NoBom) } function Read-JsonFile { param( [Parameter(Mandatory = $true)] [string]$Path ) return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json } function Convert-TaskToken { param( [string]$Value ) if ([string]::IsNullOrWhiteSpace($Value)) { return 'unknown' } return ($Value -replace '[^A-Za-z0-9_-]', '_') } function Get-TaskOwnedProcessIds { param( [Parameter(Mandatory = $true)] [string]$RunnerPath ) $ProcessSnapshots = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue) $OwnedProcessIds = New-Object 'System.Collections.Generic.HashSet[int]' foreach ($ProcessSnapshot in $ProcessSnapshots) { if (-not [string]::IsNullOrWhiteSpace([string]$ProcessSnapshot.CommandLine) ` -and ([string]$ProcessSnapshot.CommandLine).IndexOf( $RunnerPath, [System.StringComparison]::OrdinalIgnoreCase ) -ge 0) { [void]$OwnedProcessIds.Add([int]$ProcessSnapshot.ProcessId) } } $AddedProcess = $true while ($AddedProcess) { $AddedProcess = $false foreach ($ProcessSnapshot in $ProcessSnapshots) { if ($OwnedProcessIds.Contains([int]$ProcessSnapshot.ParentProcessId) ` -and $OwnedProcessIds.Add([int]$ProcessSnapshot.ProcessId)) { $AddedProcess = $true } } } return @($OwnedProcessIds) } if ([string]::IsNullOrWhiteSpace($InlineCommand)) { throw 'Provide -InlineCommand.' } $TaskToken = Convert-TaskToken -Value ( '{0}-{1}' -f $TaskNamePrefix, (Get-Date).ToString('yyyyMMdd-HHmmss') ) $TaskName = $TaskToken $TaskRunnerPath = 'C:\Windows\Temp\{0}.runner.ps1' -f $TaskToken $TaskResultPath = 'C:\Windows\Temp\{0}.result.json' -f $TaskToken $EncodedInlineCommand = [Convert]::ToBase64String( [System.Text.Encoding]::Unicode.GetBytes($InlineCommand) ) if (Test-Path -LiteralPath $TaskResultPath) { Remove-Item -LiteralPath $TaskResultPath -Force } $RunnerScript = @" `$ErrorActionPreference = 'Stop' `$Result = [ordered]@{ succeeded = `$false user = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name processId = `$PID sessionId = (Get-Process -Id `$PID).SessionId explorerSessionIds = @((Get-Process explorer -ErrorAction SilentlyContinue | Select-Object -ExpandProperty SessionId -Unique)) generatedAtUtc = [DateTime]::UtcNow.ToString('o') outputLines = @() error = `$null } try { `$CommandSource = [System.Text.Encoding]::Unicode.GetString( [Convert]::FromBase64String('$EncodedInlineCommand') ) `$CapturedOutput = @(& ([scriptblock]::Create(`$CommandSource)) 2>&1 | ForEach-Object { `$_.ToString() }) `$Result.outputLines = @(`$CapturedOutput) `$Result.succeeded = `$true } catch { if (`$null -ne `$CapturedOutput) { `$Result.outputLines = @(`$CapturedOutput) } `$Result.error = `$_.Exception.Message } [System.IO.File]::WriteAllText( '$TaskResultPath', (`$Result | ConvertTo-Json -Depth 12), (New-Object System.Text.UTF8Encoding(`$false)) ) if (-not `$Result.succeeded) { exit 1 } "@ [System.IO.File]::WriteAllText( $TaskRunnerPath, $RunnerScript, (New-Object System.Text.UTF8Encoding($false)) ) $Result = [ordered]@{ reportVersion = 'ht-interactive-desktop-task/v1' generatedAtUtc = [DateTime]::UtcNow.ToString('o') taskName = $TaskName taskRunnerPath = $TaskRunnerPath taskResultPath = $TaskResultPath timeoutSeconds = $TimeoutSeconds currentSessionId = (Get-Process -Id $PID).SessionId explorerSessionIds = @( Get-Process explorer -ErrorAction SilentlyContinue | Select-Object -ExpandProperty SessionId -Unique ) createExitCode = $null runExitCode = $null taskOutput = $null terminatedOwnedProcessIds = @() result = 'failed' error = $null } $TaskCreated = $false $FailureResultPath = $null try { $CreateArgs = @( '/Create', '/F', '/TN', $TaskName, '/SC', 'ONCE', '/ST', '23:59', '/TR', ('C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -NoProfile -ExecutionPolicy Bypass -File {0}' -f $TaskRunnerPath), '/RU', 'INTERACTIVE' ) & schtasks.exe @CreateArgs | Out-Null $Result.createExitCode = $LASTEXITCODE if ($Result.createExitCode -ne 0) { throw "Creating the interactive desktop task failed with exit code $($Result.createExitCode)." } $TaskCreated = $true & schtasks.exe /Run /TN ('\' + $TaskName) | Out-Null $Result.runExitCode = $LASTEXITCODE if ($Result.runExitCode -ne 0) { throw "Running the interactive desktop task failed with exit code $($Result.runExitCode)." } $Deadline = [DateTime]::UtcNow.AddSeconds([Math]::Max($TimeoutSeconds, 1)) while ([DateTime]::UtcNow -lt $Deadline) { if (Test-Path -LiteralPath $TaskResultPath) { break } Start-Sleep -Milliseconds 500 } if (-not (Test-Path -LiteralPath $TaskResultPath)) { throw "The interactive desktop task did not write '$TaskResultPath' before timeout." } $TaskOutput = Read-JsonFile -Path $TaskResultPath if ($null -eq $TaskOutput) { throw "The interactive desktop task result '$TaskResultPath' could not be parsed." } $Result.taskOutput = $TaskOutput if (-not [bool]$TaskOutput.succeeded) { $TaskError = if ([string]::IsNullOrWhiteSpace([string]$TaskOutput.error)) { 'The interactive desktop task reported failure without an explicit error message.' } else { [string]$TaskOutput.error } throw $TaskError } $Result.result = 'passed' } catch { $Result.error = $_.Exception.Message if ($null -eq $Result.taskOutput -and (Test-Path -LiteralPath $TaskResultPath)) { $Result.taskOutput = Read-JsonFile -Path $TaskResultPath } $FailureResultPath = if ([string]::IsNullOrWhiteSpace($ResultPath)) { $TaskResultPath } else { $ResultPath } Write-Utf8JsonFile -Path $FailureResultPath -Value $Result throw } finally { if (-not $KeepArtifacts) { $TaskOwnedProcessIds = if ($TaskCreated) { @(Get-TaskOwnedProcessIds -RunnerPath $TaskRunnerPath) } else { @() } if ($TaskCreated) { # A timed-out interactive child must not survive as a hidden, # memory-consuming desktop task. & schtasks.exe /End /TN ('\' + $TaskName) *> $null if ($LASTEXITCODE -eq 0) { Start-Sleep -Milliseconds 250 } & schtasks.exe /Delete /TN $TaskName /F | Out-Null } foreach ($TaskOwnedProcessId in @($TaskOwnedProcessIds | Sort-Object -Descending)) { if ($TaskOwnedProcessId -ne $PID ` -and $null -ne (Get-Process -Id $TaskOwnedProcessId -ErrorAction SilentlyContinue)) { Stop-Process -Id $TaskOwnedProcessId -Force -ErrorAction SilentlyContinue $Result.terminatedOwnedProcessIds += [int]$TaskOwnedProcessId } } if (Test-Path -LiteralPath $TaskRunnerPath) { Remove-Item -LiteralPath $TaskRunnerPath -Force } if (-not [string]::IsNullOrWhiteSpace($FailureResultPath)) { Write-Utf8JsonFile -Path $FailureResultPath -Value $Result } } } if ([string]::IsNullOrWhiteSpace($ResultPath)) { $ResultPath = $TaskResultPath } Write-Utf8JsonFile -Path $ResultPath -Value $Result $Result | ConvertTo-Json -Depth 12