scripts/internal/continuous-co-review/review-posix-process-host.ps1

[CmdletBinding()]
param(
    [Parameter(Mandatory)][ValidateSet('linux-cgroup', 'process-group')][string]$Mode,
    [Parameter(Mandatory)][string]$ReadyPath,
    [string]$CgroupPath
)

$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest

if (-not (Get-Command -Name 'Wait-ReviewRuntimeOutputDrains' -ErrorAction SilentlyContinue)) {
    . (Join-Path $PSScriptRoot 'review-runtime-contract.ps1')
}

function Initialize-ReviewPosixSessionType {
    if ('SpecrewReviewPosixSessionNative' -as [type]) { return }
    Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
 
public static class SpecrewReviewPosixSessionNative
{
    [DllImport("libc", SetLastError = true)]
    public static extern int setsid();
}
'@

}

try {
    if ($IsWindows) { throw 'posix-host-wrong-platform' }
    if ($Mode -ceq 'linux-cgroup') {
        if ([string]::IsNullOrWhiteSpace($CgroupPath)) { throw 'posix-host-cgroup-path-missing' }
        $procsPath = Join-Path ([IO.Path]::GetFullPath($CgroupPath)) 'cgroup.procs'
        if (-not [IO.File]::Exists($procsPath)) { throw 'posix-host-cgroup-procs-missing' }
        [IO.File]::WriteAllText($procsPath, [string]$PID, [Text.UTF8Encoding]::new($false))
        $containmentId = [IO.Path]::GetFullPath($CgroupPath)
    }
    else {
        Initialize-ReviewPosixSessionType
        $sessionId = [SpecrewReviewPosixSessionNative]::setsid()
        if ($sessionId -ne $PID) { throw "posix-host-setsid-failed:$([Runtime.InteropServices.Marshal]::GetLastWin32Error())" }
        $containmentId = [string]$sessionId
    }

    $ready = [ordered]@{ schema_version = '1.0'; mode = $Mode; pid = $PID; containment_id = $containmentId }
    [IO.File]::WriteAllText([IO.Path]::GetFullPath($ReadyPath), ($ready | ConvertTo-Json -Compress), [Text.UTF8Encoding]::new($false))

    # The controller sends the launch payload only after containment is verified and the immutable
    # invocation claim is recorded. The rendered prompt therefore travels over this pipe, never a file.
    $payloadText = [Console]::In.ReadToEnd()
    if ([string]::IsNullOrWhiteSpace($payloadText)) { exit 124 }
    $payload = $payloadText | ConvertFrom-Json -Depth 20
    foreach ($name in @('executable', 'argument_list', 'working_directory', 'environment_delta', 'prompt_transport', 'timeout_seconds', 'termination_wait_milliseconds', 'drain_timeout_milliseconds')) {
        if (-not $payload.PSObject.Properties[$name]) { throw "posix-host-payload-missing:$name" }
    }
    if ([string]$payload.prompt_transport -cnotin @('stdin', 'argument')) { throw 'posix-host-prompt-transport-invalid' }
    [int]$timeoutSeconds = 0
    if (-not [int]::TryParse([string]$payload.timeout_seconds, [ref]$timeoutSeconds) -or $timeoutSeconds -lt 1 -or $timeoutSeconds -gt 7200) {
        throw 'posix-host-timeout-invalid'
    }
    [int]$terminationWaitMilliseconds = 0
    if (-not [int]::TryParse([string]$payload.termination_wait_milliseconds, [ref]$terminationWaitMilliseconds) -or
        $terminationWaitMilliseconds -lt 1 -or $terminationWaitMilliseconds -gt 30000) {
        throw 'posix-host-termination-wait-invalid'
    }
    [int]$drainTimeoutMilliseconds = 0
    if (-not [int]::TryParse([string]$payload.drain_timeout_milliseconds, [ref]$drainTimeoutMilliseconds) -or
        $drainTimeoutMilliseconds -lt 1 -or $drainTimeoutMilliseconds -gt 30000) {
        throw 'posix-host-drain-timeout-invalid'
    }

    $startInfo = [Diagnostics.ProcessStartInfo]::new()
    $startInfo.FileName = [IO.Path]::GetFullPath([string]$payload.executable)
    foreach ($argument in @($payload.argument_list)) { [void]$startInfo.ArgumentList.Add([string]$argument) }
    $startInfo.WorkingDirectory = [IO.Path]::GetFullPath([string]$payload.working_directory)
    $startInfo.UseShellExecute = $false; $startInfo.CreateNoWindow = $true
    $startInfo.RedirectStandardInput = $true; $startInfo.RedirectStandardOutput = $true; $startInfo.RedirectStandardError = $true
    $startInfo.StandardInputEncoding = [Text.UTF8Encoding]::new($false)
    foreach ($entry in @($payload.environment_delta.PSObject.Properties)) {
        if ([string]$entry.Name -cnotin @('SPECREW_REFOCUS_DISABLE', 'SPECREW_DISABLE_EVENTS')) { throw "posix-host-environment-key-invalid:$($entry.Name)" }
        $startInfo.Environment[[string]$entry.Name] = [string]$entry.Value
    }

    $reviewer = [Diagnostics.Process]::new(); $reviewer.StartInfo = $startInfo
    if (-not $reviewer.Start()) { throw 'posix-host-reviewer-start-failed' }
    $stdoutDrain = $reviewer.StandardOutput.BaseStream.CopyToAsync([IO.Stream]::Null)
    $stderrDrain = $reviewer.StandardError.BaseStream.CopyToAsync([IO.Stream]::Null)
    if ([string]$payload.prompt_transport -ceq 'stdin' -and $payload.PSObject.Properties['stdin_text']) {
        $reviewer.StandardInput.Write([string]$payload.stdin_text)
    }
    $reviewer.StandardInput.Close()
    $reviewerTimedOut = -not $reviewer.WaitForExit($timeoutSeconds * 1000)
    if ($reviewerTimedOut) {
        try { $reviewer.Kill($true) } catch { $null = $_ }
        try { [void]$reviewer.WaitForExit($terminationWaitMilliseconds) } catch { $null = $_ }
    }
    $exitCode = if ($reviewer.HasExited) { $reviewer.ExitCode } else { 125 }
    $null = Wait-ReviewRuntimeOutputDrains -StdoutDrain $stdoutDrain -StderrDrain $stderrDrain -TimeoutMilliseconds $drainTimeoutMilliseconds
    try { $reviewer.StandardOutput.Close() } catch { $null = $_ }
    try { $reviewer.StandardError.Close() } catch { $null = $_ }
    $reviewer.Dispose()
    if ($reviewerTimedOut) { exit 124 }
    # Descendants can legitimately retain the reviewer's pipe handles after its root exits. The
    # outer controller owns the containment and reaps those descendants after this host returns;
    # incomplete nested drains therefore cannot override the reviewer's successful exit code.
    exit $exitCode
}
catch {
    try { [Console]::Error.WriteLine(('specrew-posix-host:' + $_.Exception.Message)) } catch { $null = $_ }
    exit 125
}