#requires -Version 7.0 [CmdletBinding()] param( [ValidateSet("Pin", "Baseline", "Candidate")] [string]$Mode = "Pin", [string]$CandidateVersion = "2.0.0", [switch]$KeepTemporaryCopy ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $stableVersion = "1.4.1" $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path $projectPath = Join-Path $repoRoot "backend\Nexus.Api.csproj" $testProjectPath = Join-Path $repoRoot "backend-tests\Nexus.Api.Tests.csproj" $projectHashBefore = (Get-FileHash -LiteralPath $projectPath -Algorithm SHA256).Hash $temporaryRoot = $null $dotnetExe = $null function Get-McpPackageVersion { param([Parameter(Mandatory)][string]$Path) [xml]$project = Get-Content -LiteralPath $Path -Raw $reference = @( @($project.Project.ItemGroup.PackageReference) | Where-Object { $_.Include -eq "ModelContextProtocol.AspNetCore" } ) if ($reference.Count -ne 1) { throw "Expected exactly one ModelContextProtocol.AspNetCore reference in $Path." } return $reference[0].GetAttribute("Version") } function Assert-StaticGate { $version = Get-McpPackageVersion -Path $projectPath if ($version -ne $stableVersion) { throw "MCP production pin must remain $stableVersion; found $version." } $testFiles = @( "backend-tests\McpServerConfigurationTests.cs", "backend-tests\McpToolsTests.cs", "backend-tests\AgentProposalServiceTests.cs" ) foreach ($relativePath in $testFiles) { $path = Join-Path $repoRoot $relativePath if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Required MCP compatibility coverage is missing: $relativePath" } } $toolSource = Get-Content -LiteralPath (Join-Path $repoRoot "backend\Services\NexusMcpTools.cs") -Raw foreach ($marker in @( "nexus_propose_agent", "nexus_get_agent_proposal", "UseStructuredContent = true", "OutputSchemaType = typeof(AgentProposalToolResult)", "Idempotent = true", "OpenWorld = false" )) { if (-not $toolSource.Contains($marker, [StringComparison]::Ordinal)) { throw "Required MCP contract marker is missing: $marker" } } } function Resolve-DotNet10 { $candidates = @() $pathDotnet = Get-Command dotnet -ErrorAction SilentlyContinue if ($pathDotnet) { $candidates += $pathDotnet.Source } if (-not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) { $candidates += (Join-Path $env:USERPROFILE ".dotnet\dotnet.exe") } foreach ($candidate in $candidates | Select-Object -Unique) { if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue } $sdks = & $candidate --list-sdks if ($LASTEXITCODE -eq 0 -and ($sdks | Where-Object { $_ -match "^10\." })) { return (Resolve-Path -LiteralPath $candidate).Path } } throw ".NET SDK 10 is required for Baseline and Candidate modes. Add it to PATH or install it under USERPROFILE\\.dotnet." } function Invoke-DotNetTest { param( [Parameter(Mandatory)][string]$Project, [Parameter(Mandatory)][string]$WorkingDirectory ) Push-Location $WorkingDirectory try { & $script:dotnetExe test $Project ` --configuration Release ` --verbosity minimal if ($LASTEXITCODE -ne 0) { throw "dotnet test failed for $Project." } } finally { Pop-Location } } function Copy-SourceTree { param( [Parameter(Mandatory)][string]$Source, [Parameter(Mandatory)][string]$Destination ) New-Item -ItemType Directory -Path $Destination -Force | Out-Null $sourceRoot = [System.IO.Path]::GetFullPath($Source) Get-ChildItem -LiteralPath $sourceRoot -Recurse -File | Where-Object { $relative = [System.IO.Path]::GetRelativePath($sourceRoot, $_.FullName) $segments = $relative -split "[\\/]" -not ($segments -contains "bin" -or $segments -contains "obj") } | ForEach-Object { $relative = [System.IO.Path]::GetRelativePath($sourceRoot, $_.FullName) $target = Join-Path $Destination $relative $targetDirectory = Split-Path -Parent $target New-Item -ItemType Directory -Path $targetDirectory -Force | Out-Null Copy-Item -LiteralPath $_.FullName -Destination $target -Force } } function Set-CandidatePackageVersion { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Version ) [xml]$project = Get-Content -LiteralPath $Path -Raw $reference = @( @($project.Project.ItemGroup.PackageReference) | Where-Object { $_.Include -eq "ModelContextProtocol.AspNetCore" } ) if ($reference.Count -ne 1) { throw "Candidate copy does not contain exactly one MCP package reference." } $reference[0].SetAttribute("Version", $Version) $project.Save($Path) } try { Assert-StaticGate Write-Host "PASS: MCP production dependency is pinned to $stableVersion and required source/test markers are present." Write-Host "Static Pin mode did not execute tests or a live MCP/OpenClaw negotiation." if ($Mode -eq "Pin") { return } $dotnetExe = Resolve-DotNet10 # MSBuild launches helper applications (for example the OpenAPI document # generator) through `dotnet`, not through the absolute executable used by # this script. Keep those child processes on the same .NET 10 runtime. $dotnetRoot = Split-Path -Parent $dotnetExe $env:DOTNET_ROOT = $dotnetRoot $pathSeparator = [System.IO.Path]::PathSeparator if (-not (($env:PATH -split [Regex]::Escape([string]$pathSeparator)) -contains $dotnetRoot)) { $env:PATH = "$dotnetRoot$pathSeparator$env:PATH" } Invoke-DotNetTest -Project $testProjectPath -WorkingDirectory $repoRoot Write-Host "PASS: MCP $stableVersion baseline command completed successfully." Write-Warning "Environment-gated Docker, Toxiproxy, or live OpenClaw tests may still be skipped; inspect the test summary before treating this as full compatibility evidence." if ($Mode -eq "Baseline") { return } if ($CandidateVersion -notmatch "^2\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$") { throw "CandidateVersion must be an explicit MCP 2.x package version." } $temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) "nexus-mcp2-gate-$([Guid]::NewGuid().ToString('N'))" New-Item -ItemType Directory -Path $temporaryRoot -Force | Out-Null Copy-SourceTree -Source (Join-Path $repoRoot "backend") -Destination (Join-Path $temporaryRoot "backend") Copy-SourceTree -Source (Join-Path $repoRoot "backend-tests") -Destination (Join-Path $temporaryRoot "backend-tests") $candidateProject = Join-Path $temporaryRoot "backend\Nexus.Api.csproj" $candidateTests = Join-Path $temporaryRoot "backend-tests\Nexus.Api.Tests.csproj" Set-CandidatePackageVersion -Path $candidateProject -Version $CandidateVersion & $dotnetExe restore $candidateTests --force-evaluate if ($LASTEXITCODE -ne 0) { throw "MCP $CandidateVersion restore failed in the isolated copy." } $assetsPath = Join-Path $temporaryRoot "backend\obj\project.assets.json" if (-not (Test-Path -LiteralPath $assetsPath -PathType Leaf)) { throw "Candidate restore did not create project.assets.json." } $resolvedMarker = "`"ModelContextProtocol.AspNetCore/$CandidateVersion`"" $assets = Get-Content -LiteralPath $assetsPath -Raw if (-not $assets.Contains($resolvedMarker, [StringComparison]::OrdinalIgnoreCase)) { throw "Restore did not resolve the exact requested candidate $CandidateVersion." } Invoke-DotNetTest -Project $candidateTests -WorkingDirectory $temporaryRoot if ($CandidateVersion.Contains("-", [StringComparison]::Ordinal)) { Write-Warning "COMPATIBILITY PROBE PASS: $CandidateVersion compiled and passed all backend tests." Write-Warning "PROMOTION GATE CLOSED: prerelease packages are not approved for production. The repository remains on $stableVersion." } else { Write-Host "SDK COMPATIBILITY PROBE PASS: stable candidate $CandidateVersion compiled and passed the backend suite." Write-Warning "LIVE OPENCLAW GATE CLOSED: this probe does not test OpenClaw tools/list, client identity, Streamable HTTP, or protocol down-level negotiation." Write-Host "The repository was not modified; live isolated OpenClaw compatibility evidence and a separate reviewed dependency change are still required." } } finally { $projectHashAfter = (Get-FileHash -LiteralPath $projectPath -Algorithm SHA256).Hash if ($projectHashAfter -ne $projectHashBefore) { throw "Safety invariant violated: $projectPath changed during the compatibility gate." } if ($temporaryRoot -and (Test-Path -LiteralPath $temporaryRoot)) { if ($KeepTemporaryCopy) { Write-Host "Temporary candidate copy retained at $temporaryRoot" } else { $resolvedTemporaryRoot = [System.IO.Path]::GetFullPath($temporaryRoot) $systemTemporaryRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()) if ( -not $resolvedTemporaryRoot.StartsWith($systemTemporaryRoot, [StringComparison]::OrdinalIgnoreCase) -or -not ([System.IO.Path]::GetFileName($resolvedTemporaryRoot)).StartsWith( "nexus-mcp2-gate-", [StringComparison]::Ordinal) ) { throw "Refusing to remove an unverified temporary path: $resolvedTemporaryRoot" } Remove-Item -LiteralPath $resolvedTemporaryRoot -Recurse -Force } } }