[CmdletBinding()] param( [ValidateSet('StaticAudit', 'DryPreflight', 'Initialize', 'Inspect', 'RunStage', 'RunNext')] [string] $Action = 'StaticAudit', [string] $EvidenceDirectory = '', [ValidateSet('Core5001', 'ThirtyRoutes', 'PagedBoundary', 'S6001AssetIndependent', 'Recovery8001', 'Recovery5077Page2', 'ThirtyRoutes20To29')] [string] $Workflow = 'Core5001', [string] $Stage = '', [int] $RouteIndex = -1, [int] $PageIndex = -1, [int] $DebugPort = 9339, [int] $ExpectedPgmPort = 30001, [int] $StageTimeoutSeconds = 900, [string] $ExpectedPackageName = 'Wickedness.MBNStockWebView.LegacyParity', [string] $ExpectedPgmExecutable = '', [string] $ExpectedAppWindowTitle = '', [string] $ExpectedPgmWindowTitle = 'PGM', [string] $ExpectedNetworkWindowTitle = '', [string] $OriginalCutsDirectory = '', [string] $NodeExecutable = '' ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' if ([string]::IsNullOrWhiteSpace($ExpectedNetworkWindowTitle)) { $ExpectedNetworkWindowTitle = [Text.Encoding]::Unicode.GetString( [Convert]::FromBase64String('JLG40szGbNAgAKi6yLIw0cG5IAA9zA==')) } if ([string]::IsNullOrWhiteSpace($ExpectedAppWindowTitle)) { $ExpectedAppWindowTitle = [Text.Encoding]::Unicode.GetString( [Convert]::FromBase64String('VgAtAFMAdABvAGMAawAgAJ3JjK0VyPS8ocGczdzCpMJc0SAAZgBvAHIAIADkuXzHvawcyFQAVgAgACgAMgA2AC4AMAAzAC4AMgA2ACkA')) } $workspaceRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) if ([string]::IsNullOrWhiteSpace($OriginalCutsDirectory)) { $OriginalCutsDirectory = [IO.Path]::GetFullPath((Join-Path $workspaceRoot ` '..\MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Cuts')) } else { $OriginalCutsDirectory = [IO.Path]::GetFullPath($OriginalCutsDirectory) } if ([string]::IsNullOrWhiteSpace($ExpectedPgmExecutable) -and -not [string]::IsNullOrWhiteSpace($env:MBN_STOCK_PGM_EXECUTABLE)) { $ExpectedPgmExecutable = [IO.Path]::GetFullPath($env:MBN_STOCK_PGM_EXECUTABLE) } elseif (-not [string]::IsNullOrWhiteSpace($ExpectedPgmExecutable)) { $ExpectedPgmExecutable = [IO.Path]::GetFullPath($ExpectedPgmExecutable) } $script:Utf8NoBom = [Text.UTF8Encoding]::new($false) $script:TargetUrl = 'https://legacy-parity.mbn.local/index.html' $script:NodeStageScript = Join-Path $PSScriptRoot 'LegacyPgmParityStage.mjs' $script:RouteCodes = @( '5001', '5074', '5016', '50160', '5078', '8067', '5086', '50860', '5023', '5024', '5077', '5082', '5083', '5084', '5085', '5088', '6067', '8035', '5011', '8003', '5037', '8001', '5026', '5087', '5029', '8032', '5032', '5076', '5079', '5080', '5081', '5025', '6001' ) $script:S6001AssetRelativePaths = @( [Text.Encoding]::Unicode.GetString( [Convert]::FromBase64String('aQBtAGEAZwBlAHMAXAD8yCDHMK5tAGUAcgBnAGUALgBwAG4AZwA=')), [Text.Encoding]::Unicode.GetString( [Convert]::FromBase64String('aQBtAGEAZwBlAHMAXAAzADUANwA1ADIAOQAxADMAXwBsAC4AagBwAGcA')) ) function Write-NewText { param( [Parameter(Mandatory = $true)] [string] $Path, [Parameter(Mandatory = $true)] [AllowEmptyString()] [string] $Text ) $fullPath = [IO.Path]::GetFullPath($Path) $stream = [IO.File]::Open($fullPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::Read) try { $writer = [IO.StreamWriter]::new($stream, $script:Utf8NoBom) try { $writer.Write($Text) } finally { $writer.Dispose() } } finally { if ($null -ne $stream) { $stream.Dispose() } } } function Write-NewJson { param( [Parameter(Mandatory = $true)] [string] $Path, [Parameter(Mandatory = $true)] [object] $Value ) Write-NewText -Path $Path -Text (($Value | ConvertTo-Json -Depth 40) + [Environment]::NewLine) } function Get-CanonicalJson { param([Parameter(Mandatory = $true)] [object] $Value) return ($Value | ConvertTo-Json -Depth 40 -Compress) } function Get-ResolvedNodeExecutable { if (-not [string]::IsNullOrWhiteSpace($NodeExecutable)) { $resolved = [IO.Path]::GetFullPath($NodeExecutable) if (-not [IO.File]::Exists($resolved)) { throw "Node executable does not exist: $resolved" } return $resolved } $command = Get-Command node.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if ($null -ne $command) { return [IO.Path]::GetFullPath($command.Source) } $bundled = Join-Path $env:USERPROFILE '.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe' if ([IO.File]::Exists($bundled)) { return [IO.Path]::GetFullPath($bundled) } throw 'Node was not found. Supply -NodeExecutable with an absolute node.exe path.' } function Get-FileIdentity { param([Parameter(Mandatory = $true)] [string] $Path) $fullPath = [IO.Path]::GetFullPath($Path) if (-not [IO.File]::Exists($fullPath)) { throw "Pinned file does not exist: $fullPath" } $file = [IO.FileInfo]::new($fullPath) return [ordered]@{ path = $fullPath length = $file.Length lastWriteUtc = $file.LastWriteTimeUtc.ToString('o') sha256 = (Get-FileHash -LiteralPath $fullPath -Algorithm SHA256).Hash } } function Initialize-NativeTypes { if ('CodexLegacyParityNative20260722' -as [type]) { return } Add-Type -AssemblyName System.Drawing Add-Type @' using System; using System.Collections.Generic; using System.Runtime.InteropServices; using System.Text; public static class CodexLegacyParityNative20260722 { public delegate bool WindowCallback(IntPtr window, IntPtr parameter); [StructLayout(LayoutKind.Sequential)] public struct Rect { public int Left, Top, Right, Bottom; } [DllImport("user32.dll", SetLastError=true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool EnumWindows(WindowCallback callback, IntPtr parameter); [DllImport("user32.dll", SetLastError=true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool EnumChildWindows(IntPtr parent, WindowCallback callback, IntPtr parameter); [DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true)] private static extern int GetWindowText(IntPtr window, StringBuilder text, int count); [DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true)] private static extern int GetClassName(IntPtr window, StringBuilder text, int count); [DllImport("user32.dll", SetLastError=true)] private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool IsWindowVisible(IntPtr window); [DllImport("user32.dll", SetLastError=true)] private static extern IntPtr SendMessageTimeout(IntPtr window, uint message, UIntPtr wParam, IntPtr lParam, uint flags, uint timeoutMilliseconds, out UIntPtr result); [DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true)] private static extern IntPtr SendMessageTimeout(IntPtr window, uint message, UIntPtr wParam, StringBuilder lParam, uint flags, uint timeoutMilliseconds, out UIntPtr result); [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] public static extern bool GetWindowRect(IntPtr window, out Rect rect); [DllImport("user32.dll", EntryPoint="GetWindowLongPtrW", SetLastError=true)] private static extern IntPtr GetWindowLongPtr64(IntPtr window, int index); [DllImport("user32.dll", EntryPoint="GetWindowLongW", SetLastError=true)] private static extern int GetWindowLong32(IntPtr window, int index); [DllImport("user32.dll", SetLastError=true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool SetWindowPos(IntPtr window, IntPtr insertAfter, int x, int y, int width, int height, uint flags); public static bool IsTopMost(IntPtr window) { const int GwlExStyle = -20; const long WsExTopMost = 0x00000008L; long style = IntPtr.Size == 8 ? GetWindowLongPtr64(window, GwlExStyle).ToInt64() : GetWindowLong32(window, GwlExStyle); return (style & WsExTopMost) != 0; } public static bool SetTopMostForCapture(IntPtr window, bool topMost) { IntPtr insertAfter = topMost ? new IntPtr(-1) : new IntPtr(-2); const uint SwpNoSize = 0x0001; const uint SwpNoMove = 0x0002; const uint SwpNoActivate = 0x0010; const uint SwpShowWindow = 0x0040; return SetWindowPos(window, insertAfter, 0, 0, 0, 0, SwpNoSize | SwpNoMove | SwpNoActivate | SwpShowWindow); } [DllImport("kernel32.dll", CharSet=CharSet.Unicode)] private static extern int GetPackageFullName(IntPtr process, ref uint packageFullNameLength, StringBuilder packageFullName); public static string GetProcessPackageFullName(IntPtr process) { const int AppmodelErrorNoPackage = 15700; const int ErrorInsufficientBuffer = 122; uint length = 0; int first = GetPackageFullName(process, ref length, null); if (first == AppmodelErrorNoPackage) return null; if (first != ErrorInsufficientBuffer || length == 0) throw new InvalidOperationException("GetPackageFullName length query failed: " + first); var buffer = new StringBuilder((int)length); int second = GetPackageFullName(process, ref length, buffer); if (second != 0) throw new InvalidOperationException("GetPackageFullName failed: " + second); return buffer.ToString(); } public static IntPtr[] FindTopLevelWindows(uint processId, string exactTitle) { var matches = new List(); if (!EnumWindows(delegate(IntPtr window, IntPtr parameter) { uint owner; if (GetWindowThreadProcessId(window, out owner) == 0 || owner != processId) return true; var title = new StringBuilder(1024); GetWindowText(window, title, title.Capacity); if (String.Equals(title.ToString(), exactTitle, StringComparison.Ordinal)) matches.Add(window); return true; }, IntPtr.Zero)) throw new InvalidOperationException("Top-level window enumeration failed."); return matches.ToArray(); } public static string GetWindowTitle(IntPtr window) { var title = new StringBuilder(1024); GetWindowText(window, title, title.Capacity); return title.ToString(); } public static bool GetVisible(IntPtr window) { return IsWindowVisible(window); } private static IntPtr FindUniqueRichEdit(IntPtr parent) { var matches = new List(); if (!EnumChildWindows(parent, delegate(IntPtr window, IntPtr parameter) { var className = new StringBuilder(256); if (GetClassName(window, className, className.Capacity) > 0 && String.Equals(className.ToString(), "RichEdit20W", StringComparison.Ordinal)) matches.Add(window); return true; }, IntPtr.Zero)) throw new InvalidOperationException("Network child enumeration failed."); if (matches.Count != 1) throw new InvalidOperationException("Expected exactly one Network Monitoring RichEdit20W; found " + matches.Count + "."); return matches[0]; } private static string ReadTextOnce(IntPtr window) { const uint WmGetText = 0x000D, WmGetTextLength = 0x000E, Flags = 0x0003; UIntPtr lengthResult; if (SendMessageTimeout(window, WmGetTextLength, UIntPtr.Zero, IntPtr.Zero, Flags, 5000, out lengthResult) == IntPtr.Zero) throw new InvalidOperationException("Network length read timed out."); long length = checked((long)lengthResult.ToUInt64()); if (length < 0 || length > 10000000) throw new InvalidOperationException("Network text length is unsafe."); var text = new StringBuilder(checked((int)length + 65537)); UIntPtr copied; if (SendMessageTimeout(window, WmGetText, new UIntPtr((uint)text.Capacity), text, Flags, 5000, out copied) == IntPtr.Zero || copied.ToUInt64() != (ulong)text.Length) throw new InvalidOperationException("Network text copy failed."); return text.ToString(); } public static string ReadStableNetworkText(IntPtr parent) { IntPtr edit = FindUniqueRichEdit(parent); string first = ReadTextOnce(edit); string second = ReadTextOnce(edit); if (!String.Equals(first, second, StringComparison.Ordinal)) throw new InvalidOperationException("Network text changed during the read."); return first; } } '@ } function Get-ExactWindowDescriptor { param( [Parameter(Mandatory = $true)] [int] $ProcessIdValue, [Parameter(Mandatory = $true)] [string] $ExactTitle, [bool] $RequireVisible ) $matches = @([CodexLegacyParityNative20260722]::FindTopLevelWindows([uint32]$ProcessIdValue, $ExactTitle)) if ($matches.Count -ne 1) { throw "Expected one '$ExactTitle' window owned by PID $ProcessIdValue; found $($matches.Count)." } $handle = [IntPtr]$matches[0] $visible = [CodexLegacyParityNative20260722]::GetVisible($handle) if ($RequireVisible -and -not $visible) { throw "Pinned '$ExactTitle' window is not visible." } return [ordered]@{ handle = $handle.ToInt64().ToString([Globalization.CultureInfo]::InvariantCulture) title = [CodexLegacyParityNative20260722]::GetWindowTitle($handle) visible = $visible } } function Get-ProcessPathSafe { param([Parameter(Mandatory = $true)] [Diagnostics.Process] $Process) try { return $Process.Path } catch { return $null } } function Find-UniqueProcessByPath { param([Parameter(Mandatory = $true)] [string] $ExpectedPath) $fullPath = [IO.Path]::GetFullPath($ExpectedPath) $matches = @() foreach ($candidate in @(Get-Process -ErrorAction Stop)) { $candidatePath = Get-ProcessPathSafe -Process $candidate if ($null -ne $candidatePath -and [string]::Equals( [IO.Path]::GetFullPath($candidatePath), $fullPath, [StringComparison]::OrdinalIgnoreCase)) { $matches += $candidate } } if ($matches.Count -ne 1) { throw "Expected exactly one process at '$fullPath'; found $($matches.Count)." } return $matches[0] } function Get-ProcessDescriptor { param( [Parameter(Mandatory = $true)] [Diagnostics.Process] $Process, [string] $WindowTitle = '', [bool] $RequireVisibleWindow = $false ) $path = Get-ProcessPathSafe -Process $Process if ([string]::IsNullOrWhiteSpace($path)) { throw "PID $($Process.Id) has no readable executable path." } $packageFullName = [CodexLegacyParityNative20260722]::GetProcessPackageFullName($Process.Handle) $descriptor = [ordered]@{ processId = $Process.Id startUtc = $Process.StartTime.ToUniversalTime().ToString('o') executable = Get-FileIdentity -Path $path packageFullName = $packageFullName } if (-not [string]::IsNullOrWhiteSpace($WindowTitle)) { $descriptor.window = Get-ExactWindowDescriptor -ProcessIdValue $Process.Id -ExactTitle $WindowTitle -RequireVisible $RequireVisibleWindow } return $descriptor } function Get-ListenerDescriptor { param( [Parameter(Mandatory = $true)] [int] $Port, [int] $ExpectedOwner = -1 ) $listeners = @(Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction Stop) if ($listeners.Count -eq 0) { throw "No TCP listener is present on port $Port." } $owners = @($listeners | Select-Object -ExpandProperty OwningProcess -Unique) if ($owners.Count -ne 1) { throw "TCP port $Port has more than one listener owner." } if ($ExpectedOwner -ge 0 -and $owners[0] -ne $ExpectedOwner) { throw "TCP port $Port is owned by PID $($owners[0]), expected $ExpectedOwner." } $endpoints = @($listeners | ForEach-Object { '{0}:{1}|{2}|{3}' -f $_.LocalAddress, $_.LocalPort, $_.State, $_.OwningProcess } | Sort-Object) return [ordered]@{ port = $Port; ownerProcessId = [int]$owners[0]; endpoints = $endpoints } } function Get-EstablishedPgmConnection { param( [Parameter(Mandatory = $true)] [int] $AppProcessId, [Parameter(Mandatory = $true)] [int] $PgmProcessId, [Parameter(Mandatory = $true)] [int] $PgmPort ) $connections = @(Get-NetTCPConnection -State Established -RemotePort $PgmPort -ErrorAction Stop | Where-Object { $_.OwningProcess -eq $AppProcessId }) if ($connections.Count -ne 1) { throw "Expected one established app-to-PGM connection owned by PID $AppProcessId; found $($connections.Count)." } $connection = $connections[0] $localAddress = $null $remoteAddress = $null if (-not [Net.IPAddress]::TryParse([string]$connection.LocalAddress, [ref]$localAddress) -or -not [Net.IPAddress]::TryParse([string]$connection.RemoteAddress, [ref]$remoteAddress) -or -not [Net.IPAddress]::IsLoopback($localAddress) -or -not [Net.IPAddress]::IsLoopback($remoteAddress)) { throw 'The app-to-PGM connection is not entirely loopback.' } $reciprocal = @(Get-NetTCPConnection -State Established -LocalPort $PgmPort ` -RemotePort $connection.LocalPort -ErrorAction Stop | Where-Object { $_.OwningProcess -eq $PgmProcessId -and [string]$_.LocalAddress -ceq [string]$connection.RemoteAddress -and [string]$_.RemoteAddress -ceq [string]$connection.LocalAddress }) if ($reciprocal.Count -ne 1) { throw "Expected one reciprocal PGM-to-app connection owned by PID $PgmProcessId; found $($reciprocal.Count)." } $pgmSide = $reciprocal[0] return [ordered]@{ appSide = [ordered]@{ ownerProcessId = $connection.OwningProcess localAddress = $connection.LocalAddress localPort = $connection.LocalPort remoteAddress = $connection.RemoteAddress remotePort = $connection.RemotePort state = [string]$connection.State } pgmSide = [ordered]@{ ownerProcessId = $pgmSide.OwningProcess localAddress = $pgmSide.LocalAddress localPort = $pgmSide.LocalPort remoteAddress = $pgmSide.RemoteAddress remotePort = $pgmSide.RemotePort state = [string]$pgmSide.State } } } function Get-CdpTargetDescriptor { param([Parameter(Mandatory = $true)] [int] $Port) $targets = @(Invoke-RestMethod -Uri "http://127.0.0.1:$Port/json/list" -Method Get -TimeoutSec 10) $pages = @($targets | Where-Object { $_.type -ceq 'page' -and $_.url -ceq $script:TargetUrl }) if ($pages.Count -ne 1) { throw "Expected exactly one CDP parity target; found $($pages.Count)." } $target = $pages[0] if ([string]::IsNullOrWhiteSpace([string]$target.id) -or [string]::IsNullOrWhiteSpace([string]$target.webSocketDebuggerUrl)) { throw 'The parity CDP target is missing an ID or WebSocket endpoint.' } $endpoint = [Uri]::new([string]$target.webSocketDebuggerUrl) if ($endpoint.Scheme -cne 'ws' -or $endpoint.Host -cne '127.0.0.1' -or $endpoint.Port -ne $Port -or $endpoint.AbsolutePath -cne "/devtools/page/$($target.id)" -or -not [string]::IsNullOrEmpty($endpoint.Query) -or -not [string]::IsNullOrEmpty($endpoint.Fragment)) { throw 'The parity CDP WebSocket endpoint identity is not exact.' } return [ordered]@{ id = [string]$target.id type = [string]$target.type title = [string]$target.title url = [string]$target.url webSocketDebuggerUrl = [string]$target.webSocketDebuggerUrl } } function Get-CutInventory { param( [Parameter(Mandatory = $true)] [string] $SourceDirectory, [Parameter(Mandatory = $true)] [string] $RuntimeDirectory ) $sourceRoot = [IO.Path]::GetFullPath($SourceDirectory) $runtimeRoot = [IO.Path]::GetFullPath($RuntimeDirectory) $items = @() foreach ($code in $script:RouteCodes) { $sourcePath = Join-Path $sourceRoot "$code.t2s" $runtimePath = Join-Path $runtimeRoot "$code.t2s" $source = Get-FileIdentity -Path $sourcePath $runtime = Get-FileIdentity -Path $runtimePath if ($source.sha256 -cne $runtime.sha256 -or $source.length -ne $runtime.length) { throw "Runtime cut $code does not exactly match the authoritative legacy cut." } $items += [ordered]@{ code = $code sourcePath = $source.path runtimePath = $runtime.path length = $runtime.length sha256 = $runtime.sha256 } } return $items } function Get-AssetInventory { param( [Parameter(Mandatory = $true)] [string] $SourceDirectory, [Parameter(Mandatory = $true)] [string] $RuntimeDirectory ) $sourceRoot = [IO.Path]::GetFullPath($SourceDirectory) $runtimeRoot = [IO.Path]::GetFullPath($RuntimeDirectory) $items = @() foreach ($relativePath in $script:S6001AssetRelativePaths) { $source = Get-FileIdentity -Path (Join-Path $sourceRoot $relativePath) $runtime = Get-FileIdentity -Path (Join-Path $runtimeRoot $relativePath) if ($source.sha256 -cne $runtime.sha256 -or $source.length -ne $runtime.length) { throw "Runtime asset $relativePath does not exactly match the authoritative legacy asset." } $items += [ordered]@{ relativePath = $relativePath sourcePath = $source.path runtimePath = $runtime.path length = $runtime.length sha256 = $runtime.sha256 } } return $items } function Get-CurrentPins { Initialize-NativeTypes if ($DebugPort -le 0 -or $DebugPort -gt 65535) { throw "Invalid CDP port: $DebugPort" } if ($ExpectedPgmPort -le 0 -or $ExpectedPgmPort -gt 65535) { throw "Invalid PGM port: $ExpectedPgmPort" } if ([string]::IsNullOrWhiteSpace($ExpectedPgmExecutable)) { throw 'Pass -ExpectedPgmExecutable or set MBN_STOCK_PGM_EXECUTABLE for runtime checks.' } $packages = @(Get-AppxPackage -Name $ExpectedPackageName -ErrorAction Stop | Where-Object { $_.Name -ceq $ExpectedPackageName }) if ($packages.Count -ne 1) { throw "Expected one registered package '$ExpectedPackageName'; found $($packages.Count)." } $package = $packages[0] if ($package.IsDevelopmentMode -ne $true -or [string]$package.Architecture -cne 'X64') { throw 'Only the x64 loose Debug development package is authorized for this run.' } $installLocation = [IO.Path]::GetFullPath($package.InstallLocation) $workspaceRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $debugPackageRoot = [IO.Path]::GetFullPath((Join-Path $workspaceRoot ` 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\bin\x64\Debug')) $debugPrefix = $debugPackageRoot.TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar if (-not $installLocation.StartsWith($debugPrefix, [StringComparison]::OrdinalIgnoreCase)) { throw "Package InstallLocation is outside the workspace x64 Debug boundary: $installLocation" } $debugRelative = $installLocation.Substring($debugPrefix.Length) $debugSegments = @($debugRelative.Split([IO.Path]::DirectorySeparatorChar) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) if ($debugSegments.Count -ne 3 -or $debugSegments[0] -notmatch '^net[0-9]+\.[0-9]+-windows[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' -or $debugSegments[1] -cne 'win-x64' -or $debugSegments[2] -cne 'AppX') { throw "Package InstallLocation is not an exact Debug target-framework/win-x64/AppX path: $installLocation" } $appExecutable = Join-Path $installLocation 'MBN_STOCK_WEBVIEW.LegacyParityApp.exe' if (-not [IO.File]::Exists($appExecutable)) { throw "Package app executable was not found under $installLocation." } $appProcess = Find-UniqueProcessByPath -ExpectedPath $appExecutable $pgmProcess = Find-UniqueProcessByPath -ExpectedPath $ExpectedPgmExecutable $app = Get-ProcessDescriptor -Process $appProcess -WindowTitle $ExpectedAppWindowTitle -RequireVisibleWindow $true $pgm = Get-ProcessDescriptor -Process $pgmProcess -WindowTitle $ExpectedPgmWindowTitle -RequireVisibleWindow $true if ($app.packageFullName -cne $package.PackageFullName) { throw "App PID $($app.processId) does not carry the registered package identity." } $pgm.networkWindow = Get-ExactWindowDescriptor -ProcessIdValue $pgmProcess.Id ` -ExactTitle $ExpectedNetworkWindowTitle -RequireVisible $false $pgmListener = Get-ListenerDescriptor -Port $ExpectedPgmPort -ExpectedOwner $pgmProcess.Id $appConnection = Get-EstablishedPgmConnection -AppProcessId $appProcess.Id ` -PgmProcessId $pgmProcess.Id -PgmPort $ExpectedPgmPort $cdpListener = Get-ListenerDescriptor -Port $DebugPort $cdpOwnerProcess = Get-Process -Id $cdpListener.ownerProcessId -ErrorAction Stop $cdpOwner = Get-ProcessDescriptor -Process $cdpOwnerProcess $cdpTarget = Get-CdpTargetDescriptor -Port $DebugPort $runtimeFiles = @() foreach ($relative in @( 'MBN_STOCK_WEBVIEW.LegacyParityApp.exe', 'MBN_STOCK_WEBVIEW.LegacyParityApp.dll', 'MBN_STOCK_WEBVIEW.Playout.dll')) { $runtimeFiles += Get-FileIdentity -Path (Join-Path $installLocation $relative) } $nodePath = Get-ResolvedNodeExecutable $cutInventory = Get-CutInventory -SourceDirectory $OriginalCutsDirectory ` -RuntimeDirectory (Join-Path $installLocation 'Cuts') $assetInventory = Get-AssetInventory -SourceDirectory $OriginalCutsDirectory ` -RuntimeDirectory (Join-Path $installLocation 'Cuts') return [ordered]@{ schemaVersion = 1 package = [ordered]@{ name = [string]$package.Name packageFullName = [string]$package.PackageFullName packageFamilyName = [string]$package.PackageFamilyName publisher = [string]$package.Publisher version = $package.Version.ToString() architecture = [string]$package.Architecture isDevelopmentMode = [bool]$package.IsDevelopmentMode installLocation = $installLocation debugPackageRoot = $debugPackageRoot } app = $app pgm = $pgm pgmListener = $pgmListener appToPgmConnection = $appConnection cdp = [ordered]@{ listener = $cdpListener owner = $cdpOwner target = $cdpTarget } runtimeFiles = $runtimeFiles cutInventory = $cutInventory assetInventory = $assetInventory tools = [ordered]@{ wrapper = Get-FileIdentity -Path $PSCommandPath node = Get-FileIdentity -Path $nodePath nodeStage = Get-FileIdentity -Path $script:NodeStageScript } } } function Assert-PinsExact { param([Parameter(Mandatory = $true)] [object] $Pinned) $current = Get-CurrentPins $expectedJson = Get-CanonicalJson -Value $Pinned $currentJson = Get-CanonicalJson -Value $current if ($currentJson -cne $expectedJson) { throw 'A pinned package, process, executable hash, window, listener, connection, CDP target, cut, or tool identity changed.' } return $current } function Get-NetworkCounts { param([Parameter(Mandatory = $true)] [AllowEmptyString()] [string] $Text) $lines = @($Text -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) return [ordered]@{ helloRequest = @($lines | Where-Object { $_ -match '\[R\]\s+HELLO\b' }).Count helloSuccess = @($lines | Where-Object { $_ -match '\[S\]\s+SUCCESS\s+HELLO\b' }).Count loadSceneRequest = @($lines | Where-Object { $_ -match '\[R\]\s+LOAD_SCENE\b' }).Count loadSceneSuccess = @($lines | Where-Object { $_ -match '\[S\]\s+SUCCESS\s+LOAD_SCENE\b' }).Count prepareRequest = @($lines | Where-Object { $_ -match '\[R\]\s+SCENE_PREPARE\b' }).Count prepareSuccess = @($lines | Where-Object { $_ -match '\[S\]\s+SUCCESS\s+SCENE_PREPARE\b' }).Count playRequest = @($lines | Where-Object { $_ -match '\[R\]\s+PLAY\b' }).Count playSuccess = @($lines | Where-Object { $_ -match '\[S\]\s+SUCCESS\s+PLAY\b' }).Count scenePlayed = @($lines | Where-Object { $_ -match '\[A\]\s+SCENE_PLAYED\b' }).Count unloadRequest = @($lines | Where-Object { $_ -match '\[R\]\s+UNLOAD_SCENE\b' }).Count unloadSuccess = @($lines | Where-Object { $_ -match '\[S\]\s+SUCCESS\s+UNLOAD_SCENE\b' }).Count failure = @($lines | Where-Object { $_ -match '\bFAILURE\b' }).Count error = @($lines | Where-Object { $_ -match '\bERROR\b' }).Count } } function Read-StableNetworkText { param([Parameter(Mandatory = $true)] [IntPtr] $NetworkWindow) $lastError = $null for ($attempt = 1; $attempt -le 8; $attempt += 1) { try { return [CodexLegacyParityNative20260722]::ReadStableNetworkText($NetworkWindow) } catch { $lastError = $_ Start-Sleep -Milliseconds 125 } } throw $lastError } function Capture-WindowImage { param( [Parameter(Mandatory = $true)] [IntPtr] $Window, [Parameter(Mandatory = $true)] [string] $Path ) if ([IO.File]::Exists($Path)) { throw "Evidence image already exists: $Path" } $rect = New-Object CodexLegacyParityNative20260722+Rect if (-not [CodexLegacyParityNative20260722]::GetWindowRect($Window, [ref]$rect)) { throw 'Window rectangle could not be read.' } $width = $rect.Right - $rect.Left $height = $rect.Bottom - $rect.Top if ($width -lt 100 -or $height -lt 100 -or $width -gt 8000 -or $height -gt 5000) { throw "Window rectangle is unsafe: ${width}x${height}." } # CopyFromScreen is required for the DirectX PGM surface, but it otherwise # records an unrelated foreground window when the target is occluded. Raise # only this exact pinned window without activating it, allow one repaint, # capture, and restore its original top-most state in a finally block. $wasTopMost = [CodexLegacyParityNative20260722]::IsTopMost($Window) if (-not [CodexLegacyParityNative20260722]::SetTopMostForCapture($Window, $true)) { throw 'The exact evidence window could not be raised for an unobscured capture.' } Start-Sleep -Milliseconds 250 $bitmap = $null $graphics = $null try { $bitmap = [Drawing.Bitmap]::new($width, $height) $graphics = [Drawing.Graphics]::FromImage($bitmap) $graphics.CopyFromScreen($rect.Left, $rect.Top, 0, 0, $bitmap.Size) $bitmap.Save($Path, [Drawing.Imaging.ImageFormat]::Png) } finally { if ($null -ne $graphics) { $graphics.Dispose() } if ($null -ne $bitmap) { $bitmap.Dispose() } if (-not $wasTopMost -and -not [CodexLegacyParityNative20260722]::SetTopMostForCapture($Window, $false)) { throw 'The evidence window top-most state could not be restored.' } } } function Get-NetworkDelta { param( [Parameter(Mandatory = $true)] [string] $Current, [string] $PreviousPath = '' ) if ([string]::IsNullOrWhiteSpace($PreviousPath)) { return [ordered]@{ anchorMatched = $null; anchorLength = 0; delta = $Current } } $previousFullPath = [IO.Path]::GetFullPath($PreviousPath) if (-not [IO.File]::Exists($previousFullPath)) { throw "Previous Network evidence is missing: $previousFullPath" } $previous = [IO.File]::ReadAllText($previousFullPath, [Text.Encoding]::UTF8) if ($previous.Length -eq 0) { return [ordered]@{ anchorMatched = $true; anchorLength = 0; delta = $Current } } $anchorLength = [Math]::Min(4096, $previous.Length) $anchor = $previous.Substring($previous.Length - $anchorLength) $first = $Current.IndexOf($anchor, [StringComparison]::Ordinal) $last = $Current.LastIndexOf($anchor, [StringComparison]::Ordinal) if ($first -lt 0 -or $first -ne $last) { throw 'The rolling Network Monitoring anchor is missing or non-unique.' } return [ordered]@{ anchorMatched = $true anchorLength = $anchorLength delta = $Current.Substring($first + $anchorLength) } } function Capture-ReadOnlyEvidence { param( [Parameter(Mandatory = $true)] [object] $Pins, [Parameter(Mandatory = $true)] [string] $OutputDirectory, [string] $PreviousNetworkPath = '' ) Initialize-NativeTypes $directory = [IO.Path]::GetFullPath($OutputDirectory) if (-not [IO.Directory]::Exists($directory)) { throw "Evidence stage directory is missing: $directory" } $pgmImage = Join-Path $directory 'pgm-screen.png' $appImage = Join-Path $directory 'app-screen.png' $networkPath = Join-Path $directory 'network-full.txt' $networkDeltaPath = Join-Path $directory 'network-delta.txt' $summaryPath = Join-Path $directory 'capture.json' $pgmWindow = [IntPtr]::new([long]$Pins.pgm.window.handle) $appWindow = [IntPtr]::new([long]$Pins.app.window.handle) $networkWindow = [IntPtr]::new([long]$Pins.pgm.networkWindow.handle) # Capture the PGM frame first. Capture-WindowImage temporarily changes only # the pinned window's z-order; it never focuses, restores, clicks, or sends a # command to either application. Capture-WindowImage -Window $pgmWindow -Path $pgmImage Capture-WindowImage -Window $appWindow -Path $appImage $networkText = Read-StableNetworkText -NetworkWindow $networkWindow Write-NewText -Path $networkPath -Text $networkText $deltaResult = Get-NetworkDelta -Current $networkText -PreviousPath $PreviousNetworkPath Write-NewText -Path $networkDeltaPath -Text $deltaResult.delta $summary = [ordered]@{ schemaVersion = 1 capturedAtUtc = [DateTime]::UtcNow.ToString('o') previousNetworkPath = $(if ([string]::IsNullOrWhiteSpace($PreviousNetworkPath)) { $null } else { [IO.Path]::GetFullPath($PreviousNetworkPath) }) anchorMatched = $deltaResult.anchorMatched anchorLength = $deltaResult.anchorLength fullCharacters = $networkText.Length deltaCharacters = $deltaResult.delta.Length counts = Get-NetworkCounts -Text $deltaResult.delta pgmImagePath = $pgmImage appImagePath = $appImage networkFullPath = $networkPath networkDeltaPath = $networkDeltaPath } Write-NewJson -Path $summaryPath -Value $summary return $summary } function Get-WorkflowStages { param([Parameter(Mandatory = $true)] [string] $Name) if ($Name -ceq 'Recovery8001') { return @([pscustomobject]@{ action = 'take-out-recovery-8001'; routeIndex = 19; pageIndex = $null }) } if ($Name -ceq 'Recovery5077Page2') { return @([pscustomobject]@{ action = 'take-out-recovery-5077-page2'; routeIndex = $null; pageIndex = 2 }) } if ($Name -ceq 'ThirtyRoutes20To29') { $resume = @([pscustomobject]@{ action = 'resume-30-manifest'; routeIndex = $null; pageIndex = $null }) for ($index = 20; $index -lt 30; $index += 1) { foreach ($actionName in @('activate-30', 'take-in-30-fast', 'wait-30', 'take-out-30')) { $resume += [pscustomobject]@{ action = $actionName; routeIndex = $index; pageIndex = $null } } } return $resume } $items = @([pscustomobject]@{ action = 'bootstrap'; routeIndex = $null; pageIndex = $null }) switch ($Name) { 'Core5001' { foreach ($actionName in @('setup-5001-5074', 'take-in-5001', 'next-5074', 'page-next-5074', 'take-out-5074')) { $items += [pscustomobject]@{ action = $actionName; routeIndex = $null; pageIndex = $null } } } 'ThirtyRoutes' { foreach ($actionName in @('build-30-base', 'build-30-comparison', 'build-30-financial', 'build-30-manual')) { $items += [pscustomobject]@{ action = $actionName; routeIndex = $null; pageIndex = $null } } for ($index = 0; $index -lt 30; $index += 1) { foreach ($actionName in @('activate-30', 'take-in-30-fast', 'wait-30', 'take-out-30')) { $items += [pscustomobject]@{ action = $actionName; routeIndex = $index; pageIndex = $null } } } } 'PagedBoundary' { $items += [pscustomobject]@{ action = 'build-paged'; routeIndex = $null; pageIndex = $null } $items += [pscustomobject]@{ action = 'take-in-5077'; routeIndex = $null; pageIndex = $null } for ($page = 1; $page -le 19; $page += 1) { $items += [pscustomobject]@{ action = 'page-next-5077'; routeIndex = $null; pageIndex = $page } } $items += [pscustomobject]@{ action = 'next-5088'; routeIndex = $null; pageIndex = $null } $items += [pscustomobject]@{ action = 'take-out-5088'; routeIndex = $null; pageIndex = $null } } 'S6001AssetIndependent' { $items += [pscustomobject]@{ action = 'build-s6001-asset-independent'; routeIndex = $null; pageIndex = $null } for ($index = 0; $index -lt 4; $index += 1) { foreach ($actionName in @('activate-s6001', 'take-in-s6001', 'wait-s6001', 'take-out-s6001')) { $items += [pscustomobject]@{ action = $actionName; routeIndex = $index; pageIndex = $null } } } } default { throw "Unknown workflow: $Name" } } return $items } function Get-SuccessfulStageRecords { param([Parameter(Mandatory = $true)] [string] $Directory) $records = @() foreach ($child in @(Get-ChildItem -LiteralPath $Directory -Directory -ErrorAction Stop | Where-Object { $_.Name -match '^(?!0000-)\d{4}-' } | Sort-Object Name)) { $successPath = Join-Path $child.FullName 'SUCCESS.json' if ([IO.File]::Exists($successPath)) { $records += (Get-Content -LiteralPath $successPath -Raw -Encoding UTF8 | ConvertFrom-Json) } } return $records } function Assert-CompletedStageSequence { param( [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [object[]] $Records, [Parameter(Mandatory = $true)] [object[]] $ExpectedStages ) if ($Records.Count -gt $ExpectedStages.Count) { throw 'Evidence contains more successful stages than the workflow.' } for ($index = 0; $index -lt $Records.Count; $index += 1) { $record = $Records[$index] $expected = $ExpectedStages[$index] $recordRoute = if ($null -ne $record.stage.routeIndex) { [int]$record.stage.routeIndex } else { $null } $recordPage = if ($null -ne $record.stage.pageIndex) { [int]$record.stage.pageIndex } else { $null } if ([int]$record.sequence -ne ($index + 1) -or [string]$record.stage.action -cne [string]$expected.action -or $recordRoute -ne $expected.routeIndex -or $recordPage -ne $expected.pageIndex) { throw "Successful evidence sequence is not exact at index $index." } } } function Get-PreviousNetworkPath { param([Parameter(Mandatory = $true)] [string] $Directory) $candidates = @(Get-ChildItem -LiteralPath $Directory -Recurse -File -Filter 'network-full.txt' -ErrorAction Stop | Sort-Object FullName) if ($candidates.Count -eq 0) { return '' } return $candidates[-1].FullName } function New-StopLatch { param( [Parameter(Mandatory = $true)] [string] $Directory, [Parameter(Mandatory = $true)] [string] $Reason, [string] $StageDirectory = '', [object] $Details = $null ) $path = Join-Path $Directory 'STOPPED.json' if ([IO.File]::Exists($path)) { return } $record = [ordered]@{ schemaVersion = 1 stoppedAtUtc = [DateTime]::UtcNow.ToString('o') reason = $Reason stageDirectory = $(if ([string]::IsNullOrWhiteSpace($StageDirectory)) { $null } else { $StageDirectory }) details = $Details rule = 'No later playout command may be dispatched from this run directory.' } try { Write-NewJson -Path $path -Value $record } catch { if (-not [IO.File]::Exists($path)) { throw } } } function ConvertTo-NodeArgumentString { param([Parameter(Mandatory = $true)] [object[]] $Values) $quoted = foreach ($value in $Values) { $text = [string]$value if ($text.Contains('"') -or $text.Contains([char]0)) { throw 'A Node stage argument contains a forbidden character.' } '"' + $text + '"' } return ($quoted -join ' ') } function Invoke-NodeStage { param( [Parameter(Mandatory = $true)] [object] $Manifest, [Parameter(Mandatory = $true)] [object] $ExpectedStage, [Parameter(Mandatory = $true)] [string] $OutputDirectory ) $stdoutPath = Join-Path $OutputDirectory 'node-stdout.json' $stderrPath = Join-Path $OutputDirectory 'node-stderr.json' $nodePath = [string]$Manifest.pins.tools.node.path $nodeScriptPath = [string]$Manifest.pins.tools.nodeStage.path $arguments = @($nodeScriptPath, '--port', [string]$Manifest.debugPort, '--action', [string]$ExpectedStage.action) if ($null -ne $ExpectedStage.routeIndex) { $arguments += @('--route-index', [string]$ExpectedStage.routeIndex) } if ($null -ne $ExpectedStage.pageIndex) { $arguments += @('--page-index', [string]$ExpectedStage.pageIndex) } $start = [Diagnostics.ProcessStartInfo]::new() $start.FileName = $nodePath $start.WorkingDirectory = $PSScriptRoot $start.UseShellExecute = $false $start.CreateNoWindow = $true $start.RedirectStandardOutput = $true $start.RedirectStandardError = $true $start.Arguments = ConvertTo-NodeArgumentString -Values $arguments $process = [Diagnostics.Process]::new() $process.StartInfo = $start try { if (-not $process.Start()) { throw 'The Node stage process did not start.' } $stdoutTask = $process.StandardOutput.ReadToEndAsync() $stderrTask = $process.StandardError.ReadToEndAsync() $exited = $process.WaitForExit($StageTimeoutSeconds * 1000) if (-not $exited) { $terminated = $false try { $process.Kill() $terminated = $process.WaitForExit(5000) } catch { } if ($terminated) { $process.WaitForExit() Write-NewText -Path $stdoutPath -Text $stdoutTask.GetAwaiter().GetResult() Write-NewText -Path $stderrPath -Text $stderrTask.GetAwaiter().GetResult() } return [ordered]@{ ok = $false timedOut = $true exitCode = $null parsed = $null error = "Node stage exceeded $StageTimeoutSeconds seconds. Outcome is unknown." stdoutPath = $stdoutPath stderrPath = $stderrPath } } $process.WaitForExit() $exitCode = [int]$process.ExitCode $stdout = $stdoutTask.GetAwaiter().GetResult() $stderr = $stderrTask.GetAwaiter().GetResult() Write-NewText -Path $stdoutPath -Text $stdout Write-NewText -Path $stderrPath -Text $stderr $stdout = $stdout.Trim() $stderr = $stderr.Trim() $parsed = $null $parseError = $null try { $candidate = if (-not [string]::IsNullOrWhiteSpace($stdout)) { $stdout } else { $stderr } if ([string]::IsNullOrWhiteSpace($candidate)) { throw 'Node stage returned no JSON.' } $parsed = $candidate | ConvertFrom-Json } catch { $parseError = $_.Exception.Message } $ok = $exitCode -eq 0 -and $null -ne $parsed -and $parsed.ok -eq $true return [ordered]@{ ok = $ok timedOut = $false exitCode = $exitCode parsed = $parsed error = $parseError stdoutPath = $stdoutPath stderrPath = $stderrPath } } finally { $process.Dispose() } } function Invoke-StaticAudit { $nodePath = Get-ResolvedNodeExecutable if (-not [IO.File]::Exists($script:NodeStageScript)) { throw "Node stage script is missing: $script:NodeStageScript" } $tokens = $null $errors = $null [Management.Automation.Language.Parser]::ParseFile($PSCommandPath, [ref]$tokens, [ref]$errors) | Out-Null if ($errors.Count -ne 0) { throw "PowerShell parser reported $($errors.Count) error(s): $($errors[0].Message)" } $nodeCheckOutput = @(& $nodePath --check $script:NodeStageScript 2>&1) if ($LASTEXITCODE -ne 0) { throw "Node syntax check failed: $($nodeCheckOutput -join [Environment]::NewLine)" } $nodeSource = [IO.File]::ReadAllText($script:NodeStageScript, [Text.Encoding]::UTF8) foreach ($required in @('OUTCOME_UNKNOWN', 'TARGET_IDENTITY_MISMATCH', 'take-in-30-fast', 'page-next-5077', 'take-out-recovery-5077-page2', 'build-s6001-asset-independent', 'take-in-s6001')) { if (-not $nodeSource.Contains($required)) { throw "Node stage script is missing required safety token: $required" } } $sourceRoot = [IO.Path]::GetFullPath($OriginalCutsDirectory) $cuts = @() foreach ($code in $script:RouteCodes) { $path = Join-Path $sourceRoot "$code.t2s" $cuts += Get-FileIdentity -Path $path } $assets = @() foreach ($relativePath in $script:S6001AssetRelativePaths) { $assets += Get-FileIdentity -Path (Join-Path $sourceRoot $relativePath) } $workflowCounts = [ordered]@{} foreach ($name in @('Core5001', 'ThirtyRoutes', 'PagedBoundary', 'S6001AssetIndependent', 'Recovery8001', 'Recovery5077Page2', 'ThirtyRoutes20To29')) { $workflowCounts[$name] = @(Get-WorkflowStages -Name $name).Count } return [ordered]@{ ok = $true audit = 'static-only' auditedAtUtc = [DateTime]::UtcNow.ToString('o') wrapper = Get-FileIdentity -Path $PSCommandPath node = Get-FileIdentity -Path $nodePath nodeStage = Get-FileIdentity -Path $script:NodeStageScript authoritativeCutCount = $cuts.Count # OrderedDictionary keys are not projected as ordinary properties by # Select-Object in Windows PowerShell 5.1. Preserve the validated # identity dictionaries directly so the evidence cannot silently emit # null paths or hashes. authoritativeCutHashes = @($cuts) authoritativeAssetCount = $assets.Count authoritativeAssetHashes = @($assets) workflowStageCounts = $workflowCounts runtimeProcessesQueried = $false cdpAttached = $false nativeMessagesSent = 0 } } function Resolve-EvidenceDirectory { if ([string]::IsNullOrWhiteSpace($EvidenceDirectory)) { throw "-EvidenceDirectory is required for action '$Action'." } return [IO.Path]::GetFullPath($EvidenceDirectory) } function Invoke-InitializeRun { $directory = Resolve-EvidenceDirectory if ([IO.Directory]::Exists($directory) -or [IO.File]::Exists($directory)) { throw "Initialization never overwrites an existing evidence path: $directory" } $pins = Get-CurrentPins [IO.Directory]::CreateDirectory($directory) | Out-Null $baselineDirectory = Join-Path $directory '0000-baseline' [IO.Directory]::CreateDirectory($baselineDirectory) | Out-Null $manifest = [ordered]@{ schemaVersion = 1 runId = [IO.Path]::GetFileName($directory) initializedAtUtc = [DateTime]::UtcNow.ToString('o') workflow = $Workflow debugPort = $DebugPort targetUrl = $script:TargetUrl discovery = [ordered]@{ packageName = $ExpectedPackageName pgmExecutable = [string]$pins.pgm.executable.path appWindowTitle = [string]$pins.app.window.title pgmWindowTitle = [string]$pins.pgm.window.title networkWindowTitle = [string]$pins.pgm.networkWindow.title originalCutsDirectory = [IO.Path]::GetFullPath($OriginalCutsDirectory) nodeExecutable = [string]$pins.tools.node.path pgmPort = [int]$pins.pgmListener.port } rules = @( 'One Node invocation dispatches at most one user-visible playout command.', 'No playout command is retried after timeout, OutcomeUnknown, or identity mismatch.', 'STOPPED.json permanently blocks later commands in this evidence directory.', 'Playlist rows are built in memory; no named playlist fixture is loaded.' ) expectedStages = Get-WorkflowStages -Name $Workflow pins = $pins baselineNetworkPath = Join-Path $baselineDirectory 'network-full.txt' } Write-NewJson -Path (Join-Path $directory 'manifest.json') -Value $manifest try { $capture = Capture-ReadOnlyEvidence -Pins $pins -OutputDirectory $baselineDirectory Assert-PinsExact -Pinned $pins | Out-Null Write-NewJson -Path (Join-Path $baselineDirectory 'SUCCESS.json') -Value ([ordered]@{ initializedAtUtc = [DateTime]::UtcNow.ToString('o') capture = $capture }) } catch { New-StopLatch -Directory $directory -Reason 'Initialization evidence or post-capture identity validation failed.' ` -StageDirectory $baselineDirectory -Details $_.Exception.Message throw } return $manifest } function Read-RunManifest { param([Parameter(Mandatory = $true)] [string] $Directory) $manifestPath = Join-Path $Directory 'manifest.json' if (-not [IO.File]::Exists($manifestPath)) { throw "Run manifest is missing: $manifestPath" } $manifest = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json if ($manifest.schemaVersion -ne 1 -or $manifest.targetUrl -cne $script:TargetUrl -or $null -eq $manifest.discovery) { throw 'Run manifest schema or target identity is unsupported.' } return $manifest } function Use-ManifestDiscoverySettings { param([Parameter(Mandatory = $true)] [object] $Manifest) $script:DebugPort = [int]$Manifest.debugPort $script:ExpectedPgmPort = [int]$Manifest.discovery.pgmPort $script:ExpectedPackageName = [string]$Manifest.discovery.packageName $script:ExpectedPgmExecutable = [string]$Manifest.discovery.pgmExecutable $script:ExpectedAppWindowTitle = [string]$Manifest.discovery.appWindowTitle $script:ExpectedPgmWindowTitle = [string]$Manifest.discovery.pgmWindowTitle $script:ExpectedNetworkWindowTitle = [string]$Manifest.discovery.networkWindowTitle $script:OriginalCutsDirectory = [string]$Manifest.discovery.originalCutsDirectory $script:NodeExecutable = [string]$Manifest.discovery.nodeExecutable } function Invoke-InspectRun { $directory = Resolve-EvidenceDirectory $manifest = Read-RunManifest -Directory $directory Use-ManifestDiscoverySettings -Manifest $manifest $current = Assert-PinsExact -Pinned $manifest.pins $completed = @(Get-SuccessfulStageRecords -Directory $directory) $expected = @(Get-WorkflowStages -Name ([string]$manifest.workflow)) Assert-CompletedStageSequence -Records $completed -ExpectedStages $expected return [ordered]@{ ok = $true action = 'Inspect' stopped = [IO.File]::Exists((Join-Path $directory 'STOPPED.json')) completedStageCount = $completed.Count totalStageCount = $expected.Count nextStage = $(if ($completed.Count -lt $expected.Count) { $expected[$completed.Count] } else { $null }) pins = $current inspectedAtUtc = [DateTime]::UtcNow.ToString('o') nativeMessagesSent = 0 } } function Invoke-RunStage { param([bool] $UseExpectedStage = $false) $directory = Resolve-EvidenceDirectory $stopPath = Join-Path $directory 'STOPPED.json' if ([IO.File]::Exists($stopPath)) { throw "This evidence run is permanently stopped. Inspect: $stopPath" } $manifest = Read-RunManifest -Directory $directory Use-ManifestDiscoverySettings -Manifest $manifest if (-not $UseExpectedStage -and [string]::IsNullOrWhiteSpace($Stage)) { throw '-Stage is required for RunStage.' } $lockPath = Join-Path $directory 'RUNNING.lock' $lockStream = $null try { $lockStream = [IO.File]::Open($lockPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) } catch { throw "Another stage is running, or an interrupted lock requires inspection: $lockPath" } try { $completed = @(Get-SuccessfulStageRecords -Directory $directory) $expectedStages = @(Get-WorkflowStages -Name ([string]$manifest.workflow)) Assert-CompletedStageSequence -Records $completed -ExpectedStages $expectedStages if ($completed.Count -ge $expectedStages.Count) { throw 'All workflow stages are already complete.' } $expected = $expectedStages[$completed.Count] if (-not $UseExpectedStage) { $requestedRoute = if ($RouteIndex -ge 0) { $RouteIndex } else { $null } $requestedPage = if ($PageIndex -ge 0) { $PageIndex } else { $null } if ($Stage -cne $expected.action -or $requestedRoute -ne $expected.routeIndex -or $requestedPage -ne $expected.pageIndex) { throw "Out-of-order stage request. Expected action=$($expected.action), routeIndex=$($expected.routeIndex), pageIndex=$($expected.pageIndex)." } } $sequence = $completed.Count + 1 $safeStage = $expected.action -replace '[^a-z0-9-]', '-' $stageDirectory = Join-Path $directory ('{0:D4}-{1}-{2}' -f $sequence, $safeStage, [Guid]::NewGuid().ToString('N').Substring(0, 8)) [IO.Directory]::CreateDirectory($stageDirectory) | Out-Null Write-NewJson -Path (Join-Path $stageDirectory 'REQUEST.json') -Value ([ordered]@{ sequence = $sequence requestedAtUtc = [DateTime]::UtcNow.ToString('o') stage = $expected }) try { $beforePins = Assert-PinsExact -Pinned $manifest.pins Write-NewJson -Path (Join-Path $stageDirectory 'pins-before.json') -Value $beforePins } catch { New-StopLatch -Directory $directory -Reason 'Pinned runtime identity changed before stage dispatch.' ` -StageDirectory $stageDirectory -Details $_.Exception.Message throw } $nodeResult = Invoke-NodeStage -Manifest $manifest -ExpectedStage $expected -OutputDirectory $stageDirectory Write-NewJson -Path (Join-Path $stageDirectory 'node-result.json') -Value $nodeResult $postIdentityError = $null try { $afterPins = Assert-PinsExact -Pinned $manifest.pins Write-NewJson -Path (Join-Path $stageDirectory 'pins-after.json') -Value $afterPins } catch { $postIdentityError = $_.Exception.Message } $capture = $null $captureError = $null if ([string]::IsNullOrWhiteSpace($postIdentityError)) { try { $previousNetworkPath = Get-PreviousNetworkPath -Directory $directory # Exclude the current stage file if an earlier partial capture somehow exists. if ($previousNetworkPath -like "$stageDirectory*") { $previousCandidates = @(Get-ChildItem -LiteralPath $directory -Recurse -File -Filter 'network-full.txt' | Where-Object { $_.FullName -notlike "$stageDirectory*" } | Sort-Object FullName) $previousNetworkPath = if ($previousCandidates.Count -gt 0) { $previousCandidates[-1].FullName } else { '' } } $capture = Capture-ReadOnlyEvidence -Pins $manifest.pins -OutputDirectory $stageDirectory ` -PreviousNetworkPath $previousNetworkPath $afterCapturePins = Assert-PinsExact -Pinned $manifest.pins Write-NewJson -Path (Join-Path $stageDirectory 'pins-after-capture.json') -Value $afterCapturePins } catch { $captureError = $_.Exception.Message } } else { $captureError = 'Capture was skipped because the pinned runtime identity had already changed.' } $failureReasons = @() if (-not $nodeResult.ok) { $failureReasons += 'Node one-shot stage failed, timed out, or returned invalid JSON.' } if (-not [string]::IsNullOrWhiteSpace($postIdentityError)) { $failureReasons += 'Pinned runtime identity changed after stage execution.' } if (-not [string]::IsNullOrWhiteSpace($captureError)) { $failureReasons += 'Read-only evidence capture or rolling Network anchor validation failed.' } if ($failureReasons.Count -gt 0) { $failure = [ordered]@{ failedAtUtc = [DateTime]::UtcNow.ToString('o') reasons = $failureReasons node = $nodeResult postIdentityError = $postIdentityError captureError = $captureError capture = $capture } Write-NewJson -Path (Join-Path $stageDirectory 'FAILURE.json') -Value $failure New-StopLatch -Directory $directory -Reason ($failureReasons -join ' ') ` -StageDirectory $stageDirectory -Details $failure throw "Stage failed and the run is now stopped: $($failureReasons -join ' ')" } $success = [ordered]@{ schemaVersion = 1 sequence = $sequence completedAtUtc = [DateTime]::UtcNow.ToString('o') stage = $expected node = $nodeResult.parsed capture = $capture } Write-NewJson -Path (Join-Path $stageDirectory 'SUCCESS.json') -Value $success return $success } finally { if ($null -ne $lockStream) { $lockStream.Dispose() } if ([IO.File]::Exists($lockPath)) { [IO.File]::Delete($lockPath) } } } switch ($Action) { 'StaticAudit' { Invoke-StaticAudit | ConvertTo-Json -Depth 40 -Compress } 'DryPreflight' { $pins = Get-CurrentPins [ordered]@{ ok = $true action = 'DryPreflight' inspectedAtUtc = [DateTime]::UtcNow.ToString('o') pins = $pins evidenceWritten = $false cdpAttached = $false nativeMessagesSent = 0 } | ConvertTo-Json -Depth 40 -Compress } 'Initialize' { Invoke-InitializeRun | ConvertTo-Json -Depth 40 -Compress } 'Inspect' { Invoke-InspectRun | ConvertTo-Json -Depth 40 -Compress } 'RunStage' { Invoke-RunStage -UseExpectedStage $false | ConvertTo-Json -Depth 40 -Compress } 'RunNext' { Invoke-RunStage -UseExpectedStage $true | ConvertTo-Json -Depth 40 -Compress } default { throw "Unsupported action: $Action" } }