Private/Invoke-WUSearch.ps1

function Invoke-WUSearch {
    <#
    .SYNOPSIS
        Runs a Windows Update Agent search in an isolated process with a hard timeout.

    .DESCRIPTION
        The Windows Update Agent Search method is synchronous and can block indefinitely.
        This helper performs the COM operation in a child PowerShell process, normalizes
        the COM results into serializable objects, and terminates the child if the timeout
        is exceeded.

        Search results remain policy-scoped. On devices managed by Intune, Windows Update
        for Business, or Windows Autopatch, this function cannot bypass deployment
        approvals, rollout timing, safeguard holds, or the configured scan source.
    #>


    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Criteria,

        [ValidateRange(5, 1800)]
        [int]$TimeoutSeconds = 120,

        [ValidateRange(0, 2000)]
        [int]$HistoryLimit = 200,

        [string]$LogPath
    )

    $outputPath = Join-Path ([System.IO.Path]::GetTempPath()) (
        'WindowsUpdateTools-WUSearch-{0}.clixml' -f ([guid]::NewGuid().ToString('N'))
    )

    $payload = @{
        Criteria = $Criteria
        HistoryLimit = $HistoryLimit
        OutputPath = $outputPath
    } | ConvertTo-Json -Compress

    $payloadBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($payload))

    $childScript = @'
$ErrorActionPreference = 'Stop'
$payloadJson = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('@@PAYLOAD@@'))
$payload = $payloadJson | ConvertFrom-Json

$envelope = [ordered]@{
    Success = $false
    Error = $null
    ErrorHResult = $null
    Updates = @()
    History = @()
}

try {
    $updateSession = New-Object -ComObject Microsoft.Update.Session
    $updateSearcher = $updateSession.CreateUpdateSearcher()

    if ([int]$payload.HistoryLimit -gt 0) {
        $historyCount = $updateSearcher.GetTotalHistoryCount()
        if ($historyCount -gt 0) {
            $history = $updateSearcher.QueryHistory(0, [Math]::Min($historyCount, [int]$payload.HistoryLimit))
            $historyItems = foreach ($entry in $history) {
                $kbNumber = $null
                if ($entry.Title -match 'KB(\d+)') {
                    $kbNumber = "KB$($matches[1])"
                }

                [PSCustomObject]@{
                    Title = $entry.Title
                    KBNumber = $kbNumber
                    Date = $entry.Date
                    ResultCode = $entry.ResultCode
                    HResult = $entry.HResult
                    Operation = $entry.Operation
                }
            }
            $envelope.History = @($historyItems)
        }
    }

    $searchResult = $updateSearcher.Search([string]$payload.Criteria)
    $updateItems = foreach ($update in $searchResult.Updates) {
        $categories = @()
        foreach ($category in $update.Categories) {
            $categories += $category.Name
        }

        $kbArticleIds = @()
        foreach ($kbArticleId in $update.KBArticleIDs) {
            $kbArticleIds += $kbArticleId
        }

        $securityBulletinIds = @()
        foreach ($securityBulletinId in $update.SecurityBulletinIDs) {
            $securityBulletinIds += $securityBulletinId
        }

        $actualDownloadSize = [int64]0
        $downloadContentsCount = 0
        try {
            if ($update.DownloadContents) {
                $downloadContentsCount = $update.DownloadContents.Count
                foreach ($content in $update.DownloadContents) {
                    if ($content.Size -gt 0) {
                        $actualDownloadSize += [int64]$content.Size
                    }
                }
            }
        }
        catch {
            $actualDownloadSize = [int64]0
            $downloadContentsCount = 0
        }

        $rebootRequired = $false
        if ($update.InstallationBehavior) {
            $rebootRequired = $update.InstallationBehavior.RebootBehavior -ne 0
        }

        $updateType = if ($categories -match 'Driver') { 'Driver' } else { 'Software' }

        [PSCustomObject]@{
            Title = $update.Title
            Description = $update.Description
            Categories = @($categories)
            Type = $updateType
            KBArticleIDs = @($kbArticleIds)
            SecurityBulletinIDs = @($securityBulletinIds)
            MsrcSeverity = $update.MsrcSeverity
            AutoSelectOnWebSites = $update.AutoSelectOnWebSites
            MaxDownloadSize = [int64]$update.MaxDownloadSize
            ActualDownloadSize = $actualDownloadSize
            DownloadContentsCount = $downloadContentsCount
            IsDownloaded = [bool]$update.IsDownloaded
            RebootRequired = [bool]$rebootRequired
            IsHidden = [bool]$update.IsHidden
            IsMandatory = [bool]$update.IsMandatory
            ReleaseDate = $update.LastDeploymentChangeTime
            SupportUrl = $update.SupportUrl
            UpdateID = $update.Identity.UpdateID
            RevisionNumber = $update.Identity.RevisionNumber
        }
    }

    $envelope.Updates = @($updateItems)
    $envelope.Success = $true
}
catch {
    $envelope.Error = $_.Exception.Message
    $envelope.ErrorHResult = $_.Exception.HResult
}

[PSCustomObject]$envelope | Export-Clixml -LiteralPath ([string]$payload.OutputPath) -Force
'@
.Replace('@@PAYLOAD@@', $payloadBase64)

    $encodedCommand = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($childScript))
    $hostPath = Join-Path $PSHOME 'powershell.exe'
    if (-not (Test-Path -LiteralPath $hostPath)) {
        $hostPath = Join-Path $PSHOME 'pwsh.exe'
    }
    if (-not (Test-Path -LiteralPath $hostPath)) {
        throw "Could not locate a PowerShell host under '$PSHOME'."
    }

    $process = $null
    try {
        Write-WULog -Message "Starting policy-scoped Windows Update Agent search (timeout: $TimeoutSeconds seconds)" -LogPath $LogPath

        $process = Start-Process -FilePath $hostPath `
            -ArgumentList @('-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', $encodedCommand) `
            -WindowStyle Hidden `
            -PassThru `
            -ErrorAction Stop

        if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {
            try {
                $process.Kill()
                $process.WaitForExit()
            }
            catch {
                Write-WULog -Message "Could not terminate timed-out WUA search process: $($_.Exception.Message)" -Level Warning -LogPath $LogPath
            }

            throw [TimeoutException]::new(
                "Windows Update Agent search exceeded the $TimeoutSeconds-second timeout. The isolated search process was terminated."
            )
        }

        if (-not (Test-Path -LiteralPath $outputPath)) {
            throw "Windows Update Agent search process exited with code $($process.ExitCode) without returning a result."
        }

        $result = Import-Clixml -LiteralPath $outputPath
        if (-not $result.Success) {
            $errorSuffix = if ($null -ne $result.ErrorHResult) {
                ' (HRESULT 0x{0:X8})' -f ([uint32]$result.ErrorHResult)
            }
            else {
                ''
            }
            throw "Windows Update Agent search failed: $($result.Error)$errorSuffix"
        }

        return $result
    }
    finally {
        if ($process -and -not $process.HasExited) {
            try {
                $process.Kill()
                $process.WaitForExit()
            }
            catch {
                Write-WULog -Message "Could not terminate WUA search child process during cleanup: $($_.Exception.Message)" -Level Warning -LogPath $LogPath
            }
        }

        Remove-Item -LiteralPath $outputPath -Force -ErrorAction SilentlyContinue
    }
}