diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 63f9cb20..2e2fb7a4 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -150,6 +150,10 @@ jobs: shell: pwsh run: .\scripts\Test-NodeRuntimeInputs.Tests.ps1 + - name: Test MXC packaging inputs + shell: pwsh + run: .\scripts\Test-Get-MxcRuntime.Tests.ps1 + - name: Test signing workflow configuration shell: pwsh run: > diff --git a/.gitignore b/.gitignore index 74472608..75876857 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ uninstall-validation-output/ # Generated payloads and build output content/openclaw/* !content/openclaw/.gitkeep +content/mxc/ +content/session-host/ artifacts/ test-signed/ bin/ diff --git a/mxc-runtime.lock.json b/mxc-runtime.lock.json new file mode 100644 index 00000000..ced5ef3b --- /dev/null +++ b/mxc-runtime.lock.json @@ -0,0 +1,65 @@ +{ + "$comment": [ + "Pinned MXC native runtime. This is a release trust-chain input: the", + "archive integrity, the per-file SHA-256 values, and the PE machine type", + "are all verified before any file is staged into the package.", + "", + "This pin is temporary. It is replaced by the official Microsoft.Mxc.Sdk", + ".NET package when that package is published; see docs/mxc-runtime.md.", + "", + "'wireSchemaVersion' is the state-aware envelope schema, which is", + "versioned independently of the npm package version. Both are pinned." + ], + "package": "@microsoft/mxc-sdk", + "version": "0.8.0", + "wireSchemaVersion": "0.6.0-alpha", + "minimumWindowsBuild": "26340.9212", + "tarballUrl": "https://registry.npmjs.org/@microsoft/mxc-sdk/-/mxc-sdk-0.8.0.tgz", + "tarballIntegrity": "sha512-pnf5QsASwp+qtRi5uth2GDjwuyG0rHWRpxCf3RbAjQ4wDTNfBX/9l0A+RVZspU2agpF3/11uWB1JisIS7WrNYg==", + "architectures": { + "x64": { + "files": [ + { + "archivePath": "package/bin/x64/wxc-exec.exe", + "stagedPath": "wxc-exec.exe", + "length": 9478968, + "sha256": "6049c64723af1173c3739dc6cd6b2f33f6c021bb2832c4216233cba7f71aee9a", + "peMachine": "0x8664" + }, + { + "archivePath": "package/bin/x64/plm.exe", + "stagedPath": "plm.exe", + "length": 2106688, + "sha256": "f0ddecb7a1097e5108a14fcfc609dea692733d77f9f8e2435b05085b0b7b25f0", + "peMachine": "0x8664" + } + ] + }, + "arm64": { + "files": [ + { + "archivePath": "package/bin/arm64/wxc-exec.exe", + "stagedPath": "wxc-exec.exe", + "length": 4836152, + "sha256": "dde1c592270e9a659b01dccad70362da7b99fec114885fa4d625507aa775a503", + "peMachine": "0xaa64" + }, + { + "archivePath": "package/bin/arm64/plm.exe", + "stagedPath": "plm.exe", + "length": 1857848, + "sha256": "f8060c7d463479f6e64da7f1e082553113ca0a9c1c39347ae513eca4a0439dfd", + "peMachine": "0xaa64" + } + ] + } + }, + "licenseFiles": [ + { + "archivePath": "package/LICENSE.md", + "stagedPath": "LICENSE.md", + "length": 1140, + "sha256": "d9a1b1e30d633d5732ea18e3cba9538d293ebc53e1a9e4e96ab739e0c5c4f1cb" + } + ] +} diff --git a/scripts/Build-MSIX.ps1 b/scripts/Build-MSIX.ps1 index 62da6fa1..e00225b7 100644 --- a/scripts/Build-MSIX.ps1 +++ b/scripts/Build-MSIX.ps1 @@ -312,6 +312,40 @@ $payloadInventoryPath = Join-Path $openClawContent 'payload-files.json' ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $payloadInventoryPath -Encoding utf8 +& (Join-Path $PSScriptRoot 'Get-MxcRuntime.ps1') ` + -Architecture $Architecture + +$mxcRuntimeDirectory = Join-Path $repositoryRoot "content\mxc\$Architecture" +$mxcRuntimeFiles = @( + Get-ChildItem -LiteralPath $mxcRuntimeDirectory -File -Force -Recurse | + ForEach-Object { + [ordered]@{ + path = ( + [IO.Path]::GetRelativePath( + $mxcRuntimeDirectory, + $_.FullName + ) + ).Replace('\', '/') + length = $_.Length + sha256 = ( + Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256 + ).Hash.ToLowerInvariant() + } + } | + Sort-Object path +) +if ( + $mxcRuntimeFiles.Count -eq 0 -or + -not ($mxcRuntimeFiles.path -contains 'wxc-exec.exe') -or + -not ($mxcRuntimeFiles.path -contains 'mxc-runtime.json') +) { + throw "The staged $Architecture MXC runtime is incomplete." +} +$mxcProvenance = Get-Content ` + -LiteralPath (Join-Path $mxcRuntimeDirectory 'mxc-runtime.json') ` + -Raw | + ConvertFrom-Json + $temporaryRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } @@ -402,6 +436,14 @@ try { Hash = $nodeArchiveHash } ) + foreach ($mxcFile in $mxcRuntimeFiles) { + $expectedPackageFiles.Add( + "mxc/$Architecture/$($mxcFile.path)", + [pscustomobject]@{ + Hash = $mxcFile.sha256 + } + ) + } $packageEntries = [System.Collections.Generic.HashSet[string]]::new( [System.StringComparer]::OrdinalIgnoreCase ) @@ -544,6 +586,8 @@ try { nodeRuntimeVersion = $nodeVersion nodeRuntimeArchive = $expectedNodeArchiveName nodeRuntimeSha256 = $nodeArchiveHash + mxcRuntimeVersion = [string]$mxcProvenance.version + mxcRuntimeFiles = $mxcRuntimeFiles architecture = $Architecture archive = $msixName sha256 = $msixHash diff --git a/scripts/Get-MxcRuntime.ps1 b/scripts/Get-MxcRuntime.ps1 new file mode 100644 index 00000000..61895eb3 --- /dev/null +++ b/scripts/Get-MxcRuntime.ps1 @@ -0,0 +1,365 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Stages the pinned MXC native runtime for packaging. + +.DESCRIPTION + Acquires the exact npm archive named in mxc-runtime.lock.json, verifies its + registry integrity hash, extracts only the allowlisted runtime and licence + entries, and verifies each staged file's length, SHA-256, and PE machine + type before it is written to the output directory. + + The archive is treated as untrusted input until it is verified. This script + never runs 'npm install', never runs package lifecycle scripts, and never + executes any downloaded binary. + +.PARAMETER Architecture + Runtime architecture to stage. x64 and arm64 are staged separately. + +.PARAMETER ArchivePath + Optional path to an already-downloaded archive, for offline iteration. It + is subject to the same integrity, length, hash, and architecture checks as + a freshly downloaded archive. + +.PARAMETER OutputDirectory + Directory to stage into. Defaults to content\mxc\. + +.PARAMETER CacheDirectory + Directory holding verified archives. Defaults to artifacts\mxc-cache. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateSet('x64', 'arm64')] + [string]$Architecture, + + [string]$ArchivePath, + + [string]$OutputDirectory, + + [string]$CacheDirectory, + + [switch]$Force +) + +$ErrorActionPreference = 'Stop' +$repositoryRoot = Split-Path $PSScriptRoot -Parent +$lockPath = Join-Path $repositoryRoot 'mxc-runtime.lock.json' + +function Get-Sha256Hex { + param( + [Parameter(Mandatory)] + [string]$Path + ) + + (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} + +function Get-NpmIntegrity { + param( + [Parameter(Mandatory)] + [string]$Path + ) + + $stream = [IO.File]::OpenRead($Path) + try { + $sha512 = [Security.Cryptography.SHA512]::Create() + try { + 'sha512-' + [Convert]::ToBase64String($sha512.ComputeHash($stream)) + } + finally { + $sha512.Dispose() + } + } + finally { + $stream.Dispose() + } +} + +function Get-PeMachine { + param( + [Parameter(Mandatory)] + [string]$Path + ) + + $stream = [IO.File]::OpenRead($Path) + try { + $reader = [IO.BinaryReader]::new($stream) + try { + if ($reader.ReadUInt16() -ne 0x5A4D) { + throw "'$Path' is not a PE image." + } + + $stream.Position = 0x3C + $stream.Position = $reader.ReadUInt32() + if ($reader.ReadUInt32() -ne 0x00004550) { + throw "'$Path' has no PE signature." + } + + '0x{0:x4}' -f $reader.ReadUInt16() + } + finally { + $reader.Dispose() + } + } + finally { + $stream.Dispose() + } +} + +function Resolve-VerifiedArchive { + param( + [Parameter(Mandatory)] + [psobject]$Lock, + + [string]$SuppliedPath, + + [Parameter(Mandatory)] + [string]$CachePath + ) + + if ($SuppliedPath) { + if (-not (Test-Path -LiteralPath $SuppliedPath -PathType Leaf)) { + throw "The supplied MXC archive '$SuppliedPath' does not exist." + } + + $suppliedIntegrity = Get-NpmIntegrity -Path $SuppliedPath + if ($suppliedIntegrity -ne $Lock.tarballIntegrity) { + throw ( + "The supplied MXC archive does not match the pinned " + + "integrity value. Expected '$($Lock.tarballIntegrity)'; " + + "computed '$suppliedIntegrity'." + ) + } + + Write-Host "Using the verified local MXC archive '$SuppliedPath'." + return $SuppliedPath + } + + if (Test-Path -LiteralPath $CachePath -PathType Leaf) { + $cachedIntegrity = Get-NpmIntegrity -Path $CachePath + if ($cachedIntegrity -eq $Lock.tarballIntegrity) { + Write-Host "Using the verified cached MXC archive '$CachePath'." + return $CachePath + } + + Write-Warning ( + "Discarding the cached MXC archive '$CachePath': it no longer " + + 'matches the pinned integrity value.' + ) + Remove-Item -LiteralPath $CachePath -Force + } + + New-Item -Path (Split-Path $CachePath -Parent) -ItemType Directory -Force | + Out-Null + Write-Host "Downloading $($Lock.tarballUrl)." + $downloadPath = "$CachePath.$([guid]::NewGuid().ToString('N')).partial" + try { + Invoke-WebRequest ` + -Uri $Lock.tarballUrl ` + -OutFile $downloadPath ` + -MaximumRedirection 5 ` + -UseBasicParsing + + $downloadedIntegrity = Get-NpmIntegrity -Path $downloadPath + if ($downloadedIntegrity -ne $Lock.tarballIntegrity) { + throw ( + "The downloaded MXC archive does not match the pinned " + + "integrity value. Expected '$($Lock.tarballIntegrity)'; " + + "computed '$downloadedIntegrity'." + ) + } + + Move-Item -LiteralPath $downloadPath -Destination $CachePath -Force + return $CachePath + } + finally { + if (Test-Path -LiteralPath $downloadPath -PathType Leaf) { + Remove-Item -LiteralPath $downloadPath -Force + } + } +} + +if (-not (Test-Path -LiteralPath $lockPath -PathType Leaf)) { + throw "Missing the pinned runtime lock file '$lockPath'." +} + +$lock = Get-Content -LiteralPath $lockPath -Raw | ConvertFrom-Json +$architectureLock = $lock.architectures.$Architecture +if (-not $architectureLock) { + throw "mxc-runtime.lock.json does not pin a runtime for '$Architecture'." +} + +if (-not $OutputDirectory) { + $OutputDirectory = Join-Path $repositoryRoot "content\mxc\$Architecture" +} +if (-not $CacheDirectory) { + $CacheDirectory = Join-Path $repositoryRoot 'artifacts\mxc-cache' +} + +$provenancePath = Join-Path $OutputDirectory 'mxc-runtime.json' +$stagedEntries = @($architectureLock.files) + @($lock.licenseFiles) +$expectedStagedPaths = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase +) +foreach ($entry in $stagedEntries) { + [void]$expectedStagedPaths.Add( + $entry.stagedPath.Replace('/', [IO.Path]::DirectorySeparatorChar) + ) +} +[void]$expectedStagedPaths.Add('mxc-runtime.json') + +# Re-verify rather than trusting the directory's existence: a stale or +# tampered staging directory must not silently become package content. +if (-not $Force -and (Test-Path -LiteralPath $provenancePath -PathType Leaf)) { + $upToDate = $true + foreach ($entry in $stagedEntries) { + $candidate = Join-Path $OutputDirectory $entry.stagedPath + if ( + -not (Test-Path -LiteralPath $candidate -PathType Leaf) -or + (Get-Item -LiteralPath $candidate).Length -ne $entry.length -or + (Get-Sha256Hex -Path $candidate) -ne $entry.sha256 + ) { + $upToDate = $false + break + } + } + if ($upToDate) { + foreach ($candidate in @( + Get-ChildItem -LiteralPath $OutputDirectory -File -Force -Recurse + )) { + $relativePath = [IO.Path]::GetRelativePath( + $OutputDirectory, + $candidate.FullName + ) + if (-not $expectedStagedPaths.Contains($relativePath)) { + $upToDate = $false + break + } + } + } + + if ($upToDate) { + Write-Host ( + "MXC runtime $($lock.version) ($Architecture) is already staged " + + "in '$OutputDirectory'." + ) + return + } + + Write-Host 'Restaging the MXC runtime: the staged files no longer verify.' +} + +$cachePath = Join-Path ` + $CacheDirectory ` + "mxc-sdk-$($lock.version).tgz" +$verifiedArchive = Resolve-VerifiedArchive ` + -Lock $lock ` + -SuppliedPath $ArchivePath ` + -CachePath $cachePath + +$extractRoot = Join-Path ` + ([IO.Path]::GetTempPath()) ` + "openclaw-mxc-extract-$([guid]::NewGuid().ToString('N'))" +New-Item -Path $extractRoot -ItemType Directory -Force | Out-Null +try { + # Name every member explicitly so nothing outside the allowlist is written, + # and so a large archive is not fully expanded. + $members = @($stagedEntries | ForEach-Object { $_.archivePath }) + & tar -xzf $verifiedArchive -C $extractRoot @members + if ($LASTEXITCODE -ne 0) { + throw "Extracting the MXC archive failed with exit code $LASTEXITCODE." + } + + $stagingDirectory = Join-Path ` + $extractRoot ` + "staged-$([guid]::NewGuid().ToString('N'))" + New-Item -Path $stagingDirectory -ItemType Directory -Force | Out-Null + + foreach ($entry in $stagedEntries) { + $extracted = Join-Path $extractRoot ($entry.archivePath -replace '/', '\') + if (-not (Test-Path -LiteralPath $extracted -PathType Leaf)) { + throw ( + "The MXC archive does not contain '$($entry.archivePath)'. " + + 'Update mxc-runtime.lock.json before changing the pin.' + ) + } + + $length = (Get-Item -LiteralPath $extracted).Length + if ($length -ne $entry.length) { + throw ( + "'$($entry.archivePath)' is $length bytes; the pin expects " + + "$($entry.length)." + ) + } + + $sha256 = Get-Sha256Hex -Path $extracted + if ($sha256 -ne $entry.sha256) { + throw ( + "'$($entry.archivePath)' hashes to $sha256; the pin expects " + + "$($entry.sha256)." + ) + } + + if ($entry.PSObject.Properties.Name -contains 'peMachine') { + $machine = Get-PeMachine -Path $extracted + if ($machine -ne $entry.peMachine) { + throw ( + "'$($entry.archivePath)' targets machine $machine; the " + + "pin expects $($entry.peMachine) for $Architecture." + ) + } + } + + Copy-Item ` + -LiteralPath $extracted ` + -Destination (Join-Path $stagingDirectory $entry.stagedPath) ` + -Force + } + + [ordered]@{ + package = $lock.package + version = $lock.version + architecture = $Architecture + wireSchemaVersion = $lock.wireSchemaVersion + minimumWindowsBuild = $lock.minimumWindowsBuild + tarballIntegrity = $lock.tarballIntegrity + files = @( + $stagedEntries | + ForEach-Object { + [ordered]@{ + path = $_.stagedPath + length = $_.length + sha256 = $_.sha256 + } + } | + Sort-Object { $_.path } + ) + } | + # Keep the nested file records intact. + ConvertTo-Json -Depth 4 | + Set-Content ` + -LiteralPath (Join-Path $stagingDirectory 'mxc-runtime.json') ` + -Encoding utf8 + + # Publish only after every file verified, so a failed run cannot leave a + # partially staged runtime behind for the packaging build to pick up. + if (Test-Path -LiteralPath $OutputDirectory -PathType Container) { + Remove-Item -LiteralPath $OutputDirectory -Recurse -Force + } + New-Item -Path (Split-Path $OutputDirectory -Parent) ` + -ItemType Directory ` + -Force | + Out-Null + Move-Item -LiteralPath $stagingDirectory -Destination $OutputDirectory +} +finally { + if (Test-Path -LiteralPath $extractRoot -PathType Container) { + Remove-Item -LiteralPath $extractRoot -Recurse -Force + } +} + +Write-Host ( + "Staged MXC runtime $($lock.package) $($lock.version) ($Architecture) " + + "in '$OutputDirectory'." +) diff --git a/scripts/Test-Get-MxcRuntime.Tests.ps1 b/scripts/Test-Get-MxcRuntime.Tests.ps1 new file mode 100644 index 00000000..223cb607 --- /dev/null +++ b/scripts/Test-Get-MxcRuntime.Tests.ps1 @@ -0,0 +1,94 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = Split-Path $PSScriptRoot -Parent +$scriptPath = Join-Path $PSScriptRoot 'Get-MxcRuntime.ps1' +$testRoot = Join-Path $env:TEMP "openclaw-mxc-runtime-$([guid]::NewGuid().ToString('N'))" + +function Assert-True { + param([bool]$Condition, [string]$Message) + if (-not $Condition) { + throw $Message + } +} + +try { + $source = Join-Path $testRoot 'source' + $package = Join-Path $source 'package' + $output = Join-Path $testRoot 'staged' + $scripts = Join-Path $testRoot 'scripts' + $archive = Join-Path $testRoot 'mxc-fixture.tgz' + New-Item -Path $package, $output, $scripts -ItemType Directory -Force | Out-Null + $runtimeFile = Join-Path $package 'fixture.bin' + [IO.File]::WriteAllText($runtimeFile, 'verified runtime') + & tar -czf $archive -C $source 'package/fixture.bin' + if ($LASTEXITCODE -ne 0) { + throw "Creating the fixture archive failed (exit $LASTEXITCODE)." + } + + $stream = [IO.File]::OpenRead($archive) + try { + $sha512 = [Security.Cryptography.SHA512]::Create() + try { + $integrity = 'sha512-' + [Convert]::ToBase64String( + $sha512.ComputeHash($stream) + ) + } + finally { + $sha512.Dispose() + } + } + finally { + $stream.Dispose() + } + + $hash = (Get-FileHash -LiteralPath $runtimeFile -Algorithm SHA256).Hash.ToLowerInvariant() + $lock = [ordered]@{ + package = '@microsoft/mxc-sdk' + version = 'fixture' + wireSchemaVersion = 'fixture' + minimumWindowsBuild = '0' + tarballUrl = 'https://example.invalid/mxc-fixture.tgz' + tarballIntegrity = $integrity + architectures = @{ + x64 = @{ + files = @(@{ + archivePath = 'package/fixture.bin' + stagedPath = 'fixture.bin' + length = (Get-Item -LiteralPath $runtimeFile).Length + sha256 = $hash + }) + } + } + licenseFiles = @() + } | ConvertTo-Json -Depth 8 + [IO.File]::WriteAllText((Join-Path $testRoot 'mxc-runtime.lock.json'), $lock) + + $fixtureScript = Join-Path $scripts 'Get-MxcRuntime.ps1' + Copy-Item -LiteralPath $scriptPath -Destination $fixtureScript + & $fixtureScript -Architecture x64 -ArchivePath $archive -OutputDirectory $output + if ($LASTEXITCODE -ne 0) { + throw "Initial MXC staging failed (exit $LASTEXITCODE)." + } + + $unexpected = Join-Path $output 'unverified.bin' + [IO.File]::WriteAllText($unexpected, 'must not be packaged') + & $fixtureScript -Architecture x64 -ArchivePath $archive -OutputDirectory $output + if ($LASTEXITCODE -ne 0) { + throw "MXC restaging failed (exit $LASTEXITCODE)." + } + + Assert-True (-not (Test-Path -LiteralPath $unexpected)) ( + 'MXC runtime reuse retained an unpinned file.' + ) + Assert-True (Test-Path -LiteralPath (Join-Path $output 'fixture.bin')) ( + 'MXC restaging did not preserve the pinned runtime file.' + ) + Write-Host 'MXC runtime staging tests passed.' +} +finally { + Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/scripts/Test-SigningInputs.Tests.ps1 b/scripts/Test-SigningInputs.Tests.ps1 index b6bd73b2..530e19b0 100644 --- a/scripts/Test-SigningInputs.Tests.ps1 +++ b/scripts/Test-SigningInputs.Tests.ps1 @@ -167,6 +167,42 @@ function New-TestArtifact { Get-FileHash -LiteralPath $nodeRuntimePath -Algorithm SHA256 ).Hash.ToLowerInvariant() + $mxcRuntimeVersion = '0.8.0' + $mxcRuntimeDirectory = Join-Path $staging "mxc\$Architecture" + New-Item -Path $mxcRuntimeDirectory -ItemType Directory -Force | Out-Null + [IO.File]::WriteAllText( + (Join-Path $mxcRuntimeDirectory 'wxc-exec.exe'), + "executor-$Architecture") + [IO.File]::WriteAllText( + (Join-Path $mxcRuntimeDirectory 'plm.exe'), + "plm-$Architecture") + [IO.File]::WriteAllText( + (Join-Path $mxcRuntimeDirectory 'LICENSE.md'), + 'fixture license') + [IO.File]::WriteAllText( + (Join-Path $mxcRuntimeDirectory 'mxc-runtime.json'), + "{`"version`":`"$mxcRuntimeVersion`",`"architecture`":`"$Architecture`"}") + $mxcRuntimeFiles = @( + Get-ChildItem -LiteralPath $mxcRuntimeDirectory -File -Recurse | + ForEach-Object { + [ordered]@{ + path = ( + [IO.Path]::GetRelativePath( + $mxcRuntimeDirectory, + $_.FullName + ) + ).Replace('\', '/') + length = $_.Length + sha256 = ( + Get-FileHash ` + -LiteralPath $_.FullName ` + -Algorithm SHA256 + ).Hash.ToLowerInvariant() + } + } | + Sort-Object path + ) + $msixName = "OpenClawGateway-$Architecture.msix" $msixPath = Join-Path $directory $msixName [IO.Compression.ZipFile]::CreateFromDirectory($staging, $msixPath) @@ -189,6 +225,8 @@ function New-TestArtifact { nodeRuntimeVersion = $nodeRuntimeVersion nodeRuntimeArchive = $nodeRuntimeArchive nodeRuntimeSha256 = $nodeRuntimeHash + mxcRuntimeVersion = $mxcRuntimeVersion + mxcRuntimeFiles = $mxcRuntimeFiles architecture = $Architecture archive = $msixName sha256 = $msixHash @@ -394,6 +432,29 @@ try { Invoke-PolicyValidation -Root $testRoot -PreserveBundle } + Reset-TestArtifacts + Update-TestMsix -Root $testRoot -Architecture x64 -Mutator { + param($Expanded) + Set-Content ` + -LiteralPath (Join-Path $Expanded 'mxc\x64\wxc-exec.exe') ` + -Value 'tampered' ` + -Encoding utf8 + } + Assert-Fails ` + -MessagePattern 'MXC runtime file is invalid' ` + -Action { Invoke-PolicyValidation -Root $testRoot } + + Reset-TestArtifacts + Update-TestMsix -Root $testRoot -Architecture x64 -Mutator { + param($Expanded) + Remove-Item -LiteralPath ( + Join-Path $Expanded 'mxc\x64\mxc-runtime.json' + ) + } + Assert-Fails ` + -MessagePattern "Expected one 'mxc/x64/mxc-runtime.json' entry; found 0" ` + -Action { Invoke-PolicyValidation -Root $testRoot } + Reset-TestArtifacts Assert-Fails ` -MessagePattern 'approved immutable OpenClaw commit' ` diff --git a/scripts/Test-SigningInputs.ps1 b/scripts/Test-SigningInputs.ps1 index 8811f68f..5ff7d201 100644 --- a/scripts/Test-SigningInputs.ps1 +++ b/scripts/Test-SigningInputs.ps1 @@ -185,6 +185,7 @@ if ( $expectedPackagingCommit = $PackagingCommit.ToLowerInvariant() $expectedPackageVersion = $null $expectedNodeRuntimeVersion = $null +$expectedMxcRuntimeVersion = $null $expectedPackages = @{} foreach ($architecture in @('x64', 'arm64')) { $directory = Join-Path $resolvedArtifactsDirectory $architecture @@ -220,6 +221,8 @@ foreach ($architecture in @('x64', 'arm64')) { $metadata.nodeRuntimeArchive -ne "node-v$($metadata.nodeRuntimeVersion)-win-$architecture.zip" -or $metadata.nodeRuntimeSha256 -notmatch '^[0-9a-fA-F]{64}$' -or + [string]::IsNullOrWhiteSpace([string]$metadata.mxcRuntimeVersion) -or + @($metadata.mxcRuntimeFiles).Count -eq 0 -or $metadata.architecture -ne $architecture -or $metadata.archive -ne $msix.Name -or $metadata.sha256 -notmatch '^[0-9a-fA-F]{64}$' -or @@ -244,6 +247,13 @@ foreach ($architecture in @('x64', 'arm64')) { throw 'The x64 and ARM64 Node.js runtime versions do not match.' } + if ($null -eq $expectedMxcRuntimeVersion) { + $expectedMxcRuntimeVersion = [string]$metadata.mxcRuntimeVersion + } + elseif ($metadata.mxcRuntimeVersion -ne $expectedMxcRuntimeVersion) { + throw 'The x64 and ARM64 MXC runtime versions do not match.' + } + $actualMsixHash = ( Get-FileHash -LiteralPath $msix.FullName -Algorithm SHA256 ).Hash.ToLowerInvariant() @@ -292,6 +302,82 @@ foreach ($architecture in @('x64', 'arm64')) { ) ) + $expectedMxcPaths = + [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase + ) + $hasMxcExecutor = $false + $hasMxcProvenance = $false + foreach ($file in @($metadata.mxcRuntimeFiles)) { + $relativePath = [string]$file.path + $segments = @($relativePath.Split('/')) + if ( + [string]::IsNullOrWhiteSpace($relativePath) -or + $relativePath.StartsWith('/') -or + [IO.Path]::IsPathRooted($relativePath) -or + $relativePath.Contains('\') -or + $relativePath.Contains(':') -or + $segments -contains '' -or + $segments -contains '.' -or + $segments -contains '..' -or + $file.length -isnot [int64] -or + $file.length -lt 0 -or + $file.sha256 -notmatch '^[0-9a-fA-F]{64}$' + ) { + throw "The embedded $architecture MXC runtime inventory is invalid." + } + + $packagePath = "mxc/$architecture/$relativePath" + if (-not $expectedMxcPaths.Add($packagePath)) { + throw ( + "The embedded $architecture MXC runtime inventory has " + + 'duplicate paths.' + ) + } + + $entry = Get-PackageEntry ` + -EntriesByPath $entriesByPath ` + -Path $packagePath + if ( + $entry.Length -ne $file.length -or + (Get-PackageEntrySha256 -Entry $entry) -ine $file.sha256 + ) { + throw ( + "The embedded $architecture MXC runtime file is invalid: " + + $relativePath + ) + } + + if ($relativePath -ieq 'wxc-exec.exe') { + $hasMxcExecutor = $true + } + if ($relativePath -ieq 'mxc-runtime.json') { + $hasMxcProvenance = $true + } + } + + if (-not $hasMxcExecutor -or -not $hasMxcProvenance) { + throw "The embedded $architecture MXC runtime is incomplete." + } + + $actualMxcPaths = @( + $entriesByPath.Keys | + Where-Object { + $_.StartsWith( + "mxc/$architecture/", + [StringComparison]::OrdinalIgnoreCase + ) + } + ) + if ( + $actualMxcPaths.Count -ne $expectedMxcPaths.Count -or + @($actualMxcPaths | Where-Object { + -not $expectedMxcPaths.Contains($_) + }).Count -ne 0 + ) { + throw "The embedded $architecture MXC runtime file set is invalid." + } + [xml]$manifest = Read-ZipEntryText ` -EntriesByPath $entriesByPath ` -Path 'AppxManifest.xml' diff --git a/src/OpenClaw.Launcher/Mxc/MxcCliSessionClient.cs b/src/OpenClaw.Launcher/Mxc/MxcCliSessionClient.cs new file mode 100644 index 00000000..09273a6e --- /dev/null +++ b/src/OpenClaw.Launcher/Mxc/MxcCliSessionClient.cs @@ -0,0 +1,383 @@ +using System.Diagnostics; +using System.Text; + +namespace OpenClaw.Launcher.Mxc; + +internal sealed record MxcExecutorInvocation( + string ExecutorPath, + IReadOnlyList Arguments); + +internal sealed record MxcExecutorOutcome( + int ExitCode, + string StandardOutput, + string StandardError); + +/// +/// Runs the MXC executor. Exists so lifecycle behavior can be tested without a +/// real sandbox, a capable host, or executing a downloaded binary. +/// +internal interface IMxcExecutorInvoker +{ + Task InvokeAsync( + MxcExecutorInvocation invocation, + CancellationToken cancellationToken); +} + +/// +/// Runs the MXC executor with the host's console streams attached. +/// +/// +/// Interactive OpenClaw cannot be served by the buffered invoker: reading both +/// streams to completion only returns after the child exits, so a prompt would +/// never reach the terminal and typed input would never reach the child. +/// Nothing is captured here, so the caller cannot read a dispatch error +/// envelope off standard output and must establish the outcome another way. +/// +internal interface IMxcAttachedExecutorInvoker +{ + Task InvokeAttachedAsync( + MxcExecutorInvocation invocation, + CancellationToken cancellationToken); +} + +/// +/// Temporary transport over the executor +/// published in @microsoft/mxc-sdk. +/// +/// +/// This exists only until the official Microsoft.Mxc.Sdk .NET package ships. +/// Every preview wire detail is confined to this adapter and +/// ; the published SDK adapter must satisfy the +/// same behavior. +/// +internal sealed class MxcCliSessionClient : IMxcSessionClient +{ + private readonly MxcRuntimeLocation _runtime; + private readonly IMxcExecutorInvoker _invoker; + private readonly IMxcAttachedExecutorInvoker _attachedInvoker; + + public MxcCliSessionClient( + MxcRuntimeLocation runtime, + IMxcExecutorInvoker? invoker = null, + IMxcAttachedExecutorInvoker? attachedInvoker = null) + { + _runtime = runtime; + _invoker = invoker ?? new ProcessMxcExecutorInvoker(); + _attachedInvoker = attachedInvoker ?? + invoker as IMxcAttachedExecutorInvoker ?? + new ProcessMxcExecutorInvoker(); + } + + public async Task ProvisionAsync( + MxcProvisionRequest request, + CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(request.AppId)) + { + throw new MxcException( + MxcErrorCode.PolicyValidation, + "A packaged caller must supply PFN:."); + } + + MxcExecutorOutcome outcome = await InvokeAsync( + MxcWireProtocol.BuildProvisionEnvelope(request.AppId), + cancellationToken).ConfigureAwait(false); + return MxcWireProtocol.ReadProvisionResult(ReadResult(outcome)); + } + + public async Task StartAsync( + MxcSandboxId sandboxId, + string? correlationVector, + CancellationToken cancellationToken) => + ReadResult(await InvokeAsync( + MxcWireProtocol.BuildPhaseEnvelope( + MxcWireProtocol.StartPhase, + sandboxId, + correlationVector), + cancellationToken).ConfigureAwait(false)); + + public async Task ExecuteAsync( + MxcSandboxId sandboxId, + MxcExecutionRequest request, + string? correlationVector, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.CommandLine)) + { + throw new MxcException( + MxcErrorCode.PolicyValidation, + "An execution request requires a command line."); + } + + MxcExecutorOutcome outcome = await InvokeAsync( + MxcWireProtocol.BuildPhaseEnvelope( + MxcWireProtocol.ExecPhase, + sandboxId, + correlationVector, + request.CommandLine), + cancellationToken).ConfigureAwait(false); + + // Execution forwards the guest command's own output and exit code. A + // dispatch failure is only claimed when the executor also failed, so a + // command that legitimately prints an error-shaped JSON document is + // reported as command output rather than an MXC fault. + if (outcome.ExitCode != 0) + { + MxcException? dispatchFailure = + MxcWireProtocol.TryParseExecutionError(outcome.StandardOutput); + if (dispatchFailure is not null) + { + throw dispatchFailure; + } + } + + return new MxcExecutionResult( + outcome.ExitCode, + outcome.StandardOutput, + outcome.StandardError); + } + + /// + /// Runs a command with the host's console streams attached. + /// + /// + /// Nothing is captured, so a dispatch failure cannot be read back as a + /// structured envelope and would instead print to the user's terminal. The + /// caller establishes the real outcome from the guest helper's control + /// result, which distinguishes "the application exited with this code" from + /// "the command never started". + /// + public Task ExecuteAttachedAsync( + MxcSandboxId sandboxId, + MxcExecutionRequest request, + string? correlationVector, + CancellationToken cancellationToken) => + _attachedInvoker.InvokeAttachedAsync( + BuildInvocation(MxcWireProtocol.BuildPhaseEnvelope( + MxcWireProtocol.ExecPhase, + sandboxId, + correlationVector, + request.CommandLine)), + cancellationToken); + + public async Task StopAsync( + MxcSandboxId sandboxId, + string? correlationVector, + CancellationToken cancellationToken) => + ReadResult(await InvokeAsync( + MxcWireProtocol.BuildPhaseEnvelope( + MxcWireProtocol.StopPhase, + sandboxId, + correlationVector), + cancellationToken).ConfigureAwait(false)); + + public async Task DeprovisionAsync( + MxcSandboxId sandboxId, + string? correlationVector, + CancellationToken cancellationToken) => + ReadResult(await InvokeAsync( + MxcWireProtocol.BuildPhaseEnvelope( + MxcWireProtocol.DeprovisionPhase, + sandboxId, + correlationVector), + cancellationToken).ConfigureAwait(false)); + + /// + /// Runs the executor's host capability detector. This is the authoritative + /// answer to whether the IsolationSession backend is usable here; the + /// documented minimum Windows build only predicts it. The detector does not + /// spawn a sandbox, so this is safe on the read-only setup path. + /// + public async Task ProbeBackendAsync( + CancellationToken cancellationToken) + { + MxcExecutorOutcome outcome = await _invoker.InvokeAsync( + new MxcExecutorInvocation(_runtime.ExecutorPath, ["--probe"]), + cancellationToken).ConfigureAwait(false); + + if (outcome.ExitCode != 0 || string.IsNullOrWhiteSpace(outcome.StandardOutput)) + { + throw new MxcException( + MxcErrorCode.RuntimeUnavailable, + "The MXC host capability probe failed " + + $"(exit code {outcome.ExitCode}). " + + Describe(outcome.StandardError)); + } + + return MxcWireProtocol.ReadProbeResponse(outcome.StandardOutput); + } + + private Task InvokeAsync( + MxcRequestEnvelope envelope, + CancellationToken cancellationToken) => + _invoker.InvokeAsync(BuildInvocation(envelope), cancellationToken); + + private MxcExecutorInvocation BuildInvocation(MxcRequestEnvelope envelope) => + new( + _runtime.ExecutorPath, + [ + "--config-base64", + MxcWireProtocol.EncodeConfig(envelope), + + // The state-aware lifecycle surface is gated behind this + // flag in the pinned runtime; without it the executor + // rejects the request before reading the envelope. + "--experimental" + ]); + + private static System.Text.Json.JsonElement ReadResult( + MxcExecutorOutcome outcome) + { + if (string.IsNullOrWhiteSpace(outcome.StandardOutput)) + { + throw new MxcException( + MxcErrorCode.ProtocolViolation, + "The MXC executor produced no response envelope " + + $"(exit code {outcome.ExitCode}). " + + Describe(outcome.StandardError)); + } + + // Parse before inspecting the exit code: a structured {error} envelope + // names the real failure, and reporting the exit code instead would + // discard it. + return MxcWireProtocol.ParseNonExecutionResponse(outcome.StandardOutput); + } + + private static string Describe(string standardError) => + string.IsNullOrWhiteSpace(standardError) + ? "The executor reported no diagnostics." + : $"Executor diagnostics: {standardError.Trim()}"; +} + +internal sealed class ProcessMxcExecutorInvoker + : IMxcExecutorInvoker, IMxcAttachedExecutorInvoker +{ + private readonly IHostConsole _console; + + internal ProcessMxcExecutorInvoker(IHostConsole? console = null) => + _console = console ?? WindowsHostConsole.Instance; + + public async Task InvokeAsync( + MxcExecutorInvocation invocation, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ProcessStartInfo startInfo = new() + { + FileName = invocation.ExecutorPath, + + // No shell: the executor path and the base64 envelope must reach + // the process exactly as written. + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8 + }; + + foreach (string argument in invocation.Arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using Process process = new() { StartInfo = startInfo }; + try + { + process.Start(); + process.StandardInput.Close(); + } + catch (Exception exception) when ( + exception is System.ComponentModel.Win32Exception or + InvalidOperationException) + { + throw new MxcException( + MxcErrorCode.RuntimeUnavailable, + $"The MXC executor could not be started: {exception.Message}", + innerException: exception); + } + + // Both streams are read concurrently so a large payload on one cannot + // fill its pipe buffer and deadlock the child before exit. + Task standardOutput = process.StandardOutput.ReadToEndAsync( + cancellationToken); + Task standardError = process.StandardError.ReadToEndAsync( + cancellationToken); + using CancellationTokenRegistration registration = + cancellationToken.Register(() => TryKill(process)); + await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + return new MxcExecutorOutcome( + process.ExitCode, + await standardOutput.ConfigureAwait(false), + await standardError.ConfigureAwait(false)); + } + + public async Task InvokeAttachedAsync( + MxcExecutorInvocation invocation, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + using IDisposable capture = _console.Capture(_ => { }); + _console.InitializeUtf8(); + ProcessStartInfo startInfo = new() + { + FileName = invocation.ExecutorPath, + + // Nothing is redirected, so the child inherits this process's + // console handles. That is the point: OpenClaw draws its own + // prompts and reads typed input, and any interposed pipe would + // both buffer that output and hide the terminal from the child. + UseShellExecute = false + }; + + foreach (string argument in invocation.Arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using Process process = new() { StartInfo = startInfo }; + try + { + process.Start(); + } + catch (Exception exception) when ( + exception is System.ComponentModel.Win32Exception or + InvalidOperationException) + { + throw new MxcException( + MxcErrorCode.RuntimeUnavailable, + $"The MXC executor could not be started: {exception.Message}", + innerException: exception); + } + + // Only this invocation's process tree is killed. The session itself + // outlives the command, and other OpenClaw invocations own their own + // executor processes. Awaiting without the cancelled token guarantees + // the process is gone before cancellation is returned to the caller. + using CancellationTokenRegistration registration = + cancellationToken.Register(() => TryKill(process)); + await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + return process.ExitCode; + } + + private static void TryKill(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch (Exception exception) when ( + exception is InvalidOperationException or + System.ComponentModel.Win32Exception or + NotSupportedException) + { + } + } +} diff --git a/src/OpenClaw.Launcher/Mxc/MxcException.cs b/src/OpenClaw.Launcher/Mxc/MxcException.cs new file mode 100644 index 00000000..e63cdacf --- /dev/null +++ b/src/OpenClaw.Launcher/Mxc/MxcException.cs @@ -0,0 +1,90 @@ +using System.Diagnostics.CodeAnalysis; + +namespace OpenClaw.Launcher.Mxc; + +/// +/// Error classifications this package acts on. Unrecognized backend codes map +/// to and keep their original text in +/// rather than being reshaped into a +/// code that implies a recovery path the backend did not report. +/// +internal enum MxcErrorCode +{ + Unknown, + + /// The persisted identity is not a well-formed sandbox id. + MalformedId, + + /// The sandbox named by a well-formed id no longer exists. + StaleId, + + /// The backend rejected the requested policy. + PolicyValidation, + + /// + /// The backend could not parse or accept the request this package built. + /// This indicates a defect here, not a recoverable runtime condition. + /// + MalformedRequest, + + /// No state-aware backend is registered for the id's prefix. + UnsupportedContainment, + + /// + /// The backend attempted the operation and failed, e.g. exec against a + /// session that is provisioned but not started. + /// + BackendError, + + /// The MXC runtime is missing, unusable, or unsupported here. + RuntimeUnavailable, + + /// The runtime produced output this package cannot interpret. + ProtocolViolation +} + +[SuppressMessage( + "Design", + "CA1032:Implement standard exception constructors", + Justification = + "Every MXC failure carries the classified error code and the verbatim " + + "backend code that the error-handling paths switch on. A parameterless " + + "or message-only constructor would let a caller create an exception " + + "with no classification, which is precisely the state this type exists " + + "to prevent.")] +internal sealed class MxcException : Exception +{ + public MxcException( + MxcErrorCode code, + string message, + string? backendCode = null, + Exception? innerException = null) + : base(message, innerException) + { + Code = code; + BackendCode = backendCode; + } + + public MxcErrorCode Code { get; } + + /// + /// The verbatim backend error code, preserved even when + /// is , so diagnostics + /// report what the runtime actually said. + /// + public string? BackendCode { get; } + + // Codes observed from @microsoft/mxc-sdk 0.8.0 wxc-exec.exe against the + // Windows IsolationSession backend. Anything unlisted stays Unknown and + // keeps its verbatim text rather than being guessed into a recovery path. + internal static MxcErrorCode Classify(string? backendCode) => backendCode switch + { + "malformed_id" => MxcErrorCode.MalformedId, + "stale_id" => MxcErrorCode.StaleId, + "policy_validation" => MxcErrorCode.PolicyValidation, + "malformed_request" => MxcErrorCode.MalformedRequest, + "unsupported_containment" => MxcErrorCode.UnsupportedContainment, + "backend_error" => MxcErrorCode.BackendError, + _ => MxcErrorCode.Unknown + }; +} diff --git a/src/OpenClaw.Launcher/Mxc/MxcReadiness.cs b/src/OpenClaw.Launcher/Mxc/MxcReadiness.cs new file mode 100644 index 00000000..0e66d0b6 --- /dev/null +++ b/src/OpenClaw.Launcher/Mxc/MxcReadiness.cs @@ -0,0 +1,240 @@ +using System.Diagnostics.CodeAnalysis; + +namespace OpenClaw.Launcher.Mxc; + +/// +/// Windows build identity, including the update build revision that the +/// documented IsolationSession minimum specifies. +/// +internal sealed record MxcHostBuild(int Build, int UpdateBuildRevision) +{ + public override string ToString() => $"{Build}.{UpdateBuildRevision}"; +} + +internal enum MxcHostSupport +{ + /// The host build could not be determined. + Unknown, + + Supported, + + Unsupported +} + +/// +/// How was established. The +/// distinction matters: the documented minimum build predicts support, while +/// the backend probe measures it, and only the probe notices a host where the +/// feature is present but unusable. +/// +internal enum MxcSupportEvidence +{ + /// Nothing could be established. + None, + + /// Inferred from the Windows build against the documented minimum. + HostBuild, + + /// Measured by the runtime's own host capability detector. + BackendProbe +} + +internal sealed record MxcReadinessReport( + string? RuntimeDirectory, + MxcRuntimeProvenance? Provenance, + string? RuntimeUnavailableReason, + MxcHostSupport HostSupport, + MxcHostBuild? HostBuild, + MxcSupportEvidence SupportEvidence, + MxcBackendProbe? BackendProbe = null, + string? BackendProbeFailureReason = null) +{ + public bool RuntimeAvailable => RuntimeUnavailableReason is null; +} + +/// +/// Read-only MXC prerequisite probe. It inspects the staged runtime, asks the +/// runtime's own capability detector about the host, and falls back to the +/// documented minimum build when that detector cannot run. It never provisions, +/// starts, or otherwise mutates state. +/// +internal static class MxcReadiness +{ + /// + /// Minimum Windows build documented by the pinned runtime for the + /// IsolationSession backend. Used only when the backend probe is + /// unavailable, because a build number predicts support rather than + /// measuring it. + /// + public static readonly MxcHostBuild MinimumHostBuild = new(26340, 9212); + + /// + /// A report for a caller that has already ruled the runtime out and must + /// not pay for, or fail on, a probe it will ignore. + /// + public static MxcReadinessReport Unavailable(string reason) => + new( + null, + null, + reason, + MxcHostSupport.Unknown, + null, + MxcSupportEvidence.None); + + public static Task ProbeAsync( + CancellationToken cancellationToken) => + ProbeAsync( + AppContext.BaseDirectory, + Environment.GetEnvironmentVariable, + WindowsHostBuild.TryRead, + static (location, token) => + new MxcCliSessionClient(location).ProbeBackendAsync(token), + cancellationToken); + + [SuppressMessage( + "Design", + "CA1031:Do not catch general exception types", + Justification = + "Readiness detection reports what it could establish; it never " + + "decides an operation. A host detector that fails for an " + + "unanticipated reason is a gap in the evidence, so the reason is " + + "recorded and the report degrades to the build check. Narrowing " + + "this would instead make `clawctl setup` fail on a machine it was " + + "only being asked to describe.")] + internal static async Task ProbeAsync( + string baseDirectory, + Func readEnvironmentVariable, + Func readHostBuild, + Func> probeBackend, + CancellationToken cancellationToken) + { + string? runtimeDirectory = null; + MxcRuntimeProvenance? provenance = null; + string? unavailableReason = null; + MxcRuntimeLocation? location = null; + + try + { + location = MxcRuntimeLocator.Locate( + baseDirectory, + readEnvironmentVariable); + runtimeDirectory = location.Directory; + provenance = location.Provenance; + } + catch (MxcException exception) + { + unavailableReason = exception.Message; + } + + MxcHostBuild? hostBuild = readHostBuild(); + MxcBackendProbe? backendProbe = null; + string? probeFailure = null; + + if (location is not null) + { + try + { + backendProbe = await probeBackend(location, cancellationToken) + .ConfigureAwait(false); + } + catch (MxcException exception) + { + probeFailure = exception.Message; + } + catch (OperationCanceledException) + { + throw; + } + + // A host detector that cannot run is a readiness gap, not a reason + // to fail setup, so any other launch failure degrades to the build + // check rather than propagating. + catch (Exception exception) + { + probeFailure = exception.Message; + } + } + + (MxcHostSupport support, MxcSupportEvidence evidence) = + backendProbe is not null + ? (backendProbe.IsolationSessionAvailable + ? MxcHostSupport.Supported + : MxcHostSupport.Unsupported, + MxcSupportEvidence.BackendProbe) + : (Classify(hostBuild), + hostBuild is null + ? MxcSupportEvidence.None + : MxcSupportEvidence.HostBuild); + + return new MxcReadinessReport( + runtimeDirectory, + provenance, + unavailableReason, + support, + hostBuild, + evidence, + backendProbe, + probeFailure); + } + + private static MxcHostSupport Classify(MxcHostBuild? hostBuild) + { + if (hostBuild is null) + { + return MxcHostSupport.Unknown; + } + + if (hostBuild.Build != MinimumHostBuild.Build) + { + return hostBuild.Build > MinimumHostBuild.Build + ? MxcHostSupport.Supported + : MxcHostSupport.Unsupported; + } + + return hostBuild.UpdateBuildRevision >= MinimumHostBuild.UpdateBuildRevision + ? MxcHostSupport.Supported + : MxcHostSupport.Unsupported; + } +} + +internal static class WindowsHostBuild +{ + private const string CurrentVersionKey = + @"SOFTWARE\Microsoft\Windows NT\CurrentVersion"; + + /// + /// Reads the running Windows build and update build revision. The revision + /// is only available from the registry, so an unreadable value yields an + /// unknown build rather than a fabricated revision of zero, which would + /// wrongly report a serviced host as unsupported. + /// + public static MxcHostBuild? TryRead() + { + if (!OperatingSystem.IsWindows()) + { + return null; + } + + int build = Environment.OSVersion.Version.Build; + if (build <= 0) + { + return null; + } + + try + { + using Microsoft.Win32.RegistryKey? key = + Microsoft.Win32.Registry.LocalMachine.OpenSubKey(CurrentVersionKey); + return key?.GetValue("UBR") is int revision + ? new MxcHostBuild(build, revision) + : null; + } + catch (Exception exception) when ( + exception is System.Security.SecurityException or + UnauthorizedAccessException or + IOException) + { + return null; + } + } +} diff --git a/src/OpenClaw.Launcher/Mxc/MxcRuntimeLocator.cs b/src/OpenClaw.Launcher/Mxc/MxcRuntimeLocator.cs new file mode 100644 index 00000000..4874a125 --- /dev/null +++ b/src/OpenClaw.Launcher/Mxc/MxcRuntimeLocator.cs @@ -0,0 +1,152 @@ +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenClaw.Launcher.Mxc; + +/// +/// Provenance recorded when the pinned MXC runtime was staged into the package. +/// It is written by scripts\Get-MxcRuntime.ps1 from the verified npm archive +/// and read back so diagnostics can name the exact runtime in use. +/// +internal sealed record MxcRuntimeProvenance( + string Package, + string Version, + string Architecture); + +internal sealed record MxcRuntimeLocation( + string Directory, + string ExecutorPath, + string PackageLifecyclePath, + MxcRuntimeProvenance? Provenance); + +/// +/// Finds the MXC runtime staged beside the launcher. +/// +/// +/// Resolution never searches PATH or a user-writable location: the runtime is a +/// release trust-chain input, so an arbitrary wxc-exec.exe found on the machine +/// must not be able to service a managed OpenClaw session. +/// +internal static class MxcRuntimeLocator +{ + /// + /// Directory name under the application base that holds the staged runtime. + /// + public const string RuntimeDirectoryName = "mxc"; + + public const string ExecutorFileName = "wxc-exec.exe"; + public const string PackageLifecycleFileName = "plm.exe"; + public const string ProvenanceFileName = "mxc-runtime.json"; + + /// + /// Development and compatibility-experiment override naming a directory + /// that already contains a verified runtime layout. + /// + public const string RuntimeDirectoryVariable = "OPENCLAW_MXC_RUNTIME_DIR"; + + public static MxcRuntimeLocation Locate() => + Locate(AppContext.BaseDirectory, Environment.GetEnvironmentVariable); + + internal static MxcRuntimeLocation Locate( + string baseDirectory, + Func readEnvironmentVariable) + { + string? overrideDirectory = + readEnvironmentVariable(RuntimeDirectoryVariable); + string directory = string.IsNullOrWhiteSpace(overrideDirectory) + ? Path.Combine( + baseDirectory, + RuntimeDirectoryName, + CurrentArchitectureName()) + : overrideDirectory; + + string executorPath = Path.Combine(directory, ExecutorFileName); + if (!File.Exists(executorPath)) + { + throw new MxcException( + MxcErrorCode.RuntimeUnavailable, + $"The MXC runtime is not available: {executorPath} is missing."); + } + + string packageLifecyclePath = + Path.Combine(directory, PackageLifecycleFileName); + if (!File.Exists(packageLifecyclePath)) + { + throw new MxcException( + MxcErrorCode.RuntimeUnavailable, + "The MXC runtime is incomplete: " + + $"{packageLifecyclePath} is missing."); + } + + return new MxcRuntimeLocation( + directory, + executorPath, + packageLifecyclePath, + ReadProvenance(directory)); + } + + /// + /// Runtime identifier fragment naming the architecture-specific staging + /// directory. The process architecture is used rather than the OS + /// architecture so an x64 launcher emulated on ARM64 loads the matching + /// runtime instead of one it cannot execute. + /// + internal static string CurrentArchitectureName() => + RuntimeInformation.ProcessArchitecture switch + { + System.Runtime.InteropServices.Architecture.X64 => "x64", + System.Runtime.InteropServices.Architecture.Arm64 => "arm64", + var other => throw new MxcException( + MxcErrorCode.RuntimeUnavailable, + $"MXC does not ship a runtime for {other}.") + }; + + private static MxcRuntimeProvenance? ReadProvenance(string directory) + { + string path = Path.Combine(directory, ProvenanceFileName); + if (!File.Exists(path)) + { + return null; + } + + // Provenance is descriptive metadata for diagnostics. A damaged file + // must not block a runtime whose binaries were already verified at + // staging time, so report unknown provenance instead of failing. + try + { + MxcRuntimeProvenancePayload? payload = JsonSerializer.Deserialize( + File.ReadAllText(path), + MxcRuntimeJsonContext.Default.MxcRuntimeProvenancePayload); + return payload is null || + string.IsNullOrWhiteSpace(payload.Package) || + string.IsNullOrWhiteSpace(payload.Version) || + string.IsNullOrWhiteSpace(payload.Architecture) + ? null + : new MxcRuntimeProvenance( + payload.Package, + payload.Version, + payload.Architecture); + } + catch (Exception exception) when ( + exception is JsonException or IOException or UnauthorizedAccessException) + { + return null; + } + } +} + +internal sealed class MxcRuntimeProvenancePayload +{ + [JsonPropertyName("package")] + public string? Package { get; set; } + + [JsonPropertyName("version")] + public string? Version { get; set; } + + [JsonPropertyName("architecture")] + public string? Architecture { get; set; } +} + +[JsonSerializable(typeof(MxcRuntimeProvenancePayload))] +internal sealed partial class MxcRuntimeJsonContext : JsonSerializerContext; diff --git a/src/OpenClaw.Launcher/Mxc/MxcSessionContracts.cs b/src/OpenClaw.Launcher/Mxc/MxcSessionContracts.cs new file mode 100644 index 00000000..84f265e1 --- /dev/null +++ b/src/OpenClaw.Launcher/Mxc/MxcSessionContracts.cs @@ -0,0 +1,157 @@ +namespace OpenClaw.Launcher.Mxc; + +internal sealed record MxcBackendProbe( + bool IsolationSessionAvailable, + string? Tier, + IReadOnlyList Warnings); + +/// +/// The MXC containment backend this package uses. Only IsolationSession is +/// supported; backend selection is explicit so a host that silently resolves a +/// different backend cannot masquerade as a managed OpenClaw session. +/// +internal enum MxcContainment +{ + IsolationSession +} + +/// +/// An opaque, backend-issued sandbox identity. The full value must be persisted +/// and replayed verbatim: it carries the provisioning application identity and +/// backend routing prefix, so a bare instance GUID is not a substitute. +/// +internal readonly record struct MxcSandboxId +{ + private MxcSandboxId(string value, string backendPrefix) + { + Value = value; + BackendPrefix = backendPrefix; + } + + public string Value { get; } + + /// + /// Leading segment of that selects the backend, such as + /// iso for IsolationSession. + /// + public string BackendPrefix { get; } + + public const string IsolationSessionPrefix = "iso"; + + public bool IsIsolationSession => + string.Equals(BackendPrefix, IsolationSessionPrefix, StringComparison.Ordinal); + + /// + /// Parses a persisted or backend-returned identity. A missing or unknown + /// prefix is a malformed identity, not an unknown backend to guess at. + /// + public static MxcSandboxId Parse(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new MxcException( + MxcErrorCode.MalformedId, + "Sandbox id is empty."); + } + + int separator = value.IndexOf(':', StringComparison.Ordinal); + if (separator <= 0) + { + throw new MxcException( + MxcErrorCode.MalformedId, + "Sandbox id must carry a backend prefix."); + } + + return new MxcSandboxId(value, value[..separator]); + } + + public override string ToString() => Value; +} + +/// +/// Provision-time metadata reported by IsolationSession. The workspace path is +/// an ephemeral directory shared between the caller and the isolated agent user +/// and is removed when the sandbox is deprovisioned. +/// +internal sealed record MxcProvisionMetadata( + string AgentUserName, + string AgentUserSid, + string EphemeralWorkspacePath); + +/// +/// Request to create a sandbox. must be +/// PFN:<packageFamilyName> for a packaged caller; it is fixed for +/// the sandbox lifetime and is rejected on every later phase. +/// +internal sealed record MxcProvisionRequest(string AppId); + +internal sealed record MxcProvisionResult( + MxcSandboxId SandboxId, + MxcProvisionMetadata? Metadata, + string? CorrelationVector); + +/// +/// A command to run inside a started sandbox. +/// +/// +/// The backend accepts a single command-line string, not an argument vector, so +/// arbitrary OpenClaw arguments must never be interpolated here. Callers launch +/// a controlled guest helper and pass real arguments as request data. +/// +internal sealed record MxcExecutionRequest(string CommandLine); + +internal sealed record MxcExecutionResult( + int ExitCode, + string StandardOutput, + string StandardError); + +/// +/// The MXC lifecycle operations this package requires, shaped after the +/// upcoming Microsoft.Mxc.Sdk state-aware API so the published SDK can +/// replace the transport without changing session or gateway behavior. +/// +internal interface IMxcSessionClient +{ + Task ProvisionAsync( + MxcProvisionRequest request, + CancellationToken cancellationToken); + + Task StartAsync( + MxcSandboxId sandboxId, + string? correlationVector, + CancellationToken cancellationToken); + + Task ExecuteAsync( + MxcSandboxId sandboxId, + MxcExecutionRequest request, + string? correlationVector, + CancellationToken cancellationToken); + + /// + /// Runs a command with the caller's console attached, returning its exit + /// code. Nothing is captured. + /// + /// + /// Interactive OpenClaw requires this: a buffered execution only returns + /// after the child exits, so prompts would never reach the terminal and + /// typed input would never reach the child. Because nothing is captured, a + /// dispatch failure cannot be read back as a structured envelope, and the + /// caller must establish the outcome from the guest helper's control + /// result instead. + /// + Task ExecuteAttachedAsync( + MxcSandboxId sandboxId, + MxcExecutionRequest request, + string? correlationVector, + CancellationToken cancellationToken); + + Task StopAsync( + MxcSandboxId sandboxId, + string? correlationVector, + CancellationToken cancellationToken); + + Task DeprovisionAsync( + MxcSandboxId sandboxId, + string? correlationVector, + CancellationToken cancellationToken); +} diff --git a/src/OpenClaw.Launcher/Mxc/MxcWireModels.cs b/src/OpenClaw.Launcher/Mxc/MxcWireModels.cs new file mode 100644 index 00000000..202fcf6a --- /dev/null +++ b/src/OpenClaw.Launcher/Mxc/MxcWireModels.cs @@ -0,0 +1,165 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenClaw.Launcher.Mxc; + +// Wire shapes for the pinned @microsoft/mxc-sdk state-aware protocol. They are +// deliberately internal: the preview envelope format must not leak into this +// package's public surface, because the published .NET SDK will replace this +// transport without changing IMxcSessionClient. + +internal sealed class MxcRequestEnvelope +{ + [JsonPropertyName("version")] + public string Version { get; set; } = MxcWireProtocol.IsolationSessionSchemaVersion; + + [JsonPropertyName("phase")] + public string Phase { get; set; } = string.Empty; + + [JsonPropertyName("containment")] + public string? Containment { get; set; } + + [JsonPropertyName("sandboxId")] + public string? SandboxId { get; set; } + + [JsonPropertyName("correlationVector")] + public string? CorrelationVector { get; set; } + + [JsonPropertyName("network")] + public MxcNetworkAcknowledgement? Network { get; set; } + + [JsonPropertyName("process")] + public MxcProcessConfig? Process { get; set; } + + [JsonPropertyName("experimental")] + public MxcExperimentalSection? Experimental { get; set; } +} + +/// +/// IsolationSession's required unrestricted-network acknowledgement. The only +/// accepted value is allow + local network; the constants are fixed rather than +/// configurable so this package cannot advertise a filter the backend refuses. +/// +internal sealed class MxcNetworkAcknowledgement +{ + [JsonPropertyName("defaultPolicy")] + public string DefaultPolicy { get; set; } = "allow"; + + [JsonPropertyName("allowLocalNetwork")] + public bool AllowLocalNetwork { get; set; } = true; +} + +internal sealed class MxcProcessConfig +{ + [JsonPropertyName("commandLine")] + public string CommandLine { get; set; } = string.Empty; +} + +internal sealed class MxcExperimentalSection +{ + [JsonPropertyName("isolation_session")] + public MxcIsolationSessionPhases? IsolationSession { get; set; } +} + +internal sealed class MxcIsolationSessionPhases +{ + [JsonPropertyName("provision")] + public MxcIsolationSessionProvisionFields? Provision { get; set; } +} + +internal sealed class MxcIsolationSessionProvisionFields +{ + [JsonPropertyName("appId")] + public string? AppId { get; set; } +} + +internal sealed class MxcResponseEnvelope +{ + [JsonPropertyName("result")] + public JsonElement? Result { get; set; } + + [JsonPropertyName("error")] + public MxcErrorEnvelope? Error { get; set; } +} + +internal sealed class MxcErrorEnvelope +{ + [JsonPropertyName("code")] + public string? Code { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + /// + /// Backend operation that failed. Observed on IsolationSession exec + /// failures, e.g. "IsoSessionOps.RunProcessWithOptionsAsync". + /// + [JsonPropertyName("operation")] + public string? Operation { get; set; } + + /// Underlying Windows status, e.g. "0x80070520". + [JsonPropertyName("nativeCode")] + public string? NativeCode { get; set; } + + /// + /// Backend-authored guidance. Surfaced verbatim because it names the + /// actual recovery step more precisely than this package can infer. + /// + [JsonPropertyName("remediation")] + public string? Remediation { get; set; } +} + +internal sealed class MxcProvisionResultPayload +{ + [JsonPropertyName("sandboxId")] + public string? SandboxId { get; set; } + + [JsonPropertyName("correlationVector")] + public string? CorrelationVector { get; set; } + + [JsonPropertyName("metadata")] + public MxcProvisionMetadataPayload? Metadata { get; set; } +} + +internal sealed class MxcProvisionMetadataPayload +{ + [JsonPropertyName("agentUserName")] + public string? AgentUserName { get; set; } + + [JsonPropertyName("agentUserSid")] + public string? AgentUserSid { get; set; } + + [JsonPropertyName("ephemeralWorkspacePath")] + public string? EphemeralWorkspacePath { get; set; } +} + +internal sealed class MxcProbeResponse +{ + [JsonPropertyName("tier")] + public string? Tier { get; set; } + + [JsonPropertyName("warnings")] + public string[]? Warnings { get; set; } + + [JsonPropertyName("probes")] + public MxcProbeDetails? Probes { get; set; } +} + +internal sealed class MxcProbeDetails +{ + [JsonPropertyName("isolationSessionAvailable")] + public bool? IsolationSessionAvailable { get; set; } + + [JsonPropertyName("baseContainerApiPresent")] + public bool? BaseContainerApiPresent { get; set; } +} + +// Source generation keeps serialization reflection-free so the NativeAOT +// executable behaves the same as the JIT test build. +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(MxcRequestEnvelope))] +[JsonSerializable(typeof(MxcResponseEnvelope))] +[JsonSerializable(typeof(MxcProvisionResultPayload))] +[JsonSerializable(typeof(MxcProbeResponse))] +internal sealed partial class MxcJsonContext : JsonSerializerContext; diff --git a/src/OpenClaw.Launcher/Mxc/MxcWireProtocol.cs b/src/OpenClaw.Launcher/Mxc/MxcWireProtocol.cs new file mode 100644 index 00000000..e8d1fdc4 --- /dev/null +++ b/src/OpenClaw.Launcher/Mxc/MxcWireProtocol.cs @@ -0,0 +1,266 @@ +using System.Text; +using System.Text.Json; + +namespace OpenClaw.Launcher.Mxc; + +/// +/// Builds and interprets the pinned MXC state-aware wire protocol: a base64 +/// UTF-8 JSON request envelope on the command line, and a single JSON response +/// envelope on standard output for every phase except execution. +/// +internal static class MxcWireProtocol +{ + /// + /// Schema version stamped on IsolationSession envelopes. This is the wire + /// schema, which is versioned separately from the npm package version + /// recorded in mxc-runtime.lock.json; both are pinned independently. + /// + public const string IsolationSessionSchemaVersion = "0.6.0-alpha"; + + public const string IsolationSessionContainment = "isolation_session"; + + public const string ProvisionPhase = "provision"; + public const string StartPhase = "start"; + public const string ExecPhase = "exec"; + public const string StopPhase = "stop"; + public const string DeprovisionPhase = "deprovision"; + + public static MxcRequestEnvelope BuildProvisionEnvelope(string appId) => + new() + { + Phase = ProvisionPhase, + Containment = IsolationSessionContainment, + Network = new MxcNetworkAcknowledgement(), + Experimental = new MxcExperimentalSection + { + IsolationSession = new MxcIsolationSessionPhases + { + Provision = new MxcIsolationSessionProvisionFields + { + AppId = appId + } + } + } + }; + + /// + /// Builds a post-provision envelope. Network policy and application + /// identity are fixed at provision and are rejected on later phases, so + /// they are deliberately absent here. + /// + public static MxcRequestEnvelope BuildPhaseEnvelope( + string phase, + MxcSandboxId sandboxId, + string? correlationVector, + string? commandLine = null) + { + if (!sandboxId.IsIsolationSession) + { + throw new MxcException( + MxcErrorCode.MalformedId, + $"Sandbox id prefix '{sandboxId.BackendPrefix}' is not an " + + "IsolationSession identity."); + } + + return new MxcRequestEnvelope + { + Phase = phase, + SandboxId = sandboxId.Value, + CorrelationVector = correlationVector, + Process = commandLine is null + ? null + : new MxcProcessConfig { CommandLine = commandLine } + }; + } + + public static string EncodeConfig(MxcRequestEnvelope envelope) + { + string json = JsonSerializer.Serialize( + envelope, + MxcJsonContext.Default.MxcRequestEnvelope); + return Convert.ToBase64String(Encoding.UTF8.GetBytes(json)); + } + + internal static MxcRequestEnvelope DecodeConfig(string configBase64) + { + string json = Encoding.UTF8.GetString(Convert.FromBase64String(configBase64)); + return JsonSerializer.Deserialize( + json, + MxcJsonContext.Default.MxcRequestEnvelope) + ?? throw new MxcException( + MxcErrorCode.ProtocolViolation, + "Request envelope decoded to null."); + } + + /// + /// Parses the single response envelope produced by a non-execution phase. + /// An {error} envelope is raised as an + /// carrying the backend's own code. + /// + public static JsonElement ParseNonExecutionResponse(string standardOutput) + { + MxcResponseEnvelope envelope = DeserializeResponse(standardOutput); + if (envelope.Error is not null) + { + throw ToException(envelope.Error); + } + + if (envelope.Result is null) + { + throw new MxcException( + MxcErrorCode.ProtocolViolation, + "MXC response contained neither a result nor an error."); + } + + return envelope.Result.Value; + } + + /// + /// Discriminates an MXC dispatch failure from ordinary command output. + /// + /// + /// Execution forwards the guest command's raw output, so output that merely + /// happens to be JSON must not be reported as a dispatch error. Only a + /// complete {error:{code}} envelope qualifies. + /// + public static MxcException? TryParseExecutionError(string standardOutput) + { + MxcResponseEnvelope envelope; + try + { + envelope = DeserializeResponse(standardOutput); + } + catch (MxcException) + { + return null; + } + + return string.IsNullOrEmpty(envelope.Error?.Code) + ? null + : ToException(envelope.Error!); + } + + public static MxcProvisionResult ReadProvisionResult(JsonElement result) + { + MxcProvisionResultPayload? payload; + try + { + payload = result.Deserialize( + MxcJsonContext.Default.MxcProvisionResultPayload); + } + catch (JsonException exception) + { + throw new MxcException( + MxcErrorCode.ProtocolViolation, + "MXC provision result could not be interpreted.", + innerException: exception); + } + + if (string.IsNullOrWhiteSpace(payload?.SandboxId)) + { + throw new MxcException( + MxcErrorCode.ProtocolViolation, + "MXC provision result did not include a sandbox id."); + } + + // Metadata is reported only when the backend supplies every field. A + // partially populated workspace/agent identity is worse than none: it + // would let later phases act on an unusable path or account. + MxcProvisionMetadataPayload? metadata = payload.Metadata; + MxcProvisionMetadata? provisionMetadata = + metadata is null || + string.IsNullOrWhiteSpace(metadata.AgentUserName) || + string.IsNullOrWhiteSpace(metadata.AgentUserSid) || + string.IsNullOrWhiteSpace(metadata.EphemeralWorkspacePath) + ? null + : new MxcProvisionMetadata( + metadata.AgentUserName, + metadata.AgentUserSid, + metadata.EphemeralWorkspacePath); + + return new MxcProvisionResult( + MxcSandboxId.Parse(payload.SandboxId), + provisionMetadata, + payload.CorrelationVector); + } + + private static MxcResponseEnvelope DeserializeResponse(string standardOutput) + { + try + { + return JsonSerializer.Deserialize( + standardOutput.Trim(), + MxcJsonContext.Default.MxcResponseEnvelope) + ?? throw new MxcException( + MxcErrorCode.ProtocolViolation, + "MXC response envelope was empty."); + } + catch (JsonException exception) + { + throw new MxcException( + MxcErrorCode.ProtocolViolation, + "MXC response was not a JSON envelope.", + innerException: exception); + } + } + + /// + /// Reads the executor's --probe output. This is plain JSON, not a + /// lifecycle result/error envelope, so it is parsed separately. + /// + public static MxcBackendProbe ReadProbeResponse(string standardOutput) + { + MxcProbeResponse? payload; + try + { + payload = JsonSerializer.Deserialize( + standardOutput, + MxcJsonContext.Default.MxcProbeResponse); + } + catch (JsonException exception) + { + throw new MxcException( + MxcErrorCode.ProtocolViolation, + "The MXC host capability probe returned malformed JSON.", + innerException: exception); + } + + if (payload?.Probes?.IsolationSessionAvailable is not bool available) + { + throw new MxcException( + MxcErrorCode.ProtocolViolation, + "The MXC host capability probe did not report " + + "isolationSessionAvailable."); + } + + return new MxcBackendProbe( + available, + payload.Tier, + payload.Warnings ?? []); + } + + private static MxcException ToException(MxcErrorEnvelope error) + { + string message = error.Message is { Length: > 0 } + ? error.Message + : $"MXC reported error '{error.Code ?? "unspecified"}'."; + + // The backend's own remediation text names the recovery step more + // precisely than this package can infer from the code alone, so it is + // appended verbatim rather than replaced with a generic hint. + if (error.Remediation is { Length: > 0 }) + { + message = $"{message} {error.Remediation}"; + } + + if (error.NativeCode is { Length: > 0 }) + { + message = $"{message} (native code {error.NativeCode})"; + } + + return new MxcException( + MxcException.Classify(error.Code), + message, + error.Code); + } +} diff --git a/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj b/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj index e5c36c2d..adbf872a 100644 --- a/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj +++ b/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj @@ -79,6 +79,12 @@ + + x64 + arm64 + $(MSBuildThisFileDirectory)..\..\content\mxc\$(MxcRuntimeArchitecture)\ + + + + + + ( + () => client.ProvisionAsync( + new MxcProvisionRequest(string.Empty), + CancellationToken.None)); + + Assert.Equal(MxcErrorCode.PolicyValidation, exception.Code); + Assert.Null(invoker.Invocation); + } + + [Theory] + [InlineData(MxcWireProtocol.StartPhase)] + [InlineData(MxcWireProtocol.StopPhase)] + [InlineData(MxcWireProtocol.DeprovisionPhase)] + public async Task LifecyclePhasesReplayTheSandboxIdentityAndCorrelation( + string phase) + { + var invoker = new RecordingInvoker("""{"result":{}}"""); + var client = new MxcCliSessionClient(Runtime, invoker); + + await (phase switch + { + MxcWireProtocol.StartPhase => + client.StartAsync(SandboxId, "cv-9", CancellationToken.None), + MxcWireProtocol.StopPhase => + client.StopAsync(SandboxId, "cv-9", CancellationToken.None), + _ => client.DeprovisionAsync( + SandboxId, + "cv-9", + CancellationToken.None) + }).ConfigureAwait(true); + + MxcRequestEnvelope sent = + MxcWireProtocol.DecodeConfig(invoker.Invocation!.Arguments[1]); + Assert.Equal(phase, sent.Phase); + Assert.Equal(SandboxId.Value, sent.SandboxId); + Assert.Equal("cv-9", sent.CorrelationVector); + } + + [Fact] + public async Task LifecycleFailureReportsTheBackendErrorNotTheExitCode() + { + var client = new MxcCliSessionClient( + Runtime, + new RecordingInvoker( + """{"error":{"code":"stale_id","message":"sandbox is gone"}}""", + exitCode: 1)); + + MxcException exception = await Assert.ThrowsAsync( + () => client.StartAsync(SandboxId, null, CancellationToken.None)); + + Assert.Equal(MxcErrorCode.StaleId, exception.Code); + Assert.Equal("sandbox is gone", exception.Message); + } + + [Fact] + public async Task SilentExecutorFailureSurfacesItsDiagnostics() + { + var client = new MxcCliSessionClient( + Runtime, + new RecordingInvoker( + standardOutput: string.Empty, + exitCode: 9, + standardError: "backend unavailable")); + + MxcException exception = await Assert.ThrowsAsync( + () => client.StopAsync(SandboxId, null, CancellationToken.None)); + + Assert.Equal(MxcErrorCode.ProtocolViolation, exception.Code); + Assert.Contains("backend unavailable", exception.Message, StringComparison.Ordinal); + Assert.Contains("9", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ExecutionForwardsTheCommandsOwnOutputAndExitCode() + { + var client = new MxcCliSessionClient( + Runtime, + new RecordingInvoker( + standardOutput: "openclaw output", + exitCode: 3, + standardError: "openclaw warning")); + + MxcExecutionResult result = await client.ExecuteAsync( + SandboxId, + new MxcExecutionRequest("\"C:\\helper.exe\" --request r.json"), + null, + CancellationToken.None); + + Assert.Equal(3, result.ExitCode); + Assert.Equal("openclaw output", result.StandardOutput); + Assert.Equal("openclaw warning", result.StandardError); + } + + [Fact] + public async Task FailingCommandThatPrintsErrorShapedJsonIsNotADispatchFailure() + { + // OpenClaw legitimately emits JSON. Treating it as an MXC fault would + // replace a real command failure with a misleading session error. + var client = new MxcCliSessionClient( + Runtime, + new RecordingInvoker( + standardOutput: """{"error":{"reason":"login required"}}""", + exitCode: 1)); + + MxcExecutionResult result = await client.ExecuteAsync( + SandboxId, + new MxcExecutionRequest("helper.exe"), + null, + CancellationToken.None); + + Assert.Equal(1, result.ExitCode); + Assert.Contains("login required", result.StandardOutput, StringComparison.Ordinal); + } + + [Fact] + public async Task DispatchFailureDuringExecutionIsReportedAsASessionError() + { + var client = new MxcCliSessionClient( + Runtime, + new RecordingInvoker( + standardOutput: + """{"error":{"code":"stale_id","message":"sandbox is gone"}}""", + exitCode: 1)); + + MxcException exception = await Assert.ThrowsAsync( + () => client.ExecuteAsync( + SandboxId, + new MxcExecutionRequest("helper.exe"), + null, + CancellationToken.None)); + + Assert.Equal(MxcErrorCode.StaleId, exception.Code); + } + + [Fact] + public async Task SuccessfulCommandOutputIsNeverInspectedForDispatchErrors() + { + var client = new MxcCliSessionClient( + Runtime, + new RecordingInvoker( + standardOutput: + """{"error":{"code":"stale_id","message":"printed by the app"}}""", + exitCode: 0)); + + MxcExecutionResult result = await client.ExecuteAsync( + SandboxId, + new MxcExecutionRequest("helper.exe"), + null, + CancellationToken.None); + + Assert.Equal(0, result.ExitCode); + } + + [Fact] + public async Task ExecutionWithoutACommandLineIsRejectedLocally() + { + var invoker = new RecordingInvoker("{}"); + var client = new MxcCliSessionClient(Runtime, invoker); + + MxcException exception = await Assert.ThrowsAsync( + () => client.ExecuteAsync( + SandboxId, + new MxcExecutionRequest(" "), + null, + CancellationToken.None)); + + Assert.Equal(MxcErrorCode.PolicyValidation, exception.Code); + Assert.Null(invoker.Invocation); + } + + [Fact] + public async Task ProbeAsksTheExecutorWithoutAnEnvelopeOrSandbox() + { + // The probe must stay non-mutating: no config envelope, no + // experimental lifecycle flag, and therefore no sandbox is created on + // the read-only setup path. + var invoker = new RecordingInvoker( + """{"tier":"base-container","warnings":[],"probes":{"isolationSessionAvailable":true}}"""); + var client = new MxcCliSessionClient(Runtime, invoker); + + MxcBackendProbe probe = await client.ProbeBackendAsync( + CancellationToken.None); + + Assert.True(probe.IsolationSessionAvailable); + Assert.Equal(Runtime.ExecutorPath, invoker.Invocation!.ExecutorPath); + Assert.Equal(["--probe"], invoker.Invocation.Arguments); + } + + [Fact] + public async Task AFailedProbeInvocationSurfacesAsRuntimeUnavailable() + { + var invoker = new RecordingInvoker( + standardOutput: string.Empty, + exitCode: 1, + standardError: "probe unsupported"); + var client = new MxcCliSessionClient(Runtime, invoker); + + MxcException exception = await Assert.ThrowsAsync( + () => client.ProbeBackendAsync(CancellationToken.None)); + + Assert.Equal(MxcErrorCode.RuntimeUnavailable, exception.Code); + Assert.Contains("probe unsupported", exception.Message, StringComparison.Ordinal); + } + + private sealed class RecordingInvoker( + string standardOutput, + int exitCode = 0, + string standardError = "") : IMxcExecutorInvoker + { + public MxcExecutorInvocation? Invocation { get; private set; } + + public Task InvokeAsync( + MxcExecutorInvocation invocation, + CancellationToken cancellationToken) + { + Invocation = invocation; + return Task.FromResult( + new MxcExecutorOutcome(exitCode, standardOutput, standardError)); + } + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Mxc/MxcReadinessTests.cs b/tests/OpenClaw.Launcher.Tests/Mxc/MxcReadinessTests.cs new file mode 100644 index 00000000..4bb98655 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Mxc/MxcReadinessTests.cs @@ -0,0 +1,197 @@ +using OpenClaw.Launcher.Mxc; + +namespace OpenClaw.Launcher.Tests.Mxc; + +public sealed class MxcReadinessTests : IDisposable +{ + private readonly string _testDirectory = TestDirectory.Create(); + + private static string? NoEnvironment(string name) => null; + + /// + /// Stands in for a host whose backend detector cannot run, which is the + /// case whenever the runtime itself is missing. + /// + private static Task BackendUnreachable( + MxcRuntimeLocation location, + CancellationToken cancellationToken) => + Task.FromException( + new MxcException( + MxcErrorCode.RuntimeUnavailable, + "probe unavailable")); + + private static Func> + BackendReports(bool available, string? tier = "base-container") => + (_, _) => Task.FromResult(new MxcBackendProbe(available, tier, [])); + + private string StageRuntime() + { + string runtimeDirectory = Path.Combine(_testDirectory, "runtime"); + Directory.CreateDirectory(runtimeDirectory); + File.WriteAllText( + Path.Combine(runtimeDirectory, MxcRuntimeLocator.ExecutorFileName), + string.Empty); + File.WriteAllText( + Path.Combine( + runtimeDirectory, + MxcRuntimeLocator.PackageLifecycleFileName), + string.Empty); + File.WriteAllText( + Path.Combine(runtimeDirectory, MxcRuntimeLocator.ProvenanceFileName), + """ + {"package":"@microsoft/mxc-sdk","version":"0.8.0","architecture":"x64"} + """); + return runtimeDirectory; + } + + private static Func RuntimeAt(string runtimeDirectory) => + name => name == MxcRuntimeLocator.RuntimeDirectoryVariable + ? runtimeDirectory + : null; + + [Fact] + public async Task ProbeReportsAMissingRuntimeWithoutFailing() + { + MxcReadinessReport report = await MxcReadiness.ProbeAsync( + _testDirectory, + NoEnvironment, + () => new MxcHostBuild(27000, 1), + BackendUnreachable, + CancellationToken.None); + + Assert.False(report.RuntimeAvailable); + Assert.NotNull(report.RuntimeUnavailableReason); + Assert.Null(report.RuntimeDirectory); + + // Without the runtime the backend cannot be asked, so support falls + // back to the documented build minimum and says so. + Assert.Equal(MxcHostSupport.Supported, report.HostSupport); + Assert.Equal(MxcSupportEvidence.HostBuild, report.SupportEvidence); + } + + [Fact] + public async Task ProbeReportsAStagedRuntimeAndItsProvenance() + { + string runtimeDirectory = StageRuntime(); + + MxcReadinessReport report = await MxcReadiness.ProbeAsync( + _testDirectory, + RuntimeAt(runtimeDirectory), + () => MxcReadiness.MinimumHostBuild, + BackendReports(available: true), + CancellationToken.None); + + Assert.True(report.RuntimeAvailable); + Assert.Equal(runtimeDirectory, report.RuntimeDirectory); + Assert.Equal("0.8.0", report.Provenance?.Version); + } + + [Fact] + public async Task BackendProbeOverridesAnOtherwiseSupportedBuild() + { + // The decisive case: a new enough build whose backend is nonetheless + // unusable. Trusting the build number alone would promise a capability + // this host does not have. + string runtimeDirectory = StageRuntime(); + + MxcReadinessReport report = await MxcReadiness.ProbeAsync( + _testDirectory, + RuntimeAt(runtimeDirectory), + () => new MxcHostBuild(27000, 1), + BackendReports(available: false), + CancellationToken.None); + + Assert.Equal(MxcHostSupport.Unsupported, report.HostSupport); + Assert.Equal(MxcSupportEvidence.BackendProbe, report.SupportEvidence); + } + + [Fact] + public async Task BackendProbeOverridesAnOtherwiseUnsupportedBuild() + { + string runtimeDirectory = StageRuntime(); + + MxcReadinessReport report = await MxcReadiness.ProbeAsync( + _testDirectory, + RuntimeAt(runtimeDirectory), + () => new MxcHostBuild(19045, 1), + BackendReports(available: true), + CancellationToken.None); + + Assert.Equal(MxcHostSupport.Supported, report.HostSupport); + Assert.Equal(MxcSupportEvidence.BackendProbe, report.SupportEvidence); + } + + [Fact] + public async Task AFailedBackendProbeDegradesToTheBuildCheckAndIsReported() + { + string runtimeDirectory = StageRuntime(); + + MxcReadinessReport report = await MxcReadiness.ProbeAsync( + _testDirectory, + RuntimeAt(runtimeDirectory), + () => new MxcHostBuild(27000, 1), + (_, _) => throw new InvalidOperationException("executor crashed"), + CancellationToken.None); + + Assert.Equal(MxcHostSupport.Supported, report.HostSupport); + Assert.Equal(MxcSupportEvidence.HostBuild, report.SupportEvidence); + Assert.Contains("executor crashed", report.BackendProbeFailureReason, StringComparison.Ordinal); + } + + [Theory] + [InlineData(26339, 99999, nameof(MxcHostSupport.Unsupported))] + [InlineData(26340, 9211, nameof(MxcHostSupport.Unsupported))] + [InlineData(26340, 9212, nameof(MxcHostSupport.Supported))] + [InlineData(26340, 9300, nameof(MxcHostSupport.Supported))] + [InlineData(26341, 0, nameof(MxcHostSupport.Supported))] + public async Task HostSupportComparesTheUpdateBuildRevisionNotJustTheBuild( + int build, + int updateBuildRevision, + string expectedName) + { + MxcReadinessReport report = await MxcReadiness.ProbeAsync( + _testDirectory, + NoEnvironment, + () => new MxcHostBuild(build, updateBuildRevision), + BackendUnreachable, + CancellationToken.None); + + Assert.Equal(Enum.Parse(expectedName), report.HostSupport); + } + + [Fact] + public async Task AnUndeterminableHostBuildIsReportedAsUnknownNotUnsupported() + { + // Reporting unsupported would tell a capable machine's user that + // isolated sessions can never work there. + MxcReadinessReport report = await MxcReadiness.ProbeAsync( + _testDirectory, + NoEnvironment, + () => null, + BackendUnreachable, + CancellationToken.None); + + Assert.Equal(MxcHostSupport.Unknown, report.HostSupport); + Assert.Null(report.HostBuild); + Assert.Equal(MxcSupportEvidence.None, report.SupportEvidence); + } + + [Fact] + public async Task ProbeDoesNotCreateOrModifyAnything() + { + await MxcReadiness.ProbeAsync( + _testDirectory, + NoEnvironment, + () => null, + BackendUnreachable, + CancellationToken.None); + + Assert.Empty(Directory.GetFileSystemEntries(_testDirectory)); + } + + public void Dispose() + { + Directory.Delete(_testDirectory, recursive: true); + GC.SuppressFinalize(this); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Mxc/MxcRuntimeLocatorTests.cs b/tests/OpenClaw.Launcher.Tests/Mxc/MxcRuntimeLocatorTests.cs new file mode 100644 index 00000000..e2d27f58 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Mxc/MxcRuntimeLocatorTests.cs @@ -0,0 +1,133 @@ +using OpenClaw.Launcher.Mxc; + +namespace OpenClaw.Launcher.Tests.Mxc; + +public sealed class MxcRuntimeLocatorTests : IDisposable +{ + private readonly string _testDirectory = TestDirectory.Create(); + + private static string? NoEnvironment(string name) => null; + + [Fact] + public void RuntimeIsResolvedFromTheArchitectureSpecificPackageDirectory() + { + string runtimeDirectory = StageRuntime( + Path.Combine( + _testDirectory, + MxcRuntimeLocator.RuntimeDirectoryName, + MxcRuntimeLocator.CurrentArchitectureName())); + File.WriteAllText( + Path.Combine(runtimeDirectory, MxcRuntimeLocator.ProvenanceFileName), + """ + {"package":"@microsoft/mxc-sdk","version":"0.8.0","architecture":"x64"} + """); + + MxcRuntimeLocation location = MxcRuntimeLocator.Locate( + _testDirectory, + NoEnvironment); + + Assert.Equal(runtimeDirectory, location.Directory); + Assert.Equal( + Path.Combine(runtimeDirectory, MxcRuntimeLocator.ExecutorFileName), + location.ExecutorPath); + Assert.Equal("@microsoft/mxc-sdk", location.Provenance?.Package); + Assert.Equal("0.8.0", location.Provenance?.Version); + } + + [Fact] + public void AnExplicitRuntimeDirectoryOverridesThePackagedLayout() + { + string runtimeDirectory = StageRuntime( + Path.Combine(_testDirectory, "experiment")); + + MxcRuntimeLocation location = MxcRuntimeLocator.Locate( + _testDirectory, + name => name == MxcRuntimeLocator.RuntimeDirectoryVariable + ? runtimeDirectory + : null); + + Assert.Equal(runtimeDirectory, location.Directory); + } + + [Fact] + public void AMissingRuntimeNamesTheExpectedExecutorPath() + { + MxcException exception = Assert.Throws( + () => MxcRuntimeLocator.Locate(_testDirectory, NoEnvironment)); + + Assert.Equal(MxcErrorCode.RuntimeUnavailable, exception.Code); + Assert.Contains( + MxcRuntimeLocator.ExecutorFileName, + exception.Message, + StringComparison.Ordinal); + } + + [Fact] + public void AnIncompleteRuntimeIsRejectedRatherThanPartiallyUsed() + { + string runtimeDirectory = Path.Combine(_testDirectory, "experiment"); + Directory.CreateDirectory(runtimeDirectory); + File.WriteAllText( + Path.Combine(runtimeDirectory, MxcRuntimeLocator.ExecutorFileName), + string.Empty); + + MxcException exception = Assert.Throws( + () => MxcRuntimeLocator.Locate( + _testDirectory, + name => name == MxcRuntimeLocator.RuntimeDirectoryVariable + ? runtimeDirectory + : null)); + + Assert.Equal(MxcErrorCode.RuntimeUnavailable, exception.Code); + Assert.Contains( + MxcRuntimeLocator.PackageLifecycleFileName, + exception.Message, + StringComparison.Ordinal); + } + + [Theory] + [InlineData("not json")] + [InlineData("{}")] + [InlineData("""{"package":"@microsoft/mxc-sdk"}""")] + public void DamagedProvenanceLeavesTheVerifiedRuntimeUsable(string provenance) + { + // Provenance is descriptive. The binaries were verified at staging + // time, so an unreadable record must not disable isolated sessions. + string runtimeDirectory = StageRuntime( + Path.Combine(_testDirectory, "experiment")); + File.WriteAllText( + Path.Combine(runtimeDirectory, MxcRuntimeLocator.ProvenanceFileName), + provenance); + + MxcRuntimeLocation location = MxcRuntimeLocator.Locate( + _testDirectory, + name => name == MxcRuntimeLocator.RuntimeDirectoryVariable + ? runtimeDirectory + : null); + + Assert.Null(location.Provenance); + } + + private static string StageRuntime(string runtimeDirectory) + { + Directory.CreateDirectory(runtimeDirectory); + foreach (string fileName in new[] + { + MxcRuntimeLocator.ExecutorFileName, + MxcRuntimeLocator.PackageLifecycleFileName + }) + { + File.WriteAllText( + Path.Combine(runtimeDirectory, fileName), + string.Empty); + } + + return runtimeDirectory; + } + + public void Dispose() + { + Directory.Delete(_testDirectory, recursive: true); + GC.SuppressFinalize(this); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Mxc/MxcSandboxIdTests.cs b/tests/OpenClaw.Launcher.Tests/Mxc/MxcSandboxIdTests.cs new file mode 100644 index 00000000..96a09bde --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Mxc/MxcSandboxIdTests.cs @@ -0,0 +1,38 @@ +using OpenClaw.Launcher.Mxc; + +namespace OpenClaw.Launcher.Tests.Mxc; + +public sealed class MxcSandboxIdTests +{ + [Fact] + public void IdentityPreservesTheCompleteOpaqueValue() + { + // The backend carries the provisioning app identity inside the id, so + // persisting only the trailing instance segment loses it. + const string value = "iso:PFN:Contoso.App_8wekyb3d8bbwe:5f2c"; + + MxcSandboxId sandboxId = MxcSandboxId.Parse(value); + + Assert.Equal(value, sandboxId.Value); + Assert.Equal(value, sandboxId.ToString()); + Assert.Equal("iso", sandboxId.BackendPrefix); + Assert.True(sandboxId.IsIsolationSession); + } + + [Fact] + public void AnotherBackendsIdentityIsNotTreatedAsIsolationSession() => + Assert.False(MxcSandboxId.Parse("wslc:abc").IsIsolationSession); + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("abc123")] + [InlineData(":abc123")] + public void IdentitiesWithoutABackendPrefixAreMalformed(string value) + { + MxcException exception = + Assert.Throws(() => MxcSandboxId.Parse(value)); + + Assert.Equal(MxcErrorCode.MalformedId, exception.Code); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Mxc/MxcWireProtocolTests.cs b/tests/OpenClaw.Launcher.Tests/Mxc/MxcWireProtocolTests.cs new file mode 100644 index 00000000..8067f880 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Mxc/MxcWireProtocolTests.cs @@ -0,0 +1,321 @@ +using System.Text; +using System.Text.Json; +using OpenClaw.Launcher.Mxc; + +namespace OpenClaw.Launcher.Tests.Mxc; + +public sealed class MxcWireProtocolTests +{ + private const string SandboxIdValue = "iso:abc123"; + + [Fact] + public void ProvisionEnvelopeCarriesApplicationIdentityAndNetworkAcknowledgement() + { + MxcRequestEnvelope envelope = + MxcWireProtocol.BuildProvisionEnvelope("PFN:Contoso.App_8wekyb3d8bbwe"); + + JsonElement encoded = Decode(MxcWireProtocol.EncodeConfig(envelope)); + + Assert.Equal( + MxcWireProtocol.IsolationSessionSchemaVersion, + encoded.GetProperty("version").GetString()); + Assert.Equal("provision", encoded.GetProperty("phase").GetString()); + Assert.Equal( + "isolation_session", + encoded.GetProperty("containment").GetString()); + Assert.Equal( + "allow", + encoded.GetProperty("network").GetProperty("defaultPolicy").GetString()); + Assert.True( + encoded.GetProperty("network") + .GetProperty("allowLocalNetwork") + .GetBoolean()); + + // The backend only accepts appId nested per backend and phase; a + // top-level appId is silently ignored and the session would then be + // provisioned without this package's identity. + Assert.Equal( + "PFN:Contoso.App_8wekyb3d8bbwe", + encoded.GetProperty("experimental") + .GetProperty("isolation_session") + .GetProperty("provision") + .GetProperty("appId") + .GetString()); + Assert.False(encoded.TryGetProperty("appId", out _)); + Assert.False(encoded.TryGetProperty("sandboxId", out _)); + } + + [Fact] + public void PostProvisionEnvelopeOmitsPolicyFixedAtProvision() + { + MxcRequestEnvelope envelope = MxcWireProtocol.BuildPhaseEnvelope( + MxcWireProtocol.ExecPhase, + MxcSandboxId.Parse(SandboxIdValue), + "cv-1", + "\"C:\\Program Files\\helper.exe\" --request r.json"); + + JsonElement encoded = Decode(MxcWireProtocol.EncodeConfig(envelope)); + + Assert.Equal("exec", encoded.GetProperty("phase").GetString()); + Assert.Equal(SandboxIdValue, encoded.GetProperty("sandboxId").GetString()); + Assert.Equal("cv-1", encoded.GetProperty("correlationVector").GetString()); + Assert.Equal( + "\"C:\\Program Files\\helper.exe\" --request r.json", + encoded.GetProperty("process").GetProperty("commandLine").GetString()); + + // network and appId are fixed at provision and are rejected on later + // phases, so resending them would fail the whole request. Confirmed + // against wxc-exec.exe 0.8.0, which rejects a post-provision + // containment with malformed_request: "State-aware 'stop' requests + // must not carry 'containment'". + Assert.False(encoded.TryGetProperty("network", out _)); + Assert.False(encoded.TryGetProperty("containment", out _)); + Assert.False(encoded.TryGetProperty("experimental", out _)); + } + + [Fact] + public void PhaseEnvelopeOmitsProcessWhenNoCommandIsSupplied() + { + JsonElement encoded = Decode(MxcWireProtocol.EncodeConfig( + MxcWireProtocol.BuildPhaseEnvelope( + MxcWireProtocol.StopPhase, + MxcSandboxId.Parse(SandboxIdValue), + correlationVector: null))); + + Assert.False(encoded.TryGetProperty("process", out _)); + Assert.False(encoded.TryGetProperty("correlationVector", out _)); + } + + [Fact] + public void PhaseEnvelopeRejectsAnotherBackendsSandboxId() + { + MxcException exception = Assert.Throws( + () => MxcWireProtocol.BuildPhaseEnvelope( + MxcWireProtocol.StartPhase, + MxcSandboxId.Parse("wsb:abc123"), + correlationVector: null)); + + Assert.Equal(MxcErrorCode.MalformedId, exception.Code); + } + + [Theory] + [InlineData("policy_validation", nameof(MxcErrorCode.PolicyValidation))] + [InlineData("stale_id", nameof(MxcErrorCode.StaleId))] + [InlineData("malformed_id", nameof(MxcErrorCode.MalformedId))] + [InlineData("some_future_code", nameof(MxcErrorCode.Unknown))] + public void ErrorEnvelopeIsRaisedWithTheBackendsOwnCode( + string backendCode, + string expectedName) + { + MxcException exception = Assert.Throws( + () => MxcWireProtocol.ParseNonExecutionResponse( + $"{{\"error\":{{\"code\":\"{backendCode}\",\"message\":\"denied\"}}}}")); + + Assert.Equal(Enum.Parse(expectedName), exception.Code); + Assert.Equal(backendCode, exception.BackendCode); + Assert.Equal("denied", exception.Message); + } + + [Theory] + [InlineData("not json at all")] + [InlineData("{}")] + [InlineData("{\"unexpected\":1}")] + public void UninterpretableResponsesAreProtocolViolations(string output) + { + MxcException exception = Assert.Throws( + () => MxcWireProtocol.ParseNonExecutionResponse(output)); + + Assert.Equal(MxcErrorCode.ProtocolViolation, exception.Code); + } + + [Theory] + [InlineData("hello from the sandbox")] + [InlineData("{\"error\":\"something the command printed\"}")] + [InlineData("{\"error\":{\"message\":\"no code\"}}")] + [InlineData("{\"result\":{\"sandboxId\":\"iso:x\"}}")] + public void CommandOutputIsNotMistakenForADispatchFailure(string output) => + Assert.Null(MxcWireProtocol.TryParseExecutionError(output)); + + [Fact] + public void DispatchFailureDuringExecutionIsRecognized() + { + MxcException? exception = MxcWireProtocol.TryParseExecutionError( + """{"error":{"code":"stale_id","message":"gone"}}"""); + + Assert.NotNull(exception); + Assert.Equal(MxcErrorCode.StaleId, exception.Code); + } + + [Fact] + public void ProvisionResultReportsIdentityAndWorkspaceMetadata() + { + MxcProvisionResult result = MxcWireProtocol.ReadProvisionResult( + MxcWireProtocol.ParseNonExecutionResponse( + """ + {"result":{"sandboxId":"iso:abc123","correlationVector":"cv-1", + "metadata":{"agentUserName":"MxcAgent_1","agentUserSid":"S-1-5-21-1", + "ephemeralWorkspacePath":"C:\\ws\\1"}}} + """)); + + Assert.Equal("iso:abc123", result.SandboxId.Value); + Assert.True(result.SandboxId.IsIsolationSession); + Assert.Equal("cv-1", result.CorrelationVector); + Assert.Equal("MxcAgent_1", result.Metadata?.AgentUserName); + Assert.Equal("S-1-5-21-1", result.Metadata?.AgentUserSid); + Assert.Equal("C:\\ws\\1", result.Metadata?.EphemeralWorkspacePath); + } + + [Fact] + public void ProvisionResultWithoutASandboxIdIsAProtocolViolation() + { + MxcException exception = Assert.Throws( + () => MxcWireProtocol.ReadProvisionResult( + MxcWireProtocol.ParseNonExecutionResponse( + """{"result":{"correlationVector":"cv-1"}}"""))); + + Assert.Equal(MxcErrorCode.ProtocolViolation, exception.Code); + } + + [Fact] + public void PartialProvisionMetadataIsReportedAsAbsent() + { + // Half a workspace identity is worse than none: later phases would act + // on an unusable path or account. + MxcProvisionResult result = MxcWireProtocol.ReadProvisionResult( + MxcWireProtocol.ParseNonExecutionResponse( + """ + {"result":{"sandboxId":"iso:abc123", + "metadata":{"agentUserName":"MxcAgent_1"}}} + """)); + + Assert.Null(result.Metadata); + } + + // The envelopes below are verbatim captures from @microsoft/mxc-sdk 0.8.0 + // wxc-exec.exe running against the Windows IsolationSession backend, so + // they pin the real contract rather than an assumed one. + + [Theory] + [InlineData("malformed_id", nameof(MxcErrorCode.MalformedId))] + [InlineData("stale_id", nameof(MxcErrorCode.StaleId))] + [InlineData("policy_validation", nameof(MxcErrorCode.PolicyValidation))] + [InlineData("malformed_request", nameof(MxcErrorCode.MalformedRequest))] + [InlineData("unsupported_containment", nameof(MxcErrorCode.UnsupportedContainment))] + [InlineData("backend_error", nameof(MxcErrorCode.BackendError))] + [InlineData("some_future_code", nameof(MxcErrorCode.Unknown))] + public void ObservedBackendCodesAreClassified( + string backendCode, + string expectedName) + { + MxcException exception = Assert.Throws( + () => MxcWireProtocol.ParseNonExecutionResponse( + $"{{\"error\":{{\"code\":\"{backendCode}\",\"message\":\"m\"}}}}")); + + Assert.Equal(Enum.Parse(expectedName), exception.Code); + + // The verbatim code survives classification, including when this + // package has no specific handling for it. + Assert.Equal(backendCode, exception.BackendCode); + } + + [Fact] + public void ExecutionAgainstStoppedSessionSurfacesBackendRemediation() + { + // captured from an exec issued after a successful stop. + const string envelope = """ + {"error":{"code":"backend_error", + "message":"No active session exists.", + "operation":"IsoSessionOps.RunProcessWithOptionsAsync", + "nativeCode":"0x80070520", + "remediation":"No active session found for this agent user. Start a session first, then retry."}} + """; + + MxcException exception = Assert.Throws( + () => MxcWireProtocol.ParseNonExecutionResponse(envelope)); + + Assert.Equal(MxcErrorCode.BackendError, exception.Code); + + // Operators need the backend's own recovery step and native status, + // not just the one-line summary. + Assert.Contains("No active session exists.", exception.Message, StringComparison.Ordinal); + Assert.Contains("Start a session first", exception.Message, StringComparison.Ordinal); + Assert.Contains("0x80070520", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ProvisionResultReadsRealIsolationSessionPayload() + { + // captured from a real provision on Windows build 26686.1000. + const string envelope = """ + {"result":{"metadata":{"agentUserName":"C8-H2", + "agentUserSid":"S-1-5-21-3955704215-4282272831-1814204317-1321", + "ephemeralWorkspacePath":"C:\\Users\\C8-H2\\Shared"}, + "sandboxId":"iso:eyJ2ZXJzaW9uIjoxfQ"}} + """; + + MxcProvisionResult result = MxcWireProtocol.ReadProvisionResult( + MxcWireProtocol.ParseNonExecutionResponse(envelope)); + + Assert.Equal("iso:eyJ2ZXJzaW9uIjoxfQ", result.SandboxId.Value); + Assert.NotNull(result.Metadata); + Assert.Equal("C8-H2", result.Metadata!.AgentUserName); + Assert.Equal( + @"C:\Users\C8-H2\Shared", + result.Metadata.EphemeralWorkspacePath); + } + + [Fact] + public void ProbeResponseIsReadFromTheCapturedRuntimeOutput() + { + // Verbatim `wxc-exec --probe` output captured from the pinned runtime + // on a capable host; see docs\mxc-compatibility-evidence.md (G5). + const string captured = """ + {"tier":"base-container","needsDaclAugmentation":false,"warnings":[],"probes":{"baseContainerApiPresent":true,"isolationSessionAvailable":true}} + """; + + MxcBackendProbe probe = MxcWireProtocol.ReadProbeResponse(captured); + + Assert.True(probe.IsolationSessionAvailable); + Assert.Equal("base-container", probe.Tier); + Assert.Empty(probe.Warnings); + } + + [Fact] + public void ProbeResponseCarriesWarningsAndAnUnavailableBackend() + { + const string payload = """ + {"tier":"none","warnings":["host preparation required"],"probes":{"isolationSessionAvailable":false}} + """; + + MxcBackendProbe probe = MxcWireProtocol.ReadProbeResponse(payload); + + Assert.False(probe.IsolationSessionAvailable); + Assert.Equal("none", probe.Tier); + Assert.Equal("host preparation required", Assert.Single(probe.Warnings)); + } + + [Fact] + public void AProbeResponseMissingTheAvailabilityFlagIsRejected() + { + // Defaulting a missing flag either way would silently invent a support + // verdict, so the absent field must surface as a protocol violation. + MxcException exception = Assert.Throws( + () => MxcWireProtocol.ReadProbeResponse("""{"tier":"base-container"}""")); + + Assert.Equal(MxcErrorCode.ProtocolViolation, exception.Code); + } + + [Fact] + public void AMalformedProbeResponseIsRejected() + { + MxcException exception = Assert.Throws( + () => MxcWireProtocol.ReadProbeResponse("not json")); + + Assert.Equal(MxcErrorCode.ProtocolViolation, exception.Code); + } + + private static JsonElement Decode(string configBase64) => + JsonDocument + .Parse(Encoding.UTF8.GetString(Convert.FromBase64String(configBase64))) + .RootElement; +} diff --git a/tests/OpenClaw.Launcher.Tests/Mxc/ProcessMxcExecutorInvokerTests.cs b/tests/OpenClaw.Launcher.Tests/Mxc/ProcessMxcExecutorInvokerTests.cs new file mode 100644 index 00000000..672a11c8 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Mxc/ProcessMxcExecutorInvokerTests.cs @@ -0,0 +1,156 @@ +using System.Diagnostics; +using System.IO.Pipes; +using System.Text; +using OpenClaw.Launcher.Mxc; + +namespace OpenClaw.Launcher.Tests.Mxc; + +public sealed class ProcessMxcExecutorInvokerTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + + [Fact] + public async Task AttachedInvocationInitializesUtf8InsideConsoleCapture() + { + var console = new RecordingHostConsole(); + var invoker = new ProcessMxcExecutorInvoker(console); + + int exitCode = await invoker.InvokeAttachedAsync( + Cmd("exit /b 0"), + CancellationToken.None); + + Assert.Equal(0, exitCode); + Assert.Equal(["capture", "utf8", "restore"], console.Events); + } + + [Fact] + public async Task BufferedInvocationClosesStandardInput() + { + var invoker = new ProcessMxcExecutorInvoker(); + + MxcExecutorOutcome outcome = await invoker.InvokeAsync( + Cmd("set /p value= & echo eof"), + CancellationToken.None); + + Assert.Equal(0, outcome.ExitCode); + Assert.Contains("eof", outcome.StandardOutput, StringComparison.Ordinal); + } + + // The deadline is a hang detector, not a synchronization budget. The wait + // below completes as soon as the executor connects or exits, so a slow + // cold start cannot reach it; only a genuine hang can. + [Fact(Timeout = 300_000)] + public async Task CancellationTerminatesBufferedExecutorBeforeReturning() + { + string scriptPath = Path.Combine(_root, "wait.ps1"); + string pipeName = $"OpenClaw-{Guid.NewGuid():N}"; + await File.WriteAllTextAsync( + scriptPath, + """ + $pipe = [IO.Pipes.NamedPipeClientStream]::new( + '.', $args[0], [IO.Pipes.PipeDirection]::InOut) + $pipe.Connect() + $writer = [IO.StreamWriter]::new($pipe) + $writer.AutoFlush = $true + $writer.WriteLine($PID) + [void]$pipe.ReadByte() + """); + + using var pipe = new NamedPipeServerStream( + pipeName, + PipeDirection.InOut, + 1, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous); + using var cancellation = new CancellationTokenSource(); + var invoker = new ProcessMxcExecutorInvoker(); + string powershell = Path.Combine( + Environment.SystemDirectory, + "WindowsPowerShell", + "v1.0", + "powershell.exe"); + Task invocation = invoker.InvokeAsync( + new MxcExecutorInvocation( + powershell, + ["-NoLogo", "-NoProfile", "-NonInteractive", "-File", scriptPath, + pipeName]), + cancellation.Token); + + Task connection = pipe.WaitForConnectionAsync(CancellationToken.None); + Task completed = await Task.WhenAny(connection, invocation) + .ConfigureAwait(true); + if (completed == invocation) + { + MxcExecutorOutcome outcome = await invocation.ConfigureAwait(true); + throw new Xunit.Sdk.XunitException( + "Executor exited before connecting to the named pipe " + + $"(exit code {outcome.ExitCode}). Standard output: " + + $"{outcome.StandardOutput} Standard error: {outcome.StandardError}"); + } + + await connection.ConfigureAwait(true); + using var reader = new StreamReader( + pipe, + Encoding.UTF8, + detectEncodingFromByteOrderMarks: true, + bufferSize: 1024, + leaveOpen: true); + string processIdText = await reader.ReadLineAsync(CancellationToken.None) + ?? throw new InvalidOperationException("Executor did not report its process ID."); + int processId = int.Parse( + processIdText, + System.Globalization.CultureInfo.InvariantCulture); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => invocation); + Assert.Throws(() => Process.GetProcessById(processId)); + } + + [Fact] + public async Task PreCancelledInvocationDoesNotSpawnExecutor() + { + string markerPath = Path.Combine(_root, "spawned.txt"); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var invoker = new ProcessMxcExecutorInvoker(); + + await Assert.ThrowsAnyAsync( + () => invoker.InvokeAsync( + Cmd($"echo spawned>\"{markerPath}\""), + cancellation.Token)); + + Assert.False(File.Exists(markerPath)); + } + + public void Dispose() + { + Directory.Delete(_root, recursive: true); + GC.SuppressFinalize(this); + } + + private static MxcExecutorInvocation Cmd(string command) => + new( + Environment.GetEnvironmentVariable("ComSpec") + ?? Path.Combine(Environment.SystemDirectory, "cmd.exe"), + ["/d", "/c", command]); + + private sealed class RecordingHostConsole : IHostConsole + { + public List Events { get; } = []; + + public bool IsInteractive => true; + + public IDisposable Capture(Action log) + { + Events.Add("capture"); + return new Restore(Events); + } + + public void InitializeUtf8() => Events.Add("utf8"); + + private sealed class Restore(List events) : IDisposable + { + public void Dispose() => events.Add("restore"); + } + } +}