param( [Parameter(Mandatory = $true)] [string]$EnvFile, [string]$Workspace = "D:\workspace\vignette", [string]$ListenAddress = "127.0.0.1", [ValidateRange(1024, 65535)] [int]$Port = 9100, [string]$Python = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe", [Parameter(Mandatory = $true)] [ValidatePattern("^[0-9a-fA-F]{40}$")] [string]$ExpectedCommit, [Parameter(Mandatory = $true)] [ValidatePattern("^[0-9a-fA-F]{40}$")] [string]$ExpectedTree, [Parameter(Mandatory = $true)] [ValidatePattern("^[0-9a-fA-F]{64}$")] [string]$ExpectedScriptSha256, [Parameter(Mandatory = $true)] [ValidatePattern("^[0-9a-fA-F]{64}$")] [string]$ExpectedPythonSha256, [Parameter(Mandatory = $true)] [ValidatePattern("^[0-9a-fA-F]{64}$")] [string]$ExpectedEnvSha256, [Parameter(Mandatory = $true)] [string]$ExpectedConsumerEngineUrl, [string]$RuntimeStateDir = "$env:LOCALAPPDATA\Temp\vignette-nas-preview", [string]$ReceiptPath = "", [ValidateRange(10, 300)] [int]$ReadyTimeoutSec = 120, [switch]$ConfirmNasPreviewLanExposure, [switch]$CheckOnly ) $ErrorActionPreference = "Stop" [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) $OutputEncoding = [System.Text.UTF8Encoding]::new($false) function Get-Sha256Lower { param([Parameter(Mandatory = $true)][string]$Path) $stream = [IO.File]::Open( $Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read ) $sha = [Security.Cryptography.SHA256]::Create() try { $bytes = $sha.ComputeHash($stream) } finally { $sha.Dispose() $stream.Dispose() } return (($bytes | ForEach-Object { $_.ToString("x2") }) -join "") } function Get-EnvValue { param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$Key ) $values = @() foreach ($line in Get-Content -LiteralPath $Path -Encoding UTF8) { if ($line -match "^\s*$([Regex]::Escape($Key))\s*=(.*)$") { $values += $Matches[1].Trim().Trim('"').Trim("'") } } if ($values.Count -ne 1) { throw "$Key must appear exactly once in the NAS preview env file" } return $values[0] } function Invoke-GitText { param( [Parameter(Mandatory = $true)][string]$Repository, [Parameter(Mandatory = $true)][string[]]$GitArguments ) $output = @(& git -C $Repository @GitArguments) $exitCode = $LASTEXITCODE if ($exitCode -ne 0) { throw "git command failed with exit ${exitCode}: $($GitArguments -join ' ')" } return (($output | ForEach-Object { "$_" }) -join "`n").Trim() } function Test-IsPathWithin { param( [Parameter(Mandatory = $true)][string]$Candidate, [Parameter(Mandatory = $true)][string]$Parent ) $candidateFull = [IO.Path]::GetFullPath($Candidate).TrimEnd('\') $parentFull = [IO.Path]::GetFullPath($Parent).TrimEnd('\') if ($candidateFull.Equals($parentFull, [StringComparison]::OrdinalIgnoreCase)) { return $true } return $candidateFull.StartsWith( $parentFull + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase ) } function Assert-NoReparseAncestor { param([Parameter(Mandatory = $true)][string]$Path) $full = [IO.Path]::GetFullPath($Path).TrimEnd('\') $root = [IO.Path]::GetPathRoot($full) $current = $root $relative = $full.Substring($root.Length) foreach ($segment in $relative.Split([char[]]@('\'), [StringSplitOptions]::RemoveEmptyEntries)) { $current = Join-Path $current $segment $item = Get-Item -LiteralPath $current -Force if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "External state path contains a reparse-point ancestor: $current" } } } function Get-ListenerOwners { param([Parameter(Mandatory = $true)][int]$LocalPort) return @( Get-NetTCPConnection -State Listen -LocalPort $LocalPort -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique ) } function Stop-OwnedGateway { param( [Parameter(Mandatory = $true)][System.Diagnostics.Process]$OwnedProcess, [Parameter(Mandatory = $true)][int]$LocalPort ) $OwnedProcess.Refresh() if (!$OwnedProcess.HasExited) { Stop-Process -Id $OwnedProcess.Id -Force -ErrorAction SilentlyContinue try { $OwnedProcess.WaitForExit(10000) | Out-Null } catch { # The exact process may have exited between Refresh and WaitForExit. } } $deadline = (Get-Date).AddSeconds(10) do { $owners = @(Get-ListenerOwners -LocalPort $LocalPort) if ($owners -notcontains $OwnedProcess.Id) { return } Start-Sleep -Milliseconds 200 } while ((Get-Date) -lt $deadline) throw "Failed to remove the owned gateway listener for PID $($OwnedProcess.Id)" } function Write-AtomicReceipt { param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$Json ) if (Test-Path -LiteralPath $Path) { throw "Receipt already exists: $Path" } $temporary = "$Path.tmp.$([Guid]::NewGuid().ToString('N'))" $utf8 = [Text.UTF8Encoding]::new($false) try { [IO.File]::WriteAllText($temporary, $Json, $utf8) $stream = [IO.File]::Open( $temporary, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None ) try { $stream.Flush($true) } finally { $stream.Dispose() } [IO.File]::Move($temporary, $Path) } finally { if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Force } } } if (!(Get-Command git -ErrorAction SilentlyContinue)) { throw "git is required for source provenance verification" } if (!(Test-Path -LiteralPath $Workspace -PathType Container)) { throw "Workspace directory not found: $Workspace" } if (!(Test-Path -LiteralPath $EnvFile -PathType Leaf)) { throw "NAS preview env file not found: $EnvFile" } if (!(Test-Path -LiteralPath $Python -PathType Leaf)) { throw "Python 3.11 not found: $Python" } if (!(Test-Path -LiteralPath $RuntimeStateDir -PathType Container) -and $CheckOnly) { # Check-only is mutation-free; the actual start may create its external state directory. $runtimeParent = Split-Path -Parent $RuntimeStateDir if (!(Test-Path -LiteralPath $runtimeParent -PathType Container)) { throw "Runtime state parent directory not found: $runtimeParent" } } $workspaceResolved = (Resolve-Path -LiteralPath $Workspace).Path.TrimEnd('\') $envResolved = (Resolve-Path -LiteralPath $EnvFile).Path $pythonResolved = (Resolve-Path -LiteralPath $Python).Path $scriptResolved = (Resolve-Path -LiteralPath $PSCommandPath).Path $expectedScriptPath = (Join-Path $workspaceResolved "scripts\start-nas-preview-engine.ps1") if (!$scriptResolved.Equals($expectedScriptPath, [StringComparison]::OrdinalIgnoreCase)) { throw "The engine launcher must run from the pinned workspace" } $repositoryTop = Invoke-GitText -Repository $workspaceResolved -GitArguments @( "rev-parse", "--show-toplevel" ) $repositoryTopResolved = (Resolve-Path -LiteralPath $repositoryTop).Path.TrimEnd('\') if (!$repositoryTopResolved.Equals($workspaceResolved, [StringComparison]::OrdinalIgnoreCase)) { throw "Workspace must be the exact Git toplevel" } $symbolicRef = @(& git -C $workspaceResolved symbolic-ref -q HEAD) $symbolicExit = $LASTEXITCODE if ($symbolicExit -eq 0) { throw "NAS engine source must be a detached HEAD" } if ($symbolicExit -ne 1) { throw "Unable to verify detached HEAD state" } $dirty = Invoke-GitText -Repository $workspaceResolved -GitArguments @( "status", "--porcelain=v1", "--untracked-files=normal" ) if ($dirty) { throw "NAS engine source must be tracked-clean and untracked-clean" } $actualCommit = Invoke-GitText -Repository $workspaceResolved -GitArguments @( "rev-parse", "HEAD" ) $actualTree = Invoke-GitText -Repository $workspaceResolved -GitArguments @( "rev-parse", "HEAD^{tree}" ) if (!$actualCommit.Equals($ExpectedCommit, [StringComparison]::OrdinalIgnoreCase)) { throw "Source commit does not match ExpectedCommit" } if (!$actualTree.Equals($ExpectedTree, [StringComparison]::OrdinalIgnoreCase)) { throw "Source tree does not match ExpectedTree" } $actualScriptSha = Get-Sha256Lower -Path $scriptResolved $actualPythonSha = Get-Sha256Lower -Path $pythonResolved $actualEnvSha = Get-Sha256Lower -Path $envResolved if (!$actualScriptSha.Equals($ExpectedScriptSha256, [StringComparison]::OrdinalIgnoreCase)) { throw "Launcher SHA-256 does not match ExpectedScriptSha256" } if (!$actualPythonSha.Equals($ExpectedPythonSha256, [StringComparison]::OrdinalIgnoreCase)) { throw "Python SHA-256 does not match ExpectedPythonSha256" } if (!$actualEnvSha.Equals($ExpectedEnvSha256, [StringComparison]::OrdinalIgnoreCase)) { throw "Env SHA-256 does not match ExpectedEnvSha256" } $apiDir = Join-Path $workspaceResolved "apps\api" if (!(Test-Path -LiteralPath $apiDir -PathType Container)) { throw "API directory not found: $apiDir" } $parsedAddress = $null if (![Net.IPAddress]::TryParse($ListenAddress, [ref]$parsedAddress)) { throw "ListenAddress must be a literal IP address" } if ($parsedAddress.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) { throw "Only an explicit IPv4 ListenAddress is supported" } if ($parsedAddress.Equals([Net.IPAddress]::Any) -or $ListenAddress -eq "255.255.255.255") { throw "Wildcard and broadcast listen addresses are forbidden" } $isLoopback = [Net.IPAddress]::IsLoopback($parsedAddress) if (!$isLoopback) { if (!$ConfirmNasPreviewLanExposure) { throw "Non-loopback binding requires -ConfirmNasPreviewLanExposure" } $assigned = @( Get-NetIPAddress -AddressFamily IPv4 -AddressState Preferred -ErrorAction Stop | Where-Object { $_.IPAddress -eq $ListenAddress } ) if ($assigned.Count -ne 1) { throw "ListenAddress must be assigned exactly once on this host" } } try { $consumerUri = [Uri]$ExpectedConsumerEngineUrl } catch { throw "ExpectedConsumerEngineUrl must be an absolute HTTP URL" } if (!$consumerUri.IsAbsoluteUri -or $consumerUri.Scheme -ne "http") { throw "ExpectedConsumerEngineUrl must use http" } if ($consumerUri.Host -ne $ListenAddress -or $consumerUri.Port -ne $Port) { throw "ExpectedConsumerEngineUrl must match ListenAddress and Port exactly" } if ($consumerUri.AbsolutePath -ne "/" -or $consumerUri.Query -or $consumerUri.Fragment) { throw "ExpectedConsumerEngineUrl must not contain a path, query, or fragment" } $runtimeFull = [IO.Path]::GetFullPath($RuntimeStateDir) $runtimeLeaf = Split-Path -Leaf $runtimeFull $runtimeParent = Split-Path -Parent $runtimeFull if (!$runtimeLeaf -or $runtimeLeaf -match '[:\\/]') { throw "RuntimeStateDir must end in a plain directory name" } if (!(Test-Path -LiteralPath $runtimeParent -PathType Container)) { throw "Runtime state parent directory must be pre-provisioned: $runtimeParent" } Assert-NoReparseAncestor -Path $runtimeParent $runtimeParentResolved = (Resolve-Path -LiteralPath $runtimeParent).Path $runtimeTarget = Join-Path $runtimeParentResolved $runtimeLeaf if (Test-IsPathWithin -Candidate $runtimeTarget -Parent $workspaceResolved) { throw "RuntimeStateDir must resolve outside the source workspace" } if ($ReceiptPath) { $receiptRequestedFull = [IO.Path]::GetFullPath($ReceiptPath) $receiptLeaf = Split-Path -Leaf $receiptRequestedFull if (!$receiptLeaf -or $receiptLeaf -match '[:\\/]') { throw "ReceiptPath must end in a plain file name" } $receiptParent = Split-Path -Parent $receiptRequestedFull if (!(Test-Path -LiteralPath $receiptParent -PathType Container)) { throw "Receipt parent directory must be pre-provisioned: $receiptParent" } Assert-NoReparseAncestor -Path $receiptParent $receiptParentResolved = (Resolve-Path -LiteralPath $receiptParent).Path $receiptFull = Join-Path $receiptParentResolved $receiptLeaf if (Test-IsPathWithin -Candidate $receiptFull -Parent $workspaceResolved) { throw "ReceiptPath must resolve outside the source workspace" } if (Test-Path -LiteralPath $receiptFull) { throw "Receipt already exists: $receiptFull" } } elseif (!$CheckOnly) { throw "ReceiptPath is required for an actual start" } $secret = Get-EnvValue -Path $envResolved -Key "ENGINE_GATEWAY_SHARED_SECRET" if ($secret.Length -lt 32 -or $secret.ToLowerInvariant().StartsWith("change-me")) { throw "ENGINE_GATEWAY_SHARED_SECRET must be a non-placeholder value of at least 32 characters" } $listenerOwners = @(Get-ListenerOwners -LocalPort $Port) if ($listenerOwners.Count -gt 0) { throw "Port $Port already has a listener; silent reuse is forbidden" } $binding = [ordered]@{ source_commit = $actualCommit source_tree = $actualTree launcher_sha256 = $actualScriptSha python_sha256 = $actualPythonSha env_sha256 = $actualEnvSha listen_address = $ListenAddress port = $Port consumer_engine_url = $ExpectedConsumerEngineUrl secret_value_emitted = $false } if ($CheckOnly) { [ordered]@{ schema = "vignette.nas_preview_engine_preflight.v1" status = "preflight_passed" mutation = $false binding = $binding } | ConvertTo-Json -Depth 5 exit 0 } if (!(Test-Path -LiteralPath $runtimeTarget -PathType Container)) { New-Item -ItemType Directory -Path $runtimeTarget | Out-Null } $runtimeResolved = (Resolve-Path -LiteralPath $runtimeTarget).Path if (Test-IsPathWithin -Candidate $runtimeResolved -Parent $workspaceResolved) { throw "Resolved RuntimeStateDir must remain outside the source workspace" } # Prove receipt write/flush/delete before the process mutation. $receiptProbe = Join-Path (Split-Path -Parent $receiptFull) ( ".vignette-nas-engine-receipt-probe-$([Guid]::NewGuid().ToString('N'))" ) try { [IO.File]::WriteAllText($receiptProbe, "probe", [Text.UTF8Encoding]::new($false)) $probeStream = [IO.File]::Open( $receiptProbe, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None ) try { $probeStream.Flush($true) } finally { $probeStream.Dispose() } } finally { if (Test-Path -LiteralPath $receiptProbe) { Remove-Item -LiteralPath $receiptProbe -Force } } $stamp = [DateTime]::UtcNow.ToString("yyyyMMddTHHmmssZ") $outLog = Join-Path $runtimeResolved "engine-$Port-$stamp.out.log" $errLog = Join-Path $runtimeResolved "engine-$Port-$stamp.err.log" $gatewayProcess = $null $hadPriorSecret = Test-Path Env:ENGINE_GATEWAY_SHARED_SECRET $priorSecret = $env:ENGINE_GATEWAY_SHARED_SECRET $hadPriorPythonUtf8 = Test-Path Env:PYTHONUTF8 $priorPythonUtf8 = $env:PYTHONUTF8 try { try { $env:ENGINE_GATEWAY_SHARED_SECRET = $secret $env:PYTHONUTF8 = "1" $gatewayProcess = Start-Process -WindowStyle Hidden -FilePath $pythonResolved ` -ArgumentList @( "-X", "utf8", "-m", "uvicorn", "engine_gateway.gateway:app", "--host", $ListenAddress, "--port", "$Port" ) ` -WorkingDirectory $apiDir ` -RedirectStandardOutput $outLog ` -RedirectStandardError $errLog ` -PassThru } finally { if ($hadPriorSecret) { $env:ENGINE_GATEWAY_SHARED_SECRET = $priorSecret } else { Remove-Item Env:ENGINE_GATEWAY_SHARED_SECRET -ErrorAction SilentlyContinue } if ($hadPriorPythonUtf8) { $env:PYTHONUTF8 = $priorPythonUtf8 } else { Remove-Item Env:PYTHONUTF8 -ErrorAction SilentlyContinue } } $headers = @{ "X-Vignette-Engine-Token" = $secret } $health = $null $ready = $null $deadline = (Get-Date).AddSeconds($ReadyTimeoutSec) do { Start-Sleep -Milliseconds 500 $gatewayProcess.Refresh() if ($gatewayProcess.HasExited) { throw "Preview engine gateway exited before readiness with code $($gatewayProcess.ExitCode)" } try { $health = Invoke-RestMethod -Uri "http://$ListenAddress`:$Port/health" -TimeoutSec 2 } catch { $health = $null } } while (($null -eq $health -or !$health.ok) -and (Get-Date) -lt $deadline) if ($null -eq $health -or !$health.ok) { throw "Preview engine gateway liveness did not pass" } # force=true performs a real provider generation. Call it exactly once after # liveness is established so a failing provider cannot trigger a retry storm. try { $ready = Invoke-RestMethod ` -Uri "http://$ListenAddress`:$Port/ready?force=true" ` -Headers $headers ` -TimeoutSec $ReadyTimeoutSec } catch { $ready = $null } if ($null -eq $ready -or !$ready.ok) { throw "Preview engine gateway generation readiness did not pass" } $owners = @(Get-ListenerOwners -LocalPort $Port) if ($owners.Count -ne 1 -or $owners[0] -ne $gatewayProcess.Id) { throw "Gateway listener owner does not match the started process" } $receipt = [ordered]@{ schema = "vignette.nas_preview_engine_launch_receipt.v1" status = "passed" started_at = [DateTime]::UtcNow.ToString("o") pid = $gatewayProcess.Id binding = $binding checks = [ordered]@{ source_detached_clean = $true port_previously_unused = $true listener_owner_matches = $true liveness_ok = $true generation_ready = $true } logs = [ordered]@{ stdout = $outLog stderr = $errLog } } Write-AtomicReceipt ` -Path $receiptFull ` -Json ($receipt | ConvertTo-Json -Depth 6) Write-Output "Preview engine gateway PID=$($gatewayProcess.Id) port=$Port" Write-Output "GenerationReady=true" Write-Output "SecretValueEmitted=false" Write-Output "Receipt=$receiptFull" Write-Output "StdoutLog=$outLog" Write-Output "StderrLog=$errLog" } catch { if ($null -ne $gatewayProcess) { Stop-OwnedGateway -OwnedProcess $gatewayProcess -LocalPort $Port } throw }