241 lines
9.7 KiB
PowerShell
241 lines
9.7 KiB
PowerShell
#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
|
|
}
|
|
}
|
|
}
|