Private/Export-RaidinessArtifact.ps1

<#
    The bridge: the report page, run headlessly, writing its own files.

    The page builds run.json, report.md, the LLM bundle and a standalone HTML
    report -- but only ever into a browser download, which lands in the
    operating system's download folder and which PowerShell can neither aim
    nor observe. With ?raidiness=export the page skips rendering, puts the same
    files base64 into one script element, and marks itself ready; this reads
    that element off stdout and writes the files where the operator asked.

    Nothing here throws. A missing browser, a timeout, a half-built page: all
    of them return what was produced and let the caller fall back to opening
    the report, which is exactly what happened before this existed. A checkup
    that fails because a browser flag changed would be a bad trade.
#>

# psrunner-lint allow: New-Item — creates the local llm-bundle directory in the operator's run folder; never a tenant object
# psrunner-lint allow: Remove-Item — deletes the local temp profile and the unpacked zip; never a tenant object
function Export-RaidinessArtifact {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        # The report.html that already carries the injected payload.
        [Parameter(Mandatory = $true)]
        [string] $ReportPath,

        # The run folder the files are written into.
        [Parameter(Mandatory = $true)]
        [string] $RunPath,

        [Parameter(Mandatory = $true)]
        [string] $Browser,

        [switch] $Pdf,

        [int] $TimeoutSeconds = 180
    )

    $ErrorActionPreference = 'Stop'

    $report = (Resolve-Path -Path $ReportPath).Path
    $profileDir = Join-Path ([System.IO.Path]::GetTempPath()) ("raidiness-browser-" + [guid]::NewGuid())
    $written = [System.Collections.Generic.List[string]]::new()

    try {
        $arguments = @(
            '--headless=new'
            '--disable-gpu'
            '--no-first-run'
            '--no-default-browser-check'
            '--disable-extensions'
            "--user-data-dir=$profileDir"
            "--virtual-time-budget=$($TimeoutSeconds * 1000)"
            '--dump-dom'
            "file://$report`?raidiness=export"
        )
        # An escape hatch for an unusual environment -- a CI container running
        # as root needs --no-sandbox, for instance. Not a default: those flags
        # weaken the browser, and a checkup should not do that silently.
        if ($env:RAIDINESS_BROWSER_ARGS) {
            $arguments += $env:RAIDINESS_BROWSER_ARGS.Split(' ', [StringSplitOptions]::RemoveEmptyEntries)
        }

        Write-RaidinessLog -Phase 'export' -Message "building the exports with $Browser"
        $result = Invoke-RaidinessBrowserProcess -Browser $Browser -ArgumentList $arguments -TimeoutSeconds ($TimeoutSeconds + 30)

        if ($result.TimedOut) {
            Write-RaidinessLog -Level warn -Phase 'export' -Message "the browser did not finish within $TimeoutSeconds s"
            return [pscustomobject]@{ Ok = $false; Files = @(); Reason = "the browser did not finish within $TimeoutSeconds seconds" }
        }

        $match = [regex]::Match($result.Output, '(?s)<script id="raidiness-artifacts" type="application/json">(.*?)</script>')
        if (-not $match.Success) {
            # Keep the dump: "the page never got there" and "the page broke"
            # look identical from here, and the difference is in that file.
            $dump = Join-Path $RunPath 'raidiness-browser-dump.txt'
            $result.Output | Out-File -FilePath $dump -Encoding utf8
            if ($result.Error) { $result.Error | Out-File -FilePath $dump -Append -Encoding utf8 }

            # The browser usually says exactly what was wrong on stderr, and
            # burying that in a file while telling the operator "no artifacts"
            # sends them to read a dump for a sentence we already have.
            $why = ($result.Error -split "`n" | Where-Object { $_ -match '\S' } | Select-Object -Last 1)
            $detail = $why ? " The browser said: $($why.Trim())" : ''
            Write-RaidinessLog -Level warn -Phase 'export' -Message "the report page produced no artifacts (exit $($result.ExitCode)).$detail"
            return [pscustomobject]@{ Ok = $false; Files = @(); Reason = "the report page produced no artifacts.$detail See $dump" }
        }

        $payload = $match.Groups[1].Value.Replace('<\/', '</') | ConvertFrom-Json
        if (-not $payload.ok) {
            $reason = $payload.PSObject.Properties['error'] ? $payload.error : 'the report page refused to build the exports'
            Write-RaidinessLog -Level warn -Phase 'export' -Message "the report page refused: $reason"
            return [pscustomobject]@{ Ok = $false; Files = @(); Reason = $reason }
        }

        foreach ($file in $payload.files) {
            # The page is our own build, but it names a path that is written on
            # this machine; refusing a traversal here costs one line.
            if ($file.path -match '(^[\\/])|(\.\.)|(^[A-Za-z]:)') {
                Write-RaidinessLog -Level warn -Phase 'export' -Message "refused an artifact path: $($file.path)"
                continue
            }
            $target = Join-Path $RunPath $file.path
            [System.IO.File]::WriteAllBytes($target, [Convert]::FromBase64String($file.base64))
            $written.Add($file.path)
        }

        # The bundle travels as one zip and is unpacked here: a folder of
        # markdown is what a person wants to open, and System.IO.Compression is
        # in the box, so the page does not have to hand back twelve files.
        $zip = Join-Path $RunPath 'llm-bundle.zip'
        if (Test-Path -Path $zip) {
            $folder = Join-Path $RunPath 'llm-bundle'
            if (Test-Path -Path $folder) { Remove-Item -Path $folder -Recurse -Force }
            # The zip already carries the llm-bundle/ prefix, so it unpacks into
            # the run folder; extracting into llm-bundle/ nested it twice.
            [System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $RunPath)
            Remove-Item -Path $zip -Force
            $written.Remove('llm-bundle.zip') | Out-Null
            $written.Add('llm-bundle/')
        }

        if ($Pdf) {
            $pdfPath = Join-Path $RunPath 'report.pdf'
            $printed = Join-Path $RunPath 'report-print.html'
            if (Test-Path -Path $printed) {
                $pdfArguments = @(
                    '--headless=new'
                    '--disable-gpu'
                    '--no-first-run'
                    "--user-data-dir=$profileDir"
                    '--no-pdf-header-footer'
                    "--print-to-pdf=$pdfPath"
                    "file://$printed"
                )
                if ($env:RAIDINESS_BROWSER_ARGS) {
                    $pdfArguments += $env:RAIDINESS_BROWSER_ARGS.Split(' ', [StringSplitOptions]::RemoveEmptyEntries)
                }
                $pdfResult = Invoke-RaidinessBrowserProcess -Browser $Browser -ArgumentList $pdfArguments -TimeoutSeconds $TimeoutSeconds
                if ((-not $pdfResult.TimedOut) -and (Test-Path -Path $pdfPath)) {
                    $written.Add('report.pdf')
                }
                else {
                    # A missing PDF is a smaller loss than a failed run.
                    Write-RaidinessLog -Level warn -Phase 'export' -Message 'the PDF could not be printed'
                }
            }
        }

        Write-RaidinessLog -Phase 'export' -Message "wrote $($written.Count) file(s) into $RunPath"
        [pscustomobject]@{ Ok = $true; Files = $written.ToArray(); Reason = $null }
    }
    catch {
        Write-RaidinessLog -Level error -Phase 'export' -Message "the export bridge failed: $($_.Exception.Message)"
        [pscustomobject]@{ Ok = $false; Files = $written.ToArray(); Reason = $_.Exception.Message }
    }
    finally {
        if (Test-Path -Path $profileDir) { Remove-Item -Path $profileDir -Recurse -Force -ErrorAction SilentlyContinue }
    }
}