hypertwist/scripts/Invoke-HyperTwistInteractiveDesktopTask.ps1
2026-07-23 08:42:30 +00:00

304 lines
8.5 KiB
PowerShell

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