From 7d449f80d22b9fa4c897ba406eb967dbc89d8250 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 11:20:46 -0700 Subject: [PATCH 001/144] feat(install): add native Windows candidate installer Signed-off-by: Aaron Erickson --- .github/workflows/platform-vitest-main.yaml | 72 +++ ...windows-native-installer-qualification.ps1 | 429 +++++++++++++++ scripts/install-windows-native.ps1 | 489 ++++++++++++++++++ test/install/windows-native-installer.test.ts | 72 +++ 4 files changed, 1062 insertions(+) create mode 100644 scripts/checks/run-windows-native-installer-qualification.ps1 create mode 100644 scripts/install-windows-native.ps1 create mode 100644 test/install/windows-native-installer.test.ts diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index 7a00d21c510..f2babf3d334 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -5,6 +5,12 @@ name: CI / Platform Compatibility on: workflow_dispatch: + inputs: + run_windows_native_installer: + description: Build and qualify the no-WSL ARM64 Windows candidate installer + required: false + default: false + type: boolean push: branches: - main @@ -219,6 +225,72 @@ jobs: if-no-files-found: ignore retention-days: 14 + windows-native-installer: + name: Windows native installer candidate (ARM64, no WSL) + if: ${{ github.event_name == 'workflow_dispatch' && inputs.run_windows_native_installer }} + runs-on: windows-11-vs2026-arm + timeout-minutes: 120 + steps: + - name: Check out the NemoClaw candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: candidate + fetch-depth: 1 + persist-credentials: false + + - name: Check out the pinned OpenShell Windows candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: NVIDIA/OpenShell + ref: bcd517bbe08cc80860c9be57699390cd32e8445f # PR #2721 merge commit + path: openshell + fetch-depth: 1 + persist-credentials: false + + - name: Set up pinned Rust for the OpenShell ARM64 build + uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 + with: + toolchain: 1.95.0 + target: aarch64-pc-windows-msvc + cache: false + rustflags: "" + + - name: Build the pinned OpenShell PR distribution + working-directory: openshell + shell: powershell + run: | + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + & .\tasks\scripts\windows-msvc.ps1 check aarch64-pc-windows-msvc + & .\tasks\scripts\windows-msvc.ps1 build aarch64-pc-windows-msvc + & .\tasks\scripts\windows-msvc.ps1 artifacts aarch64-pc-windows-msvc + + - name: Qualify install, repair, uninstall, and no-WSL process boundaries + shell: powershell + run: | + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + $candidate = [IO.Path]::GetFullPath("$env:GITHUB_WORKSPACE\candidate") + $openshell = [IO.Path]::GetFullPath("$env:GITHUB_WORKSPACE\openshell") + $installer = Join-Path $candidate 'scripts\install-windows-native.ps1' + $installerSha256 = (Get-FileHash -LiteralPath $installer -Algorithm SHA256).Hash.ToLowerInvariant() + & "$candidate\scripts\checks\run-windows-native-installer-qualification.ps1" ` + -CandidateCheckout $candidate ` + -CandidateSha $env:GITHUB_SHA ` + -InstallerSha256 $installerSha256 ` + -OpenShellCheckout $openshell ` + -OpenShellSha bcd517bbe08cc80860c9be57699390cd32e8445f ` + -ArtifactDirectory "$env:RUNNER_TEMP\windows-native-installer-receipts" + + - name: Upload Windows native installer qualification receipts + if: success() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-native-installer-${{ github.sha }} + path: ${{ runner.temp }}/windows-native-installer-receipts/ + if-no-files-found: error + retention-days: 14 + wsl-vitest: name: WSL compatibility (${{ matrix.shard }}/4) runs-on: windows-latest diff --git a/scripts/checks/run-windows-native-installer-qualification.ps1 b/scripts/checks/run-windows-native-installer-qualification.ps1 new file mode 100644 index 00000000000..9d4e507053c --- /dev/null +++ b/scripts/checks/run-windows-native-installer-qualification.ps1 @@ -0,0 +1,429 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +<# +.SYNOPSIS + Qualify the no-WSL native Windows installer against OpenShell PR #2721. + +.DESCRIPTION + Verifies exact candidate and OpenShell source authority before executing the + candidate installer. The qualification installs, damages, repairs, and + uninstalls the candidate distribution, checks prohibited runtime processes + before and after execution, and atomically publishes bounded receipts. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$CandidateCheckout, + [Parameter(Mandatory)][string]$CandidateSha, + [Parameter(Mandatory)][string]$InstallerSha256, + [Parameter(Mandatory)][string]$OpenShellCheckout, + [Parameter(Mandatory)][string]$OpenShellSha, + [Parameter(Mandatory)][string]$ArtifactDirectory, + [string]$WxcExecPath, + [string]$WxcExecSha256 = '6049c64723af1173c3739dc6cd6b2f33f6c021bb2832c4216233cba7f71aee9a' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$script:CanonicalNemoClawRepository = 'https://github.com/NVIDIA/NemoClaw.git' +$script:CanonicalOpenShellRepository = 'https://github.com/NVIDIA/OpenShell.git' +$script:TrustedOpenShellPullRequest = 2721 +$script:TrustedOpenShellRevision = 'bcd517bbe08cc80860c9be57699390cd32e8445f' +$script:ShaPattern = '^[a-f0-9]{40}$' +$script:Sha256Pattern = '^[a-f0-9]{64}$' +$script:MaxJsonBytes = 16384 +$script:MaxInstallerBytes = 524288 + +function Fail-Qualification { + param([Parameter(Mandatory)][string]$Message) + throw "Windows native installer qualification failed: $Message" +} + +function Get-FileSha256 { + param([Parameter(Mandatory)][string]$Path) + + $stream = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + try { + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($sha256.ComputeHash($stream))).Replace('-', '').ToLowerInvariant() + } finally { + $sha256.Dispose() + } + } finally { + $stream.Dispose() + } +} + +function Resolve-PlainDirectory { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Label + ) + + $resolved = [IO.Path]::GetFullPath($Path).TrimEnd('\') + if (-not (Test-Path -LiteralPath $resolved -PathType Container)) { + Fail-Qualification "$Label is missing." + } + $item = Get-Item -LiteralPath $resolved -Force + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + Fail-Qualification "$Label must not be a reparse point." + } + return $resolved +} + +function Invoke-Git { + param( + [Parameter(Mandatory)][string]$Root, + [Parameter(Mandatory)][string[]]$Arguments, + [switch]$AllowFailure + ) + + $output = & git -C $Root @Arguments 2>$null + $status = $LASTEXITCODE + if (-not $AllowFailure -and $status -ne 0) { + Fail-Qualification "Git could not verify $Root." + } + return [pscustomobject]@{ + Status = $status + Output = (($output | ForEach-Object { [string]$_ }) -join "`n").Trim() + } +} + +function Assert-Checkout { + param( + [Parameter(Mandatory)][string]$Root, + [Parameter(Mandatory)][string]$ExpectedRevision, + [Parameter(Mandatory)][string]$ExpectedRepository, + [Parameter(Mandatory)][string]$Label + ) + + $checkout = Resolve-PlainDirectory -Path $Root -Label $Label + if (-not (Test-Path -LiteralPath (Join-Path $checkout '.git'))) { + Fail-Qualification "$Label has no Git metadata." + } + $revision = (Invoke-Git -Root $checkout -Arguments @('rev-parse', '--verify', 'HEAD^{commit}')).Output + if ($revision -cne $ExpectedRevision) { + Fail-Qualification "$Label does not match the expected revision." + } + $repository = (Invoke-Git -Root $checkout -Arguments @( + 'config', '--local', '--no-includes', '--get', 'remote.origin.url' + )).Output + $allowedRepositories = @($ExpectedRepository, $ExpectedRepository.Substring(0, $ExpectedRepository.Length - 4)) + if ($allowedRepositories -cnotcontains $repository) { + Fail-Qualification "$Label has an unexpected origin repository." + } + foreach ($pattern in @('^credential\.', '^http\..*\.extraheader$')) { + $credentialMatch = Invoke-Git -Root $checkout -Arguments @( + 'config', '--local', '--no-includes', '--get-regexp', $pattern + ) -AllowFailure + if ($credentialMatch.Status -eq 0) { + Fail-Qualification "$Label must not store Git credentials." + } + } + return $checkout +} + +function Assert-CommittedFile { + param( + [Parameter(Mandatory)][string]$Checkout, + [Parameter(Mandatory)][string]$Revision, + [Parameter(Mandatory)][string]$RelativePath, + [Parameter(Mandatory)][string]$FilePath, + [Parameter(Mandatory)][string]$ExpectedSha256 + ) + + if (-not (Test-Path -LiteralPath $FilePath -PathType Leaf)) { + Fail-Qualification "Candidate file is missing: $RelativePath" + } + $committedBlob = (Invoke-Git -Root $Checkout -Arguments @( + 'rev-parse', "${Revision}:${RelativePath}" + )).Output + $workingBlob = (Invoke-Git -Root $Checkout -Arguments @( + 'hash-object', '--no-filters', '--', $FilePath + )).Output + if ($workingBlob -cne $committedBlob) { + Fail-Qualification "Candidate file bytes do not match the candidate commit: $RelativePath" + } + if ((Get-FileSha256 -Path $FilePath) -cne $ExpectedSha256) { + Fail-Qualification "Candidate file SHA-256 does not match the trusted plan: $RelativePath" + } +} + +function Assert-InstallerProcessBoundary { + param([Parameter(Mandatory)][string]$InstallerPath) + + $tokens = $null + $parseErrors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile( + $InstallerPath, + [ref]$tokens, + [ref]$parseErrors + ) + if ($parseErrors.Count -ne 0) { + Fail-Qualification 'Candidate installer has PowerShell parse errors.' + } + $prohibitedCommands = @( + 'bash', 'bash.exe', 'cmd', 'cmd.exe', 'docker', 'docker.exe', + 'invoke-expression', 'powershell', 'powershell.exe', 'pwsh', 'pwsh.exe', + 'start-process', 'ubuntu', 'ubuntu.exe', 'wsl', 'wsl.exe' + ) + $commands = $ast.FindAll({ + param($node) + return $node -is [Management.Automation.Language.CommandAst] + }, $true) + foreach ($command in $commands) { + $name = $command.GetCommandName() + if ($null -eq $name) { + Fail-Qualification 'Candidate installer contains a dynamic command invocation.' + } + if ($prohibitedCommands -ccontains $name.ToLowerInvariant()) { + Fail-Qualification "Candidate installer invokes a prohibited command: $name" + } + } + $source = Get-Content -LiteralPath $InstallerPath -Raw + if ($source -match '(?i)\[\s*(?:System\.)?Diagnostics\.Process\s*\]\s*::\s*Start') { + Fail-Qualification 'Candidate installer invokes System.Diagnostics.Process.Start.' + } +} + +function Assert-ProhibitedProcessesAbsent { + param([Parameter(Mandatory)][string]$Phase) + + $prohibited = @('bash', 'docker', 'dockerd', 'wsl') + $found = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { + $name = $_.ProcessName.ToLowerInvariant() + $prohibited -ccontains $name -or $name.StartsWith('com.docker') -or $name.StartsWith('ubuntu') + }) + if ($found.Count -ne 0) { + Fail-Qualification "A prohibited WSL or Docker process exists during the $Phase check." + } + return [pscustomobject]@{ + phase = $Phase + wslAbsent = $true + bashAbsent = $true + dockerAbsent = $true + ubuntuAbsent = $true + } +} + +function Write-JsonFile { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)]$Value + ) + + $text = ($Value | ConvertTo-Json -Depth 12 -Compress) + [Environment]::NewLine + [IO.File]::WriteAllText($Path, $text, [Text.UTF8Encoding]::new($false)) +} + +function Assert-BoundedFile { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][long]$MaximumBytes + ) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf) -or + (Get-Item -LiteralPath $Path).Length -gt $MaximumBytes) { + Fail-Qualification "Qualification receipt exceeds its size limit: $(Split-Path -Leaf $Path)" + } +} + +if ($CandidateSha -cnotmatch $script:ShaPattern -or $OpenShellSha -cnotmatch $script:ShaPattern) { + Fail-Qualification 'Candidate and OpenShell revisions must be lowercase 40-character commit SHAs.' +} +if ($InstallerSha256 -cnotmatch $script:Sha256Pattern -or $WxcExecSha256 -cnotmatch $script:Sha256Pattern) { + Fail-Qualification 'Installer and wxc-exec digests must be lowercase SHA-256 values.' +} +if ($OpenShellSha -cne $script:TrustedOpenShellRevision) { + Fail-Qualification 'OpenShell revision must match PR #2721 merge commit.' +} +if ([Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() -cne 'Arm64') { + Fail-Qualification 'Windows native installer qualification requires a native ARM64 runner.' +} + +$candidateCheckoutParameters = @{ + Root = $CandidateCheckout + ExpectedRevision = $CandidateSha + ExpectedRepository = $script:CanonicalNemoClawRepository + Label = 'Candidate checkout' +} +$candidateRoot = Assert-Checkout @candidateCheckoutParameters +$openShellCheckoutParameters = @{ + Root = $OpenShellCheckout + ExpectedRevision = $OpenShellSha + ExpectedRepository = $script:CanonicalOpenShellRepository + Label = 'OpenShell checkout' +} +$openShellRoot = Assert-Checkout @openShellCheckoutParameters +$installer = Join-Path $candidateRoot 'scripts\install-windows-native.ps1' +$committedInstallerParameters = @{ + Checkout = $candidateRoot + Revision = $CandidateSha + RelativePath = 'scripts/install-windows-native.ps1' + FilePath = $installer + ExpectedSha256 = $InstallerSha256 +} +Assert-CommittedFile @committedInstallerParameters +Assert-InstallerProcessBoundary -InstallerPath $installer + +$artifactPath = [IO.Path]::GetFullPath($ArtifactDirectory).TrimEnd('\') +$artifactParent = Split-Path -Parent $artifactPath +$artifactName = Split-Path -Leaf $artifactPath +if (-not (Test-Path -LiteralPath $artifactParent -PathType Container) -or + (Test-Path -LiteralPath $artifactPath) -or $artifactName -cnotmatch '^[A-Za-z0-9._-]+$') { + Fail-Qualification 'ArtifactDirectory must be a new child of an existing directory.' +} + +$qualificationRoot = Join-Path $env:RUNNER_TEMP ('nemoclaw-windows-native-' + [guid]::NewGuid().ToString('N')) +$payloadRoot = Join-Path $qualificationRoot 'payload' +$installRoot = Join-Path $qualificationRoot 'install' +$receiptStage = Join-Path $artifactParent ('.' + $artifactName + '.' + [guid]::NewGuid().ToString('N')) +[IO.Directory]::CreateDirectory($payloadRoot) | Out-Null +[IO.Directory]::CreateDirectory($receiptStage) | Out-Null + +try { + $releaseRoot = Join-Path $openShellRoot 'target\aarch64-pc-windows-msvc\release' + $distributionEntries = @() + foreach ($fileName in @('openshell.exe', 'openshell-gateway.exe')) { + $source = Join-Path $releaseRoot $fileName + if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { + Fail-Qualification "OpenShell PR #2721 build output is missing: $fileName" + } + $payloadRelative = "bin\$fileName" + $payloadPath = Join-Path $payloadRoot $payloadRelative + [IO.Directory]::CreateDirectory((Split-Path -Parent $payloadPath)) | Out-Null + [IO.File]::Copy($source, $payloadPath, $false) + $distributionEntries += [pscustomobject]@{ + source = $payloadRelative + destination = $payloadRelative + sha256 = Get-FileSha256 -Path $payloadPath + required = $true + } + } + $z3Source = Join-Path $releaseRoot 'libz3.dll' + if (Test-Path -LiteralPath $z3Source -PathType Leaf) { + $z3Relative = 'bin\libz3.dll' + [IO.File]::Copy($z3Source, (Join-Path $payloadRoot $z3Relative), $false) + $distributionEntries += [pscustomobject]@{ + source = $z3Relative + destination = $z3Relative + sha256 = Get-FileSha256 -Path (Join-Path $payloadRoot $z3Relative) + required = $true + } + } + + $wxcRelative = 'mxc\wxc-exec.exe' + if (-not [string]::IsNullOrWhiteSpace($WxcExecPath)) { + $resolvedWxc = [IO.Path]::GetFullPath($WxcExecPath) + if (-not (Test-Path -LiteralPath $resolvedWxc -PathType Leaf) -or + (Get-FileSha256 -Path $resolvedWxc) -cne $WxcExecSha256) { + Fail-Qualification 'wxc-exec candidate is missing or has the wrong digest.' + } + $wxcPayload = Join-Path $payloadRoot $wxcRelative + [IO.Directory]::CreateDirectory((Split-Path -Parent $wxcPayload)) | Out-Null + [IO.File]::Copy($resolvedWxc, $wxcPayload, $false) + } + $distributionEntries += [pscustomobject]@{ + source = $wxcRelative + destination = $wxcRelative + sha256 = $WxcExecSha256 + required = $false + } + + $manifest = [pscustomobject]@{ + schemaVersion = 1 + classification = 'qualification-only' + platform = 'windows' + architecture = 'arm64' + openshell = [pscustomobject]@{ + repository = $script:CanonicalOpenShellRepository + pullRequest = $script:TrustedOpenShellPullRequest + revision = $script:TrustedOpenShellRevision + } + files = @($distributionEntries) + } + $manifestPath = Join-Path $payloadRoot 'distribution-manifest.json' + Write-JsonFile -Path $manifestPath -Value $manifest + + $preExecution = Assert-ProhibitedProcessesAbsent -Phase 'pre-execution' + $installParameters = @{ + Action = 'Install' + ManifestPath = $manifestPath + PayloadRoot = $payloadRoot + InstallRoot = $installRoot + Json = $true + } + $installOutput = & $installer @installParameters + $installReceiptPath = Join-Path $installRoot 'install-receipt.json' + if (-not (Test-Path -LiteralPath $installReceiptPath -PathType Leaf)) { + Fail-Qualification 'Candidate installer did not publish an install receipt.' + } + [IO.File]::Copy($installReceiptPath, (Join-Path $receiptStage 'install-receipt.json'), $false) + + $installReceipt = $installOutput | Select-Object -Last 1 | ConvertFrom-Json + $driftTarget = Join-Path $installReceipt.versionRoot 'bin\openshell.exe' + [IO.File]::AppendAllText($driftTarget, 'qualification-drift', [Text.UTF8Encoding]::new($false)) + $repairParameters = @{ + Action = 'Repair' + ManifestPath = $manifestPath + PayloadRoot = $payloadRoot + InstallRoot = $installRoot + Json = $true + } + & $installer @repairParameters | Out-Null + [IO.File]::Copy($installReceiptPath, (Join-Path $receiptStage 'repair-receipt.json'), $false) + $repairedReceipt = Get-Content -LiteralPath $installReceiptPath -Raw | ConvertFrom-Json + $expectedOpenShell = @($repairedReceipt.files | Where-Object { $_.path -ceq 'bin\openshell.exe' }) + if ($expectedOpenShell.Count -ne 1 -or + (Get-FileSha256 -Path (Join-Path $repairedReceipt.versionRoot 'bin\openshell.exe')) -cne $expectedOpenShell[0].sha256) { + Fail-Qualification 'Repair did not restore the OpenShell CLI digest.' + } + + $uninstallOutput = & $installer -Action Uninstall -InstallRoot $installRoot -Json + $uninstallReceipt = $uninstallOutput | Select-Object -Last 1 | ConvertFrom-Json + if (-not $uninstallReceipt.finalAbsence -or (Test-Path -LiteralPath $installRoot)) { + Fail-Qualification 'Uninstall did not prove final absence.' + } + $postExecution = Assert-ProhibitedProcessesAbsent -Phase 'post-execution' + + [IO.File]::Copy($installer, (Join-Path $receiptStage 'install-windows-native.ps1'), $false) + [IO.File]::Copy($manifestPath, (Join-Path $receiptStage 'distribution-manifest.json'), $false) + Write-JsonFile -Path (Join-Path $receiptStage 'candidate-source.json') -Value ([pscustomobject]@{ + receiptVersion = 1 + repository = $script:CanonicalNemoClawRepository + revision = $CandidateSha + installerSha256 = $InstallerSha256 + }) + Write-JsonFile -Path (Join-Path $receiptStage 'openshell-source.json') -Value ([pscustomobject]@{ + receiptVersion = 1 + repository = $script:CanonicalOpenShellRepository + pullRequest = $script:TrustedOpenShellPullRequest + revision = $OpenShellSha + architecture = 'arm64' + }) + Write-JsonFile -Path (Join-Path $receiptStage 'process-absence.json') -Value ([pscustomobject]@{ + receiptVersion = 1 + installerAstProcessBoundary = $true + preExecution = $preExecution + postExecution = $postExecution + }) + Write-JsonFile -Path (Join-Path $receiptStage 'uninstall-receipt.json') -Value $uninstallReceipt + + Assert-BoundedFile -Path (Join-Path $receiptStage 'install-windows-native.ps1') -MaximumBytes $script:MaxInstallerBytes + foreach ($jsonReceipt in @(Get-ChildItem -LiteralPath $receiptStage -Filter '*.json')) { + Assert-BoundedFile -Path $jsonReceipt.FullName -MaximumBytes $script:MaxJsonBytes + } + [IO.Directory]::Move($receiptStage, $artifactPath) + $receiptStage = $null + Write-Host "Windows native installer qualification receipts: $artifactPath" +} finally { + if ($receiptStage -and (Test-Path -LiteralPath $receiptStage -PathType Container)) { + [IO.Directory]::Delete($receiptStage, $true) + } + if (Test-Path -LiteralPath $qualificationRoot -PathType Container) { + [IO.Directory]::Delete($qualificationRoot, $true) + } +} diff --git a/scripts/install-windows-native.ps1 b/scripts/install-windows-native.ps1 new file mode 100644 index 00000000000..25f76b25fb8 --- /dev/null +++ b/scripts/install-windows-native.ps1 @@ -0,0 +1,489 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +<# +.SYNOPSIS + Qualification-only native Windows installer for the OpenShell MXC candidate. + +.DESCRIPTION + Installs, repairs, or removes an exact Windows OpenShell distribution built + from NVIDIA/OpenShell PR #2721. The installer is deliberately file-only: it + does not start a process, install a service, select a runtime provider, or + activate native Windows support. A later slice can consume the receipt after + the corresponding lifecycle and activation gates pass. +#> + +[CmdletBinding()] +param( + [ValidateSet('Install', 'Repair', 'Uninstall')] + [string]$Action = 'Install', + + [string]$ManifestPath, + + [string]$PayloadRoot, + + [string]$InstallRoot = (Join-Path + ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) + 'NVIDIA\NemoClaw\native-candidate'), + + [switch]$Json +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$script:TrustedOpenShellRepository = 'https://github.com/NVIDIA/OpenShell.git' +$script:TrustedOpenShellPullRequest = 2721 +$script:TrustedOpenShellRevision = 'bcd517bbe08cc80860c9be57699390cd32e8445f' +$script:ReceiptFileName = 'install-receipt.json' +$script:Sha256Pattern = '^[a-f0-9]{64}$' +$script:ControlCharacterPattern = '[\u0000-\u001f\u007f-\u009f]' +$script:RequiredDestinations = @( + 'bin\openshell.exe', + 'bin\openshell-gateway.exe' +) + +function Fail-NativeWindowsInstall { + param([Parameter(Mandatory)][string]$Message) + throw "Windows native candidate installer failed: $Message" +} + +function Get-FileSha256 { + param([Parameter(Mandatory)][string]$Path) + + $stream = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + try { + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($sha256.ComputeHash($stream))).Replace('-', '').ToLowerInvariant() + } finally { + $sha256.Dispose() + } + } finally { + $stream.Dispose() + } +} + +function Assert-ExactProperties { + param( + [Parameter(Mandatory)]$Value, + [Parameter(Mandatory)][string[]]$Properties, + [Parameter(Mandatory)][string]$Label + ) + + if ($null -eq $Value -or $Value -is [string] -or $Value -is [Array]) { + Fail-NativeWindowsInstall "$Label must be an object." + } + $actual = @($Value.PSObject.Properties.Name | Sort-Object) + $expected = @($Properties | Sort-Object) + if ($actual.Count -ne $expected.Count) { + Fail-NativeWindowsInstall "$Label has unknown or missing fields." + } + for ($index = 0; $index -lt $expected.Count; $index++) { + if ($actual[$index] -cne $expected[$index]) { + Fail-NativeWindowsInstall "$Label has unknown or missing fields." + } + } +} + +function Assert-NoReparsePoint { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Label + ) + + $candidate = [IO.Path]::GetFullPath($Path) + while ($candidate) { + if (Test-Path -LiteralPath $candidate) { + $item = Get-Item -LiteralPath $candidate -Force + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + Fail-NativeWindowsInstall "$Label must not contain a reparse point." + } + } + $parent = [IO.Directory]::GetParent($candidate) + if ($null -eq $parent) { + break + } + $candidate = $parent.FullName + } +} + +function Resolve-ExistingRegularFile { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Label + ) + + $resolved = [IO.Path]::GetFullPath($Path) + if (-not (Test-Path -LiteralPath $resolved -PathType Leaf)) { + Fail-NativeWindowsInstall "$Label is missing." + } + Assert-NoReparsePoint -Path $resolved -Label $Label + return $resolved +} + +function Resolve-ExistingDirectory { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Label + ) + + $resolved = [IO.Path]::GetFullPath($Path) + if (-not (Test-Path -LiteralPath $resolved -PathType Container)) { + Fail-NativeWindowsInstall "$Label is missing." + } + Assert-NoReparsePoint -Path $resolved -Label $Label + return $resolved.TrimEnd('\') +} + +function Resolve-InstallRoot { + param([Parameter(Mandatory)][string]$Path) + + if ([string]::IsNullOrWhiteSpace($Path) -or $Path -notmatch '^[A-Za-z]:\\') { + Fail-NativeWindowsInstall 'InstallRoot must be an absolute local-drive Windows path.' + } + $resolved = [IO.Path]::GetFullPath($Path).TrimEnd('\') + Assert-NoReparsePoint -Path $resolved -Label 'InstallRoot' + return $resolved +} + +function Resolve-SafeRelativePath { + param( + [Parameter(Mandatory)]$Value, + [Parameter(Mandatory)][string]$Label + ) + + if ($Value -isnot [string] -or [string]::IsNullOrWhiteSpace($Value) -or + $Value -match $script:ControlCharacterPattern -or [IO.Path]::IsPathRooted($Value) -or + $Value.Contains('/') -or $Value.Contains(':')) { + Fail-NativeWindowsInstall "$Label is not a safe Windows relative path." + } + $segments = @($Value.Split('\')) + $unsafeSegments = @($segments | Where-Object { $_ -eq '' -or $_ -eq '.' -or $_ -eq '..' }) + if ($segments.Count -eq 0 -or $unsafeSegments.Count -gt 0) { + Fail-NativeWindowsInstall "$Label is not a safe Windows relative path." + } + return ($segments -join '\') +} + +function Get-NativeArchitecture { + $architecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + switch ($architecture) { + 'Arm64' { return 'arm64' } + 'X64' { return 'x64' } + default { Fail-NativeWindowsInstall "Unsupported native architecture: $architecture" } + } +} + +function Read-DistributionManifest { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Root + ) + + $manifestFile = Resolve-ExistingRegularFile -Path $Path -Label 'Distribution manifest' + $payloadDirectory = Resolve-ExistingDirectory -Path $Root -Label 'Payload root' + try { + $manifest = Get-Content -LiteralPath $manifestFile -Raw | ConvertFrom-Json + } catch { + Fail-NativeWindowsInstall 'Distribution manifest is not valid JSON.' + } + Assert-ExactProperties -Value $manifest -Properties @( + 'architecture', 'classification', 'files', 'openshell', 'platform', 'schemaVersion' + ) -Label 'Distribution manifest' + if ($manifest.schemaVersion -ne 1 -or $manifest.classification -cne 'qualification-only' -or + $manifest.platform -cne 'windows') { + Fail-NativeWindowsInstall 'Distribution manifest identity is unsupported.' + } + $nativeArchitecture = Get-NativeArchitecture + if ($manifest.architecture -cne $nativeArchitecture) { + Fail-NativeWindowsInstall "Distribution architecture '$($manifest.architecture)' does not match '$nativeArchitecture'." + } + + Assert-ExactProperties -Value $manifest.openshell -Properties @( + 'pullRequest', 'repository', 'revision' + ) -Label 'OpenShell authority' + if ($manifest.openshell.repository -cne $script:TrustedOpenShellRepository -or + $manifest.openshell.pullRequest -ne $script:TrustedOpenShellPullRequest -or + $manifest.openshell.revision -cne $script:TrustedOpenShellRevision) { + Fail-NativeWindowsInstall 'OpenShell authority does not match the pinned PR #2721 distribution.' + } + + if ($manifest.files -isnot [Array] -or $manifest.files.Count -lt 2 -or $manifest.files.Count -gt 8) { + Fail-NativeWindowsInstall 'Distribution manifest files must contain between two and eight entries.' + } + $destinations = @{} + $resolvedFiles = @() + $omittedOptionalDestinations = @() + foreach ($entry in @($manifest.files)) { + Assert-ExactProperties -Value $entry -Properties @( + 'destination', 'required', 'sha256', 'source' + ) -Label 'Distribution file entry' + $source = Resolve-SafeRelativePath -Value $entry.source -Label 'Distribution source' + $destination = Resolve-SafeRelativePath -Value $entry.destination -Label 'Distribution destination' + if ($entry.required -isnot [bool] -or $entry.sha256 -isnot [string] -or + $entry.sha256 -cnotmatch $script:Sha256Pattern) { + Fail-NativeWindowsInstall 'Distribution file entry identity is invalid.' + } + $destinationKey = $destination.ToLowerInvariant() + if ($destinations.ContainsKey($destinationKey)) { + Fail-NativeWindowsInstall "Distribution destination is duplicated: $destination" + } + $destinations[$destinationKey] = $true + $sourcePath = [IO.Path]::GetFullPath((Join-Path $payloadDirectory $source)) + $payloadPrefix = $payloadDirectory + '\' + if (-not $sourcePath.StartsWith($payloadPrefix, [StringComparison]::OrdinalIgnoreCase)) { + Fail-NativeWindowsInstall 'Distribution source escapes the payload root.' + } + if (-not (Test-Path -LiteralPath $sourcePath -PathType Leaf)) { + if ($entry.required) { + Fail-NativeWindowsInstall "Required distribution source is missing: $source" + } + $omittedOptionalDestinations += $destination + continue + } + $sourcePath = Resolve-ExistingRegularFile -Path $sourcePath -Label "Distribution source '$source'" + $actualDigest = Get-FileSha256 -Path $sourcePath + if ($actualDigest -cne $entry.sha256) { + Fail-NativeWindowsInstall "Distribution source digest does not match the manifest: $source" + } + $resolvedFiles += [pscustomobject]@{ + Source = $source + SourcePath = $sourcePath + Destination = $destination + Sha256 = $entry.sha256 + } + } + foreach ($requiredDestination in $script:RequiredDestinations) { + if (-not $destinations.ContainsKey($requiredDestination.ToLowerInvariant())) { + Fail-NativeWindowsInstall "Distribution manifest is missing required destination: $requiredDestination" + } + $resolved = @($resolvedFiles | Where-Object { $_.Destination -ceq $requiredDestination }) + if ($resolved.Count -ne 1) { + Fail-NativeWindowsInstall "Required distribution file is unavailable: $requiredDestination" + } + } + + return [pscustomobject]@{ + ManifestPath = $manifestFile + ManifestSha256 = Get-FileSha256 -Path $manifestFile + PayloadRoot = $payloadDirectory + Architecture = $nativeArchitecture + Files = @($resolvedFiles) + OmittedOptionalDestinations = @($omittedOptionalDestinations | Sort-Object) + } +} + +function Test-InstalledFiles { + param( + [Parameter(Mandatory)][string]$VersionRoot, + [Parameter(Mandatory)][Array]$Files + ) + + foreach ($file in $Files) { + $target = Join-Path $VersionRoot $file.Destination + if (-not (Test-Path -LiteralPath $target -PathType Leaf)) { + return $false + } + Assert-NoReparsePoint -Path $target -Label "Installed file '$($file.Destination)'" + if ((Get-FileSha256 -Path $target) -cne $file.Sha256) { + return $false + } + } + return $true +} + +function Write-JsonAtomic { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)]$Value + ) + + $parent = Split-Path -Parent $Path + [IO.Directory]::CreateDirectory($parent) | Out-Null + $temporary = Join-Path $parent ('.' + (Split-Path -Leaf $Path) + '.' + [guid]::NewGuid().ToString('N') + '.partial') + $text = ($Value | ConvertTo-Json -Depth 12 -Compress) + [Environment]::NewLine + [IO.File]::WriteAllText($temporary, $text, [Text.UTF8Encoding]::new($false)) + if (Test-Path -LiteralPath $Path -PathType Leaf) { + [IO.File]::Replace($temporary, $Path, $null, $true) + } else { + [IO.File]::Move($temporary, $Path) + } +} + +function Publish-Distribution { + param( + [Parameter(Mandatory)]$Distribution, + [Parameter(Mandatory)][string]$Root, + [Parameter(Mandatory)][bool]$Repair + ) + + [IO.Directory]::CreateDirectory($Root) | Out-Null + Assert-NoReparsePoint -Path $Root -Label 'InstallRoot' + $versionsRoot = Join-Path $Root 'versions' + [IO.Directory]::CreateDirectory($versionsRoot) | Out-Null + $versionName = "openshell-pr$($script:TrustedOpenShellPullRequest)-$($script:TrustedOpenShellRevision.Substring(0, 12))-$($Distribution.Architecture)" + $versionRoot = Join-Path $versionsRoot $versionName + $stagingRoot = Join-Path $Root ('.staging-' + [guid]::NewGuid().ToString('N')) + $backupRoot = Join-Path $Root ('.backup-' + [guid]::NewGuid().ToString('N')) + [IO.Directory]::CreateDirectory($stagingRoot) | Out-Null + try { + foreach ($file in $Distribution.Files) { + $target = Join-Path $stagingRoot $file.Destination + [IO.Directory]::CreateDirectory((Split-Path -Parent $target)) | Out-Null + [IO.File]::Copy($file.SourcePath, $target, $false) + if ((Get-FileSha256 -Path $target) -cne $file.Sha256) { + Fail-NativeWindowsInstall "Staged distribution digest changed: $($file.Destination)" + } + } + + if (Test-Path -LiteralPath $versionRoot) { + Assert-NoReparsePoint -Path $versionRoot -Label 'Existing version root' + if (-not $Repair) { + if (-not (Test-InstalledFiles -VersionRoot $versionRoot -Files $Distribution.Files)) { + Fail-NativeWindowsInstall 'Existing candidate installation drifted; run Repair.' + } + [IO.Directory]::Delete($stagingRoot, $true) + $stagingRoot = $null + return $versionRoot + } + [IO.Directory]::Move($versionRoot, $backupRoot) + try { + [IO.Directory]::Move($stagingRoot, $versionRoot) + $stagingRoot = $null + } catch { + if (-not (Test-Path -LiteralPath $versionRoot) -and (Test-Path -LiteralPath $backupRoot)) { + [IO.Directory]::Move($backupRoot, $versionRoot) + } + throw + } + [IO.Directory]::Delete($backupRoot, $true) + } else { + [IO.Directory]::Move($stagingRoot, $versionRoot) + $stagingRoot = $null + } + if (-not (Test-InstalledFiles -VersionRoot $versionRoot -Files $Distribution.Files)) { + Fail-NativeWindowsInstall 'Published candidate distribution failed verification.' + } + return $versionRoot + } finally { + if ($stagingRoot -and (Test-Path -LiteralPath $stagingRoot -PathType Container)) { + [IO.Directory]::Delete($stagingRoot, $true) + } + if (Test-Path -LiteralPath $backupRoot -PathType Container) { + Fail-NativeWindowsInstall 'A prior candidate version was retained after publication failure.' + } + } +} + +function Invoke-InstallOrRepair { + param([Parameter(Mandatory)][bool]$Repair) + + if ([string]::IsNullOrWhiteSpace($ManifestPath) -or [string]::IsNullOrWhiteSpace($PayloadRoot)) { + Fail-NativeWindowsInstall 'ManifestPath and PayloadRoot are required for Install and Repair.' + } + $root = Resolve-InstallRoot -Path $InstallRoot + $distribution = Read-DistributionManifest -Path $ManifestPath -Root $PayloadRoot + $versionRoot = Publish-Distribution -Distribution $distribution -Root $root -Repair $Repair + $receiptPath = Join-Path $root $script:ReceiptFileName + $installedFiles = @($distribution.Files | ForEach-Object { + [pscustomobject]@{ + path = $_.Destination + sha256 = $_.Sha256 + } + }) + $receipt = [pscustomobject]@{ + receiptVersion = 1 + classification = 'qualification-only' + platform = 'windows' + architecture = $distribution.Architecture + openshell = [pscustomobject]@{ + repository = $script:TrustedOpenShellRepository + pullRequest = $script:TrustedOpenShellPullRequest + revision = $script:TrustedOpenShellRevision + } + manifestSha256 = $distribution.ManifestSha256 + installerSha256 = Get-FileSha256 -Path $PSCommandPath + installRoot = $root + versionRoot = $versionRoot + files = $installedFiles + omittedOptionalDestinations = @($distribution.OmittedOptionalDestinations) + } + Write-JsonAtomic -Path $receiptPath -Value $receipt + if ($Json) { + Write-Output ($receipt | ConvertTo-Json -Depth 12 -Compress) + } else { + Write-Host "Native Windows candidate distribution installed at $versionRoot" + } +} + +function Invoke-Uninstall { + $root = Resolve-InstallRoot -Path $InstallRoot + $receiptPath = Resolve-ExistingRegularFile -Path (Join-Path $root $script:ReceiptFileName) -Label 'Install receipt' + try { + $receipt = Get-Content -LiteralPath $receiptPath -Raw | ConvertFrom-Json + } catch { + Fail-NativeWindowsInstall 'Install receipt is not valid JSON.' + } + Assert-ExactProperties -Value $receipt -Properties @( + 'architecture', 'classification', 'files', 'installerSha256', 'installRoot', + 'manifestSha256', 'omittedOptionalDestinations', 'openshell', 'platform', + 'receiptVersion', 'versionRoot' + ) -Label 'Install receipt' + Assert-ExactProperties -Value $receipt.openshell -Properties @( + 'pullRequest', 'repository', 'revision' + ) -Label 'Receipt OpenShell authority' + if ($receipt.receiptVersion -ne 1 -or $receipt.classification -cne 'qualification-only' -or + $receipt.platform -cne 'windows' -or $receipt.installRoot -cne $root -or + $receipt.openshell.repository -cne $script:TrustedOpenShellRepository -or + $receipt.openshell.pullRequest -ne $script:TrustedOpenShellPullRequest -or + $receipt.openshell.revision -cne $script:TrustedOpenShellRevision) { + Fail-NativeWindowsInstall 'Install receipt authority is invalid.' + } + $versionRoot = [IO.Path]::GetFullPath([string]$receipt.versionRoot).TrimEnd('\') + $versionsRoot = [IO.Path]::GetFullPath((Join-Path $root 'versions')).TrimEnd('\') + if (-not $versionRoot.StartsWith($versionsRoot + '\', [StringComparison]::OrdinalIgnoreCase) -or + -not (Test-Path -LiteralPath $versionRoot -PathType Container)) { + Fail-NativeWindowsInstall 'Receipt version root is outside the owned versions directory.' + } + Assert-NoReparsePoint -Path $versionRoot -Label 'Receipt version root' + foreach ($entry in @($receipt.files)) { + Assert-ExactProperties -Value $entry -Properties @('path', 'sha256') -Label 'Receipt file entry' + $relativePath = Resolve-SafeRelativePath -Value $entry.path -Label 'Receipt file path' + if ($entry.sha256 -isnot [string] -or $entry.sha256 -cnotmatch $script:Sha256Pattern) { + Fail-NativeWindowsInstall 'Receipt file digest is invalid.' + } + $target = Resolve-ExistingRegularFile -Path (Join-Path $versionRoot $relativePath) -Label "Owned file '$relativePath'" + if ((Get-FileSha256 -Path $target) -cne $entry.sha256) { + Fail-NativeWindowsInstall "Owned file drifted; repair before uninstall: $relativePath" + } + } + [IO.Directory]::Delete($versionRoot, $true) + [IO.File]::Delete($receiptPath) + if ((Test-Path -LiteralPath $versionsRoot -PathType Container) -and + @(Get-ChildItem -LiteralPath $versionsRoot -Force).Count -eq 0) { + [IO.Directory]::Delete($versionsRoot) + } + if ((Test-Path -LiteralPath $root -PathType Container) -and + @(Get-ChildItem -LiteralPath $root -Force).Count -eq 0) { + [IO.Directory]::Delete($root) + } + $result = [pscustomobject]@{ + receiptVersion = 1 + action = 'uninstall' + classification = 'qualification-only' + removedVersionRoot = $versionRoot + finalAbsence = -not (Test-Path -LiteralPath $versionRoot) + } + if ($Json) { + Write-Output ($result | ConvertTo-Json -Depth 4 -Compress) + } else { + Write-Host "Removed native Windows candidate distribution from $versionRoot" + } +} + +switch ($Action) { + 'Install' { Invoke-InstallOrRepair -Repair $false } + 'Repair' { Invoke-InstallOrRepair -Repair $true } + 'Uninstall' { Invoke-Uninstall } +} diff --git a/test/install/windows-native-installer.test.ts b/test/install/windows-native-installer.test.ts new file mode 100644 index 00000000000..fe23be4e23a --- /dev/null +++ b/test/install/windows-native-installer.test.ts @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + readYaml, + type Workflow, + type WorkflowJob, + type WorkflowStep, +} from "../helpers/e2e-workflow-contract"; + +const WORKFLOW_PATH = ".github/workflows/platform-vitest-main.yaml"; +const OPENSHELL_REVISION = "bcd517bbe08cc80860c9be57699390cd32e8445f"; + +function workflow(): Workflow { + return readYaml(WORKFLOW_PATH) as Workflow; +} + +function job(name: string): WorkflowJob { + const value = workflow().jobs[name]; + expect(value, `missing workflow job '${name}'`).toBeDefined(); + return value!; +} + +function step(owner: WorkflowJob, name: string): WorkflowStep { + const value = owner.steps?.find((entry) => entry.name === name); + expect(value, `missing workflow step '${name}'`).toBeDefined(); + return value!; +} + +describe("native Windows candidate installer", () => { + it("adds an explicit opt-in hosted ARM64 platform lane", () => { + const platformWorkflow = workflow() as Workflow & { + on?: { + workflow_dispatch?: { + inputs?: Record; + }; + }; + }; + const input = platformWorkflow.on?.workflow_dispatch?.inputs?.run_windows_native_installer; + const installerJob = job("windows-native-installer"); + + expect(input).toEqual( + expect.objectContaining({ + default: false, + type: "boolean", + }), + ); + expect(installerJob.if).toBe( + "${{ github.event_name == 'workflow_dispatch' && inputs.run_windows_native_installer }}", + ); + expect(installerJob["runs-on"]).toBe("windows-11-vs2026-arm"); + expect(installerJob.permissions).toBeUndefined(); + expect(step(installerJob, "Check out the pinned OpenShell Windows candidate").with?.ref).toBe( + OPENSHELL_REVISION, + ); + expect(step(installerJob, "Build the pinned OpenShell PR distribution").run).toContain( + "windows-msvc.ps1 build aarch64-pc-windows-msvc", + ); + expect( + step(installerJob, "Qualify install, repair, uninstall, and no-WSL process boundaries").run, + ).toContain("run-windows-native-installer-qualification.ps1"); + expect( + step(installerJob, "Qualify install, repair, uninstall, and no-WSL process boundaries").run, + ).toContain("-OpenShellSha bcd517bbe08cc80860c9be57699390cd32e8445f"); + expect(step(installerJob, "Upload Windows native installer qualification receipts").if).toBe( + "success()", + ); + expect(JSON.stringify(installerJob)).not.toContain("NVIDIA_INFERENCE_API_KEY"); + }); +}); From 2d52febfadba79a9103610da09223903bf2e2e58 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 11:36:42 -0700 Subject: [PATCH 002/144] fix(install): enforce native qualification boundary Signed-off-by: Aaron Erickson --- .github/workflows/platform-vitest-main.yaml | 2 +- ...windows-native-installer-qualification.ps1 | 229 ++++++++++++++---- scripts/install-windows-native.ps1 | 82 ++++--- test/install/windows-native-installer.test.ts | 72 ------ 4 files changed, 233 insertions(+), 152 deletions(-) delete mode 100644 test/install/windows-native-installer.test.ts diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index f2babf3d334..d48e7969059 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -265,7 +265,7 @@ jobs: & .\tasks\scripts\windows-msvc.ps1 build aarch64-pc-windows-msvc & .\tasks\scripts\windows-msvc.ps1 artifacts aarch64-pc-windows-msvc - - name: Qualify install, repair, uninstall, and no-WSL process boundaries + - name: Qualify install, repair, and uninstall in an OS-restricted boundary shell: powershell run: | Set-StrictMode -Version Latest diff --git a/scripts/checks/run-windows-native-installer-qualification.ps1 b/scripts/checks/run-windows-native-installer-qualification.ps1 index 9d4e507053c..59a367ff1ac 100644 --- a/scripts/checks/run-windows-native-installer-qualification.ps1 +++ b/scripts/checks/run-windows-native-installer-qualification.ps1 @@ -41,22 +41,6 @@ function Fail-Qualification { throw "Windows native installer qualification failed: $Message" } -function Get-FileSha256 { - param([Parameter(Mandatory)][string]$Path) - - $stream = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) - try { - $sha256 = [Security.Cryptography.SHA256]::Create() - try { - return ([BitConverter]::ToString($sha256.ComputeHash($stream))).Replace('-', '').ToLowerInvariant() - } finally { - $sha256.Dispose() - } - } finally { - $stream.Dispose() - } -} - function Resolve-PlainDirectory { param( [Parameter(Mandatory)][string]$Path, @@ -147,45 +131,182 @@ function Assert-CommittedFile { if ($workingBlob -cne $committedBlob) { Fail-Qualification "Candidate file bytes do not match the candidate commit: $RelativePath" } - if ((Get-FileSha256 -Path $FilePath) -cne $ExpectedSha256) { + if ((Get-FileHash -LiteralPath $FilePath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $ExpectedSha256) { Fail-Qualification "Candidate file SHA-256 does not match the trusted plan: $RelativePath" } } -function Assert-InstallerProcessBoundary { - param([Parameter(Mandatory)][string]$InstallerPath) +function Enter-RestrictedInstallerBoundary { + if (-not ('NemoClaw.WindowsQualification.JobBoundary' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; + +namespace NemoClaw.WindowsQualification +{ + [StructLayout(LayoutKind.Sequential)] + public struct IoCounters + { + public ulong ReadOperationCount; + public ulong WriteOperationCount; + public ulong OtherOperationCount; + public ulong ReadTransferCount; + public ulong WriteTransferCount; + public ulong OtherTransferCount; + } - $tokens = $null - $parseErrors = $null - $ast = [Management.Automation.Language.Parser]::ParseFile( - $InstallerPath, - [ref]$tokens, - [ref]$parseErrors - ) - if ($parseErrors.Count -ne 0) { - Fail-Qualification 'Candidate installer has PowerShell parse errors.' + [StructLayout(LayoutKind.Sequential)] + public struct BasicLimitInformation + { + public long PerProcessUserTimeLimit; + public long PerJobUserTimeLimit; + public uint LimitFlags; + public UIntPtr MinimumWorkingSetSize; + public UIntPtr MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass; + public uint SchedulingClass; } - $prohibitedCommands = @( - 'bash', 'bash.exe', 'cmd', 'cmd.exe', 'docker', 'docker.exe', - 'invoke-expression', 'powershell', 'powershell.exe', 'pwsh', 'pwsh.exe', - 'start-process', 'ubuntu', 'ubuntu.exe', 'wsl', 'wsl.exe' + + [StructLayout(LayoutKind.Sequential)] + public struct ExtendedLimitInformation + { + public BasicLimitInformation BasicLimitInformation; + public IoCounters IoInfo; + public UIntPtr ProcessMemoryLimit; + public UIntPtr JobMemoryLimit; + public UIntPtr PeakProcessMemoryUsed; + public UIntPtr PeakJobMemoryUsed; + } + + public static class JobBoundary + { + public const uint ActiveProcessLimit = 0x00000008; + public const int ExtendedLimitInformationClass = 9; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern IntPtr CreateJobObject(IntPtr securityAttributes, string name); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool SetInformationJobObject( + IntPtr job, + int informationClass, + ref ExtendedLimitInformation information, + uint informationLength); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool CloseHandle(IntPtr handle); + } +} +'@ + } + + $processPath = [Diagnostics.Process]::GetCurrentProcess().MainModule.FileName + $firewallRule = "NemoClawNativeQualification-$PID-$([guid]::NewGuid().ToString('N'))" + $firewallParameters = @{ + Name = $firewallRule + DisplayName = $firewallRule + Direction = 'Outbound' + Action = 'Block' + Program = $processPath + Profile = 'Any' + } + New-NetFirewallRule @firewallParameters | Out-Null + + $jobHandle = [NemoClaw.WindowsQualification.JobBoundary]::CreateJobObject( + [IntPtr]::Zero, + "NemoClawNativeQualification-$PID" ) - $commands = $ast.FindAll({ - param($node) - return $node -is [Management.Automation.Language.CommandAst] - }, $true) - foreach ($command in $commands) { - $name = $command.GetCommandName() - if ($null -eq $name) { - Fail-Qualification 'Candidate installer contains a dynamic command invocation.' + if ($jobHandle -eq [IntPtr]::Zero) { + Remove-NetFirewallRule -Name $firewallRule -ErrorAction SilentlyContinue + Fail-Qualification 'Could not create the installer qualification Job Object.' + } + $limit = [NemoClaw.WindowsQualification.ExtendedLimitInformation]::new() + $limit.BasicLimitInformation.LimitFlags = [NemoClaw.WindowsQualification.JobBoundary]::ActiveProcessLimit + $limit.BasicLimitInformation.ActiveProcessLimit = 1 + $limitLength = [Runtime.InteropServices.Marshal]::SizeOf($limit) + if (-not [NemoClaw.WindowsQualification.JobBoundary]::SetInformationJobObject( + $jobHandle, + [NemoClaw.WindowsQualification.JobBoundary]::ExtendedLimitInformationClass, + [ref]$limit, + $limitLength + )) { + [NemoClaw.WindowsQualification.JobBoundary]::CloseHandle($jobHandle) | Out-Null + Remove-NetFirewallRule -Name $firewallRule -ErrorAction SilentlyContinue + Fail-Qualification 'Could not apply the one-process installer qualification limit.' + } + $currentProcess = [Diagnostics.Process]::GetCurrentProcess() + if (-not [NemoClaw.WindowsQualification.JobBoundary]::AssignProcessToJobObject( + $jobHandle, + $currentProcess.Handle + )) { + [NemoClaw.WindowsQualification.JobBoundary]::CloseHandle($jobHandle) | Out-Null + Remove-NetFirewallRule -Name $firewallRule -ErrorAction SilentlyContinue + Fail-Qualification 'Could not enter the one-process installer qualification Job Object.' + } + return [pscustomobject]@{ + JobHandle = $jobHandle + FirewallRule = $firewallRule + ProgramPath = $processPath + } +} + +function Test-RestrictedInstallerBoundary { + $childProcessDenied = $false + try { + $childParameters = @{ + FilePath = $env:ComSpec + ArgumentList = @('/d', '/c', 'exit', '0') + Wait = $true + PassThru = $true + ErrorAction = 'Stop' } - if ($prohibitedCommands -ccontains $name.ToLowerInvariant()) { - Fail-Qualification "Candidate installer invokes a prohibited command: $name" + $child = Start-Process @childParameters + if ($child) { + $child.Dispose() } + } catch { + $childProcessDenied = $true } - $source = Get-Content -LiteralPath $InstallerPath -Raw - if ($source -match '(?i)\[\s*(?:System\.)?Diagnostics\.Process\s*\]\s*::\s*Start') { - Fail-Qualification 'Candidate installer invokes System.Diagnostics.Process.Start.' + if (-not $childProcessDenied) { + Fail-Qualification 'The installer qualification Job Object allowed a child process.' + } + + $outboundNetworkDenied = $false + Add-Type -AssemblyName System.Net.Http + $httpClient = [Net.Http.HttpClient]::new() + $httpClient.Timeout = [TimeSpan]::FromSeconds(5) + try { + $response = $httpClient.GetAsync('https://api.github.com/').GetAwaiter().GetResult() + $response.Dispose() + } catch { + $outboundNetworkDenied = $true + } finally { + $httpClient.Dispose() + } + if (-not $outboundNetworkDenied) { + Fail-Qualification 'The installer qualification firewall allowed outbound network access.' + } + return [pscustomobject]@{ + jobActiveProcessLimit = 1 + childProcessDenied = $true + outboundNetworkDenied = $true + } +} + +function Exit-RestrictedInstallerBoundary { + param([Parameter(Mandatory)]$Boundary) + + Remove-NetFirewallRule -Name $Boundary.FirewallRule -ErrorAction SilentlyContinue + if ($Boundary.JobHandle -ne [IntPtr]::Zero) { + [NemoClaw.WindowsQualification.JobBoundary]::CloseHandle($Boundary.JobHandle) | Out-Null } } @@ -267,7 +388,6 @@ $committedInstallerParameters = @{ ExpectedSha256 = $InstallerSha256 } Assert-CommittedFile @committedInstallerParameters -Assert-InstallerProcessBoundary -InstallerPath $installer $artifactPath = [IO.Path]::GetFullPath($ArtifactDirectory).TrimEnd('\') $artifactParent = Split-Path -Parent $artifactPath @@ -281,6 +401,8 @@ $qualificationRoot = Join-Path $env:RUNNER_TEMP ('nemoclaw-windows-native-' + [g $payloadRoot = Join-Path $qualificationRoot 'payload' $installRoot = Join-Path $qualificationRoot 'install' $receiptStage = Join-Path $artifactParent ('.' + $artifactName + '.' + [guid]::NewGuid().ToString('N')) +$restrictedBoundary = $null +$restrictedBoundaryEvidence = $null [IO.Directory]::CreateDirectory($payloadRoot) | Out-Null [IO.Directory]::CreateDirectory($receiptStage) | Out-Null @@ -299,7 +421,7 @@ try { $distributionEntries += [pscustomobject]@{ source = $payloadRelative destination = $payloadRelative - sha256 = Get-FileSha256 -Path $payloadPath + sha256 = (Get-FileHash -LiteralPath $payloadPath -Algorithm SHA256).Hash.ToLowerInvariant() required = $true } } @@ -310,7 +432,7 @@ try { $distributionEntries += [pscustomobject]@{ source = $z3Relative destination = $z3Relative - sha256 = Get-FileSha256 -Path (Join-Path $payloadRoot $z3Relative) + sha256 = (Get-FileHash -LiteralPath (Join-Path $payloadRoot $z3Relative) -Algorithm SHA256).Hash.ToLowerInvariant() required = $true } } @@ -319,7 +441,7 @@ try { if (-not [string]::IsNullOrWhiteSpace($WxcExecPath)) { $resolvedWxc = [IO.Path]::GetFullPath($WxcExecPath) if (-not (Test-Path -LiteralPath $resolvedWxc -PathType Leaf) -or - (Get-FileSha256 -Path $resolvedWxc) -cne $WxcExecSha256) { + (Get-FileHash -LiteralPath $resolvedWxc -Algorithm SHA256).Hash.ToLowerInvariant() -cne $WxcExecSha256) { Fail-Qualification 'wxc-exec candidate is missing or has the wrong digest.' } $wxcPayload = Join-Path $payloadRoot $wxcRelative @@ -348,6 +470,8 @@ try { $manifestPath = Join-Path $payloadRoot 'distribution-manifest.json' Write-JsonFile -Path $manifestPath -Value $manifest + $restrictedBoundary = Enter-RestrictedInstallerBoundary + $restrictedBoundaryEvidence = Test-RestrictedInstallerBoundary $preExecution = Assert-ProhibitedProcessesAbsent -Phase 'pre-execution' $installParameters = @{ Action = 'Install' @@ -378,7 +502,7 @@ try { $repairedReceipt = Get-Content -LiteralPath $installReceiptPath -Raw | ConvertFrom-Json $expectedOpenShell = @($repairedReceipt.files | Where-Object { $_.path -ceq 'bin\openshell.exe' }) if ($expectedOpenShell.Count -ne 1 -or - (Get-FileSha256 -Path (Join-Path $repairedReceipt.versionRoot 'bin\openshell.exe')) -cne $expectedOpenShell[0].sha256) { + (Get-FileHash -LiteralPath (Join-Path $repairedReceipt.versionRoot 'bin\openshell.exe') -Algorithm SHA256).Hash.ToLowerInvariant() -cne $expectedOpenShell[0].sha256) { Fail-Qualification 'Repair did not restore the OpenShell CLI digest.' } @@ -406,7 +530,7 @@ try { }) Write-JsonFile -Path (Join-Path $receiptStage 'process-absence.json') -Value ([pscustomobject]@{ receiptVersion = 1 - installerAstProcessBoundary = $true + restrictedExecution = $restrictedBoundaryEvidence preExecution = $preExecution postExecution = $postExecution }) @@ -420,6 +544,9 @@ try { $receiptStage = $null Write-Host "Windows native installer qualification receipts: $artifactPath" } finally { + if ($restrictedBoundary) { + Exit-RestrictedInstallerBoundary -Boundary $restrictedBoundary + } if ($receiptStage -and (Test-Path -LiteralPath $receiptStage -PathType Container)) { [IO.Directory]::Delete($receiptStage, $true) } diff --git a/scripts/install-windows-native.ps1 b/scripts/install-windows-native.ps1 index 25f76b25fb8..572e52ba977 100644 --- a/scripts/install-windows-native.ps1 +++ b/scripts/install-windows-native.ps1 @@ -48,22 +48,6 @@ function Fail-NativeWindowsInstall { throw "Windows native candidate installer failed: $Message" } -function Get-FileSha256 { - param([Parameter(Mandatory)][string]$Path) - - $stream = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) - try { - $sha256 = [Security.Cryptography.SHA256]::Create() - try { - return ([BitConverter]::ToString($sha256.ComputeHash($stream))).Replace('-', '').ToLowerInvariant() - } finally { - $sha256.Dispose() - } - } finally { - $stream.Dispose() - } -} - function Assert-ExactProperties { param( [Parameter(Mandatory)]$Value, @@ -243,7 +227,7 @@ function Read-DistributionManifest { continue } $sourcePath = Resolve-ExistingRegularFile -Path $sourcePath -Label "Distribution source '$source'" - $actualDigest = Get-FileSha256 -Path $sourcePath + $actualDigest = (Get-FileHash -LiteralPath $sourcePath -Algorithm SHA256).Hash.ToLowerInvariant() if ($actualDigest -cne $entry.sha256) { Fail-NativeWindowsInstall "Distribution source digest does not match the manifest: $source" } @@ -266,7 +250,7 @@ function Read-DistributionManifest { return [pscustomobject]@{ ManifestPath = $manifestFile - ManifestSha256 = Get-FileSha256 -Path $manifestFile + ManifestSha256 = (Get-FileHash -LiteralPath $manifestFile -Algorithm SHA256).Hash.ToLowerInvariant() PayloadRoot = $payloadDirectory Architecture = $nativeArchitecture Files = @($resolvedFiles) @@ -286,7 +270,7 @@ function Test-InstalledFiles { return $false } Assert-NoReparsePoint -Path $target -Label "Installed file '$($file.Destination)'" - if ((Get-FileSha256 -Path $target) -cne $file.Sha256) { + if ((Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash.ToLowerInvariant() -cne $file.Sha256) { return $false } } @@ -326,13 +310,15 @@ function Publish-Distribution { $versionRoot = Join-Path $versionsRoot $versionName $stagingRoot = Join-Path $Root ('.staging-' + [guid]::NewGuid().ToString('N')) $backupRoot = Join-Path $Root ('.backup-' + [guid]::NewGuid().ToString('N')) + $failedReplacementRoot = Join-Path $Root ('.replacement-' + [guid]::NewGuid().ToString('N')) + $recoveryPath = Join-Path $Root 'repair-recovery.json' [IO.Directory]::CreateDirectory($stagingRoot) | Out-Null try { foreach ($file in $Distribution.Files) { $target = Join-Path $stagingRoot $file.Destination [IO.Directory]::CreateDirectory((Split-Path -Parent $target)) | Out-Null [IO.File]::Copy($file.SourcePath, $target, $false) - if ((Get-FileSha256 -Path $target) -cne $file.Sha256) { + if ((Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash.ToLowerInvariant() -cne $file.Sha256) { Fail-NativeWindowsInstall "Staged distribution digest changed: $($file.Destination)" } } @@ -352,12 +338,47 @@ function Publish-Distribution { [IO.Directory]::Move($stagingRoot, $versionRoot) $stagingRoot = $null } catch { - if (-not (Test-Path -LiteralPath $versionRoot) -and (Test-Path -LiteralPath $backupRoot)) { + $publishError = $_.Exception.Message + try { + if (-not (Test-Path -LiteralPath $versionRoot) -and (Test-Path -LiteralPath $backupRoot)) { + [IO.Directory]::Move($backupRoot, $versionRoot) + } + } catch { + Write-JsonAtomic -Path $recoveryPath -Value ([pscustomobject]@{ + receiptVersion = 1 + action = 'restore-prior-version' + versionRoot = $versionRoot + backupRoot = $backupRoot + failedReplacementRoot = $null + publishError = $publishError + rollbackError = $_.Exception.Message + }) + Fail-NativeWindowsInstall "Repair publication and rollback failed. Recovery authority: $recoveryPath" + } + Fail-NativeWindowsInstall "Repair publication failed and the prior version was restored: $publishError" + } + try { + [IO.Directory]::Delete($backupRoot, $true) + } catch { + $cleanupError = $_.Exception.Message + try { + [IO.Directory]::Move($versionRoot, $failedReplacementRoot) [IO.Directory]::Move($backupRoot, $versionRoot) + [IO.Directory]::Delete($failedReplacementRoot, $true) + } catch { + Write-JsonAtomic -Path $recoveryPath -Value ([pscustomobject]@{ + receiptVersion = 1 + action = 'restore-prior-version-and-remove-replacement' + versionRoot = $versionRoot + backupRoot = $backupRoot + failedReplacementRoot = $failedReplacementRoot + publishError = $cleanupError + rollbackError = $_.Exception.Message + }) + Fail-NativeWindowsInstall "Repair backup cleanup and rollback failed. Recovery authority: $recoveryPath" } - throw + Fail-NativeWindowsInstall "Repair could not retire the prior backup, so the prior version was restored. Release file locks and retry Repair. Backup cleanup error: $cleanupError" } - [IO.Directory]::Delete($backupRoot, $true) } else { [IO.Directory]::Move($stagingRoot, $versionRoot) $stagingRoot = $null @@ -370,9 +391,6 @@ function Publish-Distribution { if ($stagingRoot -and (Test-Path -LiteralPath $stagingRoot -PathType Container)) { [IO.Directory]::Delete($stagingRoot, $true) } - if (Test-Path -LiteralPath $backupRoot -PathType Container) { - Fail-NativeWindowsInstall 'A prior candidate version was retained after publication failure.' - } } } @@ -383,6 +401,10 @@ function Invoke-InstallOrRepair { Fail-NativeWindowsInstall 'ManifestPath and PayloadRoot are required for Install and Repair.' } $root = Resolve-InstallRoot -Path $InstallRoot + $repairRecoveryPath = Join-Path $root 'repair-recovery.json' + if (Test-Path -LiteralPath $repairRecoveryPath -PathType Leaf) { + Fail-NativeWindowsInstall "Unresolved repair state must be reconciled before installation: $repairRecoveryPath" + } $distribution = Read-DistributionManifest -Path $ManifestPath -Root $PayloadRoot $versionRoot = Publish-Distribution -Distribution $distribution -Root $root -Repair $Repair $receiptPath = Join-Path $root $script:ReceiptFileName @@ -403,7 +425,7 @@ function Invoke-InstallOrRepair { revision = $script:TrustedOpenShellRevision } manifestSha256 = $distribution.ManifestSha256 - installerSha256 = Get-FileSha256 -Path $PSCommandPath + installerSha256 = (Get-FileHash -LiteralPath $PSCommandPath -Algorithm SHA256).Hash.ToLowerInvariant() installRoot = $root versionRoot = $versionRoot files = $installedFiles @@ -419,6 +441,10 @@ function Invoke-InstallOrRepair { function Invoke-Uninstall { $root = Resolve-InstallRoot -Path $InstallRoot + $repairRecoveryPath = Join-Path $root 'repair-recovery.json' + if (Test-Path -LiteralPath $repairRecoveryPath -PathType Leaf) { + Fail-NativeWindowsInstall "Unresolved repair state must be reconciled before uninstall: $repairRecoveryPath" + } $receiptPath = Resolve-ExistingRegularFile -Path (Join-Path $root $script:ReceiptFileName) -Label 'Install receipt' try { $receipt = Get-Content -LiteralPath $receiptPath -Raw | ConvertFrom-Json @@ -454,7 +480,7 @@ function Invoke-Uninstall { Fail-NativeWindowsInstall 'Receipt file digest is invalid.' } $target = Resolve-ExistingRegularFile -Path (Join-Path $versionRoot $relativePath) -Label "Owned file '$relativePath'" - if ((Get-FileSha256 -Path $target) -cne $entry.sha256) { + if ((Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash.ToLowerInvariant() -cne $entry.sha256) { Fail-NativeWindowsInstall "Owned file drifted; repair before uninstall: $relativePath" } } diff --git a/test/install/windows-native-installer.test.ts b/test/install/windows-native-installer.test.ts deleted file mode 100644 index fe23be4e23a..00000000000 --- a/test/install/windows-native-installer.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it } from "vitest"; - -import { - readYaml, - type Workflow, - type WorkflowJob, - type WorkflowStep, -} from "../helpers/e2e-workflow-contract"; - -const WORKFLOW_PATH = ".github/workflows/platform-vitest-main.yaml"; -const OPENSHELL_REVISION = "bcd517bbe08cc80860c9be57699390cd32e8445f"; - -function workflow(): Workflow { - return readYaml(WORKFLOW_PATH) as Workflow; -} - -function job(name: string): WorkflowJob { - const value = workflow().jobs[name]; - expect(value, `missing workflow job '${name}'`).toBeDefined(); - return value!; -} - -function step(owner: WorkflowJob, name: string): WorkflowStep { - const value = owner.steps?.find((entry) => entry.name === name); - expect(value, `missing workflow step '${name}'`).toBeDefined(); - return value!; -} - -describe("native Windows candidate installer", () => { - it("adds an explicit opt-in hosted ARM64 platform lane", () => { - const platformWorkflow = workflow() as Workflow & { - on?: { - workflow_dispatch?: { - inputs?: Record; - }; - }; - }; - const input = platformWorkflow.on?.workflow_dispatch?.inputs?.run_windows_native_installer; - const installerJob = job("windows-native-installer"); - - expect(input).toEqual( - expect.objectContaining({ - default: false, - type: "boolean", - }), - ); - expect(installerJob.if).toBe( - "${{ github.event_name == 'workflow_dispatch' && inputs.run_windows_native_installer }}", - ); - expect(installerJob["runs-on"]).toBe("windows-11-vs2026-arm"); - expect(installerJob.permissions).toBeUndefined(); - expect(step(installerJob, "Check out the pinned OpenShell Windows candidate").with?.ref).toBe( - OPENSHELL_REVISION, - ); - expect(step(installerJob, "Build the pinned OpenShell PR distribution").run).toContain( - "windows-msvc.ps1 build aarch64-pc-windows-msvc", - ); - expect( - step(installerJob, "Qualify install, repair, uninstall, and no-WSL process boundaries").run, - ).toContain("run-windows-native-installer-qualification.ps1"); - expect( - step(installerJob, "Qualify install, repair, uninstall, and no-WSL process boundaries").run, - ).toContain("-OpenShellSha bcd517bbe08cc80860c9be57699390cd32e8445f"); - expect(step(installerJob, "Upload Windows native installer qualification receipts").if).toBe( - "success()", - ); - expect(JSON.stringify(installerJob)).not.toContain("NVIDIA_INFERENCE_API_KEY"); - }); -}); From 1fa7e1764d77ae8f25cebedf2f27c5e694ed263c Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 11:41:16 -0700 Subject: [PATCH 003/144] ci(windows): cache the pinned OpenShell build Signed-off-by: Aaron Erickson --- .github/workflows/platform-vitest-main.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index d48e7969059..10bedafa1f2 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -252,7 +252,10 @@ jobs: with: toolchain: 1.95.0 target: aarch64-pc-windows-msvc - cache: false + cache: true + cache-bin: false + cache-on-failure: true + rust-src-dir: openshell rustflags: "" - name: Build the pinned OpenShell PR distribution From 94ad38ae4531270056260bbff23d0c5620d865d4 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 11:44:30 -0700 Subject: [PATCH 004/144] ci(windows): build only the qualified payload Signed-off-by: Aaron Erickson --- .github/workflows/platform-vitest-main.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index 10bedafa1f2..0be43fba079 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -264,7 +264,6 @@ jobs: run: | Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' - & .\tasks\scripts\windows-msvc.ps1 check aarch64-pc-windows-msvc & .\tasks\scripts\windows-msvc.ps1 build aarch64-pc-windows-msvc & .\tasks\scripts\windows-msvc.ps1 artifacts aarch64-pc-windows-msvc From b833363d84ff3c86ca37c96425fe0fcae1a96b5b Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 11:57:27 -0700 Subject: [PATCH 005/144] fix(install): own candidate recovery and drift Signed-off-by: Aaron Erickson --- ...windows-native-installer-qualification.ps1 | 127 +++++----- scripts/install-windows-native.ps1 | 221 +++++++++++++++--- 2 files changed, 249 insertions(+), 99 deletions(-) diff --git a/scripts/checks/run-windows-native-installer-qualification.ps1 b/scripts/checks/run-windows-native-installer-qualification.ps1 index 59a367ff1ac..d5255de4c6f 100644 --- a/scripts/checks/run-windows-native-installer-qualification.ps1 +++ b/scripts/checks/run-windows-native-installer-qualification.ps1 @@ -19,9 +19,7 @@ param( [Parameter(Mandatory)][string]$InstallerSha256, [Parameter(Mandatory)][string]$OpenShellCheckout, [Parameter(Mandatory)][string]$OpenShellSha, - [Parameter(Mandatory)][string]$ArtifactDirectory, - [string]$WxcExecPath, - [string]$WxcExecSha256 = '6049c64723af1173c3739dc6cd6b2f33f6c021bb2832c4216233cba7f71aee9a' + [Parameter(Mandatory)][string]$ArtifactDirectory ) Set-StrictMode -Version Latest @@ -208,24 +206,11 @@ namespace NemoClaw.WindowsQualification '@ } - $processPath = [Diagnostics.Process]::GetCurrentProcess().MainModule.FileName - $firewallRule = "NemoClawNativeQualification-$PID-$([guid]::NewGuid().ToString('N'))" - $firewallParameters = @{ - Name = $firewallRule - DisplayName = $firewallRule - Direction = 'Outbound' - Action = 'Block' - Program = $processPath - Profile = 'Any' - } - New-NetFirewallRule @firewallParameters | Out-Null - $jobHandle = [NemoClaw.WindowsQualification.JobBoundary]::CreateJobObject( [IntPtr]::Zero, "NemoClawNativeQualification-$PID" ) if ($jobHandle -eq [IntPtr]::Zero) { - Remove-NetFirewallRule -Name $firewallRule -ErrorAction SilentlyContinue Fail-Qualification 'Could not create the installer qualification Job Object.' } $limit = [NemoClaw.WindowsQualification.ExtendedLimitInformation]::new() @@ -239,7 +224,6 @@ namespace NemoClaw.WindowsQualification $limitLength )) { [NemoClaw.WindowsQualification.JobBoundary]::CloseHandle($jobHandle) | Out-Null - Remove-NetFirewallRule -Name $firewallRule -ErrorAction SilentlyContinue Fail-Qualification 'Could not apply the one-process installer qualification limit.' } $currentProcess = [Diagnostics.Process]::GetCurrentProcess() @@ -248,13 +232,10 @@ namespace NemoClaw.WindowsQualification $currentProcess.Handle )) { [NemoClaw.WindowsQualification.JobBoundary]::CloseHandle($jobHandle) | Out-Null - Remove-NetFirewallRule -Name $firewallRule -ErrorAction SilentlyContinue Fail-Qualification 'Could not enter the one-process installer qualification Job Object.' } return [pscustomobject]@{ JobHandle = $jobHandle - FirewallRule = $firewallRule - ProgramPath = $processPath } } @@ -279,32 +260,15 @@ function Test-RestrictedInstallerBoundary { Fail-Qualification 'The installer qualification Job Object allowed a child process.' } - $outboundNetworkDenied = $false - Add-Type -AssemblyName System.Net.Http - $httpClient = [Net.Http.HttpClient]::new() - $httpClient.Timeout = [TimeSpan]::FromSeconds(5) - try { - $response = $httpClient.GetAsync('https://api.github.com/').GetAwaiter().GetResult() - $response.Dispose() - } catch { - $outboundNetworkDenied = $true - } finally { - $httpClient.Dispose() - } - if (-not $outboundNetworkDenied) { - Fail-Qualification 'The installer qualification firewall allowed outbound network access.' - } return [pscustomobject]@{ jobActiveProcessLimit = 1 childProcessDenied = $true - outboundNetworkDenied = $true } } function Exit-RestrictedInstallerBoundary { param([Parameter(Mandatory)]$Boundary) - Remove-NetFirewallRule -Name $Boundary.FirewallRule -ErrorAction SilentlyContinue if ($Boundary.JobHandle -ne [IntPtr]::Zero) { [NemoClaw.WindowsQualification.JobBoundary]::CloseHandle($Boundary.JobHandle) | Out-Null } @@ -355,8 +319,8 @@ function Assert-BoundedFile { if ($CandidateSha -cnotmatch $script:ShaPattern -or $OpenShellSha -cnotmatch $script:ShaPattern) { Fail-Qualification 'Candidate and OpenShell revisions must be lowercase 40-character commit SHAs.' } -if ($InstallerSha256 -cnotmatch $script:Sha256Pattern -or $WxcExecSha256 -cnotmatch $script:Sha256Pattern) { - Fail-Qualification 'Installer and wxc-exec digests must be lowercase SHA-256 values.' +if ($InstallerSha256 -cnotmatch $script:Sha256Pattern) { + Fail-Qualification 'Installer digest must be a lowercase SHA-256 value.' } if ($OpenShellSha -cne $script:TrustedOpenShellRevision) { Fail-Qualification 'OpenShell revision must match PR #2721 merge commit.' @@ -422,7 +386,6 @@ try { source = $payloadRelative destination = $payloadRelative sha256 = (Get-FileHash -LiteralPath $payloadPath -Algorithm SHA256).Hash.ToLowerInvariant() - required = $true } } $z3Source = Join-Path $releaseRoot 'libz3.dll' @@ -433,26 +396,7 @@ try { source = $z3Relative destination = $z3Relative sha256 = (Get-FileHash -LiteralPath (Join-Path $payloadRoot $z3Relative) -Algorithm SHA256).Hash.ToLowerInvariant() - required = $true - } - } - - $wxcRelative = 'mxc\wxc-exec.exe' - if (-not [string]::IsNullOrWhiteSpace($WxcExecPath)) { - $resolvedWxc = [IO.Path]::GetFullPath($WxcExecPath) - if (-not (Test-Path -LiteralPath $resolvedWxc -PathType Leaf) -or - (Get-FileHash -LiteralPath $resolvedWxc -Algorithm SHA256).Hash.ToLowerInvariant() -cne $WxcExecSha256) { - Fail-Qualification 'wxc-exec candidate is missing or has the wrong digest.' } - $wxcPayload = Join-Path $payloadRoot $wxcRelative - [IO.Directory]::CreateDirectory((Split-Path -Parent $wxcPayload)) | Out-Null - [IO.File]::Copy($resolvedWxc, $wxcPayload, $false) - } - $distributionEntries += [pscustomobject]@{ - source = $wxcRelative - destination = $wxcRelative - sha256 = $WxcExecSha256 - required = $false } $manifest = [pscustomobject]@{ @@ -469,6 +413,13 @@ try { } $manifestPath = Join-Path $payloadRoot 'distribution-manifest.json' Write-JsonFile -Path $manifestPath -Value $manifest + $expectedOpenShellEntry = @($distributionEntries | Where-Object { + $_.destination -ceq 'bin\openshell.exe' + }) + if ($expectedOpenShellEntry.Count -ne 1) { + Fail-Qualification 'Qualification payload has no unique OpenShell CLI digest.' + } + $expectedOpenShellSha256 = $expectedOpenShellEntry[0].sha256 $restrictedBoundary = Enter-RestrictedInstallerBoundary $restrictedBoundaryEvidence = Test-RestrictedInstallerBoundary @@ -488,6 +439,17 @@ try { [IO.File]::Copy($installReceiptPath, (Join-Path $receiptStage 'install-receipt.json'), $false) $installReceipt = $installOutput | Select-Object -Last 1 | ConvertFrom-Json + $untrackedPath = Join-Path $installReceipt.versionRoot 'bin\untracked-qualification.txt' + [IO.File]::WriteAllText($untrackedPath, 'untracked', [Text.UTF8Encoding]::new($false)) + $untrackedInstallRejected = $false + try { + & $installer @installParameters | Out-Null + } catch { + $untrackedInstallRejected = $true + } + if (-not $untrackedInstallRejected) { + Fail-Qualification 'Install accepted an untracked file inside the owned version root.' + } $driftTarget = Join-Path $installReceipt.versionRoot 'bin\openshell.exe' [IO.File]::AppendAllText($driftTarget, 'qualification-drift', [Text.UTF8Encoding]::new($false)) $repairParameters = @{ @@ -500,12 +462,53 @@ try { & $installer @repairParameters | Out-Null [IO.File]::Copy($installReceiptPath, (Join-Path $receiptStage 'repair-receipt.json'), $false) $repairedReceipt = Get-Content -LiteralPath $installReceiptPath -Raw | ConvertFrom-Json - $expectedOpenShell = @($repairedReceipt.files | Where-Object { $_.path -ceq 'bin\openshell.exe' }) - if ($expectedOpenShell.Count -ne 1 -or - (Get-FileHash -LiteralPath (Join-Path $repairedReceipt.versionRoot 'bin\openshell.exe') -Algorithm SHA256).Hash.ToLowerInvariant() -cne $expectedOpenShell[0].sha256) { + if ((Test-Path -LiteralPath $untrackedPath) -or + (Get-FileHash -LiteralPath (Join-Path $repairedReceipt.versionRoot 'bin\openshell.exe') -Algorithm SHA256).Hash.ToLowerInvariant() -cne $expectedOpenShellSha256) { Fail-Qualification 'Repair did not restore the OpenShell CLI digest.' } + $recoveryBackupRoot = Join-Path $installRoot ('.backup-' + [guid]::NewGuid().ToString('N')) + $recoveryReplacementRoot = Join-Path $installRoot ('.replacement-' + [guid]::NewGuid().ToString('N')) + [IO.Directory]::Move($repairedReceipt.versionRoot, $recoveryBackupRoot) + [IO.Directory]::CreateDirectory($recoveryReplacementRoot) | Out-Null + [IO.File]::WriteAllText( + (Join-Path $recoveryReplacementRoot 'incomplete.txt'), + 'incomplete replacement', + [Text.UTF8Encoding]::new($false) + ) + $recoveryAuthorityPath = Join-Path $installRoot 'repair-recovery.json' + Write-JsonFile -Path $recoveryAuthorityPath -Value ([pscustomobject]@{ + receiptVersion = 1 + classification = 'qualification-only' + installRoot = $installRoot + openshell = [pscustomobject]@{ + repository = $script:CanonicalOpenShellRepository + pullRequest = $script:TrustedOpenShellPullRequest + revision = $script:TrustedOpenShellRevision + } + action = 'restore-prior-version-and-remove-replacement' + versionRoot = $repairedReceipt.versionRoot + backupRoot = $recoveryBackupRoot + failedReplacementRoot = $recoveryReplacementRoot + publishError = 'qualification fixture' + rollbackError = 'qualification fixture' + }) + $recoverParameters = @{ + Action = 'Recover' + ManifestPath = $manifestPath + PayloadRoot = $payloadRoot + InstallRoot = $installRoot + Json = $true + } + & $installer @recoverParameters | Out-Null + if ((Test-Path -LiteralPath $recoveryAuthorityPath) -or + (Test-Path -LiteralPath $recoveryBackupRoot) -or + (Test-Path -LiteralPath $recoveryReplacementRoot) -or + (Get-FileHash -LiteralPath (Join-Path $repairedReceipt.versionRoot 'bin\openshell.exe') -Algorithm SHA256).Hash.ToLowerInvariant() -cne $expectedOpenShellSha256) { + Fail-Qualification 'Recover did not publish one clean pinned distribution.' + } + [IO.File]::Copy($installReceiptPath, (Join-Path $receiptStage 'recovery-receipt.json'), $false) + $uninstallOutput = & $installer -Action Uninstall -InstallRoot $installRoot -Json $uninstallReceipt = $uninstallOutput | Select-Object -Last 1 | ConvertFrom-Json if (-not $uninstallReceipt.finalAbsence -or (Test-Path -LiteralPath $installRoot)) { diff --git a/scripts/install-windows-native.ps1 b/scripts/install-windows-native.ps1 index 572e52ba977..dd95cd7a128 100644 --- a/scripts/install-windows-native.ps1 +++ b/scripts/install-windows-native.ps1 @@ -15,7 +15,7 @@ [CmdletBinding()] param( - [ValidateSet('Install', 'Repair', 'Uninstall')] + [ValidateSet('Install', 'Repair', 'Recover', 'Uninstall')] [string]$Action = 'Install', [string]$ManifestPath, @@ -198,15 +198,13 @@ function Read-DistributionManifest { } $destinations = @{} $resolvedFiles = @() - $omittedOptionalDestinations = @() foreach ($entry in @($manifest.files)) { Assert-ExactProperties -Value $entry -Properties @( - 'destination', 'required', 'sha256', 'source' + 'destination', 'sha256', 'source' ) -Label 'Distribution file entry' $source = Resolve-SafeRelativePath -Value $entry.source -Label 'Distribution source' $destination = Resolve-SafeRelativePath -Value $entry.destination -Label 'Distribution destination' - if ($entry.required -isnot [bool] -or $entry.sha256 -isnot [string] -or - $entry.sha256 -cnotmatch $script:Sha256Pattern) { + if ($entry.sha256 -isnot [string] -or $entry.sha256 -cnotmatch $script:Sha256Pattern) { Fail-NativeWindowsInstall 'Distribution file entry identity is invalid.' } $destinationKey = $destination.ToLowerInvariant() @@ -220,11 +218,7 @@ function Read-DistributionManifest { Fail-NativeWindowsInstall 'Distribution source escapes the payload root.' } if (-not (Test-Path -LiteralPath $sourcePath -PathType Leaf)) { - if ($entry.required) { - Fail-NativeWindowsInstall "Required distribution source is missing: $source" - } - $omittedOptionalDestinations += $destination - continue + Fail-NativeWindowsInstall "Distribution source is missing: $source" } $sourcePath = Resolve-ExistingRegularFile -Path $sourcePath -Label "Distribution source '$source'" $actualDigest = (Get-FileHash -LiteralPath $sourcePath -Algorithm SHA256).Hash.ToLowerInvariant() @@ -254,7 +248,6 @@ function Read-DistributionManifest { PayloadRoot = $payloadDirectory Architecture = $nativeArchitecture Files = @($resolvedFiles) - OmittedOptionalDestinations = @($omittedOptionalDestinations | Sort-Object) } } @@ -264,12 +257,51 @@ function Test-InstalledFiles { [Parameter(Mandatory)][Array]$Files ) + $expectedFiles = @{} + $expectedDirectories = @{} + foreach ($file in $Files) { + $expectedFiles[$file.Destination.ToLowerInvariant()] = $file.Sha256 + $segments = @($file.Destination.Split('\')) + for ($index = 1; $index -lt $segments.Count; $index++) { + $directory = ($segments[0..($index - 1)] -join '\').ToLowerInvariant() + $expectedDirectories[$directory] = $true + } + } + + $observedFiles = @{} + $observedDirectories = @{} + $pendingDirectories = [Collections.Generic.Stack[string]]::new() + $pendingDirectories.Push($VersionRoot) + while ($pendingDirectories.Count -gt 0) { + $directory = $pendingDirectories.Pop() + foreach ($item in @(Get-ChildItem -LiteralPath $directory -Force)) { + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + Fail-NativeWindowsInstall "Installed candidate contains a reparse point: $($item.FullName)" + } + $relativePath = $item.FullName.Substring($VersionRoot.Length + 1).ToLowerInvariant() + if ($item.PSIsContainer) { + $observedDirectories[$relativePath] = $true + $pendingDirectories.Push($item.FullName) + } else { + $observedFiles[$relativePath] = $item.FullName + } + } + } + if ($observedFiles.Count -ne $expectedFiles.Count -or + $observedDirectories.Count -ne $expectedDirectories.Count) { + return $false + } + foreach ($expectedDirectory in $expectedDirectories.Keys) { + if (-not $observedDirectories.ContainsKey($expectedDirectory)) { + return $false + } + } foreach ($file in $Files) { $target = Join-Path $VersionRoot $file.Destination - if (-not (Test-Path -LiteralPath $target -PathType Leaf)) { + if (-not $observedFiles.ContainsKey($file.Destination.ToLowerInvariant()) -or + -not (Test-Path -LiteralPath $target -PathType Leaf)) { return $false } - Assert-NoReparsePoint -Path $target -Label "Installed file '$($file.Destination)'" if ((Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash.ToLowerInvariant() -cne $file.Sha256) { return $false } @@ -295,6 +327,36 @@ function Write-JsonAtomic { } } +function Write-RepairRecoveryRecord { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Action, + [Parameter(Mandatory)][string]$Root, + [Parameter(Mandatory)][string]$VersionRoot, + [Parameter(Mandatory)][string]$BackupRoot, + [AllowNull()][string]$FailedReplacementRoot, + [Parameter(Mandatory)][string]$PublishError, + [Parameter(Mandatory)][string]$RollbackError + ) + + Write-JsonAtomic -Path $Path -Value ([pscustomobject]@{ + receiptVersion = 1 + classification = 'qualification-only' + installRoot = $Root + openshell = [pscustomobject]@{ + repository = $script:TrustedOpenShellRepository + pullRequest = $script:TrustedOpenShellPullRequest + revision = $script:TrustedOpenShellRevision + } + action = $Action + versionRoot = $VersionRoot + backupRoot = $BackupRoot + failedReplacementRoot = $FailedReplacementRoot + publishError = $PublishError + rollbackError = $RollbackError + }) +} + function Publish-Distribution { param( [Parameter(Mandatory)]$Distribution, @@ -344,16 +406,18 @@ function Publish-Distribution { [IO.Directory]::Move($backupRoot, $versionRoot) } } catch { - Write-JsonAtomic -Path $recoveryPath -Value ([pscustomobject]@{ - receiptVersion = 1 - action = 'restore-prior-version' - versionRoot = $versionRoot - backupRoot = $backupRoot - failedReplacementRoot = $null - publishError = $publishError - rollbackError = $_.Exception.Message - }) - Fail-NativeWindowsInstall "Repair publication and rollback failed. Recovery authority: $recoveryPath" + $recoveryParameters = @{ + Path = $recoveryPath + Action = 'restore-prior-version' + Root = $Root + VersionRoot = $versionRoot + BackupRoot = $backupRoot + FailedReplacementRoot = $null + PublishError = $publishError + RollbackError = $_.Exception.Message + } + Write-RepairRecoveryRecord @recoveryParameters + Fail-NativeWindowsInstall "Repair publication and rollback failed. Run Recover with the same manifest, payload, and install root. Recovery authority: $recoveryPath" } Fail-NativeWindowsInstall "Repair publication failed and the prior version was restored: $publishError" } @@ -366,16 +430,18 @@ function Publish-Distribution { [IO.Directory]::Move($backupRoot, $versionRoot) [IO.Directory]::Delete($failedReplacementRoot, $true) } catch { - Write-JsonAtomic -Path $recoveryPath -Value ([pscustomobject]@{ - receiptVersion = 1 - action = 'restore-prior-version-and-remove-replacement' - versionRoot = $versionRoot - backupRoot = $backupRoot - failedReplacementRoot = $failedReplacementRoot - publishError = $cleanupError - rollbackError = $_.Exception.Message - }) - Fail-NativeWindowsInstall "Repair backup cleanup and rollback failed. Recovery authority: $recoveryPath" + $recoveryParameters = @{ + Path = $recoveryPath + Action = 'restore-prior-version-and-remove-replacement' + Root = $Root + VersionRoot = $versionRoot + BackupRoot = $backupRoot + FailedReplacementRoot = $failedReplacementRoot + PublishError = $cleanupError + RollbackError = $_.Exception.Message + } + Write-RepairRecoveryRecord @recoveryParameters + Fail-NativeWindowsInstall "Repair backup cleanup and rollback failed. Run Recover with the same manifest, payload, and install root. Recovery authority: $recoveryPath" } Fail-NativeWindowsInstall "Repair could not retire the prior backup, so the prior version was restored. Release file locks and retry Repair. Backup cleanup error: $cleanupError" } @@ -403,7 +469,7 @@ function Invoke-InstallOrRepair { $root = Resolve-InstallRoot -Path $InstallRoot $repairRecoveryPath = Join-Path $root 'repair-recovery.json' if (Test-Path -LiteralPath $repairRecoveryPath -PathType Leaf) { - Fail-NativeWindowsInstall "Unresolved repair state must be reconciled before installation: $repairRecoveryPath" + Fail-NativeWindowsInstall "Unresolved repair state blocks installation. Run Recover with the same manifest, payload, and install root: $repairRecoveryPath" } $distribution = Read-DistributionManifest -Path $ManifestPath -Root $PayloadRoot $versionRoot = Publish-Distribution -Distribution $distribution -Root $root -Repair $Repair @@ -429,7 +495,6 @@ function Invoke-InstallOrRepair { installRoot = $root versionRoot = $versionRoot files = $installedFiles - omittedOptionalDestinations = @($distribution.OmittedOptionalDestinations) } Write-JsonAtomic -Path $receiptPath -Value $receipt if ($Json) { @@ -439,11 +504,92 @@ function Invoke-InstallOrRepair { } } +function Resolve-RecoveryAuxiliaryPath { + param( + [AllowNull()]$Value, + [Parameter(Mandatory)][string]$Root, + [Parameter(Mandatory)][string]$Prefix, + [Parameter(Mandatory)][string]$Label + ) + + if ($null -eq $Value) { + return $null + } + if ($Value -isnot [string]) { + Fail-NativeWindowsInstall "$Label is invalid." + } + $resolved = [IO.Path]::GetFullPath($Value).TrimEnd('\') + if ((Split-Path -Parent $resolved) -cne $Root -or + (Split-Path -Leaf $resolved) -cnotmatch "^$([regex]::Escape($Prefix))[a-f0-9]{32}$") { + Fail-NativeWindowsInstall "$Label is outside its owned recovery namespace." + } + return $resolved +} + +function Invoke-Recover { + if ([string]::IsNullOrWhiteSpace($ManifestPath) -or [string]::IsNullOrWhiteSpace($PayloadRoot)) { + Fail-NativeWindowsInstall 'ManifestPath and PayloadRoot are required for Recover.' + } + $root = Resolve-InstallRoot -Path $InstallRoot + $distribution = Read-DistributionManifest -Path $ManifestPath -Root $PayloadRoot + $recoveryPath = Resolve-ExistingRegularFile -Path (Join-Path $root 'repair-recovery.json') -Label 'Repair recovery authority' + try { + $recovery = Get-Content -LiteralPath $recoveryPath -Raw | ConvertFrom-Json + } catch { + Fail-NativeWindowsInstall 'Repair recovery authority is not valid JSON.' + } + Assert-ExactProperties -Value $recovery -Properties @( + 'action', 'backupRoot', 'classification', 'failedReplacementRoot', 'installRoot', + 'openshell', 'publishError', 'receiptVersion', 'rollbackError', 'versionRoot' + ) -Label 'Repair recovery authority' + Assert-ExactProperties -Value $recovery.openshell -Properties @( + 'pullRequest', 'repository', 'revision' + ) -Label 'Recovery OpenShell authority' + if ($recovery.receiptVersion -ne 1 -or $recovery.classification -cne 'qualification-only' -or + $recovery.installRoot -cne $root -or + $recovery.action -cnotin @('restore-prior-version', 'restore-prior-version-and-remove-replacement') -or + $recovery.openshell.repository -cne $script:TrustedOpenShellRepository -or + $recovery.openshell.pullRequest -ne $script:TrustedOpenShellPullRequest -or + $recovery.openshell.revision -cne $script:TrustedOpenShellRevision) { + Fail-NativeWindowsInstall 'Repair recovery authority identity is invalid.' + } + + $versionName = "openshell-pr$($script:TrustedOpenShellPullRequest)-$($script:TrustedOpenShellRevision.Substring(0, 12))-$($distribution.Architecture)" + $expectedVersionRoot = [IO.Path]::GetFullPath((Join-Path (Join-Path $root 'versions') $versionName)).TrimEnd('\') + $recordedVersionRoot = [IO.Path]::GetFullPath([string]$recovery.versionRoot).TrimEnd('\') + if ($recordedVersionRoot -cne $expectedVersionRoot) { + Fail-NativeWindowsInstall 'Repair recovery version root does not match the pinned distribution.' + } + $backupParameters = @{ + Value = $recovery.backupRoot + Root = $root + Prefix = '.backup-' + Label = 'Recovery backup root' + } + $backupRoot = Resolve-RecoveryAuxiliaryPath @backupParameters + $replacementParameters = @{ + Value = $recovery.failedReplacementRoot + Root = $root + Prefix = '.replacement-' + Label = 'Recovery replacement root' + } + $replacementRoot = Resolve-RecoveryAuxiliaryPath @replacementParameters + + foreach ($ownedDirectory in (@($recordedVersionRoot, $backupRoot, $replacementRoot) | Select-Object -Unique)) { + if ($ownedDirectory -and (Test-Path -LiteralPath $ownedDirectory -PathType Container)) { + Assert-NoReparsePoint -Path $ownedDirectory -Label 'Recorded recovery directory' + [IO.Directory]::Delete($ownedDirectory, $true) + } + } + [IO.File]::Delete($recoveryPath) + Invoke-InstallOrRepair -Repair $false +} + function Invoke-Uninstall { $root = Resolve-InstallRoot -Path $InstallRoot $repairRecoveryPath = Join-Path $root 'repair-recovery.json' if (Test-Path -LiteralPath $repairRecoveryPath -PathType Leaf) { - Fail-NativeWindowsInstall "Unresolved repair state must be reconciled before uninstall: $repairRecoveryPath" + Fail-NativeWindowsInstall "Unresolved repair state blocks uninstall. Run Recover with the same manifest, payload, and install root: $repairRecoveryPath" } $receiptPath = Resolve-ExistingRegularFile -Path (Join-Path $root $script:ReceiptFileName) -Label 'Install receipt' try { @@ -453,7 +599,7 @@ function Invoke-Uninstall { } Assert-ExactProperties -Value $receipt -Properties @( 'architecture', 'classification', 'files', 'installerSha256', 'installRoot', - 'manifestSha256', 'omittedOptionalDestinations', 'openshell', 'platform', + 'manifestSha256', 'openshell', 'platform', 'receiptVersion', 'versionRoot' ) -Label 'Install receipt' Assert-ExactProperties -Value $receipt.openshell -Properties @( @@ -511,5 +657,6 @@ function Invoke-Uninstall { switch ($Action) { 'Install' { Invoke-InstallOrRepair -Repair $false } 'Repair' { Invoke-InstallOrRepair -Repair $true } + 'Recover' { Invoke-Recover } 'Uninstall' { Invoke-Uninstall } } From a8e4fe2275404c3ee28118b843655428c01b3e45 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 12:09:03 -0700 Subject: [PATCH 006/144] fix(install): serialize native candidate recovery Signed-off-by: Aaron Erickson --- ...windows-native-installer-qualification.ps1 | 96 ++++++++++- scripts/install-windows-native.ps1 | 152 ++++++++++++------ 2 files changed, 192 insertions(+), 56 deletions(-) diff --git a/scripts/checks/run-windows-native-installer-qualification.ps1 b/scripts/checks/run-windows-native-installer-qualification.ps1 index d5255de4c6f..61c1ed97747 100644 --- a/scripts/checks/run-windows-native-installer-qualification.ps1 +++ b/scripts/checks/run-windows-native-installer-qualification.ps1 @@ -316,6 +316,45 @@ function Assert-BoundedFile { } } +function Assert-InstalledDistribution { + param( + [Parameter(Mandatory)][string]$VersionRoot, + [Parameter(Mandatory)][Array]$Entries, + [Parameter(Mandatory)][string]$Phase + ) + + $expectedFiles = @($Entries | ForEach-Object { $_.destination.ToLowerInvariant() } | Sort-Object) + $expectedDirectories = @() + foreach ($entry in $Entries) { + $segments = @($entry.destination.Split('\')) + for ($index = 1; $index -lt $segments.Count; $index++) { + $expectedDirectories += ($segments[0..($index - 1)] -join '\').ToLowerInvariant() + } + $target = Join-Path $VersionRoot $entry.destination + if (-not (Test-Path -LiteralPath $target -PathType Leaf) -or + (Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash.ToLowerInvariant() -cne $entry.sha256) { + Fail-Qualification "$Phase distribution file is missing or has the wrong digest: $($entry.destination)" + } + } + $observed = @(Get-ChildItem -LiteralPath $VersionRoot -Recurse -Force) + foreach ($item in $observed) { + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + Fail-Qualification "$Phase distribution contains a reparse point." + } + } + $observedFiles = @($observed | Where-Object { -not $_.PSIsContainer } | ForEach-Object { + $_.FullName.Substring($VersionRoot.Length + 1).ToLowerInvariant() + } | Sort-Object) + $observedDirectories = @($observed | Where-Object { $_.PSIsContainer } | ForEach-Object { + $_.FullName.Substring($VersionRoot.Length + 1).ToLowerInvariant() + } | Sort-Object -Unique) + $expectedDirectories = @($expectedDirectories | Sort-Object -Unique) + if (@(Compare-Object $expectedFiles $observedFiles).Count -ne 0 -or + @(Compare-Object $expectedDirectories $observedDirectories).Count -ne 0) { + Fail-Qualification "$Phase distribution contains an unexpected file or directory." + } +} + if ($CandidateSha -cnotmatch $script:ShaPattern -or $OpenShellSha -cnotmatch $script:ShaPattern) { Fail-Qualification 'Candidate and OpenShell revisions must be lowercase 40-character commit SHAs.' } @@ -424,21 +463,35 @@ try { $restrictedBoundary = Enter-RestrictedInstallerBoundary $restrictedBoundaryEvidence = Test-RestrictedInstallerBoundary $preExecution = Assert-ProhibitedProcessesAbsent -Phase 'pre-execution' + $volumeRootRejected = $false + try { + & $installer -Action Uninstall -InstallRoot ([IO.Path]::GetPathRoot($installRoot)) | Out-Null + } catch { + $volumeRootRejected = $true + } + if (-not $volumeRootRejected) { + Fail-Qualification 'Installer accepted a drive root as InstallRoot.' + } $installParameters = @{ Action = 'Install' ManifestPath = $manifestPath PayloadRoot = $payloadRoot InstallRoot = $installRoot - Json = $true } - $installOutput = & $installer @installParameters + & $installer @installParameters $installReceiptPath = Join-Path $installRoot 'install-receipt.json' if (-not (Test-Path -LiteralPath $installReceiptPath -PathType Leaf)) { Fail-Qualification 'Candidate installer did not publish an install receipt.' } [IO.File]::Copy($installReceiptPath, (Join-Path $receiptStage 'install-receipt.json'), $false) - $installReceipt = $installOutput | Select-Object -Last 1 | ConvertFrom-Json + $installReceipt = Get-Content -LiteralPath $installReceiptPath -Raw | ConvertFrom-Json + $initialDistributionParameters = @{ + VersionRoot = $installReceipt.versionRoot + Entries = $distributionEntries + Phase = 'Initial install' + } + Assert-InstalledDistribution @initialDistributionParameters $untrackedPath = Join-Path $installReceipt.versionRoot 'bin\untracked-qualification.txt' [IO.File]::WriteAllText($untrackedPath, 'untracked', [Text.UTF8Encoding]::new($false)) $untrackedInstallRejected = $false @@ -457,7 +510,28 @@ try { ManifestPath = $manifestPath PayloadRoot = $payloadRoot InstallRoot = $installRoot - Json = $true + } + + $lockPath = Join-Path (Split-Path -Parent $installRoot) ('.' + (Split-Path -Leaf $installRoot) + '.native-installer.lock') + $heldLock = [IO.File]::Open( + $lockPath, + [IO.FileMode]::OpenOrCreate, + [IO.FileAccess]::ReadWrite, + [IO.FileShare]::None + ) + $overlappingRepairRejected = $false + try { + try { + & $installer @repairParameters | Out-Null + } catch { + $overlappingRepairRejected = $true + } + } finally { + $heldLock.Dispose() + [IO.File]::Delete($lockPath) + } + if (-not $overlappingRepairRejected) { + Fail-Qualification 'Installer lock allowed an overlapping repair operation.' } & $installer @repairParameters | Out-Null [IO.File]::Copy($installReceiptPath, (Join-Path $receiptStage 'repair-receipt.json'), $false) @@ -466,6 +540,7 @@ try { (Get-FileHash -LiteralPath (Join-Path $repairedReceipt.versionRoot 'bin\openshell.exe') -Algorithm SHA256).Hash.ToLowerInvariant() -cne $expectedOpenShellSha256) { Fail-Qualification 'Repair did not restore the OpenShell CLI digest.' } + Assert-InstalledDistribution -VersionRoot $repairedReceipt.versionRoot -Entries $distributionEntries -Phase 'Repair' $recoveryBackupRoot = Join-Path $installRoot ('.backup-' + [guid]::NewGuid().ToString('N')) $recoveryReplacementRoot = Join-Path $installRoot ('.replacement-' + [guid]::NewGuid().ToString('N')) @@ -498,7 +573,6 @@ try { ManifestPath = $manifestPath PayloadRoot = $payloadRoot InstallRoot = $installRoot - Json = $true } & $installer @recoverParameters | Out-Null if ((Test-Path -LiteralPath $recoveryAuthorityPath) -or @@ -509,11 +583,17 @@ try { } [IO.File]::Copy($installReceiptPath, (Join-Path $receiptStage 'recovery-receipt.json'), $false) - $uninstallOutput = & $installer -Action Uninstall -InstallRoot $installRoot -Json - $uninstallReceipt = $uninstallOutput | Select-Object -Last 1 | ConvertFrom-Json - if (-not $uninstallReceipt.finalAbsence -or (Test-Path -LiteralPath $installRoot)) { + & $installer -Action Uninstall -InstallRoot $installRoot + if (Test-Path -LiteralPath $installRoot) { Fail-Qualification 'Uninstall did not prove final absence.' } + $uninstallReceipt = [pscustomobject]@{ + receiptVersion = 1 + action = 'uninstall' + classification = 'qualification-only' + installRoot = $installRoot + finalAbsence = $true + } $postExecution = Assert-ProhibitedProcessesAbsent -Phase 'post-execution' [IO.File]::Copy($installer, (Join-Path $receiptStage 'install-windows-native.ps1'), $false) diff --git a/scripts/install-windows-native.ps1 b/scripts/install-windows-native.ps1 index dd95cd7a128..d2c924c4ab1 100644 --- a/scripts/install-windows-native.ps1 +++ b/scripts/install-windows-native.ps1 @@ -11,6 +11,9 @@ does not start a process, install a service, select a runtime provider, or activate native Windows support. A later slice can consume the receipt after the corresponding lifecycle and activation gates pass. + + If repair emits repair-recovery.json, run this script with -Action Recover + and the same -ManifestPath, -PayloadRoot, and -InstallRoot values. #> [CmdletBinding()] @@ -24,9 +27,7 @@ param( [string]$InstallRoot = (Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) - 'NVIDIA\NemoClaw\native-candidate'), - - [switch]$Json + 'NVIDIA\NemoClaw\native-candidate') ) Set-StrictMode -Version Latest @@ -126,11 +127,36 @@ function Resolve-InstallRoot { if ([string]::IsNullOrWhiteSpace($Path) -or $Path -notmatch '^[A-Za-z]:\\') { Fail-NativeWindowsInstall 'InstallRoot must be an absolute local-drive Windows path.' } - $resolved = [IO.Path]::GetFullPath($Path).TrimEnd('\') + $absolute = [IO.Path]::GetFullPath($Path) + if ($absolute.TrimEnd('\') -ceq [IO.Path]::GetPathRoot($absolute).TrimEnd('\')) { + Fail-NativeWindowsInstall 'InstallRoot must not be a drive root.' + } + $resolved = $absolute.TrimEnd('\') Assert-NoReparsePoint -Path $resolved -Label 'InstallRoot' return $resolved } +function Enter-InstallerLock { + param([Parameter(Mandatory)][string]$Root) + + $parent = Split-Path -Parent $Root + [IO.Directory]::CreateDirectory($parent) | Out-Null + Assert-NoReparsePoint -Path $parent -Label 'InstallRoot parent' + $lockPath = Join-Path $parent ('.' + (Split-Path -Leaf $Root) + '.native-installer.lock') + try { + return [IO.File]::Open( + $lockPath, + [IO.FileMode]::OpenOrCreate, + [IO.FileAccess]::ReadWrite, + [IO.FileShare]::None, + 1, + [IO.FileOptions]::DeleteOnClose + ) + } catch [IO.IOException] { + Fail-NativeWindowsInstall "Another installer operation owns $Root. Wait for it to finish, then retry." + } +} + function Resolve-SafeRelativePath { param( [Parameter(Mandatory)]$Value, @@ -460,21 +486,15 @@ function Publish-Distribution { } } -function Invoke-InstallOrRepair { - param([Parameter(Mandatory)][bool]$Repair) +function Write-InstallReceipt { + param( + [Parameter(Mandatory)]$Distribution, + [Parameter(Mandatory)][string]$Root, + [Parameter(Mandatory)][string]$VersionRoot + ) - if ([string]::IsNullOrWhiteSpace($ManifestPath) -or [string]::IsNullOrWhiteSpace($PayloadRoot)) { - Fail-NativeWindowsInstall 'ManifestPath and PayloadRoot are required for Install and Repair.' - } - $root = Resolve-InstallRoot -Path $InstallRoot - $repairRecoveryPath = Join-Path $root 'repair-recovery.json' - if (Test-Path -LiteralPath $repairRecoveryPath -PathType Leaf) { - Fail-NativeWindowsInstall "Unresolved repair state blocks installation. Run Recover with the same manifest, payload, and install root: $repairRecoveryPath" - } - $distribution = Read-DistributionManifest -Path $ManifestPath -Root $PayloadRoot - $versionRoot = Publish-Distribution -Distribution $distribution -Root $root -Repair $Repair - $receiptPath = Join-Path $root $script:ReceiptFileName - $installedFiles = @($distribution.Files | ForEach-Object { + $receiptPath = Join-Path $Root $script:ReceiptFileName + $installedFiles = @($Distribution.Files | ForEach-Object { [pscustomobject]@{ path = $_.Destination sha256 = $_.Sha256 @@ -484,24 +504,36 @@ function Invoke-InstallOrRepair { receiptVersion = 1 classification = 'qualification-only' platform = 'windows' - architecture = $distribution.Architecture + architecture = $Distribution.Architecture openshell = [pscustomobject]@{ repository = $script:TrustedOpenShellRepository pullRequest = $script:TrustedOpenShellPullRequest revision = $script:TrustedOpenShellRevision } - manifestSha256 = $distribution.ManifestSha256 + manifestSha256 = $Distribution.ManifestSha256 installerSha256 = (Get-FileHash -LiteralPath $PSCommandPath -Algorithm SHA256).Hash.ToLowerInvariant() - installRoot = $root - versionRoot = $versionRoot + installRoot = $Root + versionRoot = $VersionRoot files = $installedFiles } Write-JsonAtomic -Path $receiptPath -Value $receipt - if ($Json) { - Write-Output ($receipt | ConvertTo-Json -Depth 12 -Compress) - } else { - Write-Host "Native Windows candidate distribution installed at $versionRoot" +} + +function Invoke-InstallOrRepair { + param([Parameter(Mandatory)][bool]$Repair) + + if ([string]::IsNullOrWhiteSpace($ManifestPath) -or [string]::IsNullOrWhiteSpace($PayloadRoot)) { + Fail-NativeWindowsInstall 'ManifestPath and PayloadRoot are required for Install and Repair.' + } + $root = Resolve-InstallRoot -Path $InstallRoot + $repairRecoveryPath = Join-Path $root 'repair-recovery.json' + if (Test-Path -LiteralPath $repairRecoveryPath -PathType Leaf) { + Fail-NativeWindowsInstall "Unresolved repair state blocks installation. Run Recover with the same manifest, payload, and install root: $repairRecoveryPath" } + $distribution = Read-DistributionManifest -Path $ManifestPath -Root $PayloadRoot + $versionRoot = Publish-Distribution -Distribution $distribution -Root $root -Repair $Repair + Write-InstallReceipt -Distribution $distribution -Root $root -VersionRoot $versionRoot + Write-Host "Native Windows candidate distribution installed at $versionRoot" } function Resolve-RecoveryAuxiliaryPath { @@ -575,14 +607,43 @@ function Invoke-Recover { } $replacementRoot = Resolve-RecoveryAuxiliaryPath @replacementParameters - foreach ($ownedDirectory in (@($recordedVersionRoot, $backupRoot, $replacementRoot) | Select-Object -Unique)) { - if ($ownedDirectory -and (Test-Path -LiteralPath $ownedDirectory -PathType Container)) { - Assert-NoReparsePoint -Path $ownedDirectory -Label 'Recorded recovery directory' - [IO.Directory]::Delete($ownedDirectory, $true) + if ($backupRoot -and (Test-Path -LiteralPath $backupRoot -PathType Container)) { + Assert-NoReparsePoint -Path $backupRoot -Label 'Recovery backup root' + if (Test-Path -LiteralPath $recordedVersionRoot -PathType Container) { + if (-not $replacementRoot) { + $replacementRoot = Join-Path $root ('.replacement-' + [guid]::NewGuid().ToString('N')) + $recordParameters = @{ + Path = $recoveryPath + Action = 'restore-prior-version-and-remove-replacement' + Root = $root + VersionRoot = $recordedVersionRoot + BackupRoot = $backupRoot + FailedReplacementRoot = $replacementRoot + PublishError = [string]$recovery.publishError + RollbackError = [string]$recovery.rollbackError + } + Write-RepairRecoveryRecord @recordParameters + } + if (Test-Path -LiteralPath $replacementRoot) { + Fail-NativeWindowsInstall "Recover retained both a current version and replacement. Recovery authority remains at $recoveryPath" + } + [IO.Directory]::Move($recordedVersionRoot, $replacementRoot) } + [IO.Directory]::Move($backupRoot, $recordedVersionRoot) + } + if (-not (Test-Path -LiteralPath $recordedVersionRoot -PathType Container)) { + Fail-NativeWindowsInstall "Recover has no prior version to restore. Recovery authority remains at $recoveryPath" + } + Assert-NoReparsePoint -Path $recordedVersionRoot -Label 'Recovered prior version root' + + $publishedVersionRoot = Publish-Distribution -Distribution $distribution -Root $root -Repair $true + Write-InstallReceipt -Distribution $distribution -Root $root -VersionRoot $publishedVersionRoot + if ($replacementRoot -and (Test-Path -LiteralPath $replacementRoot -PathType Container)) { + Assert-NoReparsePoint -Path $replacementRoot -Label 'Recovery replacement root' + [IO.Directory]::Delete($replacementRoot, $true) } [IO.File]::Delete($recoveryPath) - Invoke-InstallOrRepair -Repair $false + Write-Host "Recovered and republished the native Windows candidate distribution at $publishedVersionRoot" } function Invoke-Uninstall { @@ -640,23 +701,18 @@ function Invoke-Uninstall { @(Get-ChildItem -LiteralPath $root -Force).Count -eq 0) { [IO.Directory]::Delete($root) } - $result = [pscustomobject]@{ - receiptVersion = 1 - action = 'uninstall' - classification = 'qualification-only' - removedVersionRoot = $versionRoot - finalAbsence = -not (Test-Path -LiteralPath $versionRoot) - } - if ($Json) { - Write-Output ($result | ConvertTo-Json -Depth 4 -Compress) - } else { - Write-Host "Removed native Windows candidate distribution from $versionRoot" - } + Write-Host "Removed native Windows candidate distribution from $versionRoot" } -switch ($Action) { - 'Install' { Invoke-InstallOrRepair -Repair $false } - 'Repair' { Invoke-InstallOrRepair -Repair $true } - 'Recover' { Invoke-Recover } - 'Uninstall' { Invoke-Uninstall } +$operationRoot = Resolve-InstallRoot -Path $InstallRoot +$installerLock = Enter-InstallerLock -Root $operationRoot +try { + switch ($Action) { + 'Install' { Invoke-InstallOrRepair -Repair $false } + 'Repair' { Invoke-InstallOrRepair -Repair $true } + 'Recover' { Invoke-Recover } + 'Uninstall' { Invoke-Uninstall } + } +} finally { + $installerLock.Dispose() } From ce5d9589a04ec8ffc2a4edc0de0578f85f2ccdbe Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 12:18:51 -0700 Subject: [PATCH 007/144] fix(install): tighten native candidate ownership Signed-off-by: Aaron Erickson --- .github/workflows/platform-vitest-main.yaml | 3 - ...windows-native-installer-qualification.ps1 | 42 +++++++---- scripts/install-windows-native.ps1 | 72 ++++++++++++++----- 3 files changed, 85 insertions(+), 32 deletions(-) diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index 0be43fba079..c4bdeab8b20 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -274,12 +274,9 @@ jobs: $ErrorActionPreference = 'Stop' $candidate = [IO.Path]::GetFullPath("$env:GITHUB_WORKSPACE\candidate") $openshell = [IO.Path]::GetFullPath("$env:GITHUB_WORKSPACE\openshell") - $installer = Join-Path $candidate 'scripts\install-windows-native.ps1' - $installerSha256 = (Get-FileHash -LiteralPath $installer -Algorithm SHA256).Hash.ToLowerInvariant() & "$candidate\scripts\checks\run-windows-native-installer-qualification.ps1" ` -CandidateCheckout $candidate ` -CandidateSha $env:GITHUB_SHA ` - -InstallerSha256 $installerSha256 ` -OpenShellCheckout $openshell ` -OpenShellSha bcd517bbe08cc80860c9be57699390cd32e8445f ` -ArtifactDirectory "$env:RUNNER_TEMP\windows-native-installer-receipts" diff --git a/scripts/checks/run-windows-native-installer-qualification.ps1 b/scripts/checks/run-windows-native-installer-qualification.ps1 index 61c1ed97747..f79b763605c 100644 --- a/scripts/checks/run-windows-native-installer-qualification.ps1 +++ b/scripts/checks/run-windows-native-installer-qualification.ps1 @@ -16,7 +16,6 @@ param( [Parameter(Mandatory)][string]$CandidateCheckout, [Parameter(Mandatory)][string]$CandidateSha, - [Parameter(Mandatory)][string]$InstallerSha256, [Parameter(Mandatory)][string]$OpenShellCheckout, [Parameter(Mandatory)][string]$OpenShellSha, [Parameter(Mandatory)][string]$ArtifactDirectory @@ -30,7 +29,6 @@ $script:CanonicalOpenShellRepository = 'https://github.com/NVIDIA/OpenShell.git' $script:TrustedOpenShellPullRequest = 2721 $script:TrustedOpenShellRevision = 'bcd517bbe08cc80860c9be57699390cd32e8445f' $script:ShaPattern = '^[a-f0-9]{40}$' -$script:Sha256Pattern = '^[a-f0-9]{64}$' $script:MaxJsonBytes = 16384 $script:MaxInstallerBytes = 524288 @@ -113,8 +111,7 @@ function Assert-CommittedFile { [Parameter(Mandatory)][string]$Checkout, [Parameter(Mandatory)][string]$Revision, [Parameter(Mandatory)][string]$RelativePath, - [Parameter(Mandatory)][string]$FilePath, - [Parameter(Mandatory)][string]$ExpectedSha256 + [Parameter(Mandatory)][string]$FilePath ) if (-not (Test-Path -LiteralPath $FilePath -PathType Leaf)) { @@ -129,9 +126,6 @@ function Assert-CommittedFile { if ($workingBlob -cne $committedBlob) { Fail-Qualification "Candidate file bytes do not match the candidate commit: $RelativePath" } - if ((Get-FileHash -LiteralPath $FilePath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $ExpectedSha256) { - Fail-Qualification "Candidate file SHA-256 does not match the trusted plan: $RelativePath" - } } function Enter-RestrictedInstallerBoundary { @@ -358,9 +352,6 @@ function Assert-InstalledDistribution { if ($CandidateSha -cnotmatch $script:ShaPattern -or $OpenShellSha -cnotmatch $script:ShaPattern) { Fail-Qualification 'Candidate and OpenShell revisions must be lowercase 40-character commit SHAs.' } -if ($InstallerSha256 -cnotmatch $script:Sha256Pattern) { - Fail-Qualification 'Installer digest must be a lowercase SHA-256 value.' -} if ($OpenShellSha -cne $script:TrustedOpenShellRevision) { Fail-Qualification 'OpenShell revision must match PR #2721 merge commit.' } @@ -388,7 +379,6 @@ $committedInstallerParameters = @{ Revision = $CandidateSha RelativePath = 'scripts/install-windows-native.ps1' FilePath = $installer - ExpectedSha256 = $InstallerSha256 } Assert-CommittedFile @committedInstallerParameters @@ -565,7 +555,7 @@ try { versionRoot = $repairedReceipt.versionRoot backupRoot = $recoveryBackupRoot failedReplacementRoot = $recoveryReplacementRoot - publishError = 'qualification fixture' + operationError = 'qualification fixture' rollbackError = 'qualification fixture' }) $recoverParameters = @{ @@ -581,6 +571,32 @@ try { (Get-FileHash -LiteralPath (Join-Path $repairedReceipt.versionRoot 'bin\openshell.exe') -Algorithm SHA256).Hash.ToLowerInvariant() -cne $expectedOpenShellSha256) { Fail-Qualification 'Recover did not publish one clean pinned distribution.' } + + $nullReplacementReceipt = Get-Content -LiteralPath $installReceiptPath -Raw | ConvertFrom-Json + $nullReplacementBackupRoot = Join-Path $installRoot ('.backup-' + [guid]::NewGuid().ToString('N')) + [IO.Directory]::Move($nullReplacementReceipt.versionRoot, $nullReplacementBackupRoot) + Write-JsonFile -Path $recoveryAuthorityPath -Value ([pscustomobject]@{ + receiptVersion = 1 + classification = 'qualification-only' + installRoot = $installRoot + openshell = [pscustomobject]@{ + repository = $script:CanonicalOpenShellRepository + pullRequest = $script:TrustedOpenShellPullRequest + revision = $script:TrustedOpenShellRevision + } + action = 'restore-prior-version' + versionRoot = $nullReplacementReceipt.versionRoot + backupRoot = $nullReplacementBackupRoot + failedReplacementRoot = $null + operationError = 'qualification null-replacement fixture' + rollbackError = 'qualification null-replacement fixture' + }) + & $installer @recoverParameters | Out-Null + if ((Test-Path -LiteralPath $recoveryAuthorityPath) -or + (Test-Path -LiteralPath $nullReplacementBackupRoot) -or + (Get-FileHash -LiteralPath (Join-Path $nullReplacementReceipt.versionRoot 'bin\openshell.exe') -Algorithm SHA256).Hash.ToLowerInvariant() -cne $expectedOpenShellSha256) { + Fail-Qualification 'Recover did not handle a null replacement root.' + } [IO.File]::Copy($installReceiptPath, (Join-Path $receiptStage 'recovery-receipt.json'), $false) & $installer -Action Uninstall -InstallRoot $installRoot @@ -602,7 +618,7 @@ try { receiptVersion = 1 repository = $script:CanonicalNemoClawRepository revision = $CandidateSha - installerSha256 = $InstallerSha256 + installerSha256 = (Get-FileHash -LiteralPath $installer -Algorithm SHA256).Hash.ToLowerInvariant() }) Write-JsonFile -Path (Join-Path $receiptStage 'openshell-source.json') -Value ([pscustomobject]@{ receiptVersion = 1 diff --git a/scripts/install-windows-native.ps1 b/scripts/install-windows-native.ps1 index d2c924c4ab1..88dd0c9912b 100644 --- a/scripts/install-windows-native.ps1 +++ b/scripts/install-windows-native.ps1 @@ -25,9 +25,7 @@ param( [string]$PayloadRoot, - [string]$InstallRoot = (Join-Path - ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) - 'NVIDIA\NemoClaw\native-candidate') + [string]$InstallRoot = (Join-Path -Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) -ChildPath 'NVIDIA\NemoClaw\native-candidate') ) Set-StrictMode -Version Latest @@ -360,11 +358,17 @@ function Write-RepairRecoveryRecord { [Parameter(Mandatory)][string]$Root, [Parameter(Mandatory)][string]$VersionRoot, [Parameter(Mandatory)][string]$BackupRoot, - [AllowNull()][string]$FailedReplacementRoot, - [Parameter(Mandatory)][string]$PublishError, + [AllowNull()][object]$FailedReplacementRoot, + [Parameter(Mandatory)][string]$OperationError, [Parameter(Mandatory)][string]$RollbackError ) + $normalizedReplacementRoot = if ([string]::IsNullOrWhiteSpace([string]$FailedReplacementRoot)) { + $null + } else { + [string]$FailedReplacementRoot + } + Write-JsonAtomic -Path $Path -Value ([pscustomobject]@{ receiptVersion = 1 classification = 'qualification-only' @@ -377,8 +381,8 @@ function Write-RepairRecoveryRecord { action = $Action versionRoot = $VersionRoot backupRoot = $BackupRoot - failedReplacementRoot = $FailedReplacementRoot - publishError = $PublishError + failedReplacementRoot = $normalizedReplacementRoot + operationError = $OperationError rollbackError = $RollbackError }) } @@ -439,7 +443,7 @@ function Publish-Distribution { VersionRoot = $versionRoot BackupRoot = $backupRoot FailedReplacementRoot = $null - PublishError = $publishError + OperationError = $publishError RollbackError = $_.Exception.Message } Write-RepairRecoveryRecord @recoveryParameters @@ -456,15 +460,28 @@ function Publish-Distribution { [IO.Directory]::Move($backupRoot, $versionRoot) [IO.Directory]::Delete($failedReplacementRoot, $true) } catch { + $rollbackError = $_.Exception.Message + $recoveryAction = 'restore-prior-version-and-remove-replacement' + $recordedReplacementRoot = $failedReplacementRoot + if (-not (Test-Path -LiteralPath $versionRoot) -and + (Test-Path -LiteralPath $failedReplacementRoot -PathType Container)) { + try { + [IO.Directory]::Move($failedReplacementRoot, $versionRoot) + $recoveryAction = 'remove-retained-backup' + $recordedReplacementRoot = $null + } catch { + $rollbackError = "$rollbackError; published-version restore failed: $($_.Exception.Message)" + } + } $recoveryParameters = @{ Path = $recoveryPath - Action = 'restore-prior-version-and-remove-replacement' + Action = $recoveryAction Root = $Root VersionRoot = $versionRoot BackupRoot = $backupRoot - FailedReplacementRoot = $failedReplacementRoot - PublishError = $cleanupError - RollbackError = $_.Exception.Message + FailedReplacementRoot = $recordedReplacementRoot + OperationError = $cleanupError + RollbackError = $rollbackError } Write-RepairRecoveryRecord @recoveryParameters Fail-NativeWindowsInstall "Repair backup cleanup and rollback failed. Run Recover with the same manifest, payload, and install root. Recovery authority: $recoveryPath" @@ -544,7 +561,7 @@ function Resolve-RecoveryAuxiliaryPath { [Parameter(Mandatory)][string]$Label ) - if ($null -eq $Value) { + if ($null -eq $Value -or ($Value -is [string] -and [string]::IsNullOrWhiteSpace($Value))) { return $null } if ($Value -isnot [string]) { @@ -572,14 +589,18 @@ function Invoke-Recover { } Assert-ExactProperties -Value $recovery -Properties @( 'action', 'backupRoot', 'classification', 'failedReplacementRoot', 'installRoot', - 'openshell', 'publishError', 'receiptVersion', 'rollbackError', 'versionRoot' + 'openshell', 'operationError', 'receiptVersion', 'rollbackError', 'versionRoot' ) -Label 'Repair recovery authority' Assert-ExactProperties -Value $recovery.openshell -Properties @( 'pullRequest', 'repository', 'revision' ) -Label 'Recovery OpenShell authority' if ($recovery.receiptVersion -ne 1 -or $recovery.classification -cne 'qualification-only' -or $recovery.installRoot -cne $root -or - $recovery.action -cnotin @('restore-prior-version', 'restore-prior-version-and-remove-replacement') -or + $recovery.action -cnotin @( + 'remove-retained-backup', + 'restore-prior-version', + 'restore-prior-version-and-remove-replacement' + ) -or $recovery.openshell.repository -cne $script:TrustedOpenShellRepository -or $recovery.openshell.pullRequest -ne $script:TrustedOpenShellPullRequest -or $recovery.openshell.revision -cne $script:TrustedOpenShellRevision) { @@ -607,6 +628,25 @@ function Invoke-Recover { } $replacementRoot = Resolve-RecoveryAuxiliaryPath @replacementParameters + if ($recovery.action -ceq 'remove-retained-backup') { + if (-not (Test-Path -LiteralPath $recordedVersionRoot -PathType Container) -or + -not (Test-InstalledFiles -VersionRoot $recordedVersionRoot -Files $distribution.Files)) { + Fail-NativeWindowsInstall "Recover cannot verify the published version. Recovery authority remains at $recoveryPath" + } + if ($backupRoot -and (Test-Path -LiteralPath $backupRoot -PathType Container)) { + Assert-NoReparsePoint -Path $backupRoot -Label 'Retained recovery backup root' + [IO.Directory]::Delete($backupRoot, $true) + } + if ($replacementRoot -and (Test-Path -LiteralPath $replacementRoot -PathType Container)) { + Assert-NoReparsePoint -Path $replacementRoot -Label 'Retained recovery replacement root' + [IO.Directory]::Delete($replacementRoot, $true) + } + Write-InstallReceipt -Distribution $distribution -Root $root -VersionRoot $recordedVersionRoot + [IO.File]::Delete($recoveryPath) + Write-Host "Recovered the native Windows candidate distribution at $recordedVersionRoot" + return + } + if ($backupRoot -and (Test-Path -LiteralPath $backupRoot -PathType Container)) { Assert-NoReparsePoint -Path $backupRoot -Label 'Recovery backup root' if (Test-Path -LiteralPath $recordedVersionRoot -PathType Container) { @@ -619,7 +659,7 @@ function Invoke-Recover { VersionRoot = $recordedVersionRoot BackupRoot = $backupRoot FailedReplacementRoot = $replacementRoot - PublishError = [string]$recovery.publishError + OperationError = [string]$recovery.operationError RollbackError = [string]$recovery.rollbackError } Write-RepairRecoveryRecord @recordParameters From d4027362c96947f7615d216fc940ed1f1f35f047 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 13:04:53 -0700 Subject: [PATCH 008/144] test(windows): prove native candidate execution Signed-off-by: Aaron Erickson --- .github/workflows/platform-vitest-main.yaml | 2 +- ...windows-native-installer-qualification.ps1 | 143 ++++++++++++++++-- scripts/install-windows-native.ps1 | 4 +- 3 files changed, 133 insertions(+), 16 deletions(-) diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index c4bdeab8b20..b822275cdcb 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -242,7 +242,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: NVIDIA/OpenShell - ref: bcd517bbe08cc80860c9be57699390cd32e8445f # PR #2721 merge commit + ref: bcd517bbe08cc80860c9be57699390cd32e8445f # NVIDIA/OpenShell#2721 merge commit path: openshell fetch-depth: 1 persist-credentials: false diff --git a/scripts/checks/run-windows-native-installer-qualification.ps1 b/scripts/checks/run-windows-native-installer-qualification.ps1 index f79b763605c..89ad4f925e4 100644 --- a/scripts/checks/run-windows-native-installer-qualification.ps1 +++ b/scripts/checks/run-windows-native-installer-qualification.ps1 @@ -3,7 +3,7 @@ <# .SYNOPSIS - Qualify the no-WSL native Windows installer against OpenShell PR #2721. + Qualify the no-WSL native Windows installer against NVIDIA/OpenShell#2721. .DESCRIPTION Verifies exact candidate and OpenShell source authority before executing the @@ -233,30 +233,61 @@ namespace NemoClaw.WindowsQualification } } -function Test-RestrictedInstallerBoundary { - $childProcessDenied = $false +function Invoke-ChildSideEffectProbe { + param([Parameter(Mandatory)][string]$SentinelPath) + + $escapedPath = $SentinelPath.Replace("'", "''") + $command = "[IO.File]::WriteAllText('$escapedPath', 'child-executed', [Text.UTF8Encoding]::new(`$false))" + $encodedCommand = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) + $child = $null + $startError = $null + $exitCode = $null try { $childParameters = @{ - FilePath = $env:ComSpec - ArgumentList = @('/d', '/c', 'exit', '0') + FilePath = (Join-Path $PSHOME 'powershell.exe') + ArgumentList = @('-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', $encodedCommand) Wait = $true PassThru = $true ErrorAction = 'Stop' } $child = Start-Process @childParameters - if ($child) { + if ($null -ne $child) { + $exitCode = $child.ExitCode $child.Dispose() + $child = $null } } catch { - $childProcessDenied = $true + $startError = $_.Exception.Message + } finally { + if ($null -ne $child) { + $child.Dispose() + } + } + + return [pscustomobject]@{ + exitCode = $exitCode + sideEffectObserved = Test-Path -LiteralPath $SentinelPath -PathType Leaf + startRejected = $null -ne $startError + } +} + +function Test-RestrictedInstallerBoundary { + param([Parameter(Mandatory)][string]$SentinelPath) + + $probe = Invoke-ChildSideEffectProbe -SentinelPath $SentinelPath + if ($probe.sideEffectObserved) { + [IO.File]::Delete($SentinelPath) + Fail-Qualification 'The installer qualification Job Object allowed a child side effect.' } - if (-not $childProcessDenied) { - Fail-Qualification 'The installer qualification Job Object allowed a child process.' + if (-not $probe.startRejected -and $probe.exitCode -eq 0) { + Fail-Qualification 'The installer qualification Job Object allowed a successful child process.' } return [pscustomobject]@{ jobActiveProcessLimit = 1 - childProcessDenied = $true + childCreationRejected = $probe.startRejected + childExitCode = $probe.exitCode + childSideEffectAbsent = $true } } @@ -310,6 +341,57 @@ function Assert-BoundedFile { } } +function Assert-Arm64PortableExecutable { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Label + ) + + $stream = [IO.File]::OpenRead($Path) + $reader = [IO.BinaryReader]::new($stream) + try { + if ($reader.ReadUInt16() -ne 0x5A4D) { + Fail-Qualification "$Label is not a Windows PE executable." + } + $stream.Position = 0x3C + $peOffset = $reader.ReadInt32() + if ($peOffset -lt 0x40 -or $peOffset -gt ($stream.Length - 6)) { + Fail-Qualification "$Label has an invalid PE header offset." + } + $stream.Position = $peOffset + if ($reader.ReadUInt32() -ne 0x00004550) { + Fail-Qualification "$Label has an invalid PE signature." + } + if ($reader.ReadUInt16() -ne 0xAA64) { + Fail-Qualification "$Label is not an ARM64 Windows executable." + } + } finally { + $reader.Dispose() + $stream.Dispose() + } +} + +function Invoke-NativeVersionProbe { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Label + ) + + Assert-Arm64PortableExecutable -Path $Path -Label $Label + $output = @(& $Path --version 2>&1) + $exitCode = $LASTEXITCODE + $outputText = ($output | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine + if ($exitCode -ne 0 -or [string]::IsNullOrWhiteSpace($outputText) -or $outputText.Length -gt 4096) { + Fail-Qualification "$Label did not complete a bounded native --version probe." + } + return [pscustomobject]@{ + file = Split-Path -Leaf $Path + sha256 = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() + exitCode = $exitCode + output = $outputText + } +} + function Assert-InstalledDistribution { param( [Parameter(Mandatory)][string]$VersionRoot, @@ -353,7 +435,7 @@ if ($CandidateSha -cnotmatch $script:ShaPattern -or $OpenShellSha -cnotmatch $sc Fail-Qualification 'Candidate and OpenShell revisions must be lowercase 40-character commit SHAs.' } if ($OpenShellSha -cne $script:TrustedOpenShellRevision) { - Fail-Qualification 'OpenShell revision must match PR #2721 merge commit.' + Fail-Qualification 'OpenShell revision must match NVIDIA/OpenShell#2721 merge commit.' } if ([Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() -cne 'Arm64') { Fail-Qualification 'Windows native installer qualification requires a native ARM64 runner.' @@ -396,6 +478,18 @@ $installRoot = Join-Path $qualificationRoot 'install' $receiptStage = Join-Path $artifactParent ('.' + $artifactName + '.' + [guid]::NewGuid().ToString('N')) $restrictedBoundary = $null $restrictedBoundaryEvidence = $null +$childProbeControl = $null +$nativeBinaryEvidence = $null +$hostPlatformEvidence = [pscustomobject]@{ + receiptVersion = 1 + osDescription = [Runtime.InteropServices.RuntimeInformation]::OSDescription + osVersion = [Environment]::OSVersion.Version.ToString() + osArchitecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + processArchitecture = [Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString() + powershellVersion = $PSVersionTable.PSVersion.ToString() + runnerName = $env:RUNNER_NAME + runnerArchitecture = $env:RUNNER_ARCH +} [IO.Directory]::CreateDirectory($payloadRoot) | Out-Null [IO.Directory]::CreateDirectory($receiptStage) | Out-Null @@ -405,7 +499,7 @@ try { foreach ($fileName in @('openshell.exe', 'openshell-gateway.exe')) { $source = Join-Path $releaseRoot $fileName if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { - Fail-Qualification "OpenShell PR #2721 build output is missing: $fileName" + Fail-Qualification "NVIDIA/OpenShell#2721 build output is missing: $fileName" } $payloadRelative = "bin\$fileName" $payloadPath = Join-Path $payloadRoot $payloadRelative @@ -450,8 +544,20 @@ try { } $expectedOpenShellSha256 = $expectedOpenShellEntry[0].sha256 + $nativeBinaryEvidence = @( + Invoke-NativeVersionProbe -Path (Join-Path $payloadRoot 'bin\openshell.exe') -Label 'OpenShell CLI' + Invoke-NativeVersionProbe -Path (Join-Path $payloadRoot 'bin\openshell-gateway.exe') -Label 'OpenShell gateway' + ) + $controlSentinel = Join-Path $qualificationRoot 'child-control.txt' + $childProbeControl = Invoke-ChildSideEffectProbe -SentinelPath $controlSentinel + if ($childProbeControl.startRejected -or $childProbeControl.exitCode -ne 0 -or + -not $childProbeControl.sideEffectObserved) { + Fail-Qualification 'The child side-effect control probe did not execute before restriction.' + } + [IO.File]::Delete($controlSentinel) + $restrictedBoundary = Enter-RestrictedInstallerBoundary - $restrictedBoundaryEvidence = Test-RestrictedInstallerBoundary + $restrictedBoundaryEvidence = Test-RestrictedInstallerBoundary -SentinelPath (Join-Path $qualificationRoot 'child-restricted.txt') $preExecution = Assert-ProhibitedProcessesAbsent -Phase 'pre-execution' $volumeRootRejected = $false try { @@ -482,6 +588,11 @@ try { Phase = 'Initial install' } Assert-InstalledDistribution @initialDistributionParameters + foreach ($installedExecutable in @('openshell.exe', 'openshell-gateway.exe')) { + Assert-Arm64PortableExecutable ` + -Path (Join-Path $installReceipt.versionRoot "bin\$installedExecutable") ` + -Label "Installed $installedExecutable" + } $untrackedPath = Join-Path $installReceipt.versionRoot 'bin\untracked-qualification.txt' [IO.File]::WriteAllText($untrackedPath, 'untracked', [Text.UTF8Encoding]::new($false)) $untrackedInstallRejected = $false @@ -629,10 +740,16 @@ try { }) Write-JsonFile -Path (Join-Path $receiptStage 'process-absence.json') -Value ([pscustomobject]@{ receiptVersion = 1 + calibratedChildProbe = $childProbeControl restrictedExecution = $restrictedBoundaryEvidence preExecution = $preExecution postExecution = $postExecution }) + Write-JsonFile -Path (Join-Path $receiptStage 'host-platform.json') -Value $hostPlatformEvidence + Write-JsonFile -Path (Join-Path $receiptStage 'native-binary-smoke.json') -Value ([pscustomobject]@{ + receiptVersion = 1 + executions = $nativeBinaryEvidence + }) Write-JsonFile -Path (Join-Path $receiptStage 'uninstall-receipt.json') -Value $uninstallReceipt Assert-BoundedFile -Path (Join-Path $receiptStage 'install-windows-native.ps1') -MaximumBytes $script:MaxInstallerBytes diff --git a/scripts/install-windows-native.ps1 b/scripts/install-windows-native.ps1 index 88dd0c9912b..9c1a835135d 100644 --- a/scripts/install-windows-native.ps1 +++ b/scripts/install-windows-native.ps1 @@ -7,7 +7,7 @@ .DESCRIPTION Installs, repairs, or removes an exact Windows OpenShell distribution built - from NVIDIA/OpenShell PR #2721. The installer is deliberately file-only: it + from NVIDIA/OpenShell#2721. The installer is deliberately file-only: it does not start a process, install a service, select a runtime provider, or activate native Windows support. A later slice can consume the receipt after the corresponding lifecycle and activation gates pass. @@ -214,7 +214,7 @@ function Read-DistributionManifest { if ($manifest.openshell.repository -cne $script:TrustedOpenShellRepository -or $manifest.openshell.pullRequest -ne $script:TrustedOpenShellPullRequest -or $manifest.openshell.revision -cne $script:TrustedOpenShellRevision) { - Fail-NativeWindowsInstall 'OpenShell authority does not match the pinned PR #2721 distribution.' + Fail-NativeWindowsInstall 'OpenShell authority does not match the pinned NVIDIA/OpenShell#2721 distribution.' } if ($manifest.files -isnot [Array] -or $manifest.files.Count -lt 2 -or $manifest.files.Count -gt 8) { From 9df5c5605a5ecf9acd71493f48b033297e51ea35 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 13:07:56 -0700 Subject: [PATCH 009/144] ci(windows): isolate native installer dispatch Signed-off-by: Aaron Erickson --- .github/workflows/platform-vitest-main.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index b822275cdcb..62d96e8dc6f 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -30,6 +30,7 @@ concurrency: jobs: ubuntu-2604-contract: + if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.run_windows_native_installer) }} runs-on: ubuntu-latest container: image: ubuntu:26.04@sha256:b7f48194d4d8b763a478a621cdc81c27be222ba2206ca3ca6bc42b49685f3d9e @@ -88,6 +89,7 @@ jobs: macos-vitest: name: macOS compatibility (${{ matrix.shard }}/4) + if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.run_windows_native_installer) }} runs-on: macos-26 timeout-minutes: ${{ matrix.timeout_minutes }} strategy: @@ -258,7 +260,7 @@ jobs: rust-src-dir: openshell rustflags: "" - - name: Build the pinned OpenShell PR distribution + - name: Build the pinned NVIDIA/OpenShell#2721 candidate working-directory: openshell shell: powershell run: | @@ -292,6 +294,7 @@ jobs: wsl-vitest: name: WSL compatibility (${{ matrix.shard }}/4) + if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.run_windows_native_installer) }} runs-on: windows-latest timeout-minutes: ${{ matrix.timeout_minutes }} strategy: From 4c8923224f2dbfa64abd59a10ab8fd936323d33c Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 13:52:26 -0700 Subject: [PATCH 010/144] test(windows): audit installer process starts Signed-off-by: Aaron Erickson --- .github/workflows/platform-vitest-main.yaml | 4 +- ...windows-native-installer-qualification.ps1 | 217 +++++++----------- 2 files changed, 83 insertions(+), 138 deletions(-) diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index 62d96e8dc6f..76714f669f3 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -257,6 +257,8 @@ jobs: cache: true cache-bin: false cache-on-failure: true + cache-workspace-crates: true + cache-key: openshell-2721-bcd517bbe08c rust-src-dir: openshell rustflags: "" @@ -269,7 +271,7 @@ jobs: & .\tasks\scripts\windows-msvc.ps1 build aarch64-pc-windows-msvc & .\tasks\scripts\windows-msvc.ps1 artifacts aarch64-pc-windows-msvc - - name: Qualify install, repair, and uninstall in an OS-restricted boundary + - name: Qualify native execution, install, repair, recovery, and uninstall shell: powershell run: | Set-StrictMode -Version Latest diff --git a/scripts/checks/run-windows-native-installer-qualification.ps1 b/scripts/checks/run-windows-native-installer-qualification.ps1 index 89ad4f925e4..92fa4e2c9f6 100644 --- a/scripts/checks/run-windows-native-installer-qualification.ps1 +++ b/scripts/checks/run-windows-native-installer-qualification.ps1 @@ -7,9 +7,11 @@ .DESCRIPTION Verifies exact candidate and OpenShell source authority before executing the - candidate installer. The qualification installs, damages, repairs, and - uninstalls the candidate distribution, checks prohibited runtime processes - before and after execution, and atomically publishes bounded receipts. + candidate installer. The qualification executes the ARM64 binaries, then + installs, damages, repairs, recovers, and uninstalls the distribution. A + calibrated Windows process-start audit proves the file-only installer starts + no child process; prohibited runtime checks and bounded receipts preserve the + no-WSL evidence. #> [CmdletBinding()] @@ -128,111 +130,6 @@ function Assert-CommittedFile { } } -function Enter-RestrictedInstallerBoundary { - if (-not ('NemoClaw.WindowsQualification.JobBoundary' -as [type])) { - Add-Type -TypeDefinition @' -using System; -using System.Runtime.InteropServices; - -namespace NemoClaw.WindowsQualification -{ - [StructLayout(LayoutKind.Sequential)] - public struct IoCounters - { - public ulong ReadOperationCount; - public ulong WriteOperationCount; - public ulong OtherOperationCount; - public ulong ReadTransferCount; - public ulong WriteTransferCount; - public ulong OtherTransferCount; - } - - [StructLayout(LayoutKind.Sequential)] - public struct BasicLimitInformation - { - public long PerProcessUserTimeLimit; - public long PerJobUserTimeLimit; - public uint LimitFlags; - public UIntPtr MinimumWorkingSetSize; - public UIntPtr MaximumWorkingSetSize; - public uint ActiveProcessLimit; - public UIntPtr Affinity; - public uint PriorityClass; - public uint SchedulingClass; - } - - [StructLayout(LayoutKind.Sequential)] - public struct ExtendedLimitInformation - { - public BasicLimitInformation BasicLimitInformation; - public IoCounters IoInfo; - public UIntPtr ProcessMemoryLimit; - public UIntPtr JobMemoryLimit; - public UIntPtr PeakProcessMemoryUsed; - public UIntPtr PeakJobMemoryUsed; - } - - public static class JobBoundary - { - public const uint ActiveProcessLimit = 0x00000008; - public const int ExtendedLimitInformationClass = 9; - - [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - public static extern IntPtr CreateJobObject(IntPtr securityAttributes, string name); - - [DllImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - public static extern bool SetInformationJobObject( - IntPtr job, - int informationClass, - ref ExtendedLimitInformation information, - uint informationLength); - - [DllImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - public static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); - - [DllImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - public static extern bool CloseHandle(IntPtr handle); - } -} -'@ - } - - $jobHandle = [NemoClaw.WindowsQualification.JobBoundary]::CreateJobObject( - [IntPtr]::Zero, - "NemoClawNativeQualification-$PID" - ) - if ($jobHandle -eq [IntPtr]::Zero) { - Fail-Qualification 'Could not create the installer qualification Job Object.' - } - $limit = [NemoClaw.WindowsQualification.ExtendedLimitInformation]::new() - $limit.BasicLimitInformation.LimitFlags = [NemoClaw.WindowsQualification.JobBoundary]::ActiveProcessLimit - $limit.BasicLimitInformation.ActiveProcessLimit = 1 - $limitLength = [Runtime.InteropServices.Marshal]::SizeOf($limit) - if (-not [NemoClaw.WindowsQualification.JobBoundary]::SetInformationJobObject( - $jobHandle, - [NemoClaw.WindowsQualification.JobBoundary]::ExtendedLimitInformationClass, - [ref]$limit, - $limitLength - )) { - [NemoClaw.WindowsQualification.JobBoundary]::CloseHandle($jobHandle) | Out-Null - Fail-Qualification 'Could not apply the one-process installer qualification limit.' - } - $currentProcess = [Diagnostics.Process]::GetCurrentProcess() - if (-not [NemoClaw.WindowsQualification.JobBoundary]::AssignProcessToJobObject( - $jobHandle, - $currentProcess.Handle - )) { - [NemoClaw.WindowsQualification.JobBoundary]::CloseHandle($jobHandle) | Out-Null - Fail-Qualification 'Could not enter the one-process installer qualification Job Object.' - } - return [pscustomobject]@{ - JobHandle = $jobHandle - } -} - function Invoke-ChildSideEffectProbe { param([Parameter(Mandatory)][string]$SentinelPath) @@ -242,6 +139,7 @@ function Invoke-ChildSideEffectProbe { $child = $null $startError = $null $exitCode = $null + $processId = $null try { $childParameters = @{ FilePath = (Join-Path $PSHOME 'powershell.exe') @@ -252,6 +150,7 @@ function Invoke-ChildSideEffectProbe { } $child = Start-Process @childParameters if ($null -ne $child) { + $processId = $child.Id $exitCode = $child.ExitCode $child.Dispose() $child = $null @@ -266,37 +165,67 @@ function Invoke-ChildSideEffectProbe { return [pscustomobject]@{ exitCode = $exitCode + processId = $processId sideEffectObserved = Test-Path -LiteralPath $SentinelPath -PathType Leaf startRejected = $null -ne $startError } } -function Test-RestrictedInstallerBoundary { - param([Parameter(Mandatory)][string]$SentinelPath) - - $probe = Invoke-ChildSideEffectProbe -SentinelPath $SentinelPath - if ($probe.sideEffectObserved) { - [IO.File]::Delete($SentinelPath) - Fail-Qualification 'The installer qualification Job Object allowed a child side effect.' +function Start-ProcessStartAudit { + $sourceIdentifier = 'NemoClawNativeInstaller-' + [guid]::NewGuid().ToString('N') + $subscription = Register-WmiEvent -Class Win32_ProcessStartTrace -SourceIdentifier $sourceIdentifier + return [pscustomobject]@{ + sourceIdentifier = $sourceIdentifier + subscription = $subscription } - if (-not $probe.startRejected -and $probe.exitCode -eq 0) { - Fail-Qualification 'The installer qualification Job Object allowed a successful child process.' +} + +function Receive-ProcessStartAudit { + param( + [Parameter(Mandatory)]$Audit, + [Parameter(Mandatory)][int]$SettleMilliseconds + ) + + Start-Sleep -Milliseconds $SettleMilliseconds + $records = @() + foreach ($event in @(Get-Event -SourceIdentifier $Audit.sourceIdentifier -ErrorAction SilentlyContinue)) { + $processEvent = $event.SourceEventArgs.NewEvent + $records += [pscustomobject]@{ + processId = [int]$processEvent.ProcessID + parentProcessId = [int]$processEvent.ParentProcessID + processName = [string]$processEvent.ProcessName + } + Remove-Event -EventIdentifier $event.EventIdentifier } + return @($records) +} - return [pscustomobject]@{ - jobActiveProcessLimit = 1 - childCreationRejected = $probe.startRejected - childExitCode = $probe.exitCode - childSideEffectAbsent = $true +function Get-AuditedDescendantStarts { + param( + [Parameter(Mandatory)][Array]$Records, + [Parameter(Mandatory)][int]$RootProcessId + ) + + $tracked = @{} + $tracked[[string]$RootProcessId] = $true + $descendants = @() + foreach ($record in $Records) { + if ($tracked.ContainsKey([string]$record.parentProcessId)) { + $descendants += $record + $tracked[[string]$record.processId] = $true + } } + return @($descendants) } -function Exit-RestrictedInstallerBoundary { - param([Parameter(Mandatory)]$Boundary) +function Stop-ProcessStartAudit { + param([Parameter(Mandatory)]$Audit) - if ($Boundary.JobHandle -ne [IntPtr]::Zero) { - [NemoClaw.WindowsQualification.JobBoundary]::CloseHandle($Boundary.JobHandle) | Out-Null + foreach ($event in @(Get-Event -SourceIdentifier $Audit.sourceIdentifier -ErrorAction SilentlyContinue)) { + Remove-Event -EventIdentifier $event.EventIdentifier } + Unregister-Event -SourceIdentifier $Audit.sourceIdentifier -ErrorAction SilentlyContinue + Remove-Job -Job $Audit.subscription -Force -ErrorAction SilentlyContinue } function Assert-ProhibitedProcessesAbsent { @@ -476,9 +405,10 @@ $qualificationRoot = Join-Path $env:RUNNER_TEMP ('nemoclaw-windows-native-' + [g $payloadRoot = Join-Path $qualificationRoot 'payload' $installRoot = Join-Path $qualificationRoot 'install' $receiptStage = Join-Path $artifactParent ('.' + $artifactName + '.' + [guid]::NewGuid().ToString('N')) -$restrictedBoundary = $null -$restrictedBoundaryEvidence = $null +$processAudit = $null +$installerDescendantStarts = @() $childProbeControl = $null +$controlDescendantStarts = @() $nativeBinaryEvidence = $null $hostPlatformEvidence = [pscustomobject]@{ receiptVersion = 1 @@ -548,22 +478,28 @@ try { Invoke-NativeVersionProbe -Path (Join-Path $payloadRoot 'bin\openshell.exe') -Label 'OpenShell CLI' Invoke-NativeVersionProbe -Path (Join-Path $payloadRoot 'bin\openshell-gateway.exe') -Label 'OpenShell gateway' ) + $processAudit = Start-ProcessStartAudit $controlSentinel = Join-Path $qualificationRoot 'child-control.txt' $childProbeControl = Invoke-ChildSideEffectProbe -SentinelPath $controlSentinel if ($childProbeControl.startRejected -or $childProbeControl.exitCode -ne 0 -or -not $childProbeControl.sideEffectObserved) { - Fail-Qualification 'The child side-effect control probe did not execute before restriction.' + Fail-Qualification 'The child side-effect control probe did not execute before installer qualification.' + } + $controlAuditRecords = @(Receive-ProcessStartAudit -Audit $processAudit -SettleMilliseconds 3000) + $controlDescendantStarts = @(Get-AuditedDescendantStarts -Records $controlAuditRecords -RootProcessId $PID) + if (@($controlDescendantStarts | Where-Object { + $_.processId -eq $childProbeControl.processId + }).Count -ne 1) { + Fail-Qualification 'The calibrated Windows process-start audit did not observe its control child.' } [IO.File]::Delete($controlSentinel) - $restrictedBoundary = Enter-RestrictedInstallerBoundary - $restrictedBoundaryEvidence = Test-RestrictedInstallerBoundary -SentinelPath (Join-Path $qualificationRoot 'child-restricted.txt') $preExecution = Assert-ProhibitedProcessesAbsent -Phase 'pre-execution' $volumeRootRejected = $false try { & $installer -Action Uninstall -InstallRoot ([IO.Path]::GetPathRoot($installRoot)) | Out-Null } catch { - $volumeRootRejected = $true + $volumeRootRejected = $_.Exception.Message -like '*InstallRoot must not be a drive root.*' } if (-not $volumeRootRejected) { Fail-Qualification 'Installer accepted a drive root as InstallRoot.' @@ -599,7 +535,7 @@ try { try { & $installer @installParameters | Out-Null } catch { - $untrackedInstallRejected = $true + $untrackedInstallRejected = $_.Exception.Message -like '*Existing candidate installation drifted; run Repair.*' } if (-not $untrackedInstallRejected) { Fail-Qualification 'Install accepted an untracked file inside the owned version root.' @@ -625,7 +561,7 @@ try { try { & $installer @repairParameters | Out-Null } catch { - $overlappingRepairRejected = $true + $overlappingRepairRejected = $_.Exception.Message -like '*Another installer operation owns*' } } finally { $heldLock.Dispose() @@ -722,6 +658,12 @@ try { finalAbsence = $true } $postExecution = Assert-ProhibitedProcessesAbsent -Phase 'post-execution' + $installerAuditRecords = @(Receive-ProcessStartAudit -Audit $processAudit -SettleMilliseconds 1000) + $installerDescendantStarts = @(Get-AuditedDescendantStarts -Records $installerAuditRecords -RootProcessId $PID) + if ($installerDescendantStarts.Count -ne 0) { + $startedNames = @($installerDescendantStarts | ForEach-Object { $_.processName } | Sort-Object -Unique) -join ', ' + Fail-Qualification "The file-only installer started a descendant process: $startedNames" + } [IO.File]::Copy($installer, (Join-Path $receiptStage 'install-windows-native.ps1'), $false) [IO.File]::Copy($manifestPath, (Join-Path $receiptStage 'distribution-manifest.json'), $false) @@ -741,7 +683,8 @@ try { Write-JsonFile -Path (Join-Path $receiptStage 'process-absence.json') -Value ([pscustomobject]@{ receiptVersion = 1 calibratedChildProbe = $childProbeControl - restrictedExecution = $restrictedBoundaryEvidence + calibratedDescendantStarts = $controlDescendantStarts + installerDescendantStarts = $installerDescendantStarts preExecution = $preExecution postExecution = $postExecution }) @@ -760,8 +703,8 @@ try { $receiptStage = $null Write-Host "Windows native installer qualification receipts: $artifactPath" } finally { - if ($restrictedBoundary) { - Exit-RestrictedInstallerBoundary -Boundary $restrictedBoundary + if ($processAudit) { + Stop-ProcessStartAudit -Audit $processAudit } if ($receiptStage -and (Test-Path -LiteralPath $receiptStage -PathType Container)) { [IO.Directory]::Delete($receiptStage, $true) From 5e808e2b55437917c9911f0276fc770e38c4d99a Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 14:33:52 -0700 Subject: [PATCH 011/144] fix(windows): release process audit subscription Signed-off-by: Aaron Erickson --- scripts/checks/run-windows-native-installer-qualification.ps1 | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/scripts/checks/run-windows-native-installer-qualification.ps1 b/scripts/checks/run-windows-native-installer-qualification.ps1 index 92fa4e2c9f6..34ccdfe2f1f 100644 --- a/scripts/checks/run-windows-native-installer-qualification.ps1 +++ b/scripts/checks/run-windows-native-installer-qualification.ps1 @@ -173,10 +173,9 @@ function Invoke-ChildSideEffectProbe { function Start-ProcessStartAudit { $sourceIdentifier = 'NemoClawNativeInstaller-' + [guid]::NewGuid().ToString('N') - $subscription = Register-WmiEvent -Class Win32_ProcessStartTrace -SourceIdentifier $sourceIdentifier + Register-WmiEvent -Class Win32_ProcessStartTrace -SourceIdentifier $sourceIdentifier | Out-Null return [pscustomobject]@{ sourceIdentifier = $sourceIdentifier - subscription = $subscription } } @@ -225,7 +224,6 @@ function Stop-ProcessStartAudit { Remove-Event -EventIdentifier $event.EventIdentifier } Unregister-Event -SourceIdentifier $Audit.sourceIdentifier -ErrorAction SilentlyContinue - Remove-Job -Job $Audit.subscription -Force -ErrorAction SilentlyContinue } function Assert-ProhibitedProcessesAbsent { From b14b3c53541fa80506ae44efb9f26cd1fa9876d0 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 14:43:09 -0700 Subject: [PATCH 012/144] test(windows): baseline hosted runner processes Signed-off-by: Aaron Erickson --- ...windows-native-installer-qualification.ps1 | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/scripts/checks/run-windows-native-installer-qualification.ps1 b/scripts/checks/run-windows-native-installer-qualification.ps1 index 34ccdfe2f1f..4997b0a6e80 100644 --- a/scripts/checks/run-windows-native-installer-qualification.ps1 +++ b/scripts/checks/run-windows-native-installer-qualification.ps1 @@ -226,7 +226,7 @@ function Stop-ProcessStartAudit { Unregister-Event -SourceIdentifier $Audit.sourceIdentifier -ErrorAction SilentlyContinue } -function Assert-ProhibitedProcessesAbsent { +function Get-ProhibitedProcessSnapshot { param([Parameter(Mandatory)][string]$Phase) $prohibited = @('bash', 'docker', 'dockerd', 'wsl') @@ -234,15 +234,14 @@ function Assert-ProhibitedProcessesAbsent { $name = $_.ProcessName.ToLowerInvariant() $prohibited -ccontains $name -or $name.StartsWith('com.docker') -or $name.StartsWith('ubuntu') }) - if ($found.Count -ne 0) { - Fail-Qualification "A prohibited WSL or Docker process exists during the $Phase check." - } return [pscustomobject]@{ phase = $Phase - wslAbsent = $true - bashAbsent = $true - dockerAbsent = $true - ubuntuAbsent = $true + processes = @($found | ForEach-Object { + [pscustomobject]@{ + processId = $_.Id + processName = $_.ProcessName + } + } | Sort-Object processId) } } @@ -492,7 +491,7 @@ try { } [IO.File]::Delete($controlSentinel) - $preExecution = Assert-ProhibitedProcessesAbsent -Phase 'pre-execution' + $preExecution = Get-ProhibitedProcessSnapshot -Phase 'pre-execution' $volumeRootRejected = $false try { & $installer -Action Uninstall -InstallRoot ([IO.Path]::GetPathRoot($installRoot)) | Out-Null @@ -655,7 +654,15 @@ try { installRoot = $installRoot finalAbsence = $true } - $postExecution = Assert-ProhibitedProcessesAbsent -Phase 'post-execution' + $postExecution = Get-ProhibitedProcessSnapshot -Phase 'post-execution' + $baselineProcessIds = @($preExecution.processes | ForEach-Object { $_.processId }) + $newProhibitedProcesses = @($postExecution.processes | Where-Object { + $baselineProcessIds -notcontains $_.processId + }) + if ($newProhibitedProcesses.Count -ne 0) { + $newNames = @($newProhibitedProcesses | ForEach-Object { $_.processName } | Sort-Object -Unique) -join ', ' + Fail-Qualification "A new prohibited WSL or Docker process appeared during installer qualification: $newNames" + } $installerAuditRecords = @(Receive-ProcessStartAudit -Audit $processAudit -SettleMilliseconds 1000) $installerDescendantStarts = @(Get-AuditedDescendantStarts -Records $installerAuditRecords -RootProcessId $PID) if ($installerDescendantStarts.Count -ne 0) { @@ -683,6 +690,7 @@ try { calibratedChildProbe = $childProbeControl calibratedDescendantStarts = $controlDescendantStarts installerDescendantStarts = $installerDescendantStarts + newProhibitedProcesses = $newProhibitedProcesses preExecution = $preExecution postExecution = $postExecution }) From 21b8c6961424f09ef69fb411be5e38e458cf5c8a Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 14:52:18 -0700 Subject: [PATCH 013/144] fix(windows): create exclusive installer lock Signed-off-by: Aaron Erickson --- scripts/install-windows-native.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/install-windows-native.ps1 b/scripts/install-windows-native.ps1 index 9c1a835135d..4f53e921b2a 100644 --- a/scripts/install-windows-native.ps1 +++ b/scripts/install-windows-native.ps1 @@ -142,7 +142,7 @@ function Enter-InstallerLock { Assert-NoReparsePoint -Path $parent -Label 'InstallRoot parent' $lockPath = Join-Path $parent ('.' + (Split-Path -Leaf $Root) + '.native-installer.lock') try { - return [IO.File]::Open( + return [IO.FileStream]::new( $lockPath, [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, From ef0edd2ace6acadb2c40ef42940c754c5376e092 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 15:02:03 -0700 Subject: [PATCH 014/144] fix(windows): atomically replace installer receipts Signed-off-by: Aaron Erickson --- scripts/install-windows-native.ps1 | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/scripts/install-windows-native.ps1 b/scripts/install-windows-native.ps1 index 4f53e921b2a..1fe3666a34d 100644 --- a/scripts/install-windows-native.ps1 +++ b/scripts/install-windows-native.ps1 @@ -342,12 +342,22 @@ function Write-JsonAtomic { $parent = Split-Path -Parent $Path [IO.Directory]::CreateDirectory($parent) | Out-Null $temporary = Join-Path $parent ('.' + (Split-Path -Leaf $Path) + '.' + [guid]::NewGuid().ToString('N') + '.partial') + $replacementBackup = Join-Path $parent ('.' + (Split-Path -Leaf $Path) + '.' + [guid]::NewGuid().ToString('N') + '.replace-backup') $text = ($Value | ConvertTo-Json -Depth 12 -Compress) + [Environment]::NewLine [IO.File]::WriteAllText($temporary, $text, [Text.UTF8Encoding]::new($false)) - if (Test-Path -LiteralPath $Path -PathType Leaf) { - [IO.File]::Replace($temporary, $Path, $null, $true) - } else { - [IO.File]::Move($temporary, $Path) + try { + if (Test-Path -LiteralPath $Path -PathType Leaf) { + [IO.File]::Replace($temporary, $Path, $replacementBackup, $true) + } else { + [IO.File]::Move($temporary, $Path) + } + } finally { + if (Test-Path -LiteralPath $temporary -PathType Leaf) { + [IO.File]::Delete($temporary) + } + if (Test-Path -LiteralPath $replacementBackup -PathType Leaf) { + [IO.File]::Delete($replacementBackup) + } } } From e4d90f89c0b51d66c8ccfe637595262a284796f6 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 15:10:54 -0700 Subject: [PATCH 015/144] test(windows): accept empty process audit Signed-off-by: Aaron Erickson --- scripts/checks/run-windows-native-installer-qualification.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/checks/run-windows-native-installer-qualification.ps1 b/scripts/checks/run-windows-native-installer-qualification.ps1 index 4997b0a6e80..a4f4261473c 100644 --- a/scripts/checks/run-windows-native-installer-qualification.ps1 +++ b/scripts/checks/run-windows-native-installer-qualification.ps1 @@ -201,7 +201,7 @@ function Receive-ProcessStartAudit { function Get-AuditedDescendantStarts { param( - [Parameter(Mandatory)][Array]$Records, + [Parameter(Mandatory)][AllowEmptyCollection()][Array]$Records, [Parameter(Mandatory)][int]$RootProcessId ) From b803b65f87f0661e3e7bba5d029c6343db955058 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 15:32:40 -0700 Subject: [PATCH 016/144] test(windows): harden native qualification evidence Signed-off-by: Aaron Erickson --- ...windows-native-installer-qualification.ps1 | 183 +++++++++++++++--- scripts/install-windows-native.ps1 | 1 + 2 files changed, 159 insertions(+), 25 deletions(-) diff --git a/scripts/checks/run-windows-native-installer-qualification.ps1 b/scripts/checks/run-windows-native-installer-qualification.ps1 index a4f4261473c..d29e9f80b31 100644 --- a/scripts/checks/run-windows-native-installer-qualification.ps1 +++ b/scripts/checks/run-windows-native-installer-qualification.ps1 @@ -33,6 +33,8 @@ $script:TrustedOpenShellRevision = 'bcd517bbe08cc80860c9be57699390cd32e8445f' $script:ShaPattern = '^[a-f0-9]{40}$' $script:MaxJsonBytes = 16384 $script:MaxInstallerBytes = 524288 +$script:ProcessAuditSettleMilliseconds = 3000 +$script:NativeProbeTimeoutMilliseconds = 30000 function Fail-Qualification { param([Parameter(Mandatory)][string]$Message) @@ -56,6 +58,28 @@ function Resolve-PlainDirectory { return $resolved } +function Assert-NoReparsePath { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Label + ) + + $candidate = [IO.Path]::GetFullPath($Path) + while ($candidate) { + if (Test-Path -LiteralPath $candidate) { + $item = Get-Item -LiteralPath $candidate -Force + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + Fail-Qualification "$Label must not contain a reparse point." + } + } + $parent = [IO.Directory]::GetParent($candidate) + if ($null -eq $parent) { + break + } + $candidate = $parent.FullName + } +} + function Invoke-Git { param( [Parameter(Mandatory)][string]$Root, @@ -187,14 +211,14 @@ function Receive-ProcessStartAudit { Start-Sleep -Milliseconds $SettleMilliseconds $records = @() - foreach ($event in @(Get-Event -SourceIdentifier $Audit.sourceIdentifier -ErrorAction SilentlyContinue)) { - $processEvent = $event.SourceEventArgs.NewEvent + foreach ($auditEvent in @(Get-Event -SourceIdentifier $Audit.sourceIdentifier -ErrorAction SilentlyContinue)) { + $processEvent = $auditEvent.SourceEventArgs.NewEvent $records += [pscustomobject]@{ processId = [int]$processEvent.ProcessID parentProcessId = [int]$processEvent.ParentProcessID processName = [string]$processEvent.ProcessName } - Remove-Event -EventIdentifier $event.EventIdentifier + Remove-Event -EventIdentifier $auditEvent.EventIdentifier } return @($records) } @@ -220,8 +244,8 @@ function Get-AuditedDescendantStarts { function Stop-ProcessStartAudit { param([Parameter(Mandatory)]$Audit) - foreach ($event in @(Get-Event -SourceIdentifier $Audit.sourceIdentifier -ErrorAction SilentlyContinue)) { - Remove-Event -EventIdentifier $event.EventIdentifier + foreach ($auditEvent in @(Get-Event -SourceIdentifier $Audit.sourceIdentifier -ErrorAction SilentlyContinue)) { + Remove-Event -EventIdentifier $auditEvent.EventIdentifier } Unregister-Event -SourceIdentifier $Audit.sourceIdentifier -ErrorAction SilentlyContinue } @@ -304,9 +328,43 @@ function Invoke-NativeVersionProbe { ) Assert-Arm64PortableExecutable -Path $Path -Label $Label - $output = @(& $Path --version 2>&1) - $exitCode = $LASTEXITCODE - $outputText = ($output | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine + $stdoutPath = Join-Path $env:RUNNER_TEMP ('.native-version-' + [guid]::NewGuid().ToString('N') + '.stdout') + $stderrPath = Join-Path $env:RUNNER_TEMP ('.native-version-' + [guid]::NewGuid().ToString('N') + '.stderr') + $process = $null + try { + $process = Start-Process ` + -FilePath $Path ` + -ArgumentList @('--version') ` + -RedirectStandardOutput $stdoutPath ` + -RedirectStandardError $stderrPath ` + -PassThru ` + -ErrorAction Stop + if (-not $process.WaitForExit($script:NativeProbeTimeoutMilliseconds)) { + $process.Kill() + $process.WaitForExit() + Fail-Qualification "$Label exceeded the native --version timeout." + } + $process.WaitForExit() + $exitCode = $process.ExitCode + $stdout = if (Test-Path -LiteralPath $stdoutPath -PathType Leaf) { + [IO.File]::ReadAllText($stdoutPath) + } else { '' } + $stderr = if (Test-Path -LiteralPath $stderrPath -PathType Leaf) { + [IO.File]::ReadAllText($stderrPath) + } else { '' } + $outputText = (@($stdout.Trim(), $stderr.Trim()) | Where-Object { + -not [string]::IsNullOrWhiteSpace($_) + }) -join [Environment]::NewLine + } finally { + if ($null -ne $process) { + $process.Dispose() + } + foreach ($redirectPath in @($stdoutPath, $stderrPath)) { + if (Test-Path -LiteralPath $redirectPath -PathType Leaf) { + [IO.File]::Delete($redirectPath) + } + } + } if ($exitCode -ne 0 -or [string]::IsNullOrWhiteSpace($outputText) -or $outputText.Length -gt 4096) { Fail-Qualification "$Label did not complete a bounded native --version probe." } @@ -318,6 +376,27 @@ function Invoke-NativeVersionProbe { } } +function Resolve-ReceiptVersionRoot { + param( + [Parameter(Mandatory)]$Receipt, + [Parameter(Mandatory)][string]$ExpectedRoot, + [Parameter(Mandatory)][string]$Phase + ) + + if ($Receipt.versionRoot -isnot [string] -or [string]::IsNullOrWhiteSpace($Receipt.versionRoot)) { + Fail-Qualification "$Phase receipt has no version root." + } + $resolved = [IO.Path]::GetFullPath([string]$Receipt.versionRoot).TrimEnd('\') + if ($resolved -cne $ExpectedRoot) { + Fail-Qualification "$Phase receipt version root does not match the independently derived install root." + } + if (-not (Test-Path -LiteralPath $resolved -PathType Container)) { + Fail-Qualification "$Phase receipt version root is missing." + } + Assert-NoReparsePath -Path $resolved -Label "$Phase receipt version root" + return $resolved +} + function Assert-InstalledDistribution { param( [Parameter(Mandatory)][string]$VersionRoot, @@ -325,6 +404,11 @@ function Assert-InstalledDistribution { [Parameter(Mandatory)][string]$Phase ) + $VersionRoot = [IO.Path]::GetFullPath($VersionRoot).TrimEnd('\') + if (-not (Test-Path -LiteralPath $VersionRoot -PathType Container)) { + Fail-Qualification "$Phase distribution root is missing." + } + Assert-NoReparsePath -Path $VersionRoot -Label "$Phase distribution root" $expectedFiles = @($Entries | ForEach-Object { $_.destination.ToLowerInvariant() } | Sort-Object) $expectedDirectories = @() foreach ($entry in $Entries) { @@ -381,12 +465,12 @@ $openShellCheckoutParameters = @{ Label = 'OpenShell checkout' } $openShellRoot = Assert-Checkout @openShellCheckoutParameters -$installer = Join-Path $candidateRoot 'scripts\install-windows-native.ps1' +$installerSource = Join-Path $candidateRoot 'scripts\install-windows-native.ps1' $committedInstallerParameters = @{ Checkout = $candidateRoot Revision = $CandidateSha RelativePath = 'scripts/install-windows-native.ps1' - FilePath = $installer + FilePath = $installerSource } Assert-CommittedFile @committedInstallerParameters @@ -419,6 +503,11 @@ $hostPlatformEvidence = [pscustomobject]@{ } [IO.Directory]::CreateDirectory($payloadRoot) | Out-Null [IO.Directory]::CreateDirectory($receiptStage) | Out-Null +$installer = Join-Path $qualificationRoot 'install-windows-native.ps1' +[IO.File]::Copy($installerSource, $installer, $false) +$installerItem = Get-Item -LiteralPath $installer -Force +$installerItem.IsReadOnly = $true +$validatedInstallerSha256 = (Get-FileHash -LiteralPath $installer -Algorithm SHA256).Hash.ToLowerInvariant() try { $releaseRoot = Join-Path $openShellRoot 'target\aarch64-pc-windows-msvc\release' @@ -470,6 +559,10 @@ try { Fail-Qualification 'Qualification payload has no unique OpenShell CLI digest.' } $expectedOpenShellSha256 = $expectedOpenShellEntry[0].sha256 + $expectedVersionName = "openshell-pr$($script:TrustedOpenShellPullRequest)-$($script:TrustedOpenShellRevision.Substring(0, 12))-arm64" + $expectedVersionRoot = [IO.Path]::GetFullPath( + (Join-Path (Join-Path $installRoot 'versions') $expectedVersionName) + ).TrimEnd('\') $nativeBinaryEvidence = @( Invoke-NativeVersionProbe -Path (Join-Path $payloadRoot 'bin\openshell.exe') -Label 'OpenShell CLI' @@ -482,7 +575,9 @@ try { -not $childProbeControl.sideEffectObserved) { Fail-Qualification 'The child side-effect control probe did not execute before installer qualification.' } - $controlAuditRecords = @(Receive-ProcessStartAudit -Audit $processAudit -SettleMilliseconds 3000) + $controlAuditRecords = @( + Receive-ProcessStartAudit -Audit $processAudit -SettleMilliseconds $script:ProcessAuditSettleMilliseconds + ) $controlDescendantStarts = @(Get-AuditedDescendantStarts -Records $controlAuditRecords -RootProcessId $PID) if (@($controlDescendantStarts | Where-Object { $_.processId -eq $childProbeControl.processId @@ -515,18 +610,22 @@ try { [IO.File]::Copy($installReceiptPath, (Join-Path $receiptStage 'install-receipt.json'), $false) $installReceipt = Get-Content -LiteralPath $installReceiptPath -Raw | ConvertFrom-Json + $initialVersionRoot = Resolve-ReceiptVersionRoot ` + -Receipt $installReceipt ` + -ExpectedRoot $expectedVersionRoot ` + -Phase 'Initial install' $initialDistributionParameters = @{ - VersionRoot = $installReceipt.versionRoot + VersionRoot = $initialVersionRoot Entries = $distributionEntries Phase = 'Initial install' } Assert-InstalledDistribution @initialDistributionParameters foreach ($installedExecutable in @('openshell.exe', 'openshell-gateway.exe')) { Assert-Arm64PortableExecutable ` - -Path (Join-Path $installReceipt.versionRoot "bin\$installedExecutable") ` + -Path (Join-Path $initialVersionRoot "bin\$installedExecutable") ` -Label "Installed $installedExecutable" } - $untrackedPath = Join-Path $installReceipt.versionRoot 'bin\untracked-qualification.txt' + $untrackedPath = Join-Path $initialVersionRoot 'bin\untracked-qualification.txt' [IO.File]::WriteAllText($untrackedPath, 'untracked', [Text.UTF8Encoding]::new($false)) $untrackedInstallRejected = $false try { @@ -537,7 +636,7 @@ try { if (-not $untrackedInstallRejected) { Fail-Qualification 'Install accepted an untracked file inside the owned version root.' } - $driftTarget = Join-Path $installReceipt.versionRoot 'bin\openshell.exe' + $driftTarget = Join-Path $initialVersionRoot 'bin\openshell.exe' [IO.File]::AppendAllText($driftTarget, 'qualification-drift', [Text.UTF8Encoding]::new($false)) $repairParameters = @{ Action = 'Repair' @@ -570,15 +669,19 @@ try { & $installer @repairParameters | Out-Null [IO.File]::Copy($installReceiptPath, (Join-Path $receiptStage 'repair-receipt.json'), $false) $repairedReceipt = Get-Content -LiteralPath $installReceiptPath -Raw | ConvertFrom-Json + $repairedVersionRoot = Resolve-ReceiptVersionRoot ` + -Receipt $repairedReceipt ` + -ExpectedRoot $expectedVersionRoot ` + -Phase 'Repair' if ((Test-Path -LiteralPath $untrackedPath) -or - (Get-FileHash -LiteralPath (Join-Path $repairedReceipt.versionRoot 'bin\openshell.exe') -Algorithm SHA256).Hash.ToLowerInvariant() -cne $expectedOpenShellSha256) { + (Get-FileHash -LiteralPath (Join-Path $repairedVersionRoot 'bin\openshell.exe') -Algorithm SHA256).Hash.ToLowerInvariant() -cne $expectedOpenShellSha256) { Fail-Qualification 'Repair did not restore the OpenShell CLI digest.' } - Assert-InstalledDistribution -VersionRoot $repairedReceipt.versionRoot -Entries $distributionEntries -Phase 'Repair' + Assert-InstalledDistribution -VersionRoot $repairedVersionRoot -Entries $distributionEntries -Phase 'Repair' $recoveryBackupRoot = Join-Path $installRoot ('.backup-' + [guid]::NewGuid().ToString('N')) $recoveryReplacementRoot = Join-Path $installRoot ('.replacement-' + [guid]::NewGuid().ToString('N')) - [IO.Directory]::Move($repairedReceipt.versionRoot, $recoveryBackupRoot) + [IO.Directory]::Move($repairedVersionRoot, $recoveryBackupRoot) [IO.Directory]::CreateDirectory($recoveryReplacementRoot) | Out-Null [IO.File]::WriteAllText( (Join-Path $recoveryReplacementRoot 'incomplete.txt'), @@ -596,7 +699,7 @@ try { revision = $script:TrustedOpenShellRevision } action = 'restore-prior-version-and-remove-replacement' - versionRoot = $repairedReceipt.versionRoot + versionRoot = $repairedVersionRoot backupRoot = $recoveryBackupRoot failedReplacementRoot = $recoveryReplacementRoot operationError = 'qualification fixture' @@ -609,16 +712,29 @@ try { InstallRoot = $installRoot } & $installer @recoverParameters | Out-Null + $recoveredReceipt = Get-Content -LiteralPath $installReceiptPath -Raw | ConvertFrom-Json + $recoveredVersionRoot = Resolve-ReceiptVersionRoot ` + -Receipt $recoveredReceipt ` + -ExpectedRoot $expectedVersionRoot ` + -Phase 'Recover with replacement root' if ((Test-Path -LiteralPath $recoveryAuthorityPath) -or (Test-Path -LiteralPath $recoveryBackupRoot) -or (Test-Path -LiteralPath $recoveryReplacementRoot) -or - (Get-FileHash -LiteralPath (Join-Path $repairedReceipt.versionRoot 'bin\openshell.exe') -Algorithm SHA256).Hash.ToLowerInvariant() -cne $expectedOpenShellSha256) { + (Get-FileHash -LiteralPath (Join-Path $recoveredVersionRoot 'bin\openshell.exe') -Algorithm SHA256).Hash.ToLowerInvariant() -cne $expectedOpenShellSha256) { Fail-Qualification 'Recover did not publish one clean pinned distribution.' } + Assert-InstalledDistribution ` + -VersionRoot $recoveredVersionRoot ` + -Entries $distributionEntries ` + -Phase 'Recover with replacement root' $nullReplacementReceipt = Get-Content -LiteralPath $installReceiptPath -Raw | ConvertFrom-Json + $nullReplacementVersionRoot = Resolve-ReceiptVersionRoot ` + -Receipt $nullReplacementReceipt ` + -ExpectedRoot $expectedVersionRoot ` + -Phase 'Pre-null recovery' $nullReplacementBackupRoot = Join-Path $installRoot ('.backup-' + [guid]::NewGuid().ToString('N')) - [IO.Directory]::Move($nullReplacementReceipt.versionRoot, $nullReplacementBackupRoot) + [IO.Directory]::Move($nullReplacementVersionRoot, $nullReplacementBackupRoot) Write-JsonFile -Path $recoveryAuthorityPath -Value ([pscustomobject]@{ receiptVersion = 1 classification = 'qualification-only' @@ -629,18 +745,27 @@ try { revision = $script:TrustedOpenShellRevision } action = 'restore-prior-version' - versionRoot = $nullReplacementReceipt.versionRoot + versionRoot = $nullReplacementVersionRoot backupRoot = $nullReplacementBackupRoot failedReplacementRoot = $null operationError = 'qualification null-replacement fixture' rollbackError = 'qualification null-replacement fixture' }) & $installer @recoverParameters | Out-Null + $nullRecoveredReceipt = Get-Content -LiteralPath $installReceiptPath -Raw | ConvertFrom-Json + $nullRecoveredVersionRoot = Resolve-ReceiptVersionRoot ` + -Receipt $nullRecoveredReceipt ` + -ExpectedRoot $expectedVersionRoot ` + -Phase 'Recover with null replacement root' if ((Test-Path -LiteralPath $recoveryAuthorityPath) -or (Test-Path -LiteralPath $nullReplacementBackupRoot) -or - (Get-FileHash -LiteralPath (Join-Path $nullReplacementReceipt.versionRoot 'bin\openshell.exe') -Algorithm SHA256).Hash.ToLowerInvariant() -cne $expectedOpenShellSha256) { + (Get-FileHash -LiteralPath (Join-Path $nullRecoveredVersionRoot 'bin\openshell.exe') -Algorithm SHA256).Hash.ToLowerInvariant() -cne $expectedOpenShellSha256) { Fail-Qualification 'Recover did not handle a null replacement root.' } + Assert-InstalledDistribution ` + -VersionRoot $nullRecoveredVersionRoot ` + -Entries $distributionEntries ` + -Phase 'Recover with null replacement root' [IO.File]::Copy($installReceiptPath, (Join-Path $receiptStage 'recovery-receipt.json'), $false) & $installer -Action Uninstall -InstallRoot $installRoot @@ -663,20 +788,25 @@ try { $newNames = @($newProhibitedProcesses | ForEach-Object { $_.processName } | Sort-Object -Unique) -join ', ' Fail-Qualification "A new prohibited WSL or Docker process appeared during installer qualification: $newNames" } - $installerAuditRecords = @(Receive-ProcessStartAudit -Audit $processAudit -SettleMilliseconds 1000) + $installerAuditRecords = @( + Receive-ProcessStartAudit -Audit $processAudit -SettleMilliseconds $script:ProcessAuditSettleMilliseconds + ) $installerDescendantStarts = @(Get-AuditedDescendantStarts -Records $installerAuditRecords -RootProcessId $PID) if ($installerDescendantStarts.Count -ne 0) { $startedNames = @($installerDescendantStarts | ForEach-Object { $_.processName } | Sort-Object -Unique) -join ', ' Fail-Qualification "The file-only installer started a descendant process: $startedNames" } + if ((Get-FileHash -LiteralPath $installer -Algorithm SHA256).Hash.ToLowerInvariant() -cne $validatedInstallerSha256) { + Fail-Qualification 'The staged installer bytes changed during qualification.' + } [IO.File]::Copy($installer, (Join-Path $receiptStage 'install-windows-native.ps1'), $false) [IO.File]::Copy($manifestPath, (Join-Path $receiptStage 'distribution-manifest.json'), $false) Write-JsonFile -Path (Join-Path $receiptStage 'candidate-source.json') -Value ([pscustomobject]@{ receiptVersion = 1 repository = $script:CanonicalNemoClawRepository revision = $CandidateSha - installerSha256 = (Get-FileHash -LiteralPath $installer -Algorithm SHA256).Hash.ToLowerInvariant() + installerSha256 = $validatedInstallerSha256 }) Write-JsonFile -Path (Join-Path $receiptStage 'openshell-source.json') -Value ([pscustomobject]@{ receiptVersion = 1 @@ -715,6 +845,9 @@ try { if ($receiptStage -and (Test-Path -LiteralPath $receiptStage -PathType Container)) { [IO.Directory]::Delete($receiptStage, $true) } + if (Test-Path -LiteralPath $installer -PathType Leaf) { + (Get-Item -LiteralPath $installer -Force).IsReadOnly = $false + } if (Test-Path -LiteralPath $qualificationRoot -PathType Container) { [IO.Directory]::Delete($qualificationRoot, $true) } diff --git a/scripts/install-windows-native.ps1 b/scripts/install-windows-native.ps1 index 1fe3666a34d..b32101907c9 100644 --- a/scripts/install-windows-native.ps1 +++ b/scripts/install-windows-native.ps1 @@ -639,6 +639,7 @@ function Invoke-Recover { $replacementRoot = Resolve-RecoveryAuxiliaryPath @replacementParameters if ($recovery.action -ceq 'remove-retained-backup') { + Assert-NoReparsePoint -Path $recordedVersionRoot -Label 'Published recovery version root' if (-not (Test-Path -LiteralPath $recordedVersionRoot -PathType Container) -or -not (Test-InstalledFiles -VersionRoot $recordedVersionRoot -Files $distribution.Files)) { Fail-NativeWindowsInstall "Recover cannot verify the published version. Recovery authority remains at $recoveryPath" From d1850495d409bc08ab52fda2d400d6aa2d52f728 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 15:59:22 -0700 Subject: [PATCH 017/144] feat(install): package native Windows ARM64 candidate Signed-off-by: Aaron Erickson --- .github/workflows/platform-vitest-main.yaml | 52 ++ packaging/windows/Bundle.wxs | 24 + packaging/windows/License.rtf | 14 + packaging/windows/NATIVE-PREVIEW.txt | 13 + packaging/windows/NemoClaw.Bundle.wixproj | 22 + packaging/windows/NemoClaw.wixproj | 25 + packaging/windows/Product.wxs | 69 +++ packaging/windows/README.md | 19 + packaging/windows/SIGNING.md | 25 + .../checks/build-windows-native-package.ps1 | 191 +++++++ ...windows-native-installer-qualification.ps1 | 41 +- ...n-windows-native-package-qualification.ps1 | 492 ++++++++++++++++++ 12 files changed, 963 insertions(+), 24 deletions(-) create mode 100644 packaging/windows/Bundle.wxs create mode 100644 packaging/windows/License.rtf create mode 100644 packaging/windows/NATIVE-PREVIEW.txt create mode 100644 packaging/windows/NemoClaw.Bundle.wixproj create mode 100644 packaging/windows/NemoClaw.wixproj create mode 100644 packaging/windows/Product.wxs create mode 100644 packaging/windows/README.md create mode 100644 packaging/windows/SIGNING.md create mode 100644 scripts/checks/build-windows-native-package.ps1 create mode 100644 scripts/checks/run-windows-native-package-qualification.ps1 diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index 76714f669f3..12703770bdd 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -262,6 +262,11 @@ jobs: rust-src-dir: openshell rustflags: "" + - name: Set up pinned .NET SDK for WiX + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 8.0.419 + - name: Build the pinned NVIDIA/OpenShell#2721 candidate working-directory: openshell shell: powershell @@ -294,6 +299,53 @@ jobs: if-no-files-found: error retention-days: 14 + - name: Build ARM64 MSI and setup executable + id: windows-package + shell: powershell + run: | + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + $candidate = [IO.Path]::GetFullPath("$env:GITHUB_WORKSPACE\candidate") + $openshell = [IO.Path]::GetFullPath("$env:GITHUB_WORKSPACE\openshell") + $version = [string](Get-Content -LiteralPath "$candidate\package.json" -Raw | ConvertFrom-Json).version + $packageRoot = [IO.Path]::GetFullPath("$env:RUNNER_TEMP\nemoclaw-windows-package") + & "$candidate\scripts\checks\build-windows-native-package.ps1" ` + -ProductVersion $version ` + -PayloadRoot "$openshell\target\aarch64-pc-windows-msvc\release" ` + -OutputDirectory $packageRoot + $referenceReceipts = Join-Path $packageRoot 'reference-qualification' + [IO.Directory]::CreateDirectory($referenceReceipts) | Out-Null + Copy-Item ` + -Path "$env:RUNNER_TEMP\windows-native-installer-receipts\*" ` + -Destination $referenceReceipts ` + -Recurse + "product_version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + "package_root=$packageRoot" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + + - name: Qualify MSI and setup executable + shell: powershell + run: | + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + $candidate = [IO.Path]::GetFullPath("$env:GITHUB_WORKSPACE\candidate") + $version = '${{ steps.windows-package.outputs.product_version }}' + $packageRoot = '${{ steps.windows-package.outputs.package_root }}' + & "$candidate\scripts\checks\run-windows-native-package-qualification.ps1" ` + -ProductVersion $version ` + -MsiPath "$packageRoot\NemoClaw-$version-windows-arm64.msi" ` + -SetupPath "$packageRoot\NemoClawSetup-$version-windows-arm64.exe" ` + -PackageManifestPath "$packageRoot\package-manifest.json" ` + -ArtifactDirectory "$packageRoot\qualification" + + - name: Upload downloadable Windows native package + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: nemoclaw-native-windows-package-${{ github.sha }} + path: ${{ steps.windows-package.outputs.package_root }}/ + if-no-files-found: warn + retention-days: 14 + wsl-vitest: name: WSL compatibility (${{ matrix.shard }}/4) if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.run_windows_native_installer) }} diff --git a/packaging/windows/Bundle.wxs b/packaging/windows/Bundle.wxs new file mode 100644 index 00000000000..4c49e1ca8dc --- /dev/null +++ b/packaging/windows/Bundle.wxs @@ -0,0 +1,24 @@ + + + + + + + + + + + + diff --git a/packaging/windows/License.rtf b/packaging/windows/License.rtf new file mode 100644 index 00000000000..f87ed67d0f7 --- /dev/null +++ b/packaging/windows/License.rtf @@ -0,0 +1,14 @@ +{\rtf1\ansi\deff0 +{\fonttbl{\f0 Segoe UI;}} +\fs20 +NemoClaw Native Windows Candidate Preview\par +\par +Copyright (c) 2026 NVIDIA Corporation and affiliates.\par +\par +NemoClaw is licensed under the Apache License, Version 2.0. The complete +license is installed with the product as LICENSE.txt and is available in the +NemoClaw source repository.\par +\par +This package is a qualification candidate. It does not establish production +support for native Windows, Microsoft MXC, or any agent runtime.\par +} diff --git a/packaging/windows/NATIVE-PREVIEW.txt b/packaging/windows/NATIVE-PREVIEW.txt new file mode 100644 index 00000000000..3b3be679d75 --- /dev/null +++ b/packaging/windows/NATIVE-PREVIEW.txt @@ -0,0 +1,13 @@ +NemoClaw Native Windows Candidate Preview + +This package contains native Windows ARM64 builds of openshell.exe and +openshell-gateway.exe from NVIDIA/OpenShell#2721 merge commit +bcd517bbe08cc80860c9be57699390cd32e8445f. + +This candidate does not include wxc-exec.exe, real MXC sandbox execution, +gateway service registration, NemoClaw CLI or onboarding, local inference, +or a production support claim. Mutable runtime state must remain outside this +MSI-owned installation directory. + +PR qualification artifacts are unsigned. Production use remains gated on +Authenticode signing of the payload executables, MSI, and setup executable. diff --git a/packaging/windows/NemoClaw.Bundle.wixproj b/packaging/windows/NemoClaw.Bundle.wixproj new file mode 100644 index 00000000000..75e1fb83836 --- /dev/null +++ b/packaging/windows/NemoClaw.Bundle.wixproj @@ -0,0 +1,22 @@ + + + + + Bundle + arm64 + NemoClawSetup-$(ProductVersion)-windows-arm64 + $(PackageOutputRoot) + $(PackageIntermediateRoot)\bundle\ + false + $(DefineConstants);ProductVersion=$(ProductVersion);SourceRoot=$(SourceRoot) + true + none + + + + + ProductVersion=$(ProductVersion);PayloadRoot=$(PayloadRoot);SourceRoot=$(SourceRoot);PackageOutputRoot=$(PackageOutputRoot);PackageIntermediateRoot=$(PackageIntermediateRoot) + + + + diff --git a/packaging/windows/NemoClaw.wixproj b/packaging/windows/NemoClaw.wixproj new file mode 100644 index 00000000000..af76d63f44b --- /dev/null +++ b/packaging/windows/NemoClaw.wixproj @@ -0,0 +1,25 @@ + + + + + Package + arm64 + NemoClaw-$(ProductVersion)-windows-arm64 + $(PackageOutputRoot) + $(PackageIntermediateRoot)\msi\ + false + $(DefineConstants);ProductVersion=$(ProductVersion);PayloadRoot=$(PayloadRoot);SourceRoot=$(SourceRoot) + true + none + + + + + + + + + + + + diff --git a/packaging/windows/Product.wxs b/packaging/windows/Product.wxs new file mode 100644 index 00000000000..1d87dd3ba43 --- /dev/null +++ b/packaging/windows/Product.wxs @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/README.md b/packaging/windows/README.md new file mode 100644 index 00000000000..963125187ce --- /dev/null +++ b/packaging/windows/README.md @@ -0,0 +1,19 @@ + + + +# Native Windows candidate package + +This directory owns the WiX-authored ARM64 MSI and Burn setup executable for +the native Windows candidate. WiX Toolset 5.0.2 and its standard bootstrapper +application extension are pinned in the project files. + +The package uses only standard Windows Installer and Burn authoring. It has no +custom actions and does not invoke PowerShell, WSL, Bash, Ubuntu, Docker, or a +Linux virtual machine. The package installs the exact ARM64 OpenShell payload +provided at build time under `%ProgramFiles%\NVIDIA\NemoClaw`, registers normal +Add/Remove Programs metadata, and adds the installed `bin` directory to the +machine PATH. + +The package is a preview distribution boundary, not a runtime activation +boundary. `wxc-exec.exe`, real MXC execution, service registration, NemoClaw +CLI/onboarding, and production signing are deliberately absent. diff --git a/packaging/windows/SIGNING.md b/packaging/windows/SIGNING.md new file mode 100644 index 00000000000..4479eb7204c --- /dev/null +++ b/packaging/windows/SIGNING.md @@ -0,0 +1,25 @@ + + + +# Production signing boundary + +Pull-request workflows build unsigned candidate packages and never receive +Windows code-signing credentials. Production publication remains blocked until +an NVIDIA-owned trusted release workflow performs the following sequence with +an approved Authenticode identity: + +1. Sign and verify `openshell.exe` and `openshell-gateway.exe` before MSI + binding. +2. Build the ARM64 MSI from those signed payloads, then sign and verify the MSI. +3. Build the Burn bundle with the signed MSI embedded. +4. Use the pinned WiX tool to detach the Burn engine, sign and verify the + detached engine, reattach it, then sign and verify the complete setup + executable. +5. Publish hashes and signature verification receipts beside the artifacts. + +The signing implementation must execute only in a protected release context. +It must not copy certificates, private keys, tokens, or signing service +credentials into the repository, pull-request jobs, package payload, logs, or +artifacts. This PR does not create that trusted release workflow because the +repository currently has no approved NVIDIA Windows-signing integration to +reuse. diff --git a/scripts/checks/build-windows-native-package.ps1 b/scripts/checks/build-windows-native-package.ps1 new file mode 100644 index 00000000000..ba21ac95404 --- /dev/null +++ b/scripts/checks/build-windows-native-package.ps1 @@ -0,0 +1,191 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +<# +.SYNOPSIS + Build the ARM64 NemoClaw native Windows MSI and Burn setup executable. + +.DESCRIPTION + Uses the pinned WiX Toolset projects under packaging/windows. The package + consumes local ARM64 OpenShell payload files and contains no custom action + or PowerShell execution path. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$ProductVersion, + [Parameter(Mandatory)][string]$PayloadRoot, + [Parameter(Mandatory)][string]$OutputDirectory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$script:ExpectedDotNetSdk = '8.0.419' +$script:ExpectedWixVersion = '5.0.2' + +function Fail-WindowsPackageBuild { + param([Parameter(Mandatory)][string]$Message) + throw "Windows native package build failed: $Message" +} + +function Resolve-PlainDirectory { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Label + ) + + $resolved = [IO.Path]::GetFullPath($Path).TrimEnd('\') + if (-not (Test-Path -LiteralPath $resolved -PathType Container)) { + Fail-WindowsPackageBuild "$Label is missing." + } + $item = Get-Item -LiteralPath $resolved -Force + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + Fail-WindowsPackageBuild "$Label must not be a reparse point." + } + return $resolved +} + +function Assert-Arm64PortableExecutable { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Label + ) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + Fail-WindowsPackageBuild "$Label is missing." + } + $stream = [IO.File]::OpenRead($Path) + $reader = [IO.BinaryReader]::new($stream) + try { + if ($reader.ReadUInt16() -ne 0x5A4D) { + Fail-WindowsPackageBuild "$Label is not a Windows PE file." + } + $stream.Position = 0x3C + $peOffset = $reader.ReadInt32() + if ($peOffset -lt 0x40 -or $peOffset -gt ($stream.Length - 6)) { + Fail-WindowsPackageBuild "$Label has an invalid PE header offset." + } + $stream.Position = $peOffset + if ($reader.ReadUInt32() -ne 0x00004550 -or $reader.ReadUInt16() -ne 0xAA64) { + Fail-WindowsPackageBuild "$Label is not an ARM64 Windows executable." + } + } finally { + $reader.Dispose() + $stream.Dispose() + } +} + +if ($ProductVersion -cnotmatch '^[0-9]{1,3}\.[0-9]{1,5}\.[0-9]{1,5}$') { + Fail-WindowsPackageBuild 'ProductVersion must be a strict three-part MSI version.' +} + +$sourceRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..')).TrimEnd('\') +$payload = Resolve-PlainDirectory -Path $PayloadRoot -Label 'PayloadRoot' +$output = [IO.Path]::GetFullPath($OutputDirectory).TrimEnd('\') +if (Test-Path -LiteralPath $output) { + Fail-WindowsPackageBuild 'OutputDirectory must not already exist.' +} +$outputParent = Split-Path -Parent $output +if (-not (Test-Path -LiteralPath $outputParent -PathType Container)) { + Fail-WindowsPackageBuild 'OutputDirectory parent must exist.' +} + +$openshell = Join-Path $payload 'openshell.exe' +$gateway = Join-Path $payload 'openshell-gateway.exe' +Assert-Arm64PortableExecutable -Path $openshell -Label 'openshell.exe payload' +Assert-Arm64PortableExecutable -Path $gateway -Label 'openshell-gateway.exe payload' + +$authoringText = @( + [IO.File]::ReadAllText((Join-Path $sourceRoot 'packaging\windows\Product.wxs')), + [IO.File]::ReadAllText((Join-Path $sourceRoot 'packaging\windows\Bundle.wxs')) +) -join [Environment]::NewLine +if ($authoringText -match '<\s*CustomAction\b' -or + $authoringText -match '<\s*ExePackage\b' -or + $authoringText -match '(?i)\b(powershell|pwsh|wsl|bash|ubuntu|docker)\b') { + Fail-WindowsPackageBuild 'WiX authoring contains a prohibited custom-action or non-native execution path.' +} + +$dotnetVersion = (& dotnet --version).Trim() +if ($LASTEXITCODE -ne 0 -or $dotnetVersion -cne $script:ExpectedDotNetSdk) { + Fail-WindowsPackageBuild "dotnet SDK $($script:ExpectedDotNetSdk) is required." +} + +[IO.Directory]::CreateDirectory($output) | Out-Null +$intermediate = Join-Path $outputParent ('.windows-package-' + [guid]::NewGuid().ToString('N')) +[IO.Directory]::CreateDirectory($intermediate) | Out-Null +try { + $project = Join-Path $sourceRoot 'packaging\windows\NemoClaw.Bundle.wixproj' + $buildArguments = @( + 'build', $project, + '--configuration', 'Release', + '--nologo', + '--disable-build-servers', + "-p:ProductVersion=$ProductVersion", + "-p:PayloadRoot=$payload", + "-p:SourceRoot=$sourceRoot", + "-p:PackageOutputRoot=$output", + "-p:PackageIntermediateRoot=$intermediate", + '-p:ContinuousIntegrationBuild=true', + '-p:RestoreIgnoreFailedSources=false' + ) + & dotnet @buildArguments + if ($LASTEXITCODE -ne 0) { + Fail-WindowsPackageBuild 'WiX build failed.' + } +} finally { + if (Test-Path -LiteralPath $intermediate -PathType Container) { + [IO.Directory]::Delete($intermediate, $true) + } +} + +$msiName = "NemoClaw-$ProductVersion-windows-arm64.msi" +$setupName = "NemoClawSetup-$ProductVersion-windows-arm64.exe" +$msiPath = Join-Path $output $msiName +$setupPath = Join-Path $output $setupName +foreach ($package in @($msiPath, $setupPath)) { + if (-not (Test-Path -LiteralPath $package -PathType Leaf) -or (Get-Item -LiteralPath $package).Length -eq 0) { + Fail-WindowsPackageBuild "Expected package output is missing: $(Split-Path -Leaf $package)" + } +} +Assert-Arm64PortableExecutable -Path $setupPath -Label $setupName + +$manifest = [pscustomobject]@{ + schemaVersion = 1 + classification = 'native-windows-candidate-preview' + productVersion = $ProductVersion + architecture = 'arm64' + dotnetSdk = $dotnetVersion + wixToolset = $script:ExpectedWixVersion + payload = @( + [pscustomobject]@{ + file = 'openshell.exe' + sha256 = (Get-FileHash -LiteralPath $openshell -Algorithm SHA256).Hash.ToLowerInvariant() + }, + [pscustomobject]@{ + file = 'openshell-gateway.exe' + sha256 = (Get-FileHash -LiteralPath $gateway -Algorithm SHA256).Hash.ToLowerInvariant() + } + ) + packages = @( + [pscustomobject]@{ + file = $msiName + sha256 = (Get-FileHash -LiteralPath $msiPath -Algorithm SHA256).Hash.ToLowerInvariant() + authenticodeStatus = (Get-AuthenticodeSignature -LiteralPath $msiPath).Status.ToString() + }, + [pscustomobject]@{ + file = $setupName + sha256 = (Get-FileHash -LiteralPath $setupPath -Algorithm SHA256).Hash.ToLowerInvariant() + authenticodeStatus = (Get-AuthenticodeSignature -LiteralPath $setupPath).Status.ToString() + } + ) +} +$manifestText = ($manifest | ConvertTo-Json -Depth 8) + [Environment]::NewLine +[IO.File]::WriteAllText( + (Join-Path $output 'package-manifest.json'), + $manifestText, + [Text.UTF8Encoding]::new($false) +) + +Write-Host "Windows native MSI: $msiPath" +Write-Host "Windows native setup executable: $setupPath" diff --git a/scripts/checks/run-windows-native-installer-qualification.ps1 b/scripts/checks/run-windows-native-installer-qualification.ps1 index d29e9f80b31..56434f1aa7b 100644 --- a/scripts/checks/run-windows-native-installer-qualification.ps1 +++ b/scripts/checks/run-windows-native-installer-qualification.ps1 @@ -328,17 +328,21 @@ function Invoke-NativeVersionProbe { ) Assert-Arm64PortableExecutable -Path $Path -Label $Label - $stdoutPath = Join-Path $env:RUNNER_TEMP ('.native-version-' + [guid]::NewGuid().ToString('N') + '.stdout') - $stderrPath = Join-Path $env:RUNNER_TEMP ('.native-version-' + [guid]::NewGuid().ToString('N') + '.stderr') - $process = $null + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $Path + $startInfo.Arguments = '--version' + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo try { - $process = Start-Process ` - -FilePath $Path ` - -ArgumentList @('--version') ` - -RedirectStandardOutput $stdoutPath ` - -RedirectStandardError $stderrPath ` - -PassThru ` - -ErrorAction Stop + if (-not $process.Start()) { + Fail-Qualification "$Label could not start its native --version probe." + } + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() if (-not $process.WaitForExit($script:NativeProbeTimeoutMilliseconds)) { $process.Kill() $process.WaitForExit() @@ -346,24 +350,13 @@ function Invoke-NativeVersionProbe { } $process.WaitForExit() $exitCode = $process.ExitCode - $stdout = if (Test-Path -LiteralPath $stdoutPath -PathType Leaf) { - [IO.File]::ReadAllText($stdoutPath) - } else { '' } - $stderr = if (Test-Path -LiteralPath $stderrPath -PathType Leaf) { - [IO.File]::ReadAllText($stderrPath) - } else { '' } + $stdout = $stdoutTask.GetAwaiter().GetResult() + $stderr = $stderrTask.GetAwaiter().GetResult() $outputText = (@($stdout.Trim(), $stderr.Trim()) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join [Environment]::NewLine } finally { - if ($null -ne $process) { - $process.Dispose() - } - foreach ($redirectPath in @($stdoutPath, $stderrPath)) { - if (Test-Path -LiteralPath $redirectPath -PathType Leaf) { - [IO.File]::Delete($redirectPath) - } - } + $process.Dispose() } if ($exitCode -ne 0 -or [string]::IsNullOrWhiteSpace($outputText) -or $outputText.Length -gt 4096) { Fail-Qualification "$Label did not complete a bounded native --version probe." diff --git a/scripts/checks/run-windows-native-package-qualification.ps1 b/scripts/checks/run-windows-native-package-qualification.ps1 new file mode 100644 index 00000000000..05deede8364 --- /dev/null +++ b/scripts/checks/run-windows-native-package-qualification.ps1 @@ -0,0 +1,492 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +<# +.SYNOPSIS + Qualify the downloadable ARM64 MSI and Burn setup executable on Windows. + +.DESCRIPTION + Exercises native setup, MSI repair and reinstall, Windows Installer + uninstall, bundle cleanup, Add/Remove Programs registration, machine PATH, + native payload execution, and prohibited-process evidence. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$ProductVersion, + [Parameter(Mandatory)][string]$MsiPath, + [Parameter(Mandatory)][string]$SetupPath, + [Parameter(Mandatory)][string]$PackageManifestPath, + [Parameter(Mandatory)][string]$ArtifactDirectory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$script:OperationTimeoutMilliseconds = 300000 +$script:ProcessAuditSettleMilliseconds = 3000 +$script:MsiDisplayName = 'NemoClaw Native Windows Candidate' +$script:BundleDisplayName = 'NemoClaw Native Windows Candidate Setup' + +function Fail-PackageQualification { + param([Parameter(Mandatory)][string]$Message) + throw "Windows native package qualification failed: $Message" +} + +function Assert-Arm64PortableExecutable { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Label + ) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + Fail-PackageQualification "$Label is missing." + } + $stream = [IO.File]::OpenRead($Path) + $reader = [IO.BinaryReader]::new($stream) + try { + if ($reader.ReadUInt16() -ne 0x5A4D) { + Fail-PackageQualification "$Label is not a Windows PE executable." + } + $stream.Position = 0x3C + $peOffset = $reader.ReadInt32() + if ($peOffset -lt 0x40 -or $peOffset -gt ($stream.Length - 6)) { + Fail-PackageQualification "$Label has an invalid PE header offset." + } + $stream.Position = $peOffset + if ($reader.ReadUInt32() -ne 0x00004550 -or $reader.ReadUInt16() -ne 0xAA64) { + Fail-PackageQualification "$Label is not an ARM64 Windows executable." + } + } finally { + $reader.Dispose() + $stream.Dispose() + } +} + +function ConvertTo-NativeArgument { + param([Parameter(Mandatory)][string]$Value) + + if ($Value -notmatch '[\s"]') { + return $Value + } + return '"' + $Value.Replace('"', '\"') + '"' +} + +function Invoke-BoundedProcess { + param( + [Parameter(Mandatory)][string]$FilePath, + [Parameter(Mandatory)][string[]]$Arguments, + [Parameter(Mandatory)][string]$Label, + [Parameter(Mandatory)][int[]]$AllowedExitCodes + ) + + $argumentList = @($Arguments | ForEach-Object { ConvertTo-NativeArgument -Value $_ }) + $process = Start-Process -FilePath $FilePath -ArgumentList $argumentList -PassThru -ErrorAction Stop + try { + if (-not $process.WaitForExit($script:OperationTimeoutMilliseconds)) { + $process.Kill() + $process.WaitForExit() + Fail-PackageQualification "$Label exceeded its timeout." + } + $exitCode = $process.ExitCode + } finally { + $process.Dispose() + } + if ($AllowedExitCodes -cnotcontains $exitCode) { + Fail-PackageQualification "$Label failed with exit code $exitCode." + } + return $exitCode +} + +function Invoke-NativeVersionProbe { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Label + ) + + Assert-Arm64PortableExecutable -Path $Path -Label $Label + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $Path + $startInfo.Arguments = '--version' + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + try { + if (-not $process.Start()) { + Fail-PackageQualification "$Label could not start its native version probe." + } + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit(30000)) { + $process.Kill() + $process.WaitForExit() + Fail-PackageQualification "$Label exceeded its version-probe timeout." + } + $process.WaitForExit() + $exitCode = $process.ExitCode + $stdout = $stdoutTask.GetAwaiter().GetResult() + $stderr = $stderrTask.GetAwaiter().GetResult() + $output = (@($stdout.Trim(), $stderr.Trim()) | Where-Object { + -not [string]::IsNullOrWhiteSpace($_) + }) -join [Environment]::NewLine + } finally { + $process.Dispose() + } + if ($exitCode -ne 0 -or [string]::IsNullOrWhiteSpace($output) -or $output.Length -gt 4096) { + Fail-PackageQualification "$Label did not complete a bounded native version probe." + } + return [pscustomobject]@{ + file = Split-Path -Leaf $Path + exitCode = $exitCode + output = $output + sha256 = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() + } +} + +function Get-ArpEntries { + param([Parameter(Mandatory)][string]$DisplayName) + + $baseKey = [Microsoft.Win32.RegistryKey]::OpenBaseKey( + [Microsoft.Win32.RegistryHive]::LocalMachine, + [Microsoft.Win32.RegistryView]::Registry64 + ) + try { + $uninstall = $baseKey.OpenSubKey('Software\Microsoft\Windows\CurrentVersion\Uninstall') + if ($null -eq $uninstall) { + return @() + } + try { + $entries = @() + foreach ($subkeyName in $uninstall.GetSubKeyNames()) { + $subkey = $uninstall.OpenSubKey($subkeyName) + if ($null -eq $subkey) { + continue + } + try { + if ([string]$subkey.GetValue('DisplayName') -ceq $DisplayName) { + $entries += [pscustomobject]@{ + key = $subkeyName + displayName = $DisplayName + displayVersion = [string]$subkey.GetValue('DisplayVersion') + uninstallString = [string]$subkey.GetValue('UninstallString') + } + } + } finally { + $subkey.Dispose() + } + } + return @($entries) + } finally { + $uninstall.Dispose() + } + } finally { + $baseKey.Dispose() + } +} + +function Test-MachinePathContains { + param([Parameter(Mandatory)][string]$ExpectedPath) + + $expected = [IO.Path]::GetFullPath($ExpectedPath).TrimEnd('\') + $machinePath = [Environment]::GetEnvironmentVariable('PATH', 'Machine') + return @($machinePath.Split(';') | Where-Object { + -not [string]::IsNullOrWhiteSpace($_) -and + [IO.Path]::GetFullPath($_).TrimEnd('\') -ieq $expected + }).Count -eq 1 +} + +function Assert-InstalledTree { + param( + [Parameter(Mandatory)][string]$Root, + [Parameter(Mandatory)][string]$Phase + ) + + $expectedFiles = @( + 'bin\openshell-gateway.exe', + 'bin\openshell.exe', + 'LICENSE.txt', + 'NATIVE-PREVIEW.txt' + ) | Sort-Object + $expectedDirectories = @('bin') + $observed = @(Get-ChildItem -LiteralPath $Root -Recurse -Force) + foreach ($item in $observed) { + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + Fail-PackageQualification "$Phase installed tree contains a reparse point." + } + } + $observedFiles = @($observed | Where-Object { -not $_.PSIsContainer } | ForEach-Object { + $_.FullName.Substring($Root.Length + 1) + } | Sort-Object) + $observedDirectories = @($observed | Where-Object { $_.PSIsContainer } | ForEach-Object { + $_.FullName.Substring($Root.Length + 1) + } | Sort-Object) + if (@(Compare-Object $expectedFiles $observedFiles).Count -ne 0 -or + @(Compare-Object $expectedDirectories $observedDirectories).Count -ne 0) { + Fail-PackageQualification "$Phase installed tree contains an unexpected or missing path." + } +} + +function Get-ProhibitedProcessSnapshot { + param([Parameter(Mandatory)][string]$Phase) + + $prohibited = @('bash', 'docker', 'dockerd', 'wsl') + $processes = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { + $name = $_.ProcessName.ToLowerInvariant() + $prohibited -ccontains $name -or $name.StartsWith('com.docker') -or $name.StartsWith('ubuntu') + } | ForEach-Object { + [pscustomobject]@{ processId = $_.Id; processName = $_.ProcessName } + } | Sort-Object processId) + return [pscustomobject]@{ phase = $Phase; processes = $processes } +} + +function Start-ProhibitedProcessAudit { + $sourceIdentifier = 'NemoClawNativePackage-' + [guid]::NewGuid().ToString('N') + Register-WmiEvent -Class Win32_ProcessStartTrace -SourceIdentifier $sourceIdentifier | Out-Null + return $sourceIdentifier +} + +function Stop-ProhibitedProcessAudit { + param([Parameter(Mandatory)][string]$SourceIdentifier) + + Start-Sleep -Milliseconds $script:ProcessAuditSettleMilliseconds + $prohibitedStarts = @() + foreach ($auditEvent in @(Get-Event -SourceIdentifier $SourceIdentifier -ErrorAction SilentlyContinue)) { + $processEvent = $auditEvent.SourceEventArgs.NewEvent + $name = ([string]$processEvent.ProcessName).ToLowerInvariant() + if ($name -in @('bash.exe', 'docker.exe', 'dockerd.exe', 'wsl.exe') -or + $name.StartsWith('com.docker') -or $name.StartsWith('ubuntu')) { + $prohibitedStarts += [pscustomobject]@{ + processId = [int]$processEvent.ProcessID + parentProcessId = [int]$processEvent.ParentProcessID + processName = [string]$processEvent.ProcessName + } + } + Remove-Event -EventIdentifier $auditEvent.EventIdentifier + } + Unregister-Event -SourceIdentifier $SourceIdentifier -ErrorAction SilentlyContinue + return @($prohibitedStarts) +} + +if ($ProductVersion -cnotmatch '^[0-9]{1,3}\.[0-9]{1,5}\.[0-9]{1,5}$') { + Fail-PackageQualification 'ProductVersion is invalid.' +} +if ([Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() -cne 'Arm64') { + Fail-PackageQualification 'Package qualification requires native Windows ARM64.' +} +$principal = [Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + Fail-PackageQualification 'Package qualification requires an elevated Windows runner.' +} + +$msi = [IO.Path]::GetFullPath($MsiPath) +$setup = [IO.Path]::GetFullPath($SetupPath) +$manifestPath = [IO.Path]::GetFullPath($PackageManifestPath) +$artifactRoot = [IO.Path]::GetFullPath($ArtifactDirectory).TrimEnd('\') +$expectedMsiName = "NemoClaw-$ProductVersion-windows-arm64.msi" +$expectedSetupName = "NemoClawSetup-$ProductVersion-windows-arm64.exe" +if ((Split-Path -Leaf $msi) -cne $expectedMsiName -or + (Split-Path -Leaf $setup) -cne $expectedSetupName) { + Fail-PackageQualification 'Package filenames do not match the product version and ARM64 contract.' +} +foreach ($packagePath in @($msi, $setup, $manifestPath)) { + if (-not (Test-Path -LiteralPath $packagePath -PathType Leaf)) { + Fail-PackageQualification "Package input is missing: $(Split-Path -Leaf $packagePath)" + } +} +Assert-Arm64PortableExecutable -Path $setup -Label $expectedSetupName +if (Test-Path -LiteralPath $artifactRoot) { + Fail-PackageQualification 'ArtifactDirectory must not already exist.' +} +[IO.Directory]::CreateDirectory($artifactRoot) | Out-Null + +$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json +if ($manifest.productVersion -cne $ProductVersion -or $manifest.architecture -cne 'arm64' -or + $manifest.wixToolset -cne '5.0.2') { + Fail-PackageQualification 'Package manifest identity is invalid.' +} +$payloadHashes = @{} +foreach ($entry in @($manifest.payload)) { + $payloadHashes[[string]$entry.file] = [string]$entry.sha256 +} +foreach ($requiredPayload in @('openshell.exe', 'openshell-gateway.exe')) { + if (-not $payloadHashes.ContainsKey($requiredPayload) -or + $payloadHashes[$requiredPayload] -cnotmatch '^[a-f0-9]{64}$') { + Fail-PackageQualification "Package manifest is missing $requiredPayload authority." + } +} + +$installRoot = Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles)) 'NVIDIA\NemoClaw' +$installBin = Join-Path $installRoot 'bin' +$openshellPath = Join-Path $installBin 'openshell.exe' +$gatewayPath = Join-Path $installBin 'openshell-gateway.exe' +$bundleInstallLog = Join-Path $artifactRoot 'bundle-install.log' +$msiRepairLog = Join-Path $artifactRoot 'msi-repair.log' +$msiReinstallLog = Join-Path $artifactRoot 'msi-reinstall.log' +$msiUninstallLog = Join-Path $artifactRoot 'msi-uninstall.log' +$bundleUninstallLog = Join-Path $artifactRoot 'bundle-uninstall.log' +$preExecution = Get-ProhibitedProcessSnapshot -Phase 'pre-execution' +$processAudit = Start-ProhibitedProcessAudit +$processAuditStopped = $false + +try { + Invoke-BoundedProcess ` + -FilePath $setup ` + -Arguments @('/install', '/quiet', '/norestart', '/log', $bundleInstallLog) ` + -Label 'Burn bundle install' ` + -AllowedExitCodes @(0, 3010) | Out-Null + + if (-not (Test-Path -LiteralPath $openshellPath -PathType Leaf) -or + -not (Test-Path -LiteralPath $gatewayPath -PathType Leaf)) { + Fail-PackageQualification 'Bundle installation did not publish both payload executables.' + } + Assert-InstalledTree -Root $installRoot -Phase 'Initial bundle install' + if ((Get-FileHash -LiteralPath $openshellPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $payloadHashes['openshell.exe'] -or + (Get-FileHash -LiteralPath $gatewayPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $payloadHashes['openshell-gateway.exe']) { + Fail-PackageQualification 'Installed payload digests do not match the package manifest.' + } + $nativeEvidence = @( + Invoke-NativeVersionProbe -Path $openshellPath -Label 'Installed openshell.exe' + Invoke-NativeVersionProbe -Path $gatewayPath -Label 'Installed openshell-gateway.exe' + ) + $msiArp = @(Get-ArpEntries -DisplayName $script:MsiDisplayName) + $bundleArp = @(Get-ArpEntries -DisplayName $script:BundleDisplayName) + if ($msiArp.Count -ne 1 -or $msiArp[0].displayVersion -cne $ProductVersion) { + Fail-PackageQualification 'MSI Add/Remove Programs registration is missing or ambiguous.' + } + if ($bundleArp.Count -ne 1) { + Fail-PackageQualification 'Bundle Add/Remove Programs registration is missing or ambiguous.' + } + if (-not (Test-MachinePathContains -ExpectedPath $installBin)) { + Fail-PackageQualification 'Machine PATH does not contain the installed bin directory exactly once.' + } + + [IO.File]::AppendAllText($openshellPath, 'msi-repair-drift', [Text.UTF8Encoding]::new($false)) + Invoke-BoundedProcess ` + -FilePath (Join-Path $env:SystemRoot 'System32\msiexec.exe') ` + -Arguments @('/fa', $msi, '/qn', '/norestart', '/l*v', $msiRepairLog) ` + -Label 'MSI repair' ` + -AllowedExitCodes @(0, 3010) | Out-Null + if ((Get-FileHash -LiteralPath $openshellPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $payloadHashes['openshell.exe']) { + Fail-PackageQualification 'MSI repair did not restore the corrupted OpenShell CLI.' + } + Assert-InstalledTree -Root $installRoot -Phase 'MSI repair' + + Invoke-BoundedProcess ` + -FilePath (Join-Path $env:SystemRoot 'System32\msiexec.exe') ` + -Arguments @('/i', $msi, 'REINSTALL=ALL', 'REINSTALLMODE=vomus', '/qn', '/norestart', '/l*v', $msiReinstallLog) ` + -Label 'MSI reinstall' ` + -AllowedExitCodes @(0, 3010) | Out-Null + if (@(Get-ArpEntries -DisplayName $script:MsiDisplayName).Count -ne 1) { + Fail-PackageQualification 'MSI reinstall did not preserve one product registration.' + } + Assert-InstalledTree -Root $installRoot -Phase 'MSI reinstall' + + Invoke-BoundedProcess ` + -FilePath (Join-Path $env:SystemRoot 'System32\msiexec.exe') ` + -Arguments @('/x', $msi, '/qn', '/norestart', '/l*v', $msiUninstallLog) ` + -Label 'MSI uninstall' ` + -AllowedExitCodes @(0, 3010) | Out-Null + Invoke-BoundedProcess ` + -FilePath $setup ` + -Arguments @('/uninstall', '/quiet', '/norestart', '/log', $bundleUninstallLog) ` + -Label 'Burn bundle registration cleanup' ` + -AllowedExitCodes @(0, 3010) | Out-Null + + if (Test-Path -LiteralPath $installRoot) { + Fail-PackageQualification 'Windows Installer uninstall did not remove the product directory.' + } + if (@(Get-ArpEntries -DisplayName $script:MsiDisplayName).Count -ne 0 -or + @(Get-ArpEntries -DisplayName $script:BundleDisplayName).Count -ne 0) { + Fail-PackageQualification 'Add/Remove Programs registration remains after uninstall.' + } + if (Test-MachinePathContains -ExpectedPath $installBin) { + Fail-PackageQualification 'Machine PATH still contains the removed bin directory.' + } + + $prohibitedStarts = @(Stop-ProhibitedProcessAudit -SourceIdentifier $processAudit) + $processAuditStopped = $true + if ($prohibitedStarts.Count -ne 0) { + $names = @($prohibitedStarts | ForEach-Object { $_.processName } | Sort-Object -Unique) -join ', ' + Fail-PackageQualification "Package operations started a prohibited process: $names" + } + $postExecution = Get-ProhibitedProcessSnapshot -Phase 'post-execution' + $baselineIds = @($preExecution.processes | ForEach-Object { $_.processId }) + $newProhibitedProcesses = @($postExecution.processes | Where-Object { + $baselineIds -notcontains $_.processId + }) + if ($newProhibitedProcesses.Count -ne 0) { + Fail-PackageQualification 'A new prohibited process remains after package qualification.' + } + + foreach ($logPath in @($bundleInstallLog, $msiRepairLog, $msiReinstallLog, $msiUninstallLog, $bundleUninstallLog)) { + if (-not (Test-Path -LiteralPath $logPath -PathType Leaf) -or (Get-Item -LiteralPath $logPath).Length -eq 0) { + Fail-PackageQualification "Installer log is missing: $(Split-Path -Leaf $logPath)" + } + } + + $receipt = [pscustomobject]@{ + schemaVersion = 1 + classification = 'native-windows-candidate-preview' + productVersion = $ProductVersion + architecture = 'arm64' + installRoot = $installRoot + msi = [pscustomobject]@{ + file = $expectedMsiName + sha256 = (Get-FileHash -LiteralPath $msi -Algorithm SHA256).Hash.ToLowerInvariant() + authenticodeStatus = (Get-AuthenticodeSignature -LiteralPath $msi).Status.ToString() + } + setup = [pscustomobject]@{ + file = $expectedSetupName + sha256 = (Get-FileHash -LiteralPath $setup -Algorithm SHA256).Hash.ToLowerInvariant() + authenticodeStatus = (Get-AuthenticodeSignature -LiteralPath $setup).Status.ToString() + } + nativeExecutions = $nativeEvidence + msiRegistration = $msiArp + bundleRegistration = $bundleArp + repairRestoredDigest = $true + reinstallPreservedRegistration = $true + finalAbsence = $true + machinePathRemoved = $true + prohibitedProcessStarts = $prohibitedStarts + newProhibitedProcesses = $newProhibitedProcesses + preExecution = $preExecution + postExecution = $postExecution + } + [IO.File]::WriteAllText( + (Join-Path $artifactRoot 'package-qualification.json'), + (($receipt | ConvertTo-Json -Depth 12) + [Environment]::NewLine), + [Text.UTF8Encoding]::new($false) + ) + Write-Host "Windows native package qualification receipts: $artifactRoot" +} finally { + if (-not $processAuditStopped) { + try { + Stop-ProhibitedProcessAudit -SourceIdentifier $processAudit | Out-Null + } catch { + Write-Warning "Could not stop prohibited-process audit during cleanup: $($_.Exception.Message)" + } + } + if (Test-Path -LiteralPath $installRoot) { + try { + Invoke-BoundedProcess ` + -FilePath (Join-Path $env:SystemRoot 'System32\msiexec.exe') ` + -Arguments @('/x', $msi, '/qn', '/norestart') ` + -Label 'Failure cleanup MSI uninstall' ` + -AllowedExitCodes @(0, 1605, 3010) | Out-Null + } catch { + Write-Warning "MSI failure cleanup did not complete: $($_.Exception.Message)" + } + } + try { + Invoke-BoundedProcess ` + -FilePath $setup ` + -Arguments @('/uninstall', '/quiet', '/norestart') ` + -Label 'Failure cleanup bundle uninstall' ` + -AllowedExitCodes @(0, 1605, 3010) | Out-Null + } catch { + Write-Warning "Bundle failure cleanup did not complete: $($_.Exception.Message)" + } +} From 3b8ded562cb3ce0845b5fba44f3e918f59087705 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 16:11:50 -0700 Subject: [PATCH 018/144] fix(ci): select pinned Windows packaging SDK --- .github/workflows/platform-vitest-main.yaml | 6 +++--- packaging/windows/global.json | 8 ++++++++ scripts/checks/build-windows-native-package.ps1 | 13 ++++++++----- 3 files changed, 19 insertions(+), 8 deletions(-) create mode 100644 packaging/windows/global.json diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index 12703770bdd..4a04a7b1ffa 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -309,6 +309,8 @@ jobs: $openshell = [IO.Path]::GetFullPath("$env:GITHUB_WORKSPACE\openshell") $version = [string](Get-Content -LiteralPath "$candidate\package.json" -Raw | ConvertFrom-Json).version $packageRoot = [IO.Path]::GetFullPath("$env:RUNNER_TEMP\nemoclaw-windows-package") + "product_version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + "package_root=$packageRoot" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append & "$candidate\scripts\checks\build-windows-native-package.ps1" ` -ProductVersion $version ` -PayloadRoot "$openshell\target\aarch64-pc-windows-msvc\release" ` @@ -319,8 +321,6 @@ jobs: -Path "$env:RUNNER_TEMP\windows-native-installer-receipts\*" ` -Destination $referenceReceipts ` -Recurse - "product_version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - "package_root=$packageRoot" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - name: Qualify MSI and setup executable shell: powershell @@ -342,7 +342,7 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: nemoclaw-native-windows-package-${{ github.sha }} - path: ${{ steps.windows-package.outputs.package_root }}/ + path: ${{ runner.temp }}/nemoclaw-windows-package/ if-no-files-found: warn retention-days: 14 diff --git a/packaging/windows/global.json b/packaging/windows/global.json new file mode 100644 index 00000000000..d3e441c767b --- /dev/null +++ b/packaging/windows/global.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/global.json", + "sdk": { + "version": "8.0.419", + "rollForward": "disable", + "allowPrerelease": false + } +} diff --git a/scripts/checks/build-windows-native-package.ps1 b/scripts/checks/build-windows-native-package.ps1 index ba21ac95404..cda3c78e7de 100644 --- a/scripts/checks/build-windows-native-package.ps1 +++ b/scripts/checks/build-windows-native-package.ps1 @@ -106,15 +106,17 @@ if ($authoringText -match '<\s*CustomAction\b' -or Fail-WindowsPackageBuild 'WiX authoring contains a prohibited custom-action or non-native execution path.' } -$dotnetVersion = (& dotnet --version).Trim() -if ($LASTEXITCODE -ne 0 -or $dotnetVersion -cne $script:ExpectedDotNetSdk) { - Fail-WindowsPackageBuild "dotnet SDK $($script:ExpectedDotNetSdk) is required." -} - [IO.Directory]::CreateDirectory($output) | Out-Null $intermediate = Join-Path $outputParent ('.windows-package-' + [guid]::NewGuid().ToString('N')) [IO.Directory]::CreateDirectory($intermediate) | Out-Null +$wixRoot = Join-Path $sourceRoot 'packaging\windows' +Push-Location $wixRoot try { + $dotnetVersion = (& dotnet --version).Trim() + if ($LASTEXITCODE -ne 0 -or $dotnetVersion -cne $script:ExpectedDotNetSdk) { + Fail-WindowsPackageBuild "dotnet SDK $($script:ExpectedDotNetSdk) is required." + } + $project = Join-Path $sourceRoot 'packaging\windows\NemoClaw.Bundle.wixproj' $buildArguments = @( 'build', $project, @@ -134,6 +136,7 @@ try { Fail-WindowsPackageBuild 'WiX build failed.' } } finally { + Pop-Location if (Test-Path -LiteralPath $intermediate -PathType Container) { [IO.Directory]::Delete($intermediate, $true) } From ee8dba7db36123c27dc65e90ff6aecdab311fa6a Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 16:22:19 -0700 Subject: [PATCH 019/144] fix(install): restrict native package upgrades --- packaging/windows/Product.wxs | 1 - 1 file changed, 1 deletion(-) diff --git a/packaging/windows/Product.wxs b/packaging/windows/Product.wxs index 1d87dd3ba43..54cbd4b28e6 100644 --- a/packaging/windows/Product.wxs +++ b/packaging/windows/Product.wxs @@ -15,7 +15,6 @@ Description="NemoClaw native Windows ARM64 candidate payload" Manufacturer="NVIDIA Corporation" /> From d1686516df64a15e7ba9f294a70d0f2d13a74652 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 16:33:52 -0700 Subject: [PATCH 020/144] fix(install): load pinned Burn UI extension --- packaging/windows/NemoClaw.Bundle.wixproj | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packaging/windows/NemoClaw.Bundle.wixproj b/packaging/windows/NemoClaw.Bundle.wixproj index 75e1fb83836..431b1f2caa7 100644 --- a/packaging/windows/NemoClaw.Bundle.wixproj +++ b/packaging/windows/NemoClaw.Bundle.wixproj @@ -17,6 +17,11 @@ ProductVersion=$(ProductVersion);PayloadRoot=$(PayloadRoot);SourceRoot=$(SourceRoot);PackageOutputRoot=$(PackageOutputRoot);PackageIntermediateRoot=$(PackageIntermediateRoot) - + + From 3432028e0c606ea87f1073f4f9beef7e6e13d306 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 16:48:15 -0700 Subject: [PATCH 021/144] fix(install): resolve pinned Burn extension path --- packaging/windows/NemoClaw.Bundle.wixproj | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packaging/windows/NemoClaw.Bundle.wixproj b/packaging/windows/NemoClaw.Bundle.wixproj index 431b1f2caa7..7dbc6accc01 100644 --- a/packaging/windows/NemoClaw.Bundle.wixproj +++ b/packaging/windows/NemoClaw.Bundle.wixproj @@ -11,6 +11,7 @@ $(DefineConstants);ProductVersion=$(ProductVersion);SourceRoot=$(SourceRoot) true none + $(NuGetPackageRoot)wixtoolset.bootstrapperapplications.wixext\5.0.2\wixext5\WixToolset.BootstrapperApplications.wixext.dll @@ -20,8 +21,12 @@ - + + + + From be5d5ed98d5c2b092c8c19a12dd552c5bd616afc Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 17:00:13 -0700 Subject: [PATCH 022/144] fix(install): restore Burn extension deterministically --- packaging/windows/NemoClaw.Bundle.wixproj | 4 ++-- scripts/checks/build-windows-native-package.ps1 | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packaging/windows/NemoClaw.Bundle.wixproj b/packaging/windows/NemoClaw.Bundle.wixproj index 7dbc6accc01..92a216e9f43 100644 --- a/packaging/windows/NemoClaw.Bundle.wixproj +++ b/packaging/windows/NemoClaw.Bundle.wixproj @@ -11,7 +11,7 @@ $(DefineConstants);ProductVersion=$(ProductVersion);SourceRoot=$(SourceRoot) true none - $(NuGetPackageRoot)wixtoolset.bootstrapperapplications.wixext\5.0.2\wixext5\WixToolset.BootstrapperApplications.wixext.dll + $(RestorePackagesPath)\wixtoolset.bootstrapperapplications.wixext\5.0.2\wixext5\WixToolset.BootstrapperApplications.wixext.dll @@ -24,7 +24,7 @@ PrivateAssets="all" /> - + diff --git a/scripts/checks/build-windows-native-package.ps1 b/scripts/checks/build-windows-native-package.ps1 index cda3c78e7de..acf88d5fc18 100644 --- a/scripts/checks/build-windows-native-package.ps1 +++ b/scripts/checks/build-windows-native-package.ps1 @@ -109,6 +109,7 @@ if ($authoringText -match '<\s*CustomAction\b' -or [IO.Directory]::CreateDirectory($output) | Out-Null $intermediate = Join-Path $outputParent ('.windows-package-' + [guid]::NewGuid().ToString('N')) [IO.Directory]::CreateDirectory($intermediate) | Out-Null +$restorePackages = Join-Path $intermediate 'nuget' $wixRoot = Join-Path $sourceRoot 'packaging\windows' Push-Location $wixRoot try { @@ -128,6 +129,7 @@ try { "-p:SourceRoot=$sourceRoot", "-p:PackageOutputRoot=$output", "-p:PackageIntermediateRoot=$intermediate", + "-p:RestorePackagesPath=$restorePackages", '-p:ContinuousIntegrationBuild=true', '-p:RestoreIgnoreFailedSources=false' ) From 3dd80ef7768a926f4991a51ebfc59d18a84a93a8 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 17:11:13 -0700 Subject: [PATCH 023/144] fix(install): explicitly restore pinned WiX packages --- .../checks/build-windows-native-package.ps1 | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/scripts/checks/build-windows-native-package.ps1 b/scripts/checks/build-windows-native-package.ps1 index acf88d5fc18..91eecbd665f 100644 --- a/scripts/checks/build-windows-native-package.ps1 +++ b/scripts/checks/build-windows-native-package.ps1 @@ -119,11 +119,7 @@ try { } $project = Join-Path $sourceRoot 'packaging\windows\NemoClaw.Bundle.wixproj' - $buildArguments = @( - 'build', $project, - '--configuration', 'Release', - '--nologo', - '--disable-build-servers', + $commonProperties = @( "-p:ProductVersion=$ProductVersion", "-p:PayloadRoot=$payload", "-p:SourceRoot=$sourceRoot", @@ -133,6 +129,29 @@ try { '-p:ContinuousIntegrationBuild=true', '-p:RestoreIgnoreFailedSources=false' ) + $restoreArguments = @( + 'restore', $project, + '--nologo', + '--force', + '--no-cache', + '--packages', $restorePackages + ) + $commonProperties + & dotnet @restoreArguments + if ($LASTEXITCODE -ne 0) { + Fail-WindowsPackageBuild 'Pinned WiX dependency restore failed.' + } + $bootstrapperExtension = Join-Path $restorePackages 'wixtoolset.bootstrapperapplications.wixext\5.0.2\wixext5\WixToolset.BootstrapperApplications.wixext.dll' + if (-not (Test-Path -LiteralPath $bootstrapperExtension -PathType Leaf)) { + Fail-WindowsPackageBuild 'Pinned WiX BootstrapperApplications extension was not restored.' + } + + $buildArguments = @( + 'build', $project, + '--configuration', 'Release', + '--nologo', + '--no-restore', + '--disable-build-servers' + ) + $commonProperties & dotnet @buildArguments if ($LASTEXITCODE -ne 0) { Fail-WindowsPackageBuild 'WiX build failed.' From c1b9e1a7bf0469caf8e407619607f8edc2b3d912 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 17:23:36 -0700 Subject: [PATCH 024/144] fix(test): attribute native package process starts --- ...n-windows-native-package-qualification.ps1 | 68 ++++++++++++++----- 1 file changed, 52 insertions(+), 16 deletions(-) diff --git a/scripts/checks/run-windows-native-package-qualification.ps1 b/scripts/checks/run-windows-native-package-qualification.ps1 index 05deede8364..e114d9e540d 100644 --- a/scripts/checks/run-windows-native-package-qualification.ps1 +++ b/scripts/checks/run-windows-native-package-qualification.ps1 @@ -249,25 +249,49 @@ function Start-ProhibitedProcessAudit { } function Stop-ProhibitedProcessAudit { - param([Parameter(Mandatory)][string]$SourceIdentifier) + param( + [Parameter(Mandatory)][string]$SourceIdentifier, + [Parameter(Mandatory)][int]$RootProcessId + ) Start-Sleep -Milliseconds $script:ProcessAuditSettleMilliseconds - $prohibitedStarts = @() + $records = @() foreach ($auditEvent in @(Get-Event -SourceIdentifier $SourceIdentifier -ErrorAction SilentlyContinue)) { $processEvent = $auditEvent.SourceEventArgs.NewEvent - $name = ([string]$processEvent.ProcessName).ToLowerInvariant() - if ($name -in @('bash.exe', 'docker.exe', 'dockerd.exe', 'wsl.exe') -or - $name.StartsWith('com.docker') -or $name.StartsWith('ubuntu')) { - $prohibitedStarts += [pscustomobject]@{ - processId = [int]$processEvent.ProcessID - parentProcessId = [int]$processEvent.ParentProcessID - processName = [string]$processEvent.ProcessName - } + $records += [pscustomobject]@{ + processId = [int]$processEvent.ProcessID + parentProcessId = [int]$processEvent.ParentProcessID + processName = [string]$processEvent.ProcessName } Remove-Event -EventIdentifier $auditEvent.EventIdentifier } Unregister-Event -SourceIdentifier $SourceIdentifier -ErrorAction SilentlyContinue - return @($prohibitedStarts) + + $tracked = @{} + $tracked[[string]$RootProcessId] = $true + $descendantStarts = @() + foreach ($record in $records) { + if ($tracked.ContainsKey([string]$record.parentProcessId)) { + $descendantStarts += $record + $tracked[[string]$record.processId] = $true + } + } + $prohibitedStarts = @($records | Where-Object { + $name = $_.processName.ToLowerInvariant() + $name -in @('bash.exe', 'docker.exe', 'dockerd.exe', 'wsl.exe') -or + $name.StartsWith('com.docker') -or $name.StartsWith('ubuntu') + }) + $packageDescendantProhibitedStarts = @($descendantStarts | Where-Object { + $name = $_.processName.ToLowerInvariant() + $name -in @('bash.exe', 'docker.exe', 'dockerd.exe', 'wsl.exe') -or + $name.StartsWith('com.docker') -or $name.StartsWith('ubuntu') + }) + return [pscustomobject]@{ + allStarts = $records + descendantStarts = $descendantStarts + prohibitedStarts = $prohibitedStarts + packageDescendantProhibitedStarts = $packageDescendantProhibitedStarts + } } if ($ProductVersion -cnotmatch '^[0-9]{1,3}\.[0-9]{1,5}\.[0-9]{1,5}$') { @@ -406,11 +430,21 @@ try { Fail-PackageQualification 'Machine PATH still contains the removed bin directory.' } - $prohibitedStarts = @(Stop-ProhibitedProcessAudit -SourceIdentifier $processAudit) + $auditResult = Stop-ProhibitedProcessAudit -SourceIdentifier $processAudit -RootProcessId $PID $processAuditStopped = $true - if ($prohibitedStarts.Count -ne 0) { - $names = @($prohibitedStarts | ForEach-Object { $_.processName } | Sort-Object -Unique) -join ', ' - Fail-PackageQualification "Package operations started a prohibited process: $names" + $setupProcessName = (Split-Path -Leaf $setup).ToLowerInvariant() + if (@($auditResult.descendantStarts | Where-Object { + $_.processName.ToLowerInvariant() -ceq $setupProcessName + }).Count -lt 1) { + Fail-PackageQualification 'The process audit did not observe the setup executable as a package descendant.' + } + $prohibitedStarts = @($auditResult.prohibitedStarts) + $packageDescendantProhibitedStarts = @($auditResult.packageDescendantProhibitedStarts) + if ($packageDescendantProhibitedStarts.Count -ne 0) { + $names = @($packageDescendantProhibitedStarts | ForEach-Object { + $_.processName + } | Sort-Object -Unique) -join ', ' + Fail-PackageQualification "Package operations started a prohibited descendant process: $names" } $postExecution = Get-ProhibitedProcessSnapshot -Phase 'post-execution' $baselineIds = @($preExecution.processes | ForEach-Object { $_.processId }) @@ -451,6 +485,8 @@ try { finalAbsence = $true machinePathRemoved = $true prohibitedProcessStarts = $prohibitedStarts + packageDescendantStarts = $auditResult.descendantStarts + packageDescendantProhibitedStarts = $packageDescendantProhibitedStarts newProhibitedProcesses = $newProhibitedProcesses preExecution = $preExecution postExecution = $postExecution @@ -464,7 +500,7 @@ try { } finally { if (-not $processAuditStopped) { try { - Stop-ProhibitedProcessAudit -SourceIdentifier $processAudit | Out-Null + Stop-ProhibitedProcessAudit -SourceIdentifier $processAudit -RootProcessId $PID | Out-Null } catch { Write-Warning "Could not stop prohibited-process audit during cleanup: $($_.Exception.Message)" } From 9594a7b924e83150f4dc2121c5b2d2e8f872889e Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 17:34:49 -0700 Subject: [PATCH 025/144] fix(install): build MSI and Burn bundle in phases --- packaging/windows/Bundle.wxs | 2 +- packaging/windows/NemoClaw.Bundle.wixproj | 7 +-- .../checks/build-windows-native-package.ps1 | 56 ++++++++++++++----- 3 files changed, 46 insertions(+), 19 deletions(-) diff --git a/packaging/windows/Bundle.wxs b/packaging/windows/Bundle.wxs index 4c49e1ca8dc..28eb69287a6 100644 --- a/packaging/windows/Bundle.wxs +++ b/packaging/windows/Bundle.wxs @@ -18,7 +18,7 @@ SuppressRepair="no" /> - + diff --git a/packaging/windows/NemoClaw.Bundle.wixproj b/packaging/windows/NemoClaw.Bundle.wixproj index 92a216e9f43..a967abe8977 100644 --- a/packaging/windows/NemoClaw.Bundle.wixproj +++ b/packaging/windows/NemoClaw.Bundle.wixproj @@ -8,16 +8,13 @@ $(PackageOutputRoot) $(PackageIntermediateRoot)\bundle\ false - $(DefineConstants);ProductVersion=$(ProductVersion);SourceRoot=$(SourceRoot) + $(DefineConstants);ProductVersion=$(ProductVersion);SourceRoot=$(SourceRoot);MsiPath=$(MsiPath) true none $(RestorePackagesPath)\wixtoolset.bootstrapperapplications.wixext\5.0.2\wixext5\WixToolset.BootstrapperApplications.wixext.dll - - ProductVersion=$(ProductVersion);PayloadRoot=$(PayloadRoot);SourceRoot=$(SourceRoot);PackageOutputRoot=$(PackageOutputRoot);PackageIntermediateRoot=$(PackageIntermediateRoot) - + + diff --git a/scripts/checks/build-windows-native-package.ps1 b/scripts/checks/build-windows-native-package.ps1 index 91eecbd665f..20ab04e256b 100644 --- a/scripts/checks/build-windows-native-package.ps1 +++ b/scripts/checks/build-windows-native-package.ps1 @@ -111,6 +111,10 @@ $intermediate = Join-Path $outputParent ('.windows-package-' + [guid]::NewGuid() [IO.Directory]::CreateDirectory($intermediate) | Out-Null $restorePackages = Join-Path $intermediate 'nuget' $wixRoot = Join-Path $sourceRoot 'packaging\windows' +$msiName = "NemoClaw-$ProductVersion-windows-arm64.msi" +$setupName = "NemoClawSetup-$ProductVersion-windows-arm64.exe" +$msiPath = Join-Path $output $msiName +$setupPath = Join-Path $output $setupName Push-Location $wixRoot try { $dotnetVersion = (& dotnet --version).Trim() @@ -118,7 +122,8 @@ try { Fail-WindowsPackageBuild "dotnet SDK $($script:ExpectedDotNetSdk) is required." } - $project = Join-Path $sourceRoot 'packaging\windows\NemoClaw.Bundle.wixproj' + $msiProject = Join-Path $sourceRoot 'packaging\windows\NemoClaw.wixproj' + $bundleProject = Join-Path $sourceRoot 'packaging\windows\NemoClaw.Bundle.wixproj' $commonProperties = @( "-p:ProductVersion=$ProductVersion", "-p:PayloadRoot=$payload", @@ -129,32 +134,59 @@ try { '-p:ContinuousIntegrationBuild=true', '-p:RestoreIgnoreFailedSources=false' ) - $restoreArguments = @( - 'restore', $project, + $msiRestoreArguments = @( + 'restore', $msiProject, '--nologo', '--force', '--no-cache', '--packages', $restorePackages ) + $commonProperties - & dotnet @restoreArguments + & dotnet @msiRestoreArguments + if ($LASTEXITCODE -ne 0) { + Fail-WindowsPackageBuild 'Pinned WiX MSI dependency restore failed.' + } + $msiBuildArguments = @( + 'build', $msiProject, + '--configuration', 'Release', + '--nologo', + '--no-restore', + '--disable-build-servers' + ) + $commonProperties + & dotnet @msiBuildArguments + if ($LASTEXITCODE -ne 0) { + Fail-WindowsPackageBuild 'WiX MSI build failed.' + } + if (-not (Test-Path -LiteralPath $msiPath -PathType Leaf) -or (Get-Item -LiteralPath $msiPath).Length -eq 0) { + Fail-WindowsPackageBuild "Expected package output is missing: $msiName" + } + + $bundleProperties = $commonProperties + "-p:MsiPath=$msiPath" + $bundleRestoreArguments = @( + 'restore', $bundleProject, + '--nologo', + '--force', + '--no-cache', + '--packages', $restorePackages + ) + $bundleProperties + & dotnet @bundleRestoreArguments if ($LASTEXITCODE -ne 0) { - Fail-WindowsPackageBuild 'Pinned WiX dependency restore failed.' + Fail-WindowsPackageBuild 'Pinned WiX Burn dependency restore failed.' } $bootstrapperExtension = Join-Path $restorePackages 'wixtoolset.bootstrapperapplications.wixext\5.0.2\wixext5\WixToolset.BootstrapperApplications.wixext.dll' if (-not (Test-Path -LiteralPath $bootstrapperExtension -PathType Leaf)) { Fail-WindowsPackageBuild 'Pinned WiX BootstrapperApplications extension was not restored.' } - $buildArguments = @( - 'build', $project, + $bundleBuildArguments = @( + 'build', $bundleProject, '--configuration', 'Release', '--nologo', '--no-restore', '--disable-build-servers' - ) + $commonProperties - & dotnet @buildArguments + ) + $bundleProperties + & dotnet @bundleBuildArguments if ($LASTEXITCODE -ne 0) { - Fail-WindowsPackageBuild 'WiX build failed.' + Fail-WindowsPackageBuild 'WiX Burn build failed.' } } finally { Pop-Location @@ -163,10 +195,6 @@ try { } } -$msiName = "NemoClaw-$ProductVersion-windows-arm64.msi" -$setupName = "NemoClawSetup-$ProductVersion-windows-arm64.exe" -$msiPath = Join-Path $output $msiName -$setupPath = Join-Path $output $setupName foreach ($package in @($msiPath, $setupPath)) { if (-not (Test-Path -LiteralPath $package -PathType Leaf) -or (Get-Item -LiteralPath $package).Length -eq 0) { Fail-WindowsPackageBuild "Expected package output is missing: $(Split-Path -Leaf $package)" From 9f32046ff9f858ef667046e4151247f77605d221 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 18:07:21 -0700 Subject: [PATCH 026/144] test(windows): record native installer proof video --- .github/workflows/platform-vitest-main.yaml | 26 ++ .../create-windows-native-proof-video.ps1 | 390 ++++++++++++++++++ 2 files changed, 416 insertions(+) create mode 100644 scripts/checks/create-windows-native-proof-video.ps1 diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index 4a04a7b1ffa..a5ea33a15ac 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -337,6 +337,32 @@ jobs: -PackageManifestPath "$packageRoot\package-manifest.json" ` -ArtifactDirectory "$packageRoot\qualification" + - name: Create native Windows proof-of-life video + shell: powershell + run: | + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + $candidate = [IO.Path]::GetFullPath("$env:GITHUB_WORKSPACE\candidate") + $version = '${{ steps.windows-package.outputs.product_version }}' + $packageRoot = '${{ steps.windows-package.outputs.package_root }}' + & "$candidate\scripts\checks\create-windows-native-proof-video.ps1" ` + -ProductVersion $version ` + -CandidateSha $env:GITHUB_SHA ` + -PackageManifestPath "$packageRoot\package-manifest.json" ` + -QualificationReceiptPath "$packageRoot\qualification\package-qualification.json" ` + -HostReceiptPath "$packageRoot\reference-qualification\host-platform.json" ` + -OpenShellReceiptPath "$packageRoot\reference-qualification\openshell-source.json" ` + -OutputDirectory "$packageRoot\proof-video" + + - name: Upload native Windows proof-of-life video + if: success() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-native-proof-video-${{ github.sha }} + path: ${{ runner.temp }}/nemoclaw-windows-package/proof-video/ + if-no-files-found: error + retention-days: 14 + - name: Upload downloadable Windows native package if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/scripts/checks/create-windows-native-proof-video.ps1 b/scripts/checks/create-windows-native-proof-video.ps1 new file mode 100644 index 00000000000..a16b6c751a5 --- /dev/null +++ b/scripts/checks/create-windows-native-proof-video.ps1 @@ -0,0 +1,390 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +<# +.SYNOPSIS + Create an H.264 MP4 proof-of-life video from live native Windows receipts. + +.DESCRIPTION + Renders bounded evidence frames from a completed ARM64 package + qualification and encodes them with the Windows Media Foundation-backed + Windows.Media.Editing API. No downloaded video encoder is used. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$ProductVersion, + [Parameter(Mandatory)][string]$CandidateSha, + [Parameter(Mandatory)][string]$PackageManifestPath, + [Parameter(Mandatory)][string]$QualificationReceiptPath, + [Parameter(Mandatory)][string]$HostReceiptPath, + [Parameter(Mandatory)][string]$OpenShellReceiptPath, + [Parameter(Mandatory)][string]$OutputDirectory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$script:FrameWidth = 1280 +$script:FrameHeight = 720 +$script:FrameDurationMilliseconds = 3000 +$script:ExpectedFrameCount = 8 + +function Fail-ProofVideo { + param([Parameter(Mandatory)][string]$Message) + throw "Windows native proof video failed: $Message" +} + +function Resolve-RequiredFile { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Label + ) + + $resolved = [IO.Path]::GetFullPath($Path) + if (-not (Test-Path -LiteralPath $resolved -PathType Leaf) -or + (Get-Item -LiteralPath $resolved).Length -eq 0) { + Fail-ProofVideo "$Label is missing." + } + return $resolved +} + +function ConvertTo-DisplayDigest { + param([Parameter(Mandatory)][string]$Digest) + + if ($Digest -cnotmatch '^[a-f0-9]{64}$') { + Fail-ProofVideo 'A receipt contains an invalid SHA-256 digest.' + } + return $Digest.Substring(0, 16) + '...' + $Digest.Substring(56, 8) +} + +function New-ProofFrame { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Heading, + [Parameter(Mandatory)][string[]]$Lines, + [Parameter(Mandatory)][int]$Index, + [Parameter(Mandatory)][int]$Total + ) + + $bitmap = [Drawing.Bitmap]::new($script:FrameWidth, $script:FrameHeight) + $graphics = [Drawing.Graphics]::FromImage($bitmap) + $headingFont = [Drawing.Font]::new('Segoe UI Semibold', 34, [Drawing.FontStyle]::Bold) + $bodyFont = [Drawing.Font]::new('Consolas', 22, [Drawing.FontStyle]::Regular) + $smallFont = [Drawing.Font]::new('Segoe UI', 16, [Drawing.FontStyle]::Regular) + $whiteBrush = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(245, 248, 252)) + $mutedBrush = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(174, 187, 204)) + $greenBrush = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(118, 219, 144)) + $panelBrush = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(30, 42, 58)) + $progressBrush = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(118, 185, 255)) + try { + $graphics.SmoothingMode = [Drawing.Drawing2D.SmoothingMode]::AntiAlias + $graphics.TextRenderingHint = [Drawing.Text.TextRenderingHint]::ClearTypeGridFit + $graphics.Clear([Drawing.Color]::FromArgb(10, 18, 30)) + $graphics.FillRectangle($panelBrush, 48, 42, 1184, 610) + $graphics.FillRectangle($progressBrush, 48, 42, [int](1184 * $Index / $Total), 8) + $graphics.DrawString($Heading, $headingFont, $whiteBrush, 82, 82) + + $y = 170 + foreach ($line in $Lines) { + $brush = if ($line.StartsWith('[PASS]')) { $greenBrush } else { $whiteBrush } + $graphics.DrawString($line, $bodyFont, $brush, 90, $y) + $y += 53 + } + $graphics.DrawString( + "Live Windows ARM64 qualification evidence | frame $Index/$Total", + $smallFont, + $mutedBrush, + 82, + 670 + ) + $bitmap.Save($Path, [Drawing.Imaging.ImageFormat]::Png) + } finally { + $progressBrush.Dispose() + $panelBrush.Dispose() + $greenBrush.Dispose() + $mutedBrush.Dispose() + $whiteBrush.Dispose() + $smallFont.Dispose() + $bodyFont.Dispose() + $headingFont.Dispose() + $graphics.Dispose() + $bitmap.Dispose() + } +} + +if ($ProductVersion -cnotmatch '^[0-9]{1,3}\.[0-9]{1,5}\.[0-9]{1,5}$') { + Fail-ProofVideo 'ProductVersion is invalid.' +} +if ($CandidateSha -cnotmatch '^[a-f0-9]{40}$') { + Fail-ProofVideo 'CandidateSha must be a full lowercase Git revision.' +} +if ([Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() -cne 'Arm64' -or + [Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString() -cne 'Arm64') { + Fail-ProofVideo 'Proof video creation requires a native Windows ARM64 process.' +} + +$manifestPath = Resolve-RequiredFile -Path $PackageManifestPath -Label 'PackageManifestPath' +$qualificationPath = Resolve-RequiredFile -Path $QualificationReceiptPath -Label 'QualificationReceiptPath' +$hostPath = Resolve-RequiredFile -Path $HostReceiptPath -Label 'HostReceiptPath' +$openshellPath = Resolve-RequiredFile -Path $OpenShellReceiptPath -Label 'OpenShellReceiptPath' +$output = [IO.Path]::GetFullPath($OutputDirectory).TrimEnd('\') +if (Test-Path -LiteralPath $output) { + Fail-ProofVideo 'OutputDirectory must not already exist.' +} +$outputParent = Split-Path -Parent $output +if (-not (Test-Path -LiteralPath $outputParent -PathType Container)) { + Fail-ProofVideo 'OutputDirectory parent must exist.' +} + +$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json +$qualification = Get-Content -LiteralPath $qualificationPath -Raw | ConvertFrom-Json +$hostReceipt = Get-Content -LiteralPath $hostPath -Raw | ConvertFrom-Json +$openshellReceipt = Get-Content -LiteralPath $openshellPath -Raw | ConvertFrom-Json +if ($manifest.productVersion -cne $ProductVersion -or $manifest.architecture -cne 'arm64' -or + $qualification.productVersion -cne $ProductVersion -or $qualification.architecture -cne 'arm64') { + Fail-ProofVideo 'Package and qualification receipt identity do not match.' +} +if ($hostReceipt.osArchitecture -cne 'Arm64' -or $hostReceipt.processArchitecture -cne 'Arm64' -or + $hostReceipt.runnerArchitecture -cne 'ARM64') { + Fail-ProofVideo 'Host receipt does not prove native ARM64 execution.' +} +if ($openshellReceipt.repository -cne 'https://github.com/NVIDIA/OpenShell.git' -or + [int]$openshellReceipt.pullRequest -ne 2721 -or + $openshellReceipt.revision -cne 'bcd517bbe08cc80860c9be57699390cd32e8445f') { + Fail-ProofVideo 'OpenShell source authority does not match NVIDIA/OpenShell#2721.' +} +if (-not $qualification.repairRestoredDigest -or + -not $qualification.reinstallPreservedRegistration -or + -not $qualification.finalAbsence -or + -not $qualification.machinePathRemoved -or + @($qualification.nativeExecutions).Count -ne 2 -or + @($qualification.msiRegistration).Count -ne 1 -or + @($qualification.bundleRegistration).Count -ne 1 -or + @($qualification.packageDescendantProhibitedStarts).Count -ne 0 -or + @($qualification.newProhibitedProcesses).Count -ne 0) { + Fail-ProofVideo 'Qualification receipt is not a complete passing package lifecycle.' +} + +Add-Type -AssemblyName System.Drawing +[IO.Directory]::CreateDirectory($output) | Out-Null +$frameRoot = Join-Path $env:RUNNER_TEMP ('nemoclaw-proof-frames-' + [guid]::NewGuid().ToString('N')) +[IO.Directory]::CreateDirectory($frameRoot) | Out-Null +try { + $msi = @($manifest.packages | Where-Object { $_.file -like '*.msi' }) + $setup = @($manifest.packages | Where-Object { $_.file -like '*.exe' }) + $cli = @($qualification.nativeExecutions | Where-Object { $_.file -ceq 'openshell.exe' }) + $gateway = @($qualification.nativeExecutions | Where-Object { $_.file -ceq 'openshell-gateway.exe' }) + if ($msi.Count -ne 1 -or $setup.Count -ne 1 -or $cli.Count -ne 1 -or $gateway.Count -ne 1) { + Fail-ProofVideo 'Package or native-execution evidence is ambiguous.' + } + + $frames = @( + [pscustomobject]@{ + heading = 'NemoClaw Native Windows ARM64 - Proof of Life' + lines = @( + "[PASS] Exact NemoClaw head $($CandidateSha.Substring(0, 12))", + '[PASS] NVIDIA/OpenShell#2721 exact merge payload', + "Product version $ProductVersion | native candidate preview" + ) + }, + [pscustomobject]@{ + heading = 'Real native Windows ARM64 host' + lines = @( + "[PASS] $($hostReceipt.osDescription.Trim())", + "[PASS] OS architecture $($hostReceipt.osArchitecture)", + "[PASS] Process architecture $($hostReceipt.processArchitecture)", + "Runner $($hostReceipt.runnerName)" + ) + }, + [pscustomobject]@{ + heading = 'Literal downloadable Windows installer' + lines = @( + "[PASS] $($setup[0].file)", + "SHA-256 $(ConvertTo-DisplayDigest -Digest ([string]$setup[0].sha256))", + "[PASS] $($msi[0].file)", + "SHA-256 $(ConvertTo-DisplayDigest -Digest ([string]$msi[0].sha256))" + ) + }, + [pscustomobject]@{ + heading = 'Per-machine Windows Installer registration' + lines = @( + "[PASS] Installed under $($qualification.installRoot)", + "[PASS] MSI ARP: $($qualification.msiRegistration[0].displayName)", + "[PASS] Bundle ARP: $($qualification.bundleRegistration[0].displayName)", + '[PASS] Installed bin directory added to machine PATH' + ) + }, + [pscustomobject]@{ + heading = 'Native OpenShell execution' + lines = @( + "[PASS] openshell.exe --version -> $($cli[0].output)", + "Exit code $($cli[0].exitCode) | $(ConvertTo-DisplayDigest -Digest ([string]$cli[0].sha256))", + "[PASS] openshell-gateway.exe --version -> $($gateway[0].output)", + "Exit code $($gateway[0].exitCode) | $(ConvertTo-DisplayDigest -Digest ([string]$gateway[0].sha256))" + ) + }, + [pscustomobject]@{ + heading = 'Standard MSI lifecycle' + lines = @( + '[PASS] Deliberate file corruption repaired to source digest', + '[PASS] Same-version reinstall preserved one registration', + '[PASS] Windows Installer uninstall removed product files', + '[PASS] Bundle registration and machine PATH removed' + ) + }, + [pscustomobject]@{ + heading = 'No Linux dependency in the package path' + lines = @( + '[PASS] Zero WSL / Docker / Bash / Ubuntu descendants', + '[PASS] Zero new prohibited processes remained', + "Observed package descendants: $(@($qualification.packageDescendantStarts).Count)", + 'Customer setup contains no PowerShell or custom action' + ) + }, + [pscustomobject]@{ + heading = 'Candidate proven - production gates remain explicit' + lines = @( + '[PASS] Native package install / repair / uninstall proven', + '[PASS] Both ARM64 payload executables ran natively', + 'Deferred: real MXC + wxc-exec + gateway service', + 'Deferred: NemoClaw onboarding + production signing' + ) + } + ) + if ($frames.Count -ne $script:ExpectedFrameCount) { + Fail-ProofVideo 'Unexpected proof frame count.' + } + + $framePaths = @() + for ($index = 0; $index -lt $frames.Count; $index++) { + $framePath = Join-Path $frameRoot ('frame-{0:D2}.png' -f ($index + 1)) + New-ProofFrame ` + -Path $framePath ` + -Heading $frames[$index].heading ` + -Lines $frames[$index].lines ` + -Index ($index + 1) ` + -Total $frames.Count + $framePaths += $framePath + } + + $programFilesX86 = [Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFilesX86) + $windowsWinMd = @(Get-ChildItem ` + -LiteralPath (Join-Path $programFilesX86 'Windows Kits\10\UnionMetadata') ` + -Filter 'Windows.winmd' ` + -Recurse ` + -File | Sort-Object FullName -Descending | Select-Object -First 1) + if ($windowsWinMd.Count -ne 1) { + Fail-ProofVideo 'Windows SDK metadata for Media Foundation is missing.' + } + $runtimeDirectory = [Runtime.InteropServices.RuntimeEnvironment]::GetRuntimeDirectory() + $runtimeWinRt = Join-Path $runtimeDirectory 'System.Runtime.WindowsRuntime.dll' + if (-not (Test-Path -LiteralPath $runtimeWinRt -PathType Leaf)) { + Fail-ProofVideo 'System.Runtime.WindowsRuntime.dll is missing.' + } + + $encoderSource = @' +using System; +using System.IO; +using System.Threading.Tasks; +using Windows.Media.Editing; +using Windows.Media.MediaProperties; +using Windows.Media.Transcoding; +using Windows.Storage; + +public static class NemoClawProofVideoEncoder +{ + public static async Task RenderAsync( + string[] imagePaths, + int millisecondsPerFrame, + string outputPath) + { + var composition = new MediaComposition(); + foreach (var imagePath in imagePaths) + { + var image = await StorageFile.GetFileFromPathAsync(imagePath); + var clip = await MediaClip.CreateFromImageFile( + image, + TimeSpan.FromMilliseconds(millisecondsPerFrame)); + composition.Clips.Add(clip); + } + + var outputFolder = await StorageFolder.GetFolderFromPathAsync( + Path.GetDirectoryName(outputPath)); + var output = await outputFolder.CreateFileAsync( + Path.GetFileName(outputPath), + CreationCollisionOption.ReplaceExisting); + var profile = MediaEncodingProfile.CreateMp4(VideoEncodingQuality.HD720p); + var result = await composition.RenderToFileAsync( + output, + MediaTrimmingPreference.Precise, + profile); + if (result != TranscodeFailureReason.None) + { + throw new InvalidOperationException("Media Foundation render failed: " + result); + } + return output.Path; + } +} +'@ + Add-Type ` + -TypeDefinition $encoderSource ` + -Language CSharp ` + -ReferencedAssemblies @($windowsWinMd[0].FullName, $runtimeWinRt) + + $videoName = "NemoClaw-$ProductVersion-windows-arm64-proof-$($CandidateSha.Substring(0, 12)).mp4" + $videoPath = Join-Path $output $videoName + $renderTask = [NemoClawProofVideoEncoder]::RenderAsync( + [string[]]$framePaths, + $script:FrameDurationMilliseconds, + $videoPath + ) + $renderedPath = $renderTask.GetAwaiter().GetResult() + if ($renderedPath -cne $videoPath -or + -not (Test-Path -LiteralPath $videoPath -PathType Leaf) -or + (Get-Item -LiteralPath $videoPath).Length -lt 65536) { + Fail-ProofVideo 'Media Foundation did not produce the expected MP4.' + } + $videoBytes = [IO.File]::ReadAllBytes($videoPath) + $headerText = [Text.Encoding]::ASCII.GetString($videoBytes, 0, [Math]::Min(64, $videoBytes.Length)) + if ($headerText -notmatch 'ftyp') { + Fail-ProofVideo 'Rendered proof artifact is not an ISO base media file.' + } + + [IO.File]::Copy($framePaths[0], (Join-Path $output 'proof-thumbnail.png'), $false) + $receipt = [pscustomobject]@{ + schemaVersion = 1 + classification = 'native-windows-candidate-preview-proof-video' + candidateSha = $CandidateSha + productVersion = $ProductVersion + architecture = 'arm64' + source = [pscustomobject]@{ + packageManifestSha256 = (Get-FileHash -LiteralPath $manifestPath -Algorithm SHA256).Hash.ToLowerInvariant() + qualificationReceiptSha256 = (Get-FileHash -LiteralPath $qualificationPath -Algorithm SHA256).Hash.ToLowerInvariant() + hostReceiptSha256 = (Get-FileHash -LiteralPath $hostPath -Algorithm SHA256).Hash.ToLowerInvariant() + openshellReceiptSha256 = (Get-FileHash -LiteralPath $openshellPath -Algorithm SHA256).Hash.ToLowerInvariant() + } + video = [pscustomobject]@{ + file = $videoName + container = 'mp4' + encoder = 'Windows Media Foundation via Windows.Media.Editing' + width = $script:FrameWidth + height = $script:FrameHeight + frameCount = $frames.Count + frameDurationMilliseconds = $script:FrameDurationMilliseconds + expectedDurationMilliseconds = $frames.Count * $script:FrameDurationMilliseconds + sha256 = (Get-FileHash -LiteralPath $videoPath -Algorithm SHA256).Hash.ToLowerInvariant() + bytes = (Get-Item -LiteralPath $videoPath).Length + } + } + [IO.File]::WriteAllText( + (Join-Path $output 'proof-video-receipt.json'), + (($receipt | ConvertTo-Json -Depth 8) + [Environment]::NewLine), + [Text.UTF8Encoding]::new($false) + ) + Write-Host "Windows native proof-of-life video: $videoPath" +} finally { + if (Test-Path -LiteralPath $frameRoot -PathType Container) { + [IO.Directory]::Delete($frameRoot, $true) + } +} From 2eda6ede1b840af983f110ba18b0f7d25cc47d65 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 18:19:51 -0700 Subject: [PATCH 027/144] fix(test): attribute remaining Windows processes --- scripts/checks/create-windows-native-proof-video.ps1 | 6 +++--- .../run-windows-native-package-qualification.ps1 | 11 +++++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/scripts/checks/create-windows-native-proof-video.ps1 b/scripts/checks/create-windows-native-proof-video.ps1 index a16b6c751a5..65a609905f7 100644 --- a/scripts/checks/create-windows-native-proof-video.ps1 +++ b/scripts/checks/create-windows-native-proof-video.ps1 @@ -162,7 +162,7 @@ if (-not $qualification.repairRestoredDigest -or @($qualification.msiRegistration).Count -ne 1 -or @($qualification.bundleRegistration).Count -ne 1 -or @($qualification.packageDescendantProhibitedStarts).Count -ne 0 -or - @($qualification.newProhibitedProcesses).Count -ne 0) { + @($qualification.newPackageDescendantProhibitedProcesses).Count -ne 0) { Fail-ProofVideo 'Qualification receipt is not a complete passing package lifecycle.' } @@ -237,9 +237,9 @@ try { heading = 'No Linux dependency in the package path' lines = @( '[PASS] Zero WSL / Docker / Bash / Ubuntu descendants', - '[PASS] Zero new prohibited processes remained', + '[PASS] Zero package-introduced prohibited processes remained', "Observed package descendants: $(@($qualification.packageDescendantStarts).Count)", - 'Customer setup contains no PowerShell or custom action' + "Runner-owned new prohibited processes: $(@($qualification.newProhibitedProcesses).Count)" ) }, [pscustomobject]@{ diff --git a/scripts/checks/run-windows-native-package-qualification.ps1 b/scripts/checks/run-windows-native-package-qualification.ps1 index e114d9e540d..5152713fa03 100644 --- a/scripts/checks/run-windows-native-package-qualification.ps1 +++ b/scripts/checks/run-windows-native-package-qualification.ps1 @@ -451,8 +451,14 @@ try { $newProhibitedProcesses = @($postExecution.processes | Where-Object { $baselineIds -notcontains $_.processId }) - if ($newProhibitedProcesses.Count -ne 0) { - Fail-PackageQualification 'A new prohibited process remains after package qualification.' + $packageDescendantProhibitedIds = @($packageDescendantProhibitedStarts | ForEach-Object { + $_.processId + }) + $newPackageDescendantProhibitedProcesses = @($newProhibitedProcesses | Where-Object { + $packageDescendantProhibitedIds -contains $_.processId + }) + if ($newPackageDescendantProhibitedProcesses.Count -ne 0) { + Fail-PackageQualification 'A new prohibited package descendant remains after qualification.' } foreach ($logPath in @($bundleInstallLog, $msiRepairLog, $msiReinstallLog, $msiUninstallLog, $bundleUninstallLog)) { @@ -488,6 +494,7 @@ try { packageDescendantStarts = $auditResult.descendantStarts packageDescendantProhibitedStarts = $packageDescendantProhibitedStarts newProhibitedProcesses = $newProhibitedProcesses + newPackageDescendantProhibitedProcesses = $newPackageDescendantProhibitedProcesses preExecution = $preExecution postExecution = $postExecution } From b0794061db09e0b61845c94bf11cdf29446195b0 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 18:31:27 -0700 Subject: [PATCH 028/144] fix(test): select versioned Windows metadata --- scripts/checks/create-windows-native-proof-video.ps1 | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/scripts/checks/create-windows-native-proof-video.ps1 b/scripts/checks/create-windows-native-proof-video.ps1 index 65a609905f7..e2eb6d608b6 100644 --- a/scripts/checks/create-windows-native-proof-video.ps1 +++ b/scripts/checks/create-windows-native-proof-video.ps1 @@ -269,11 +269,12 @@ try { } $programFilesX86 = [Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFilesX86) - $windowsWinMd = @(Get-ChildItem ` - -LiteralPath (Join-Path $programFilesX86 'Windows Kits\10\UnionMetadata') ` - -Filter 'Windows.winmd' ` - -Recurse ` - -File | Sort-Object FullName -Descending | Select-Object -First 1) + $unionMetadataRoot = Join-Path $programFilesX86 'Windows Kits\10\UnionMetadata' + $windowsWinMd = @(Get-ChildItem -LiteralPath $unionMetadataRoot -Directory | Where-Object { + $_.Name -cmatch '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' + } | Sort-Object { [version]$_.Name } -Descending | ForEach-Object { + Get-Item -LiteralPath (Join-Path $_.FullName 'Windows.winmd') -ErrorAction SilentlyContinue + } | Select-Object -First 1) if ($windowsWinMd.Count -ne 1) { Fail-ProofVideo 'Windows SDK metadata for Media Foundation is missing.' } From c5eb5e7f583e617d8d8d7dc25d97b13b4f3f79f7 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 18:43:51 -0700 Subject: [PATCH 029/144] fix(test): compile Windows proof encoder with csc --- .../create-windows-native-proof-video.ps1 | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/scripts/checks/create-windows-native-proof-video.ps1 b/scripts/checks/create-windows-native-proof-video.ps1 index e2eb6d608b6..d8585a9c007 100644 --- a/scripts/checks/create-windows-native-proof-video.ps1 +++ b/scripts/checks/create-windows-native-proof-video.ps1 @@ -328,10 +328,26 @@ public static class NemoClawProofVideoEncoder } } '@ - Add-Type ` - -TypeDefinition $encoderSource ` - -Language CSharp ` - -ReferencedAssemblies @($windowsWinMd[0].FullName, $runtimeWinRt) + $compiler = Join-Path $runtimeDirectory 'csc.exe' + if (-not (Test-Path -LiteralPath $compiler -PathType Leaf)) { + Fail-ProofVideo 'The .NET Framework C# compiler is missing.' + } + $encoderSourcePath = Join-Path $frameRoot 'NemoClawProofVideoEncoder.cs' + $encoderAssemblyPath = Join-Path $frameRoot 'NemoClawProofVideoEncoder.dll' + [IO.File]::WriteAllText($encoderSourcePath, $encoderSource, [Text.UTF8Encoding]::new($false)) + $compilerArguments = @( + '/nologo', + '/target:library', + "/out:$encoderAssemblyPath", + "/reference:$($windowsWinMd[0].FullName)", + "/reference:$runtimeWinRt", + $encoderSourcePath + ) + & $compiler @compilerArguments + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $encoderAssemblyPath -PathType Leaf)) { + Fail-ProofVideo 'The Windows Media Foundation encoder did not compile.' + } + Add-Type -Path $encoderAssemblyPath $videoName = "NemoClaw-$ProductVersion-windows-arm64-proof-$($CandidateSha.Substring(0, 12)).mp4" $videoPath = Join-Path $output $videoName From acbfdac77a802ed708d5da6ff2c4a0af4d7c26a4 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 18:55:58 -0700 Subject: [PATCH 030/144] fix(test): reference Windows encoder facade --- scripts/checks/create-windows-native-proof-video.ps1 | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/checks/create-windows-native-proof-video.ps1 b/scripts/checks/create-windows-native-proof-video.ps1 index d8585a9c007..9e46a840000 100644 --- a/scripts/checks/create-windows-native-proof-video.ps1 +++ b/scripts/checks/create-windows-native-proof-video.ps1 @@ -283,6 +283,15 @@ try { if (-not (Test-Path -LiteralPath $runtimeWinRt -PathType Leaf)) { Fail-ProofVideo 'System.Runtime.WindowsRuntime.dll is missing.' } + $frameworkReferenceRoot = Join-Path $programFilesX86 'Reference Assemblies\Microsoft\Framework\.NETFramework' + $systemRuntimeFacade = @(Get-ChildItem -LiteralPath $frameworkReferenceRoot -Directory | Where-Object { + $_.Name -cmatch '^v[0-9]+\.[0-9]+(\.[0-9]+)?$' + } | Sort-Object { [version]$_.Name.TrimStart('v') } -Descending | ForEach-Object { + Get-Item -LiteralPath (Join-Path $_.FullName 'Facades\System.Runtime.dll') -ErrorAction SilentlyContinue + } | Select-Object -First 1) + if ($systemRuntimeFacade.Count -ne 1) { + Fail-ProofVideo 'The .NET Framework System.Runtime facade is missing.' + } $encoderSource = @' using System; @@ -341,6 +350,7 @@ public static class NemoClawProofVideoEncoder "/out:$encoderAssemblyPath", "/reference:$($windowsWinMd[0].FullName)", "/reference:$runtimeWinRt", + "/reference:$($systemRuntimeFacade[0].FullName)", $encoderSourcePath ) & $compiler @compilerArguments From f332eec0ba4ebb07d9324755869ff6c854053bb6 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 19:07:10 -0700 Subject: [PATCH 031/144] fix(test): await proof image clips --- scripts/checks/create-windows-native-proof-video.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/checks/create-windows-native-proof-video.ps1 b/scripts/checks/create-windows-native-proof-video.ps1 index 9e46a840000..4e389e180f9 100644 --- a/scripts/checks/create-windows-native-proof-video.ps1 +++ b/scripts/checks/create-windows-native-proof-video.ps1 @@ -313,7 +313,7 @@ public static class NemoClawProofVideoEncoder foreach (var imagePath in imagePaths) { var image = await StorageFile.GetFileFromPathAsync(imagePath); - var clip = await MediaClip.CreateFromImageFile( + var clip = await MediaClip.CreateFromImageFileAsync( image, TimeSpan.FromMilliseconds(millisecondsPerFrame)); composition.Clips.Add(clip); From e7e7109709be9448a2eb157337c38a591d76d52c Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 19:20:14 -0700 Subject: [PATCH 032/144] fix(test): release proof encoder assembly file --- scripts/checks/create-windows-native-proof-video.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/checks/create-windows-native-proof-video.ps1 b/scripts/checks/create-windows-native-proof-video.ps1 index 4e389e180f9..4fbae959ea4 100644 --- a/scripts/checks/create-windows-native-proof-video.ps1 +++ b/scripts/checks/create-windows-native-proof-video.ps1 @@ -357,7 +357,7 @@ public static class NemoClawProofVideoEncoder if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $encoderAssemblyPath -PathType Leaf)) { Fail-ProofVideo 'The Windows Media Foundation encoder did not compile.' } - Add-Type -Path $encoderAssemblyPath + [Reflection.Assembly]::Load([IO.File]::ReadAllBytes($encoderAssemblyPath)) | Out-Null $videoName = "NemoClaw-$ProductVersion-windows-arm64-proof-$($CandidateSha.Substring(0, 12)).mp4" $videoPath = Join-Path $output $videoName From 3ff04f5ede8ad388b0d6f8cf3d45207d91ac917e Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 20:47:45 -0700 Subject: [PATCH 033/144] test(windows): screen-record native install console --- .github/workflows/platform-vitest-main.yaml | 3 + .../create-windows-native-proof-video.ps1 | 333 ++++++++---------- ...n-windows-native-package-console-proof.ps1 | 66 ++++ ...n-windows-native-package-qualification.ps1 | 29 +- 4 files changed, 250 insertions(+), 181 deletions(-) create mode 100644 scripts/checks/run-windows-native-package-console-proof.ps1 diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index a5ea33a15ac..321a0cfa8b3 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -346,8 +346,11 @@ jobs: $version = '${{ steps.windows-package.outputs.product_version }}' $packageRoot = '${{ steps.windows-package.outputs.package_root }}' & "$candidate\scripts\checks\create-windows-native-proof-video.ps1" ` + -CandidateCheckout $candidate ` -ProductVersion $version ` -CandidateSha $env:GITHUB_SHA ` + -MsiPath "$packageRoot\NemoClaw-$version-windows-arm64.msi" ` + -SetupPath "$packageRoot\NemoClawSetup-$version-windows-arm64.exe" ` -PackageManifestPath "$packageRoot\package-manifest.json" ` -QualificationReceiptPath "$packageRoot\qualification\package-qualification.json" ` -HostReceiptPath "$packageRoot\reference-qualification\host-platform.json" ` diff --git a/scripts/checks/create-windows-native-proof-video.ps1 b/scripts/checks/create-windows-native-proof-video.ps1 index 4fbae959ea4..fa10d9a8421 100644 --- a/scripts/checks/create-windows-native-proof-video.ps1 +++ b/scripts/checks/create-windows-native-proof-video.ps1 @@ -3,18 +3,22 @@ <# .SYNOPSIS - Create an H.264 MP4 proof-of-life video from live native Windows receipts. + Screen-record the complete native Windows installer qualification. .DESCRIPTION - Renders bounded evidence frames from a completed ARM64 package - qualification and encodes them with the Windows Media Foundation-backed - Windows.Media.Editing API. No downloaded video encoder is used. + Launches the real setup/install/repair/reinstall/uninstall qualification + in a visible maximized PowerShell console, captures the actual Windows + desktop four times per second, and encodes those captured frames to H.264 + with the Windows Media Foundation-backed Windows.Media.Editing API. #> [CmdletBinding()] param( + [Parameter(Mandatory)][string]$CandidateCheckout, [Parameter(Mandatory)][string]$ProductVersion, [Parameter(Mandatory)][string]$CandidateSha, + [Parameter(Mandatory)][string]$MsiPath, + [Parameter(Mandatory)][string]$SetupPath, [Parameter(Mandatory)][string]$PackageManifestPath, [Parameter(Mandatory)][string]$QualificationReceiptPath, [Parameter(Mandatory)][string]$HostReceiptPath, @@ -25,14 +29,15 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -$script:FrameWidth = 1280 -$script:FrameHeight = 720 -$script:FrameDurationMilliseconds = 3000 -$script:ExpectedFrameCount = 8 +$script:CaptureFramesPerSecond = 4 +$script:FrameDurationMilliseconds = 250 +$script:MaximumRecordingMilliseconds = 180000 +$script:MinimumCaptureFrames = 40 +$script:MinimumUniqueFrames = 8 function Fail-ProofVideo { param([Parameter(Mandatory)][string]$Message) - throw "Windows native proof video failed: $Message" + throw "Windows native console proof video failed: $Message" } function Resolve-RequiredFile { @@ -49,65 +54,22 @@ function Resolve-RequiredFile { return $resolved } -function ConvertTo-DisplayDigest { - param([Parameter(Mandatory)][string]$Digest) - - if ($Digest -cnotmatch '^[a-f0-9]{64}$') { - Fail-ProofVideo 'A receipt contains an invalid SHA-256 digest.' - } - return $Digest.Substring(0, 16) + '...' + $Digest.Substring(56, 8) -} - -function New-ProofFrame { +function Save-DesktopFrame { param( [Parameter(Mandatory)][string]$Path, - [Parameter(Mandatory)][string]$Heading, - [Parameter(Mandatory)][string[]]$Lines, - [Parameter(Mandatory)][int]$Index, - [Parameter(Mandatory)][int]$Total + [Parameter(Mandatory)][Drawing.Rectangle]$Bounds ) - $bitmap = [Drawing.Bitmap]::new($script:FrameWidth, $script:FrameHeight) + $bitmap = [Drawing.Bitmap]::new( + $Bounds.Width, + $Bounds.Height, + [Drawing.Imaging.PixelFormat]::Format24bppRgb + ) $graphics = [Drawing.Graphics]::FromImage($bitmap) - $headingFont = [Drawing.Font]::new('Segoe UI Semibold', 34, [Drawing.FontStyle]::Bold) - $bodyFont = [Drawing.Font]::new('Consolas', 22, [Drawing.FontStyle]::Regular) - $smallFont = [Drawing.Font]::new('Segoe UI', 16, [Drawing.FontStyle]::Regular) - $whiteBrush = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(245, 248, 252)) - $mutedBrush = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(174, 187, 204)) - $greenBrush = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(118, 219, 144)) - $panelBrush = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(30, 42, 58)) - $progressBrush = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(118, 185, 255)) try { - $graphics.SmoothingMode = [Drawing.Drawing2D.SmoothingMode]::AntiAlias - $graphics.TextRenderingHint = [Drawing.Text.TextRenderingHint]::ClearTypeGridFit - $graphics.Clear([Drawing.Color]::FromArgb(10, 18, 30)) - $graphics.FillRectangle($panelBrush, 48, 42, 1184, 610) - $graphics.FillRectangle($progressBrush, 48, 42, [int](1184 * $Index / $Total), 8) - $graphics.DrawString($Heading, $headingFont, $whiteBrush, 82, 82) - - $y = 170 - foreach ($line in $Lines) { - $brush = if ($line.StartsWith('[PASS]')) { $greenBrush } else { $whiteBrush } - $graphics.DrawString($line, $bodyFont, $brush, 90, $y) - $y += 53 - } - $graphics.DrawString( - "Live Windows ARM64 qualification evidence | frame $Index/$Total", - $smallFont, - $mutedBrush, - 82, - 670 - ) + $graphics.CopyFromScreen($Bounds.Location, [Drawing.Point]::Empty, $Bounds.Size) $bitmap.Save($Path, [Drawing.Imaging.ImageFormat]::Png) } finally { - $progressBrush.Dispose() - $panelBrush.Dispose() - $greenBrush.Dispose() - $mutedBrush.Dispose() - $whiteBrush.Dispose() - $smallFont.Dispose() - $bodyFont.Dispose() - $headingFont.Dispose() $graphics.Dispose() $bitmap.Dispose() } @@ -121,13 +83,26 @@ if ($CandidateSha -cnotmatch '^[a-f0-9]{40}$') { } if ([Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() -cne 'Arm64' -or [Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString() -cne 'Arm64') { - Fail-ProofVideo 'Proof video creation requires a native Windows ARM64 process.' + Fail-ProofVideo 'Screen recording requires a native Windows ARM64 process.' } +$candidate = [IO.Path]::GetFullPath($CandidateCheckout).TrimEnd('\') +if (-not (Test-Path -LiteralPath $candidate -PathType Container)) { + Fail-ProofVideo 'CandidateCheckout is missing.' +} +$msi = Resolve-RequiredFile -Path $MsiPath -Label 'MsiPath' +$setup = Resolve-RequiredFile -Path $SetupPath -Label 'SetupPath' $manifestPath = Resolve-RequiredFile -Path $PackageManifestPath -Label 'PackageManifestPath' $qualificationPath = Resolve-RequiredFile -Path $QualificationReceiptPath -Label 'QualificationReceiptPath' $hostPath = Resolve-RequiredFile -Path $HostReceiptPath -Label 'HostReceiptPath' $openshellPath = Resolve-RequiredFile -Path $OpenShellReceiptPath -Label 'OpenShellReceiptPath' +$qualificationScript = Resolve-RequiredFile ` + -Path (Join-Path $candidate 'scripts\checks\run-windows-native-package-qualification.ps1') ` + -Label 'Package qualification script' +$consoleDriver = Resolve-RequiredFile ` + -Path (Join-Path $candidate 'scripts\checks\run-windows-native-package-console-proof.ps1') ` + -Label 'Visible console proof driver' + $output = [IO.Path]::GetFullPath($OutputDirectory).TrimEnd('\') if (Test-Path -LiteralPath $output) { Fail-ProofVideo 'OutputDirectory must not already exist.' @@ -136,6 +111,10 @@ $outputParent = Split-Path -Parent $output if (-not (Test-Path -LiteralPath $outputParent -PathType Container)) { Fail-ProofVideo 'OutputDirectory parent must exist.' } +$consoleQualification = Join-Path $outputParent 'console-video-qualification' +if (Test-Path -LiteralPath $consoleQualification) { + Fail-ProofVideo 'Console qualification output already exists.' +} $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json $qualification = Get-Content -LiteralPath $qualificationPath -Raw | ConvertFrom-Json @@ -159,115 +138,91 @@ if (-not $qualification.repairRestoredDigest -or -not $qualification.finalAbsence -or -not $qualification.machinePathRemoved -or @($qualification.nativeExecutions).Count -ne 2 -or - @($qualification.msiRegistration).Count -ne 1 -or - @($qualification.bundleRegistration).Count -ne 1 -or @($qualification.packageDescendantProhibitedStarts).Count -ne 0 -or @($qualification.newPackageDescendantProhibitedProcesses).Count -ne 0) { - Fail-ProofVideo 'Qualification receipt is not a complete passing package lifecycle.' + Fail-ProofVideo 'Initial package qualification receipt is not a complete passing lifecycle.' } Add-Type -AssemblyName System.Drawing +Add-Type -AssemblyName System.Windows.Forms [IO.Directory]::CreateDirectory($output) | Out-Null -$frameRoot = Join-Path $env:RUNNER_TEMP ('nemoclaw-proof-frames-' + [guid]::NewGuid().ToString('N')) +$consoleTranscript = Join-Path $output 'live-console-transcript.txt' +$frameRoot = Join-Path $env:RUNNER_TEMP ('nemoclaw-console-frames-' + [guid]::NewGuid().ToString('N')) [IO.Directory]::CreateDirectory($frameRoot) | Out-Null +$proofProcess = $null try { - $msi = @($manifest.packages | Where-Object { $_.file -like '*.msi' }) - $setup = @($manifest.packages | Where-Object { $_.file -like '*.exe' }) - $cli = @($qualification.nativeExecutions | Where-Object { $_.file -ceq 'openshell.exe' }) - $gateway = @($qualification.nativeExecutions | Where-Object { $_.file -ceq 'openshell-gateway.exe' }) - if ($msi.Count -ne 1 -or $setup.Count -ne 1 -or $cli.Count -ne 1 -or $gateway.Count -ne 1) { - Fail-ProofVideo 'Package or native-execution evidence is ambiguous.' + $primaryScreen = [Windows.Forms.Screen]::PrimaryScreen + if ($null -eq $primaryScreen) { + Fail-ProofVideo 'The Windows runner has no primary desktop screen.' + } + $screenBounds = $primaryScreen.Bounds + if ($screenBounds.Width -lt 800 -or $screenBounds.Height -lt 600) { + Fail-ProofVideo "The Windows desktop is too small to record: $($screenBounds.Width)x$($screenBounds.Height)." } - $frames = @( - [pscustomobject]@{ - heading = 'NemoClaw Native Windows ARM64 - Proof of Life' - lines = @( - "[PASS] Exact NemoClaw head $($CandidateSha.Substring(0, 12))", - '[PASS] NVIDIA/OpenShell#2721 exact merge payload', - "Product version $ProductVersion | native candidate preview" - ) - }, - [pscustomobject]@{ - heading = 'Real native Windows ARM64 host' - lines = @( - "[PASS] $($hostReceipt.osDescription.Trim())", - "[PASS] OS architecture $($hostReceipt.osArchitecture)", - "[PASS] Process architecture $($hostReceipt.processArchitecture)", - "Runner $($hostReceipt.runnerName)" - ) - }, - [pscustomobject]@{ - heading = 'Literal downloadable Windows installer' - lines = @( - "[PASS] $($setup[0].file)", - "SHA-256 $(ConvertTo-DisplayDigest -Digest ([string]$setup[0].sha256))", - "[PASS] $($msi[0].file)", - "SHA-256 $(ConvertTo-DisplayDigest -Digest ([string]$msi[0].sha256))" - ) - }, - [pscustomobject]@{ - heading = 'Per-machine Windows Installer registration' - lines = @( - "[PASS] Installed under $($qualification.installRoot)", - "[PASS] MSI ARP: $($qualification.msiRegistration[0].displayName)", - "[PASS] Bundle ARP: $($qualification.bundleRegistration[0].displayName)", - '[PASS] Installed bin directory added to machine PATH' - ) - }, - [pscustomobject]@{ - heading = 'Native OpenShell execution' - lines = @( - "[PASS] openshell.exe --version -> $($cli[0].output)", - "Exit code $($cli[0].exitCode) | $(ConvertTo-DisplayDigest -Digest ([string]$cli[0].sha256))", - "[PASS] openshell-gateway.exe --version -> $($gateway[0].output)", - "Exit code $($gateway[0].exitCode) | $(ConvertTo-DisplayDigest -Digest ([string]$gateway[0].sha256))" - ) - }, - [pscustomobject]@{ - heading = 'Standard MSI lifecycle' - lines = @( - '[PASS] Deliberate file corruption repaired to source digest', - '[PASS] Same-version reinstall preserved one registration', - '[PASS] Windows Installer uninstall removed product files', - '[PASS] Bundle registration and machine PATH removed' - ) - }, - [pscustomobject]@{ - heading = 'No Linux dependency in the package path' - lines = @( - '[PASS] Zero WSL / Docker / Bash / Ubuntu descendants', - '[PASS] Zero package-introduced prohibited processes remained', - "Observed package descendants: $(@($qualification.packageDescendantStarts).Count)", - "Runner-owned new prohibited processes: $(@($qualification.newProhibitedProcesses).Count)" - ) - }, - [pscustomobject]@{ - heading = 'Candidate proven - production gates remain explicit' - lines = @( - '[PASS] Native package install / repair / uninstall proven', - '[PASS] Both ARM64 payload executables ran natively', - 'Deferred: real MXC + wxc-exec + gateway service', - 'Deferred: NemoClaw onboarding + production signing' - ) - } + $powershell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $proofArguments = @( + '-NoLogo', + '-NoProfile', + '-ExecutionPolicy', 'Bypass', + '-File', $consoleDriver, + '-QualificationScript', $qualificationScript, + '-ProductVersion', $ProductVersion, + '-MsiPath', $msi, + '-SetupPath', $setup, + '-PackageManifestPath', $manifestPath, + '-QualificationArtifactDirectory', $consoleQualification, + '-TranscriptPath', $consoleTranscript ) - if ($frames.Count -ne $script:ExpectedFrameCount) { - Fail-ProofVideo 'Unexpected proof frame count.' - } + $proofProcess = Start-Process ` + -FilePath $powershell ` + -ArgumentList $proofArguments ` + -WindowStyle Maximized ` + -PassThru ` + -ErrorAction Stop + Start-Sleep -Seconds 1 + $recordingClock = [Diagnostics.Stopwatch]::StartNew() $framePaths = @() - for ($index = 0; $index -lt $frames.Count; $index++) { - $framePath = Join-Path $frameRoot ('frame-{0:D2}.png' -f ($index + 1)) - New-ProofFrame ` - -Path $framePath ` - -Heading $frames[$index].heading ` - -Lines $frames[$index].lines ` - -Index ($index + 1) ` - -Total $frames.Count + while (-not $proofProcess.HasExited) { + if ($recordingClock.ElapsedMilliseconds -gt $script:MaximumRecordingMilliseconds) { + $proofProcess.Kill() + $proofProcess.WaitForExit() + Fail-ProofVideo 'Visible console qualification exceeded its recording timeout.' + } + $framePath = Join-Path $frameRoot ('frame-{0:D5}.png' -f ($framePaths.Count + 1)) + Save-DesktopFrame -Path $framePath -Bounds $screenBounds $framePaths += $framePath + Start-Sleep -Milliseconds $script:FrameDurationMilliseconds + $proofProcess.Refresh() + } + $proofProcess.WaitForExit() + $recordingClock.Stop() + $proofExitCode = $proofProcess.ExitCode + if ($proofExitCode -ne 0) { + Fail-ProofVideo "Visible console qualification failed with exit code $proofExitCode." + } + if ($framePaths.Count -lt $script:MinimumCaptureFrames) { + Fail-ProofVideo "The screen recording captured too few frames: $($framePaths.Count)." + } + if (-not (Test-Path -LiteralPath $consoleTranscript -PathType Leaf) -or + (Get-Item -LiteralPath $consoleTranscript).Length -eq 0) { + Fail-ProofVideo 'The visible console transcript is missing.' } + $frameHashes = @($framePaths | ForEach-Object { + (Get-FileHash -LiteralPath $_ -Algorithm SHA256).Hash + }) + $uniqueFrameCount = @($frameHashes | Sort-Object -Unique).Count + if ($uniqueFrameCount -lt $script:MinimumUniqueFrames) { + Fail-ProofVideo "The desktop capture is static or blank: only $uniqueFrameCount unique frames." + } + + $middleIndex = [int][Math]::Floor(($framePaths.Count - 1) / 2) + [IO.File]::Copy($framePaths[0], (Join-Path $output 'console-start.png'), $false) + [IO.File]::Copy($framePaths[$middleIndex], (Join-Path $output 'console-middle.png'), $false) + [IO.File]::Copy($framePaths[$framePaths.Count - 1], (Join-Path $output 'console-finish.png'), $false) + $programFilesX86 = [Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFilesX86) $unionMetadataRoot = Join-Path $programFilesX86 'Windows Kits\10\UnionMetadata' $windowsWinMd = @(Get-ChildItem -LiteralPath $unionMetadataRoot -Directory | Where-Object { @@ -280,8 +235,10 @@ try { } $runtimeDirectory = [Runtime.InteropServices.RuntimeEnvironment]::GetRuntimeDirectory() $runtimeWinRt = Join-Path $runtimeDirectory 'System.Runtime.WindowsRuntime.dll' - if (-not (Test-Path -LiteralPath $runtimeWinRt -PathType Leaf)) { - Fail-ProofVideo 'System.Runtime.WindowsRuntime.dll is missing.' + $compiler = Join-Path $runtimeDirectory 'csc.exe' + if (-not (Test-Path -LiteralPath $runtimeWinRt -PathType Leaf) -or + -not (Test-Path -LiteralPath $compiler -PathType Leaf)) { + Fail-ProofVideo 'The .NET Framework WinRT compiler support is missing.' } $frameworkReferenceRoot = Join-Path $programFilesX86 'Reference Assemblies\Microsoft\Framework\.NETFramework' $systemRuntimeFacade = @(Get-ChildItem -LiteralPath $frameworkReferenceRoot -Directory | Where-Object { @@ -302,7 +259,7 @@ using Windows.Media.MediaProperties; using Windows.Media.Transcoding; using Windows.Storage; -public static class NemoClawProofVideoEncoder +public static class NemoClawConsoleVideoEncoder { public static async Task RenderAsync( string[] imagePaths, @@ -337,12 +294,8 @@ public static class NemoClawProofVideoEncoder } } '@ - $compiler = Join-Path $runtimeDirectory 'csc.exe' - if (-not (Test-Path -LiteralPath $compiler -PathType Leaf)) { - Fail-ProofVideo 'The .NET Framework C# compiler is missing.' - } - $encoderSourcePath = Join-Path $frameRoot 'NemoClawProofVideoEncoder.cs' - $encoderAssemblyPath = Join-Path $frameRoot 'NemoClawProofVideoEncoder.dll' + $encoderSourcePath = Join-Path $frameRoot 'NemoClawConsoleVideoEncoder.cs' + $encoderAssemblyPath = Join-Path $frameRoot 'NemoClawConsoleVideoEncoder.dll' [IO.File]::WriteAllText($encoderSourcePath, $encoderSource, [Text.UTF8Encoding]::new($false)) $compilerArguments = @( '/nologo', @@ -355,13 +308,13 @@ public static class NemoClawProofVideoEncoder ) & $compiler @compilerArguments if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $encoderAssemblyPath -PathType Leaf)) { - Fail-ProofVideo 'The Windows Media Foundation encoder did not compile.' + Fail-ProofVideo 'The Windows Media Foundation console encoder did not compile.' } [Reflection.Assembly]::Load([IO.File]::ReadAllBytes($encoderAssemblyPath)) | Out-Null - $videoName = "NemoClaw-$ProductVersion-windows-arm64-proof-$($CandidateSha.Substring(0, 12)).mp4" + $videoName = "NemoClaw-$ProductVersion-windows-arm64-console-proof-$($CandidateSha.Substring(0, 12)).mp4" $videoPath = Join-Path $output $videoName - $renderTask = [NemoClawProofVideoEncoder]::RenderAsync( + $renderTask = [NemoClawConsoleVideoEncoder]::RenderAsync( [string[]]$framePaths, $script:FrameDurationMilliseconds, $videoPath @@ -370,36 +323,49 @@ public static class NemoClawProofVideoEncoder if ($renderedPath -cne $videoPath -or -not (Test-Path -LiteralPath $videoPath -PathType Leaf) -or (Get-Item -LiteralPath $videoPath).Length -lt 65536) { - Fail-ProofVideo 'Media Foundation did not produce the expected MP4.' + Fail-ProofVideo 'Media Foundation did not produce the expected console MP4.' } $videoBytes = [IO.File]::ReadAllBytes($videoPath) $headerText = [Text.Encoding]::ASCII.GetString($videoBytes, 0, [Math]::Min(64, $videoBytes.Length)) if ($headerText -notmatch 'ftyp') { - Fail-ProofVideo 'Rendered proof artifact is not an ISO base media file.' + Fail-ProofVideo 'Rendered console proof is not an ISO base media file.' } - [IO.File]::Copy($framePaths[0], (Join-Path $output 'proof-thumbnail.png'), $false) + $consoleQualificationReceipt = Resolve-RequiredFile ` + -Path (Join-Path $consoleQualification 'package-qualification.json') ` + -Label 'Recorded console qualification receipt' $receipt = [pscustomobject]@{ - schemaVersion = 1 - classification = 'native-windows-candidate-preview-proof-video' + schemaVersion = 2 + classification = 'native-windows-candidate-preview-console-screen-recording' candidateSha = $CandidateSha productVersion = $ProductVersion architecture = 'arm64' source = [pscustomobject]@{ packageManifestSha256 = (Get-FileHash -LiteralPath $manifestPath -Algorithm SHA256).Hash.ToLowerInvariant() - qualificationReceiptSha256 = (Get-FileHash -LiteralPath $qualificationPath -Algorithm SHA256).Hash.ToLowerInvariant() + initialQualificationReceiptSha256 = (Get-FileHash -LiteralPath $qualificationPath -Algorithm SHA256).Hash.ToLowerInvariant() + recordedQualificationReceiptSha256 = (Get-FileHash -LiteralPath $consoleQualificationReceipt -Algorithm SHA256).Hash.ToLowerInvariant() hostReceiptSha256 = (Get-FileHash -LiteralPath $hostPath -Algorithm SHA256).Hash.ToLowerInvariant() openshellReceiptSha256 = (Get-FileHash -LiteralPath $openshellPath -Algorithm SHA256).Hash.ToLowerInvariant() + consoleTranscriptSha256 = (Get-FileHash -LiteralPath $consoleTranscript -Algorithm SHA256).Hash.ToLowerInvariant() + } + capture = [pscustomobject]@{ + kind = 'actual Windows desktop screen capture of visible PowerShell console' + sourceWidth = $screenBounds.Width + sourceHeight = $screenBounds.Height + requestedFramesPerSecond = $script:CaptureFramesPerSecond + frameDurationMilliseconds = $script:FrameDurationMilliseconds + frameCount = $framePaths.Count + uniqueFrameCount = $uniqueFrameCount + recordingWallTimeMilliseconds = $recordingClock.ElapsedMilliseconds + qualificationExitCode = $proofExitCode } video = [pscustomobject]@{ file = $videoName container = 'mp4' encoder = 'Windows Media Foundation via Windows.Media.Editing' - width = $script:FrameWidth - height = $script:FrameHeight - frameCount = $frames.Count - frameDurationMilliseconds = $script:FrameDurationMilliseconds - expectedDurationMilliseconds = $frames.Count * $script:FrameDurationMilliseconds + outputWidth = 1280 + outputHeight = 720 + expectedDurationMilliseconds = $framePaths.Count * $script:FrameDurationMilliseconds sha256 = (Get-FileHash -LiteralPath $videoPath -Algorithm SHA256).Hash.ToLowerInvariant() bytes = (Get-Item -LiteralPath $videoPath).Length } @@ -409,8 +375,19 @@ public static class NemoClawProofVideoEncoder (($receipt | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false) ) - Write-Host "Windows native proof-of-life video: $videoPath" + Write-Host "Windows native console screen recording: $videoPath" } finally { + if ($null -ne $proofProcess) { + if (-not $proofProcess.HasExited) { + try { + $proofProcess.Kill() + $proofProcess.WaitForExit() + } catch { + Write-Warning "Could not stop visible proof console: $($_.Exception.Message)" + } + } + $proofProcess.Dispose() + } if (Test-Path -LiteralPath $frameRoot -PathType Container) { [IO.Directory]::Delete($frameRoot, $true) } diff --git a/scripts/checks/run-windows-native-package-console-proof.ps1 b/scripts/checks/run-windows-native-package-console-proof.ps1 new file mode 100644 index 00000000000..3cc055a5389 --- /dev/null +++ b/scripts/checks/run-windows-native-package-console-proof.ps1 @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +<# +.SYNOPSIS + Run the complete native Windows package qualification in a visible console. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$QualificationScript, + [Parameter(Mandatory)][string]$ProductVersion, + [Parameter(Mandatory)][string]$MsiPath, + [Parameter(Mandatory)][string]$SetupPath, + [Parameter(Mandatory)][string]$PackageManifestPath, + [Parameter(Mandatory)][string]$QualificationArtifactDirectory, + [Parameter(Mandatory)][string]$TranscriptPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$exitCode = 1 +$transcriptStarted = $false +try { + $rawUi = $Host.UI.RawUI + $rawUi.WindowTitle = 'NemoClaw Native Windows ARM64 Installer - Live Qualification' + $bufferSize = $rawUi.BufferSize + $bufferSize.Width = 140 + $bufferSize.Height = 3000 + $rawUi.BufferSize = $bufferSize +} catch { + # Window sizing is presentation-only; qualification remains authoritative. +} + +try { + Start-Transcript -LiteralPath $TranscriptPath -Force | Out-Null + $transcriptStarted = $true + Clear-Host + Write-Host 'NemoClaw Native Windows ARM64 Installer - LIVE CONSOLE PROOF' -ForegroundColor Cyan + Write-Host 'This window is executing the complete setup/install/repair/uninstall flow.' + Write-Host '' + Start-Sleep -Seconds 3 + + & $QualificationScript ` + -ProductVersion $ProductVersion ` + -MsiPath $MsiPath ` + -SetupPath $SetupPath ` + -PackageManifestPath $PackageManifestPath ` + -ArtifactDirectory $QualificationArtifactDirectory + + Write-Host '' + Write-Host '[PASS] LIVE CONSOLE PROOF COMPLETE' -ForegroundColor Green + $exitCode = 0 +} catch { + Write-Host '' + Write-Host "[FAIL] $($_.Exception.Message)" -ForegroundColor Red + $exitCode = 1 +} finally { + Start-Sleep -Seconds 4 + if ($transcriptStarted) { + Stop-Transcript | Out-Null + } +} + +exit $exitCode diff --git a/scripts/checks/run-windows-native-package-qualification.ps1 b/scripts/checks/run-windows-native-package-qualification.ps1 index 5152713fa03..be0a6f01848 100644 --- a/scripts/checks/run-windows-native-package-qualification.ps1 +++ b/scripts/checks/run-windows-native-package-qualification.ps1 @@ -77,10 +77,14 @@ function Invoke-BoundedProcess { [Parameter(Mandatory)][string]$FilePath, [Parameter(Mandatory)][string[]]$Arguments, [Parameter(Mandatory)][string]$Label, - [Parameter(Mandatory)][int[]]$AllowedExitCodes + [Parameter(Mandatory)][int[]]$AllowedExitCodes, + [switch]$SuppressProofOutput ) $argumentList = @($Arguments | ForEach-Object { ConvertTo-NativeArgument -Value $_ }) + if (-not $SuppressProofOutput) { + Write-Host "PS> $Label :: $(Split-Path -Leaf $FilePath) $($argumentList -join ' ')" + } $process = Start-Process -FilePath $FilePath -ArgumentList $argumentList -PassThru -ErrorAction Stop try { if (-not $process.WaitForExit($script:OperationTimeoutMilliseconds)) { @@ -95,6 +99,9 @@ function Invoke-BoundedProcess { if ($AllowedExitCodes -cnotcontains $exitCode) { Fail-PackageQualification "$Label failed with exit code $exitCode." } + if (-not $SuppressProofOutput) { + Write-Host "[PASS] $Label exit=$exitCode" + } return $exitCode } @@ -105,6 +112,7 @@ function Invoke-NativeVersionProbe { ) Assert-Arm64PortableExecutable -Path $Path -Label $Label + Write-Host "PS> $Label :: $(Split-Path -Leaf $Path) --version" $startInfo = [Diagnostics.ProcessStartInfo]::new() $startInfo.FileName = $Path $startInfo.Arguments = '--version' @@ -138,6 +146,8 @@ function Invoke-NativeVersionProbe { if ($exitCode -ne 0 -or [string]::IsNullOrWhiteSpace($output) -or $output.Length -gt 4096) { Fail-PackageQualification "$Label did not complete a bounded native version probe." } + Write-Host "OUTPUT> $($output -replace '[\r\n]+', ' | ')" + Write-Host "[PASS] $Label exit=$exitCode" return [pscustomobject]@{ file = Split-Path -Leaf $Path exitCode = $exitCode @@ -355,6 +365,9 @@ $preExecution = Get-ProhibitedProcessSnapshot -Phase 'pre-execution' $processAudit = Start-ProhibitedProcessAudit $processAuditStopped = $false +Write-Host "HOST> NemoClaw native Windows ARM64 package qualification" +Write-Host "HOST> os=$([Environment]::OSVersion.Version) architecture=$([Runtime.InteropServices.RuntimeInformation]::OSArchitecture) product=$ProductVersion" + try { Invoke-BoundedProcess ` -FilePath $setup ` @@ -371,6 +384,7 @@ try { (Get-FileHash -LiteralPath $gatewayPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $payloadHashes['openshell-gateway.exe']) { Fail-PackageQualification 'Installed payload digests do not match the package manifest.' } + Write-Host '[PASS] Setup installed the exact MSI-owned four-file tree' $nativeEvidence = @( Invoke-NativeVersionProbe -Path $openshellPath -Label 'Installed openshell.exe' Invoke-NativeVersionProbe -Path $gatewayPath -Label 'Installed openshell-gateway.exe' @@ -386,6 +400,8 @@ try { if (-not (Test-MachinePathContains -ExpectedPath $installBin)) { Fail-PackageQualification 'Machine PATH does not contain the installed bin directory exactly once.' } + Write-Host "[PASS] Add/Remove Programs registered MSI=$($msiArp[0].displayVersion) bundle=$($bundleArp[0].displayVersion)" + Write-Host '[PASS] Machine PATH contains the installed bin directory exactly once' [IO.File]::AppendAllText($openshellPath, 'msi-repair-drift', [Text.UTF8Encoding]::new($false)) Invoke-BoundedProcess ` @@ -397,6 +413,7 @@ try { Fail-PackageQualification 'MSI repair did not restore the corrupted OpenShell CLI.' } Assert-InstalledTree -Root $installRoot -Phase 'MSI repair' + Write-Host '[PASS] MSI repair restored the deliberately corrupted openshell.exe digest' Invoke-BoundedProcess ` -FilePath (Join-Path $env:SystemRoot 'System32\msiexec.exe') ` @@ -407,6 +424,7 @@ try { Fail-PackageQualification 'MSI reinstall did not preserve one product registration.' } Assert-InstalledTree -Root $installRoot -Phase 'MSI reinstall' + Write-Host '[PASS] MSI reinstall preserved exactly one product registration' Invoke-BoundedProcess ` -FilePath (Join-Path $env:SystemRoot 'System32\msiexec.exe') ` @@ -429,6 +447,7 @@ try { if (Test-MachinePathContains -ExpectedPath $installBin) { Fail-PackageQualification 'Machine PATH still contains the removed bin directory.' } + Write-Host '[PASS] Windows Installer uninstall removed files, registrations, and PATH' $auditResult = Stop-ProhibitedProcessAudit -SourceIdentifier $processAudit -RootProcessId $PID $processAuditStopped = $true @@ -460,6 +479,7 @@ try { if ($newPackageDescendantProhibitedProcesses.Count -ne 0) { Fail-PackageQualification 'A new prohibited package descendant remains after qualification.' } + Write-Host "[PASS] Zero prohibited package descendants; runner-wide prohibited starts recorded=$($prohibitedStarts.Count)" foreach ($logPath in @($bundleInstallLog, $msiRepairLog, $msiReinstallLog, $msiUninstallLog, $bundleUninstallLog)) { if (-not (Test-Path -LiteralPath $logPath -PathType Leaf) -or (Get-Item -LiteralPath $logPath).Length -eq 0) { @@ -503,6 +523,7 @@ try { (($receipt | ConvertTo-Json -Depth 12) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false) ) + Write-Host '[PASS] NATIVE WINDOWS PACKAGE QUALIFICATION COMPLETE' Write-Host "Windows native package qualification receipts: $artifactRoot" } finally { if (-not $processAuditStopped) { @@ -518,7 +539,8 @@ try { -FilePath (Join-Path $env:SystemRoot 'System32\msiexec.exe') ` -Arguments @('/x', $msi, '/qn', '/norestart') ` -Label 'Failure cleanup MSI uninstall' ` - -AllowedExitCodes @(0, 1605, 3010) | Out-Null + -AllowedExitCodes @(0, 1605, 3010) ` + -SuppressProofOutput | Out-Null } catch { Write-Warning "MSI failure cleanup did not complete: $($_.Exception.Message)" } @@ -528,7 +550,8 @@ try { -FilePath $setup ` -Arguments @('/uninstall', '/quiet', '/norestart') ` -Label 'Failure cleanup bundle uninstall' ` - -AllowedExitCodes @(0, 1605, 3010) | Out-Null + -AllowedExitCodes @(0, 1605, 3010) ` + -SuppressProofOutput | Out-Null } catch { Write-Warning "Bundle failure cleanup did not complete: $($_.Exception.Message)" } From 8d6365497e7cc04fdb40cdd9521b334464cd76d2 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 21:03:35 -0700 Subject: [PATCH 034/144] test(windows): record GitHub download and install --- .github/workflows/platform-vitest-main.yaml | 18 ++ .../create-windows-native-proof-video.ps1 | 195 ++++++++++++++---- ...n-windows-native-package-console-proof.ps1 | 57 ++++- 3 files changed, 218 insertions(+), 52 deletions(-) diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index 321a0cfa8b3..95288fd3d8f 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -22,6 +22,7 @@ on: - ".github/ISSUE_TEMPLATE/**" permissions: + actions: read contents: read concurrency: @@ -337,8 +338,21 @@ jobs: -PackageManifestPath "$packageRoot\package-manifest.json" ` -ArtifactDirectory "$packageRoot\qualification" + - name: Upload installer for the recorded GitHub download + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-native-download-source-${{ github.sha }} + path: | + ${{ steps.windows-package.outputs.package_root }}/NemoClaw-${{ steps.windows-package.outputs.product_version }}-windows-arm64.msi + ${{ steps.windows-package.outputs.package_root }}/NemoClawSetup-${{ steps.windows-package.outputs.product_version }}-windows-arm64.exe + ${{ steps.windows-package.outputs.package_root }}/package-manifest.json + if-no-files-found: error + retention-days: 14 + - name: Create native Windows proof-of-life video shell: powershell + env: + GH_TOKEN: ${{ github.token }} run: | Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' @@ -349,6 +363,10 @@ jobs: -CandidateCheckout $candidate ` -ProductVersion $version ` -CandidateSha $env:GITHUB_SHA ` + -GitHubRepository $env:GITHUB_REPOSITORY ` + -GitHubRunId $env:GITHUB_RUN_ID ` + -DownloadArtifactName "windows-native-download-source-$env:GITHUB_SHA" ` + -DesktopDownloadDirectory "$env:USERPROFILE\Desktop\NemoClawDownload" ` -MsiPath "$packageRoot\NemoClaw-$version-windows-arm64.msi" ` -SetupPath "$packageRoot\NemoClawSetup-$version-windows-arm64.exe" ` -PackageManifestPath "$packageRoot\package-manifest.json" ` diff --git a/scripts/checks/create-windows-native-proof-video.ps1 b/scripts/checks/create-windows-native-proof-video.ps1 index fa10d9a8421..88ce20bf889 100644 --- a/scripts/checks/create-windows-native-proof-video.ps1 +++ b/scripts/checks/create-windows-native-proof-video.ps1 @@ -3,13 +3,13 @@ <# .SYNOPSIS - Screen-record the complete native Windows installer qualification. + Record the live console running the native Windows installer qualification. .DESCRIPTION Launches the real setup/install/repair/reinstall/uninstall qualification - in a visible maximized PowerShell console, captures the actual Windows - desktop four times per second, and encodes those captured frames to H.264 - with the Windows Media Foundation-backed Windows.Media.Editing API. + in PowerShell, samples its actual evolving console output four times per + second, renders the scrolling console buffer, and encodes those live frames + to H.264 with the Windows Media Foundation-backed Windows.Media.Editing API. #> [CmdletBinding()] @@ -17,6 +17,10 @@ param( [Parameter(Mandatory)][string]$CandidateCheckout, [Parameter(Mandatory)][string]$ProductVersion, [Parameter(Mandatory)][string]$CandidateSha, + [Parameter(Mandatory)][string]$GitHubRepository, + [Parameter(Mandatory)][string]$GitHubRunId, + [Parameter(Mandatory)][string]$DownloadArtifactName, + [Parameter(Mandatory)][string]$DesktopDownloadDirectory, [Parameter(Mandatory)][string]$MsiPath, [Parameter(Mandatory)][string]$SetupPath, [Parameter(Mandatory)][string]$PackageManifestPath, @@ -54,22 +58,101 @@ function Resolve-RequiredFile { return $resolved } -function Save-DesktopFrame { +function ConvertTo-BoundedConsoleLines { param( - [Parameter(Mandatory)][string]$Path, - [Parameter(Mandatory)][Drawing.Rectangle]$Bounds + [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Lines, + [Parameter(Mandatory)][int]$MaximumCharacters ) - $bitmap = [Drawing.Bitmap]::new( - $Bounds.Width, - $Bounds.Height, - [Drawing.Imaging.PixelFormat]::Format24bppRgb + $bounded = @() + foreach ($line in $Lines) { + $remaining = $line + while ($remaining.Length -gt $MaximumCharacters) { + $splitAt = $remaining.LastIndexOf(' ', $MaximumCharacters) + if ($splitAt -lt 24) { + $splitAt = $MaximumCharacters + } + $bounded += $remaining.Substring(0, $splitAt).TrimEnd() + $remaining = ' ' + $remaining.Substring($splitAt).TrimStart() + } + $bounded += $remaining + } + return @($bounded) +} + +function Save-ConsoleFrame { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Lines, + [Parameter(Mandatory)][int]$FrameNumber, + [Parameter(Mandatory)][long]$ElapsedMilliseconds ) + + $bitmap = [Drawing.Bitmap]::new(1280, 720, [Drawing.Imaging.PixelFormat]::Format24bppRgb) $graphics = [Drawing.Graphics]::FromImage($bitmap) + $titleFont = [Drawing.Font]::new('Consolas', 18, [Drawing.FontStyle]::Bold) + $consoleFont = [Drawing.Font]::new('Consolas', 15, [Drawing.FontStyle]::Regular) + $footerFont = [Drawing.Font]::new('Consolas', 11, [Drawing.FontStyle]::Regular) + $backgroundBrush = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(10, 12, 16)) + $titleBrush = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(225, 230, 238)) + $textBrush = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(220, 224, 230)) + $commandBrush = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(102, 194, 255)) + $passBrush = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(94, 220, 126)) + $hostBrush = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(252, 210, 92)) + $failBrush = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(255, 108, 108)) + $footerBrush = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(145, 154, 168)) try { - $graphics.CopyFromScreen($Bounds.Location, [Drawing.Point]::Empty, $Bounds.Size) + $graphics.TextRenderingHint = [Drawing.Text.TextRenderingHint]::ClearTypeGridFit + $graphics.FillRectangle($backgroundBrush, 0, 0, 1280, 720) + $graphics.DrawString( + 'PowerShell - NemoClaw native Windows ARM64 installer - LIVE', + $titleFont, + $titleBrush, + 28, + 18 + ) + $graphics.DrawLine([Drawing.Pens]::DimGray, 24, 54, 1256, 54) + + $boundedLines = @(ConvertTo-BoundedConsoleLines -Lines $Lines -MaximumCharacters 120) + if ($boundedLines.Count -gt 27) { + $boundedLines = @($boundedLines[($boundedLines.Count - 27)..($boundedLines.Count - 1)]) + } + $y = 68 + foreach ($line in $boundedLines) { + $brush = if ($line.StartsWith('[PASS]')) { + $passBrush + } elseif ($line.StartsWith('[FAIL]')) { + $failBrush + } elseif ($line.StartsWith('PS>')) { + $commandBrush + } elseif ($line.StartsWith('HOST>')) { + $hostBrush + } else { + $textBrush + } + $graphics.DrawString($line, $consoleFont, $brush, 28, $y) + $y += 22 + } + $graphics.DrawString( + "LIVE CAPTURE | 4 fps | frame $FrameNumber | elapsed $([Math]::Round($ElapsedMilliseconds / 1000, 2)) s", + $footerFont, + $footerBrush, + 28, + 690 + ) $bitmap.Save($Path, [Drawing.Imaging.ImageFormat]::Png) } finally { + $footerBrush.Dispose() + $failBrush.Dispose() + $hostBrush.Dispose() + $passBrush.Dispose() + $commandBrush.Dispose() + $textBrush.Dispose() + $titleBrush.Dispose() + $backgroundBrush.Dispose() + $footerFont.Dispose() + $consoleFont.Dispose() + $titleFont.Dispose() $graphics.Dispose() $bitmap.Dispose() } @@ -81,6 +164,11 @@ if ($ProductVersion -cnotmatch '^[0-9]{1,3}\.[0-9]{1,5}\.[0-9]{1,5}$') { if ($CandidateSha -cnotmatch '^[a-f0-9]{40}$') { Fail-ProofVideo 'CandidateSha must be a full lowercase Git revision.' } +if ($GitHubRepository -cnotmatch '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$' -or + $GitHubRunId -cnotmatch '^[0-9]+$' -or + $DownloadArtifactName -cnotmatch '^[A-Za-z0-9._-]+$') { + Fail-ProofVideo 'GitHub artifact download authority is invalid.' +} if ([Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() -cne 'Arm64' -or [Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString() -cne 'Arm64') { Fail-ProofVideo 'Screen recording requires a native Windows ARM64 process.' @@ -115,6 +203,10 @@ $consoleQualification = Join-Path $outputParent 'console-video-qualification' if (Test-Path -LiteralPath $consoleQualification) { Fail-ProofVideo 'Console qualification output already exists.' } +$desktopDownload = [IO.Path]::GetFullPath($DesktopDownloadDirectory).TrimEnd('\') +if (Test-Path -LiteralPath $desktopDownload) { + Fail-ProofVideo 'DesktopDownloadDirectory must not already exist.' +} $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json $qualification = Get-Content -LiteralPath $qualificationPath -Raw | ConvertFrom-Json @@ -144,22 +236,14 @@ if (-not $qualification.repairRestoredDigest -or } Add-Type -AssemblyName System.Drawing -Add-Type -AssemblyName System.Windows.Forms [IO.Directory]::CreateDirectory($output) | Out-Null $consoleTranscript = Join-Path $output 'live-console-transcript.txt' +$consoleOutput = Join-Path $output 'live-console-output.txt' +$consoleError = Join-Path $output 'live-console-error.txt' $frameRoot = Join-Path $env:RUNNER_TEMP ('nemoclaw-console-frames-' + [guid]::NewGuid().ToString('N')) [IO.Directory]::CreateDirectory($frameRoot) | Out-Null $proofProcess = $null try { - $primaryScreen = [Windows.Forms.Screen]::PrimaryScreen - if ($null -eq $primaryScreen) { - Fail-ProofVideo 'The Windows runner has no primary desktop screen.' - } - $screenBounds = $primaryScreen.Bounds - if ($screenBounds.Width -lt 800 -or $screenBounds.Height -lt 600) { - Fail-ProofVideo "The Windows desktop is too small to record: $($screenBounds.Width)x$($screenBounds.Height)." - } - $powershell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' $proofArguments = @( '-NoLogo', @@ -168,30 +252,51 @@ try { '-File', $consoleDriver, '-QualificationScript', $qualificationScript, '-ProductVersion', $ProductVersion, - '-MsiPath', $msi, - '-SetupPath', $setup, - '-PackageManifestPath', $manifestPath, + '-GitHubRepository', $GitHubRepository, + '-GitHubRunId', $GitHubRunId, + '-DownloadArtifactName', $DownloadArtifactName, + '-DesktopDownloadDirectory', $desktopDownload, + '-ExpectedMsiPath', $msi, + '-ExpectedSetupPath', $setup, + '-ExpectedManifestPath', $manifestPath, '-QualificationArtifactDirectory', $consoleQualification, '-TranscriptPath', $consoleTranscript ) $proofProcess = Start-Process ` -FilePath $powershell ` -ArgumentList $proofArguments ` - -WindowStyle Maximized ` + -NoNewWindow ` + -RedirectStandardOutput $consoleOutput ` + -RedirectStandardError $consoleError ` -PassThru ` -ErrorAction Stop - Start-Sleep -Seconds 1 $recordingClock = [Diagnostics.Stopwatch]::StartNew() $framePaths = @() + $consoleSnapshots = @() while (-not $proofProcess.HasExited) { if ($recordingClock.ElapsedMilliseconds -gt $script:MaximumRecordingMilliseconds) { $proofProcess.Kill() $proofProcess.WaitForExit() Fail-ProofVideo 'Visible console qualification exceeded its recording timeout.' } + $consoleContent = '' + if (Test-Path -LiteralPath $consoleOutput -PathType Leaf) { + try { + $consoleContent = [IO.File]::ReadAllText($consoleOutput) + } catch { + # The child may be flushing the file; the next 250 ms sample retries. + } + } + $consoleContent = $consoleContent -replace "$([char]27)\[[0-9;?]*[ -/]*[@-~]", '' + $consoleLines = @($consoleContent -split '\r?\n') + $consoleSnapshots += $consoleContent $framePath = Join-Path $frameRoot ('frame-{0:D5}.png' -f ($framePaths.Count + 1)) - Save-DesktopFrame -Path $framePath -Bounds $screenBounds + Save-ConsoleFrame ` + -Path $framePath ` + -Lines $consoleLines ` + -FrameNumber ($framePaths.Count + 1) ` + -ElapsedMilliseconds $recordingClock.ElapsedMilliseconds $framePaths += $framePath Start-Sleep -Milliseconds $script:FrameDurationMilliseconds $proofProcess.Refresh() @@ -200,22 +305,24 @@ try { $recordingClock.Stop() $proofExitCode = $proofProcess.ExitCode if ($proofExitCode -ne 0) { - Fail-ProofVideo "Visible console qualification failed with exit code $proofExitCode." + $failureText = if (Test-Path -LiteralPath $consoleError -PathType Leaf) { + [IO.File]::ReadAllText($consoleError).Trim() + } else { + '' + } + Fail-ProofVideo "Live console qualification failed with exit code $proofExitCode. $failureText" } if ($framePaths.Count -lt $script:MinimumCaptureFrames) { - Fail-ProofVideo "The screen recording captured too few frames: $($framePaths.Count)." + Fail-ProofVideo "The live console recording captured too few frames: $($framePaths.Count)." } if (-not (Test-Path -LiteralPath $consoleTranscript -PathType Leaf) -or (Get-Item -LiteralPath $consoleTranscript).Length -eq 0) { - Fail-ProofVideo 'The visible console transcript is missing.' + Fail-ProofVideo 'The live console transcript is missing.' } - $frameHashes = @($framePaths | ForEach-Object { - (Get-FileHash -LiteralPath $_ -Algorithm SHA256).Hash - }) - $uniqueFrameCount = @($frameHashes | Sort-Object -Unique).Count - if ($uniqueFrameCount -lt $script:MinimumUniqueFrames) { - Fail-ProofVideo "The desktop capture is static or blank: only $uniqueFrameCount unique frames." + $uniqueSnapshotCount = @($consoleSnapshots | Sort-Object -Unique).Count + if ($uniqueSnapshotCount -lt $script:MinimumUniqueFrames) { + Fail-ProofVideo "The live console did not change enough to prove the install: only $uniqueSnapshotCount unique states." } $middleIndex = [int][Math]::Floor(($framePaths.Count - 1) / 2) @@ -336,7 +443,7 @@ public static class NemoClawConsoleVideoEncoder -Label 'Recorded console qualification receipt' $receipt = [pscustomobject]@{ schemaVersion = 2 - classification = 'native-windows-candidate-preview-console-screen-recording' + classification = 'native-windows-candidate-preview-live-console-recording' candidateSha = $CandidateSha productVersion = $ProductVersion architecture = 'arm64' @@ -349,13 +456,13 @@ public static class NemoClawConsoleVideoEncoder consoleTranscriptSha256 = (Get-FileHash -LiteralPath $consoleTranscript -Algorithm SHA256).Hash.ToLowerInvariant() } capture = [pscustomobject]@{ - kind = 'actual Windows desktop screen capture of visible PowerShell console' - sourceWidth = $screenBounds.Width - sourceHeight = $screenBounds.Height + kind = 'live PowerShell console output sampled while complete installer qualification executes' + sourceWidth = 1280 + sourceHeight = 720 requestedFramesPerSecond = $script:CaptureFramesPerSecond frameDurationMilliseconds = $script:FrameDurationMilliseconds frameCount = $framePaths.Count - uniqueFrameCount = $uniqueFrameCount + uniqueConsoleStateCount = $uniqueSnapshotCount recordingWallTimeMilliseconds = $recordingClock.ElapsedMilliseconds qualificationExitCode = $proofExitCode } @@ -375,7 +482,7 @@ public static class NemoClawConsoleVideoEncoder (($receipt | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false) ) - Write-Host "Windows native console screen recording: $videoPath" + Write-Host "Windows native live console recording: $videoPath" } finally { if ($null -ne $proofProcess) { if (-not $proofProcess.HasExited) { @@ -383,7 +490,7 @@ public static class NemoClawConsoleVideoEncoder $proofProcess.Kill() $proofProcess.WaitForExit() } catch { - Write-Warning "Could not stop visible proof console: $($_.Exception.Message)" + Write-Warning "Could not stop live proof console: $($_.Exception.Message)" } } $proofProcess.Dispose() diff --git a/scripts/checks/run-windows-native-package-console-proof.ps1 b/scripts/checks/run-windows-native-package-console-proof.ps1 index 3cc055a5389..f2a6616828f 100644 --- a/scripts/checks/run-windows-native-package-console-proof.ps1 +++ b/scripts/checks/run-windows-native-package-console-proof.ps1 @@ -3,16 +3,20 @@ <# .SYNOPSIS - Run the complete native Windows package qualification in a visible console. + Run the complete native Windows package qualification in a live console. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$QualificationScript, [Parameter(Mandatory)][string]$ProductVersion, - [Parameter(Mandatory)][string]$MsiPath, - [Parameter(Mandatory)][string]$SetupPath, - [Parameter(Mandatory)][string]$PackageManifestPath, + [Parameter(Mandatory)][string]$GitHubRepository, + [Parameter(Mandatory)][string]$GitHubRunId, + [Parameter(Mandatory)][string]$DownloadArtifactName, + [Parameter(Mandatory)][string]$DesktopDownloadDirectory, + [Parameter(Mandatory)][string]$ExpectedMsiPath, + [Parameter(Mandatory)][string]$ExpectedSetupPath, + [Parameter(Mandatory)][string]$ExpectedManifestPath, [Parameter(Mandatory)][string]$QualificationArtifactDirectory, [Parameter(Mandatory)][string]$TranscriptPath ) @@ -40,13 +44,50 @@ try { Write-Host 'NemoClaw Native Windows ARM64 Installer - LIVE CONSOLE PROOF' -ForegroundColor Cyan Write-Host 'This window is executing the complete setup/install/repair/uninstall flow.' Write-Host '' - Start-Sleep -Seconds 3 + Start-Sleep -Seconds 2 + + $gh = (Get-Command 'gh.exe' -ErrorAction Stop).Source + Write-Host "PS> gh run download $GitHubRunId --repo $GitHubRepository --name $DownloadArtifactName --dir $DesktopDownloadDirectory" + [IO.Directory]::CreateDirectory($DesktopDownloadDirectory) | Out-Null + & $gh run download $GitHubRunId ` + --repo $GitHubRepository ` + --name $DownloadArtifactName ` + --dir $DesktopDownloadDirectory + if ($LASTEXITCODE -ne 0) { + throw "GitHub artifact download failed with exit code $LASTEXITCODE." + } + Write-Host '[PASS] Installer artifact downloaded from GitHub Actions to the Windows Desktop' -ForegroundColor Green + + $downloadedMsi = Join-Path $DesktopDownloadDirectory (Split-Path -Leaf $ExpectedMsiPath) + $downloadedSetup = Join-Path $DesktopDownloadDirectory (Split-Path -Leaf $ExpectedSetupPath) + $downloadedManifest = Join-Path $DesktopDownloadDirectory (Split-Path -Leaf $ExpectedManifestPath) + foreach ($downloadedFile in @($downloadedSetup, $downloadedMsi, $downloadedManifest)) { + if (-not (Test-Path -LiteralPath $downloadedFile -PathType Leaf)) { + throw "Downloaded artifact is missing $(Split-Path -Leaf $downloadedFile)." + } + } + if ((Get-FileHash -LiteralPath $downloadedMsi -Algorithm SHA256).Hash -cne + (Get-FileHash -LiteralPath $ExpectedMsiPath -Algorithm SHA256).Hash -or + (Get-FileHash -LiteralPath $downloadedSetup -Algorithm SHA256).Hash -cne + (Get-FileHash -LiteralPath $ExpectedSetupPath -Algorithm SHA256).Hash -or + (Get-FileHash -LiteralPath $downloadedManifest -Algorithm SHA256).Hash -cne + (Get-FileHash -LiteralPath $ExpectedManifestPath -Algorithm SHA256).Hash) { + throw 'Downloaded artifact digests do not match the package just built on this runner.' + } + + Write-Host "PS> Get-ChildItem $DesktopDownloadDirectory" + foreach ($downloadedFile in @(Get-ChildItem -LiteralPath $DesktopDownloadDirectory -File | Sort-Object Name)) { + Write-Host ("OUTPUT> {0,-58} {1,12} bytes" -f $downloadedFile.Name, $downloadedFile.Length) + } + Write-Host '[PASS] Downloaded EXE, MSI, and manifest match the GitHub artifact digests' -ForegroundColor Green + Write-Host '' + Write-Host "PS> Launch downloaded app: $downloadedSetup /install /quiet /norestart" & $QualificationScript ` -ProductVersion $ProductVersion ` - -MsiPath $MsiPath ` - -SetupPath $SetupPath ` - -PackageManifestPath $PackageManifestPath ` + -MsiPath $downloadedMsi ` + -SetupPath $downloadedSetup ` + -PackageManifestPath $downloadedManifest ` -ArtifactDirectory $QualificationArtifactDirectory Write-Host '' From 1042a543f64e1346241b91162820217b7c8b39b0 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 21:21:43 -0700 Subject: [PATCH 035/144] fix(test): allow initial live console frame --- scripts/checks/create-windows-native-proof-video.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/checks/create-windows-native-proof-video.ps1 b/scripts/checks/create-windows-native-proof-video.ps1 index 88ce20bf889..3972aba03e8 100644 --- a/scripts/checks/create-windows-native-proof-video.ps1 +++ b/scripts/checks/create-windows-native-proof-video.ps1 @@ -60,7 +60,7 @@ function Resolve-RequiredFile { function ConvertTo-BoundedConsoleLines { param( - [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Lines, + [Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]]$Lines, [Parameter(Mandatory)][int]$MaximumCharacters ) @@ -83,7 +83,7 @@ function ConvertTo-BoundedConsoleLines { function Save-ConsoleFrame { param( [Parameter(Mandatory)][string]$Path, - [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Lines, + [Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]]$Lines, [Parameter(Mandatory)][int]$FrameNumber, [Parameter(Mandatory)][long]$ElapsedMilliseconds ) From 3645738a3e073dbc4cee582e19ff4aff2c2f785a Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 21:29:25 -0700 Subject: [PATCH 036/144] test(windows): capture real installer windows --- .../create-windows-native-proof-video.ps1 | 309 +++++++++++------- ...n-windows-native-package-console-proof.ps1 | 9 +- ...n-windows-native-package-qualification.ps1 | 10 +- 3 files changed, 209 insertions(+), 119 deletions(-) diff --git a/scripts/checks/create-windows-native-proof-video.ps1 b/scripts/checks/create-windows-native-proof-video.ps1 index 3972aba03e8..bad31cf6f2a 100644 --- a/scripts/checks/create-windows-native-proof-video.ps1 +++ b/scripts/checks/create-windows-native-proof-video.ps1 @@ -7,9 +7,9 @@ .DESCRIPTION Launches the real setup/install/repair/reinstall/uninstall qualification - in PowerShell, samples its actual evolving console output four times per - second, renders the scrolling console buffer, and encodes those live frames - to H.264 with the Windows Media Foundation-backed Windows.Media.Editing API. + in a real Windows console, captures the actual console and WiX installer + window pixels four times per second, and encodes those live frames to H.264 + with the Windows Media Foundation-backed Windows.Media.Editing API. #> [CmdletBinding()] @@ -58,103 +58,166 @@ function Resolve-RequiredFile { return $resolved } -function ConvertTo-BoundedConsoleLines { - param( - [Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]]$Lines, - [Parameter(Mandatory)][int]$MaximumCharacters - ) +function Initialize-NativeWindowCapture { + Add-Type -AssemblyName System.Drawing + $captureSource = @' +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Runtime.InteropServices; +using System.Text; + +public static class NemoClawNativeWindowCapture +{ + private delegate bool EnumWindowsProc(IntPtr hwnd, IntPtr lparam); + + [StructLayout(LayoutKind.Sequential)] + private struct Rect + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + [DllImport("user32.dll")] + private static extern bool EnumWindows(EnumWindowsProc callback, IntPtr lparam); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetWindowText(IntPtr hwnd, StringBuilder text, int maximumCount); + + [DllImport("user32.dll")] + private static extern int GetWindowTextLength(IntPtr hwnd); + + [DllImport("user32.dll")] + private static extern bool GetWindowRect(IntPtr hwnd, out Rect rect); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool PrintWindow(IntPtr hwnd, IntPtr hdc, uint flags); - $bounded = @() - foreach ($line in $Lines) { - $remaining = $line - while ($remaining.Length -gt $MaximumCharacters) { - $splitAt = $remaining.LastIndexOf(' ', $MaximumCharacters) - if ($splitAt -lt 24) { - $splitAt = $MaximumCharacters + public static IntPtr FindWindowContaining(string titleFragment, IntPtr excluded) + { + IntPtr found = IntPtr.Zero; + EnumWindows(delegate(IntPtr hwnd, IntPtr lparam) + { + if (hwnd == excluded) + { + return true; + } + int length = GetWindowTextLength(hwnd); + if (length <= 0) + { + return true; + } + var title = new StringBuilder(length + 1); + GetWindowText(hwnd, title, title.Capacity); + if (title.ToString().IndexOf(titleFragment, StringComparison.OrdinalIgnoreCase) >= 0) + { + found = hwnd; + return false; } - $bounded += $remaining.Substring(0, $splitAt).TrimEnd() - $remaining = ' ' + $remaining.Substring($splitAt).TrimStart() + return true; + }, IntPtr.Zero); + return found; + } + + public static string[] ListWindowTitles() + { + var titles = new List(); + EnumWindows(delegate(IntPtr hwnd, IntPtr lparam) + { + int length = GetWindowTextLength(hwnd); + if (length > 0) + { + var title = new StringBuilder(length + 1); + GetWindowText(hwnd, title, title.Capacity); + titles.Add(title.ToString()); + } + return true; + }, IntPtr.Zero); + return titles.ToArray(); + } + + public static Bitmap Capture(IntPtr hwnd) + { + Rect rect; + if (hwnd == IntPtr.Zero || !GetWindowRect(hwnd, out rect)) + { + throw new InvalidOperationException("Window handle is unavailable."); } - $bounded += $remaining + int width = rect.Right - rect.Left; + int height = rect.Bottom - rect.Top; + if (width < 64 || height < 64) + { + throw new InvalidOperationException("Window is too small to record."); + } + + var bitmap = new Bitmap(width, height, System.Drawing.Imaging.PixelFormat.Format24bppRgb); + using (var graphics = Graphics.FromImage(bitmap)) + { + IntPtr hdc = graphics.GetHdc(); + try + { + if (!PrintWindow(hwnd, hdc, 2)) + { + throw new InvalidOperationException("PrintWindow failed."); + } + } + finally + { + graphics.ReleaseHdc(hdc); + } + } + return bitmap; } - return @($bounded) +} +'@ + Add-Type ` + -TypeDefinition $captureSource ` + -Language CSharp ` + -ReferencedAssemblies @([Drawing.Bitmap].Assembly.Location) } -function Save-ConsoleFrame { +function Save-ActualWindowFrame { param( [Parameter(Mandatory)][string]$Path, - [Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]]$Lines, - [Parameter(Mandatory)][int]$FrameNumber, - [Parameter(Mandatory)][long]$ElapsedMilliseconds + [Parameter(Mandatory)][IntPtr]$ConsoleWindow, + [Parameter(Mandatory)][IntPtr]$InstallerWindow ) - $bitmap = [Drawing.Bitmap]::new(1280, 720, [Drawing.Imaging.PixelFormat]::Format24bppRgb) - $graphics = [Drawing.Graphics]::FromImage($bitmap) - $titleFont = [Drawing.Font]::new('Consolas', 18, [Drawing.FontStyle]::Bold) - $consoleFont = [Drawing.Font]::new('Consolas', 15, [Drawing.FontStyle]::Regular) - $footerFont = [Drawing.Font]::new('Consolas', 11, [Drawing.FontStyle]::Regular) - $backgroundBrush = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(10, 12, 16)) - $titleBrush = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(225, 230, 238)) - $textBrush = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(220, 224, 230)) - $commandBrush = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(102, 194, 255)) - $passBrush = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(94, 220, 126)) - $hostBrush = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(252, 210, 92)) - $failBrush = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(255, 108, 108)) - $footerBrush = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(145, 154, 168)) + $consoleBitmap = [NemoClawNativeWindowCapture]::Capture($ConsoleWindow) + $installerBitmap = $null + if ($InstallerWindow -ne [IntPtr]::Zero) { + $installerBitmap = [NemoClawNativeWindowCapture]::Capture($InstallerWindow) + } + $frame = [Drawing.Bitmap]::new(1280, 720, [Drawing.Imaging.PixelFormat]::Format24bppRgb) + $graphics = [Drawing.Graphics]::FromImage($frame) try { - $graphics.TextRenderingHint = [Drawing.Text.TextRenderingHint]::ClearTypeGridFit - $graphics.FillRectangle($backgroundBrush, 0, 0, 1280, 720) - $graphics.DrawString( - 'PowerShell - NemoClaw native Windows ARM64 installer - LIVE', - $titleFont, - $titleBrush, - 28, - 18 - ) - $graphics.DrawLine([Drawing.Pens]::DimGray, 24, 54, 1256, 54) + $graphics.Clear([Drawing.Color]::Black) + $consoleScale = [Math]::Min(1280 / $consoleBitmap.Width, 720 / $consoleBitmap.Height) + $consoleWidth = [int]($consoleBitmap.Width * $consoleScale) + $consoleHeight = [int]($consoleBitmap.Height * $consoleScale) + $consoleX = [int]((1280 - $consoleWidth) / 2) + $consoleY = [int]((720 - $consoleHeight) / 2) + $graphics.DrawImage($consoleBitmap, $consoleX, $consoleY, $consoleWidth, $consoleHeight) - $boundedLines = @(ConvertTo-BoundedConsoleLines -Lines $Lines -MaximumCharacters 120) - if ($boundedLines.Count -gt 27) { - $boundedLines = @($boundedLines[($boundedLines.Count - 27)..($boundedLines.Count - 1)]) - } - $y = 68 - foreach ($line in $boundedLines) { - $brush = if ($line.StartsWith('[PASS]')) { - $passBrush - } elseif ($line.StartsWith('[FAIL]')) { - $failBrush - } elseif ($line.StartsWith('PS>')) { - $commandBrush - } elseif ($line.StartsWith('HOST>')) { - $hostBrush - } else { - $textBrush - } - $graphics.DrawString($line, $consoleFont, $brush, 28, $y) - $y += 22 + if ($null -ne $installerBitmap) { + $installerScale = [Math]::Min(620 / $installerBitmap.Width, 620 / $installerBitmap.Height) + $installerWidth = [int]($installerBitmap.Width * $installerScale) + $installerHeight = [int]($installerBitmap.Height * $installerScale) + $installerX = [int]((1280 - $installerWidth) / 2) + $installerY = [int]((720 - $installerHeight) / 2) + $graphics.FillRectangle([Drawing.Brushes]::Black, $installerX - 8, $installerY - 8, $installerWidth + 16, $installerHeight + 16) + $graphics.DrawImage($installerBitmap, $installerX, $installerY, $installerWidth, $installerHeight) } - $graphics.DrawString( - "LIVE CAPTURE | 4 fps | frame $FrameNumber | elapsed $([Math]::Round($ElapsedMilliseconds / 1000, 2)) s", - $footerFont, - $footerBrush, - 28, - 690 - ) - $bitmap.Save($Path, [Drawing.Imaging.ImageFormat]::Png) + $frame.Save($Path, [Drawing.Imaging.ImageFormat]::Png) } finally { - $footerBrush.Dispose() - $failBrush.Dispose() - $hostBrush.Dispose() - $passBrush.Dispose() - $commandBrush.Dispose() - $textBrush.Dispose() - $titleBrush.Dispose() - $backgroundBrush.Dispose() - $footerFont.Dispose() - $consoleFont.Dispose() - $titleFont.Dispose() $graphics.Dispose() - $bitmap.Dispose() + $frame.Dispose() + if ($null -ne $installerBitmap) { + $installerBitmap.Dispose() + } + $consoleBitmap.Dispose() } } @@ -236,10 +299,9 @@ if (-not $qualification.repairRestoredDigest -or } Add-Type -AssemblyName System.Drawing +Initialize-NativeWindowCapture [IO.Directory]::CreateDirectory($output) | Out-Null $consoleTranscript = Join-Path $output 'live-console-transcript.txt' -$consoleOutput = Join-Path $output 'live-console-output.txt' -$consoleError = Join-Path $output 'live-console-error.txt' $frameRoot = Join-Path $env:RUNNER_TEMP ('nemoclaw-console-frames-' + [guid]::NewGuid().ToString('N')) [IO.Directory]::CreateDirectory($frameRoot) | Out-Null $proofProcess = $null @@ -265,38 +327,48 @@ try { $proofProcess = Start-Process ` -FilePath $powershell ` -ArgumentList $proofArguments ` - -NoNewWindow ` - -RedirectStandardOutput $consoleOutput ` - -RedirectStandardError $consoleError ` + -WindowStyle Normal ` -PassThru ` -ErrorAction Stop + $consoleWindowTitle = 'NemoClaw Native Windows ARM64 Installer - Live Qualification' + $consoleWindow = [IntPtr]::Zero + $windowDeadline = [DateTime]::UtcNow.AddSeconds(12) + while ($consoleWindow -eq [IntPtr]::Zero -and [DateTime]::UtcNow -lt $windowDeadline) { + $consoleWindow = [NemoClawNativeWindowCapture]::FindWindowContaining( + $consoleWindowTitle, + [IntPtr]::Zero + ) + if ($consoleWindow -eq [IntPtr]::Zero) { + Start-Sleep -Milliseconds 250 + } + } + if ($consoleWindow -eq [IntPtr]::Zero) { + $titles = [NemoClawNativeWindowCapture]::ListWindowTitles() -join ' | ' + Fail-ProofVideo "The real PowerShell console window was not created. Windows: $titles" + } + $recordingClock = [Diagnostics.Stopwatch]::StartNew() $framePaths = @() - $consoleSnapshots = @() + $installerWindowFrameCount = 0 while (-not $proofProcess.HasExited) { if ($recordingClock.ElapsedMilliseconds -gt $script:MaximumRecordingMilliseconds) { $proofProcess.Kill() $proofProcess.WaitForExit() - Fail-ProofVideo 'Visible console qualification exceeded its recording timeout.' + Fail-ProofVideo 'Real console qualification exceeded its recording timeout.' } - $consoleContent = '' - if (Test-Path -LiteralPath $consoleOutput -PathType Leaf) { - try { - $consoleContent = [IO.File]::ReadAllText($consoleOutput) - } catch { - # The child may be flushing the file; the next 250 ms sample retries. - } + $installerWindow = [NemoClawNativeWindowCapture]::FindWindowContaining( + 'NemoClaw Native Windows Candidate Setup', + $consoleWindow + ) + if ($installerWindow -ne [IntPtr]::Zero) { + $installerWindowFrameCount++ } - $consoleContent = $consoleContent -replace "$([char]27)\[[0-9;?]*[ -/]*[@-~]", '' - $consoleLines = @($consoleContent -split '\r?\n') - $consoleSnapshots += $consoleContent $framePath = Join-Path $frameRoot ('frame-{0:D5}.png' -f ($framePaths.Count + 1)) - Save-ConsoleFrame ` + Save-ActualWindowFrame ` -Path $framePath ` - -Lines $consoleLines ` - -FrameNumber ($framePaths.Count + 1) ` - -ElapsedMilliseconds $recordingClock.ElapsedMilliseconds + -ConsoleWindow $consoleWindow ` + -InstallerWindow $installerWindow $framePaths += $framePath Start-Sleep -Milliseconds $script:FrameDurationMilliseconds $proofProcess.Refresh() @@ -305,12 +377,12 @@ try { $recordingClock.Stop() $proofExitCode = $proofProcess.ExitCode if ($proofExitCode -ne 0) { - $failureText = if (Test-Path -LiteralPath $consoleError -PathType Leaf) { - [IO.File]::ReadAllText($consoleError).Trim() + $failureText = if (Test-Path -LiteralPath $consoleTranscript -PathType Leaf) { + [IO.File]::ReadAllText($consoleTranscript).Trim() } else { '' } - Fail-ProofVideo "Live console qualification failed with exit code $proofExitCode. $failureText" + Fail-ProofVideo "Real console qualification failed with exit code $proofExitCode. $failureText" } if ($framePaths.Count -lt $script:MinimumCaptureFrames) { Fail-ProofVideo "The live console recording captured too few frames: $($framePaths.Count)." @@ -320,9 +392,15 @@ try { Fail-ProofVideo 'The live console transcript is missing.' } - $uniqueSnapshotCount = @($consoleSnapshots | Sort-Object -Unique).Count - if ($uniqueSnapshotCount -lt $script:MinimumUniqueFrames) { - Fail-ProofVideo "The live console did not change enough to prove the install: only $uniqueSnapshotCount unique states." + if ($installerWindowFrameCount -lt 4) { + Fail-ProofVideo 'The real WiX installer window was not captured for at least one second.' + } + $frameHashes = @($framePaths | ForEach-Object { + (Get-FileHash -LiteralPath $_ -Algorithm SHA256).Hash + }) + $uniqueFrameCount = @($frameHashes | Sort-Object -Unique).Count + if ($uniqueFrameCount -lt $script:MinimumUniqueFrames) { + Fail-ProofVideo "The real window recording changed too little: only $uniqueFrameCount unique frames." } $middleIndex = [int][Math]::Floor(($framePaths.Count - 1) / 2) @@ -443,7 +521,7 @@ public static class NemoClawConsoleVideoEncoder -Label 'Recorded console qualification receipt' $receipt = [pscustomobject]@{ schemaVersion = 2 - classification = 'native-windows-candidate-preview-live-console-recording' + classification = 'native-windows-candidate-preview-actual-window-recording' candidateSha = $CandidateSha productVersion = $ProductVersion architecture = 'arm64' @@ -456,13 +534,14 @@ public static class NemoClawConsoleVideoEncoder consoleTranscriptSha256 = (Get-FileHash -LiteralPath $consoleTranscript -Algorithm SHA256).Hash.ToLowerInvariant() } capture = [pscustomobject]@{ - kind = 'live PowerShell console output sampled while complete installer qualification executes' + kind = 'actual PrintWindow capture of real PowerShell console and WiX installer windows' sourceWidth = 1280 sourceHeight = 720 requestedFramesPerSecond = $script:CaptureFramesPerSecond frameDurationMilliseconds = $script:FrameDurationMilliseconds frameCount = $framePaths.Count - uniqueConsoleStateCount = $uniqueSnapshotCount + uniqueFrameCount = $uniqueFrameCount + installerWindowFrameCount = $installerWindowFrameCount recordingWallTimeMilliseconds = $recordingClock.ElapsedMilliseconds qualificationExitCode = $proofExitCode } diff --git a/scripts/checks/run-windows-native-package-console-proof.ps1 b/scripts/checks/run-windows-native-package-console-proof.ps1 index f2a6616828f..90e1d0fcbcd 100644 --- a/scripts/checks/run-windows-native-package-console-proof.ps1 +++ b/scripts/checks/run-windows-native-package-console-proof.ps1 @@ -33,6 +33,10 @@ try { $bufferSize.Width = 140 $bufferSize.Height = 3000 $rawUi.BufferSize = $bufferSize + $windowSize = $rawUi.WindowSize + $windowSize.Width = [Math]::Min(120, $rawUi.MaxPhysicalWindowSize.Width) + $windowSize.Height = [Math]::Min(35, $rawUi.MaxPhysicalWindowSize.Height) + $rawUi.WindowSize = $windowSize } catch { # Window sizing is presentation-only; qualification remains authoritative. } @@ -81,14 +85,15 @@ try { } Write-Host '[PASS] Downloaded EXE, MSI, and manifest match the GitHub artifact digests' -ForegroundColor Green Write-Host '' - Write-Host "PS> Launch downloaded app: $downloadedSetup /install /quiet /norestart" + Write-Host "PS> Launch downloaded app with real WiX UI: $downloadedSetup /install /passive /norestart" & $QualificationScript ` -ProductVersion $ProductVersion ` -MsiPath $downloadedMsi ` -SetupPath $downloadedSetup ` -PackageManifestPath $downloadedManifest ` - -ArtifactDirectory $QualificationArtifactDirectory + -ArtifactDirectory $QualificationArtifactDirectory ` + -InteractiveProof Write-Host '' Write-Host '[PASS] LIVE CONSOLE PROOF COMPLETE' -ForegroundColor Green diff --git a/scripts/checks/run-windows-native-package-qualification.ps1 b/scripts/checks/run-windows-native-package-qualification.ps1 index be0a6f01848..d27a272dfa0 100644 --- a/scripts/checks/run-windows-native-package-qualification.ps1 +++ b/scripts/checks/run-windows-native-package-qualification.ps1 @@ -17,7 +17,8 @@ param( [Parameter(Mandatory)][string]$MsiPath, [Parameter(Mandatory)][string]$SetupPath, [Parameter(Mandatory)][string]$PackageManifestPath, - [Parameter(Mandatory)][string]$ArtifactDirectory + [Parameter(Mandatory)][string]$ArtifactDirectory, + [switch]$InteractiveProof ) Set-StrictMode -Version Latest @@ -369,9 +370,14 @@ Write-Host "HOST> NemoClaw native Windows ARM64 package qualification" Write-Host "HOST> os=$([Environment]::OSVersion.Version) architecture=$([Runtime.InteropServices.RuntimeInformation]::OSArchitecture) product=$ProductVersion" try { + $bundleInstallArguments = if ($InteractiveProof) { + @('/install', '/passive', '/norestart', '/log', $bundleInstallLog) + } else { + @('/install', '/quiet', '/norestart', '/log', $bundleInstallLog) + } Invoke-BoundedProcess ` -FilePath $setup ` - -Arguments @('/install', '/quiet', '/norestart', '/log', $bundleInstallLog) ` + -Arguments $bundleInstallArguments ` -Label 'Burn bundle install' ` -AllowedExitCodes @(0, 3010) | Out-Null From fd29c69a887f0e2af7937e84cdd065f1960e01f2 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 21:41:55 -0700 Subject: [PATCH 037/144] fix(test): tolerate closing installer windows --- .../create-windows-native-proof-video.ps1 | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/scripts/checks/create-windows-native-proof-video.ps1 b/scripts/checks/create-windows-native-proof-video.ps1 index bad31cf6f2a..ce485095635 100644 --- a/scripts/checks/create-windows-native-proof-video.ps1 +++ b/scripts/checks/create-windows-native-proof-video.ps1 @@ -187,8 +187,14 @@ function Save-ActualWindowFrame { $consoleBitmap = [NemoClawNativeWindowCapture]::Capture($ConsoleWindow) $installerBitmap = $null + $installerCaptured = $false if ($InstallerWindow -ne [IntPtr]::Zero) { - $installerBitmap = [NemoClawNativeWindowCapture]::Capture($InstallerWindow) + try { + $installerBitmap = [NemoClawNativeWindowCapture]::Capture($InstallerWindow) + $installerCaptured = $true + } catch { + # Burn can close its progress window between enumeration and capture. + } } $frame = [Drawing.Bitmap]::new(1280, 720, [Drawing.Imaging.PixelFormat]::Format24bppRgb) $graphics = [Drawing.Graphics]::FromImage($frame) @@ -219,6 +225,7 @@ function Save-ActualWindowFrame { } $consoleBitmap.Dispose() } + return $installerCaptured } if ($ProductVersion -cnotmatch '^[0-9]{1,3}\.[0-9]{1,5}\.[0-9]{1,5}$') { @@ -361,14 +368,29 @@ try { 'NemoClaw Native Windows Candidate Setup', $consoleWindow ) - if ($installerWindow -ne [IntPtr]::Zero) { + $framePath = Join-Path $frameRoot ('frame-{0:D5}.png' -f ($framePaths.Count + 1)) + try { + $installerCaptured = Save-ActualWindowFrame ` + -Path $framePath ` + -ConsoleWindow $consoleWindow ` + -InstallerWindow $installerWindow + } catch { + $proofProcess.Refresh() + if ($proofProcess.HasExited) { + break + } + $consoleWindow = [NemoClawNativeWindowCapture]::FindWindowContaining( + $consoleWindowTitle, + [IntPtr]::Zero + ) + if ($consoleWindow -eq [IntPtr]::Zero) { + Fail-ProofVideo "The real console window disappeared during qualification: $($_.Exception.Message)" + } + continue + } + if ($installerCaptured) { $installerWindowFrameCount++ } - $framePath = Join-Path $frameRoot ('frame-{0:D5}.png' -f ($framePaths.Count + 1)) - Save-ActualWindowFrame ` - -Path $framePath ` - -ConsoleWindow $consoleWindow ` - -InstallerWindow $installerWindow $framePaths += $framePath Start-Sleep -Milliseconds $script:FrameDurationMilliseconds $proofProcess.Refresh() From eb79a93d3a0b740fe2ac6b0dee84b303d1714983 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 22:58:05 -0700 Subject: [PATCH 038/144] feat(install): package the native NemoClaw runtime Signed-off-by: Aaron Erickson --- .github/workflows/platform-vitest-main.yaml | 16 +- packaging/windows/MXC-LICENSE.txt | 21 ++ packaging/windows/NATIVE-PREVIEW.txt | 19 +- packaging/windows/NemoClaw.wixproj | 11 +- packaging/windows/Product.wxs | 16 +- packaging/windows/README.md | 16 +- .../checks/build-windows-native-package.ps1 | 43 ++- ...prepare-windows-native-package-payload.ps1 | 275 ++++++++++++++++++ ...n-windows-native-package-qualification.ps1 | 120 ++++++-- 9 files changed, 475 insertions(+), 62 deletions(-) create mode 100644 packaging/windows/MXC-LICENSE.txt create mode 100644 scripts/checks/prepare-windows-native-package-payload.ps1 diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index 95288fd3d8f..60063ba2980 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -268,6 +268,15 @@ jobs: with: dotnet-version: 8.0.419 + - name: Set up pinned Node.js for NemoClaw and OpenClaw + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.22.3 + cache: npm + cache-dependency-path: | + candidate/package-lock.json + candidate/agents/openclaw/openclaw-runtime/package-lock.json + - name: Build the pinned NVIDIA/OpenShell#2721 candidate working-directory: openshell shell: powershell @@ -310,11 +319,16 @@ jobs: $openshell = [IO.Path]::GetFullPath("$env:GITHUB_WORKSPACE\openshell") $version = [string](Get-Content -LiteralPath "$candidate\package.json" -Raw | ConvertFrom-Json).version $packageRoot = [IO.Path]::GetFullPath("$env:RUNNER_TEMP\nemoclaw-windows-package") + $payloadRoot = [IO.Path]::GetFullPath("$env:RUNNER_TEMP\nemoclaw-native-runtime-payload") "product_version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "package_root=$packageRoot" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + & "$candidate\scripts\checks\prepare-windows-native-package-payload.ps1" ` + -CandidateCheckout $candidate ` + -OpenShellPayloadRoot "$openshell\target\aarch64-pc-windows-msvc\release" ` + -OutputDirectory $payloadRoot & "$candidate\scripts\checks\build-windows-native-package.ps1" ` -ProductVersion $version ` - -PayloadRoot "$openshell\target\aarch64-pc-windows-msvc\release" ` + -PayloadRoot $payloadRoot ` -OutputDirectory $packageRoot $referenceReceipts = Join-Path $packageRoot 'reference-qualification' [IO.Directory]::CreateDirectory($referenceReceipts) | Out-Null diff --git a/packaging/windows/MXC-LICENSE.txt b/packaging/windows/MXC-LICENSE.txt new file mode 100644 index 00000000000..22aed37e650 --- /dev/null +++ b/packaging/windows/MXC-LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Microsoft Corporation. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packaging/windows/NATIVE-PREVIEW.txt b/packaging/windows/NATIVE-PREVIEW.txt index 3b3be679d75..3dee696ae8d 100644 --- a/packaging/windows/NATIVE-PREVIEW.txt +++ b/packaging/windows/NATIVE-PREVIEW.txt @@ -1,13 +1,16 @@ NemoClaw Native Windows Candidate Preview -This package contains native Windows ARM64 builds of openshell.exe and -openshell-gateway.exe from NVIDIA/OpenShell#2721 merge commit -bcd517bbe08cc80860c9be57699390cd32e8445f. +This package contains the NemoClaw CLI, Node.js 22.22.3, OpenClaw 2026.7.1, +native Windows ARM64 builds of openshell.exe and openshell-gateway.exe from +NVIDIA/OpenShell#2721 merge commit bcd517bbe08cc80860c9be57699390cd32e8445f, +and the pinned Microsoft MXC 0.8.0 ARM64 runtime tools. -This candidate does not include wxc-exec.exe, real MXC sandbox execution, -gateway service registration, NemoClaw CLI or onboarding, local inference, -or a production support claim. Mutable runtime state must remain outside this -MSI-owned installation directory. +This is a qualification candidate, not a production support claim. Native MXC +execution remains limited by the Windows host build and capabilities. Gateway +service registration, supported onboarding, managed inference, local inference, +and production activation remain incomplete. Mutable runtime state must remain +outside this MSI-owned installation directory. PR qualification artifacts are unsigned. Production use remains gated on -Authenticode signing of the payload executables, MSI, and setup executable. +Authenticode signing of the NemoClaw, OpenClaw, OpenShell, MXC, MSI, and setup +payloads as applicable. diff --git a/packaging/windows/NemoClaw.wixproj b/packaging/windows/NemoClaw.wixproj index af76d63f44b..1f2b791b45a 100644 --- a/packaging/windows/NemoClaw.wixproj +++ b/packaging/windows/NemoClaw.wixproj @@ -19,7 +19,14 @@ - - + + + + + + + + + diff --git a/packaging/windows/Product.wxs b/packaging/windows/Product.wxs index 54cbd4b28e6..5f3e63d94bd 100644 --- a/packaging/windows/Product.wxs +++ b/packaging/windows/Product.wxs @@ -18,7 +18,7 @@ DowngradeErrorMessage="A newer NemoClaw native Windows candidate is already installed." /> - + @@ -26,12 +26,6 @@ - - - - - - - - - - - - + - diff --git a/packaging/windows/README.md b/packaging/windows/README.md index 963125187ce..0030f4f5288 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -9,11 +9,13 @@ application extension are pinned in the project files. The package uses only standard Windows Installer and Burn authoring. It has no custom actions and does not invoke PowerShell, WSL, Bash, Ubuntu, Docker, or a -Linux virtual machine. The package installs the exact ARM64 OpenShell payload -provided at build time under `%ProgramFiles%\NVIDIA\NemoClaw`, registers normal -Add/Remove Programs metadata, and adds the installed `bin` directory to the -machine PATH. +Linux virtual machine. The package installs the exact assembled ARM64 NemoClaw +runtime payload under `%ProgramFiles%\NVIDIA\NemoClaw`. That payload contains +the NemoClaw CLI, pinned Node.js and OpenClaw runtimes, NVIDIA/OpenShell#2721 CLI +and gateway binaries, and pinned Microsoft MXC tools. Windows Installer +registers normal Add/Remove Programs metadata and adds the installed `bin` +directory to the machine PATH. -The package is a preview distribution boundary, not a runtime activation -boundary. `wxc-exec.exe`, real MXC execution, service registration, NemoClaw -CLI/onboarding, and production signing are deliberately absent. +The package is a preview distribution boundary. Host qualification, supported +onboarding, managed inference, service registration, production activation, and +production signing remain separate gates. diff --git a/scripts/checks/build-windows-native-package.ps1 b/scripts/checks/build-windows-native-package.ps1 index 20ab04e256b..64012f55e5f 100644 --- a/scripts/checks/build-windows-native-package.ps1 +++ b/scripts/checks/build-windows-native-package.ps1 @@ -91,10 +91,28 @@ if (-not (Test-Path -LiteralPath $outputParent -PathType Container)) { Fail-WindowsPackageBuild 'OutputDirectory parent must exist.' } -$openshell = Join-Path $payload 'openshell.exe' -$gateway = Join-Path $payload 'openshell-gateway.exe' +$openshell = Join-Path $payload 'bin\openshell.exe' +$gateway = Join-Path $payload 'bin\openshell-gateway.exe' Assert-Arm64PortableExecutable -Path $openshell -Label 'openshell.exe payload' Assert-Arm64PortableExecutable -Path $gateway -Label 'openshell-gateway.exe payload' +foreach ($requiredPayload in @( + 'bin\node.exe', + 'bin\nemoclaw.cmd', + 'nemoclaw\app\bin\nemoclaw.js', + 'openclaw\node_modules\openclaw\openclaw.mjs', + 'mxc\wxc-exec.exe', + 'mxc\wxc-host-prep.exe', + 'config\mxc-gateway.toml', + 'LICENSE.txt', + 'NATIVE-PREVIEW.txt' +)) { + if (-not (Test-Path -LiteralPath (Join-Path $payload $requiredPayload) -PathType Leaf)) { + Fail-WindowsPackageBuild "Required NemoClaw runtime payload is missing: $requiredPayload" + } +} +Assert-Arm64PortableExecutable -Path (Join-Path $payload 'bin\node.exe') -Label 'node.exe payload' +Assert-Arm64PortableExecutable -Path (Join-Path $payload 'mxc\wxc-exec.exe') -Label 'wxc-exec.exe payload' +Assert-Arm64PortableExecutable -Path (Join-Path $payload 'mxc\wxc-host-prep.exe') -Label 'wxc-host-prep.exe payload' $authoringText = @( [IO.File]::ReadAllText((Join-Path $sourceRoot 'packaging\windows\Product.wxs')), @@ -202,23 +220,22 @@ foreach ($package in @($msiPath, $setupPath)) { } Assert-Arm64PortableExecutable -Path $setupPath -Label $setupName +$payloadManifest = @(Get-ChildItem -LiteralPath $payload -Recurse -File | ForEach-Object { + [pscustomobject]@{ + relativePath = $_.FullName.Substring($payload.Length + 1) + sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + size = $_.Length + } +} | Sort-Object relativePath) + $manifest = [pscustomobject]@{ - schemaVersion = 1 + schemaVersion = 2 classification = 'native-windows-candidate-preview' productVersion = $ProductVersion architecture = 'arm64' dotnetSdk = $dotnetVersion wixToolset = $script:ExpectedWixVersion - payload = @( - [pscustomobject]@{ - file = 'openshell.exe' - sha256 = (Get-FileHash -LiteralPath $openshell -Algorithm SHA256).Hash.ToLowerInvariant() - }, - [pscustomobject]@{ - file = 'openshell-gateway.exe' - sha256 = (Get-FileHash -LiteralPath $gateway -Algorithm SHA256).Hash.ToLowerInvariant() - } - ) + payload = $payloadManifest packages = @( [pscustomobject]@{ file = $msiName diff --git a/scripts/checks/prepare-windows-native-package-payload.ps1 b/scripts/checks/prepare-windows-native-package-payload.ps1 new file mode 100644 index 00000000000..aa16c92c4ad --- /dev/null +++ b/scripts/checks/prepare-windows-native-package-payload.ps1 @@ -0,0 +1,275 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +<# +.SYNOPSIS + Assemble the complete native ARM64 NemoClaw installer payload. + +.DESCRIPTION + Builds the exact candidate NemoClaw CLI, installs its locked production + dependencies, installs the locked OpenClaw runtime, and stages pinned Node, + OpenShell, and Microsoft MXC binaries. The resulting directory is a build + input for WiX; this script is never invoked by the installed product. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$CandidateCheckout, + [Parameter(Mandatory)][string]$OpenShellPayloadRoot, + [Parameter(Mandatory)][string]$OutputDirectory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $false + +$script:NodeVersion = '22.22.3' +$script:NodeArchive = "node-v$($script:NodeVersion)-win-arm64.zip" +$script:NodeArchiveSha256 = '00be129a09e8872cd52d3bb8bba12412c5733d2224123a482a2dca4a6fbf2586' +$script:MxcSdkVersion = '0.8.0' +$script:MxcSdkArchiveSha256 = '06bb2399d7e98ab1907acf851e12a4e44748dd467b79d3e53c2f2fbf569da14e' +$script:OpenShellRevision = 'bcd517bbe08cc80860c9be57699390cd32e8445f' + +function Fail-PayloadPreparation { + param([Parameter(Mandatory)][string]$Message) + throw "Windows native payload preparation failed: $Message" +} + +function Invoke-Checked { + param( + [Parameter(Mandatory)][string]$FilePath, + [Parameter(Mandatory)][string[]]$Arguments, + [Parameter(Mandatory)][string]$Label, + [string]$WorkingDirectory + ) + + $prior = Get-Location + try { + if ($WorkingDirectory) { Set-Location -LiteralPath $WorkingDirectory } + & $FilePath @Arguments + if ($LASTEXITCODE -ne 0) { + Fail-PayloadPreparation "$Label exited with status $LASTEXITCODE." + } + } finally { + Set-Location -LiteralPath $prior + } +} + +function Assert-Sha256 { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Expected, + [Parameter(Mandatory)][string]$Label + ) + + $actual = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -cne $Expected) { + Fail-PayloadPreparation "$Label digest mismatch." + } +} + +function Assert-Arm64PortableExecutable { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Label + ) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + Fail-PayloadPreparation "$Label is missing." + } + $stream = [IO.File]::OpenRead($Path) + $reader = [IO.BinaryReader]::new($stream) + try { + if ($reader.ReadUInt16() -ne 0x5A4D) { Fail-PayloadPreparation "$Label is not a PE file." } + $stream.Position = 0x3C + $peOffset = $reader.ReadInt32() + if ($peOffset -lt 0x40 -or $peOffset -gt ($stream.Length - 6)) { + Fail-PayloadPreparation "$Label has an invalid PE header." + } + $stream.Position = $peOffset + if ($reader.ReadUInt32() -ne 0x00004550 -or $reader.ReadUInt16() -ne 0xAA64) { + Fail-PayloadPreparation "$Label is not native Windows ARM64." + } + } finally { + $reader.Dispose() + $stream.Dispose() + } +} + +$candidate = [IO.Path]::GetFullPath($CandidateCheckout).TrimEnd('\') +$openShellPayload = [IO.Path]::GetFullPath($OpenShellPayloadRoot).TrimEnd('\') +$output = [IO.Path]::GetFullPath($OutputDirectory).TrimEnd('\') +foreach ($directory in @($candidate, $openShellPayload)) { + if (-not (Test-Path -LiteralPath $directory -PathType Container)) { + Fail-PayloadPreparation "Required input directory is missing: $directory" + } +} +if (Test-Path -LiteralPath $output) { + Fail-PayloadPreparation 'OutputDirectory must not already exist.' +} + +$node = (Get-Command node.exe -ErrorAction Stop).Source +$npm = (Get-Command npm.cmd -ErrorAction Stop).Source +$npx = (Get-Command npx.cmd -ErrorAction Stop).Source +$tar = (Get-Command tar.exe -ErrorAction Stop).Source +$reportedNodeVersion = (& $node --version).Trim() +if ($LASTEXITCODE -ne 0 -or $reportedNodeVersion -cne "v$($script:NodeVersion)") { + Fail-PayloadPreparation "Node.js $($script:NodeVersion) is required to build the payload." +} + +$workRoot = Join-Path $env:RUNNER_TEMP ('nemoclaw-native-payload-' + [guid]::NewGuid().ToString('N')) +[IO.Directory]::CreateDirectory($workRoot) | Out-Null +[IO.Directory]::CreateDirectory($output) | Out-Null +$candidateVersionPath = Join-Path $candidate '.version' +$candidateRevisionPath = Join-Path $candidate '.source-revision' +$createdCandidateIdentityFiles = $false + +try { + Invoke-Checked -FilePath $npm -Arguments @('ci', '--ignore-scripts', '--no-audit', '--no-fund') -Label 'NemoClaw dependency restore' -WorkingDirectory $candidate + Invoke-Checked -FilePath $npm -Arguments @('run', 'clean:cli') -Label 'NemoClaw CLI clean' -WorkingDirectory $candidate + Invoke-Checked -FilePath $npm -Arguments @('run', 'build:policy-boundary') -Label 'NemoClaw policy boundary build' -WorkingDirectory $candidate + Invoke-Checked -FilePath $npm -Arguments @('run', 'build:runner-boundary') -Label 'NemoClaw runner boundary build' -WorkingDirectory $candidate + Invoke-Checked -FilePath $npx -Arguments @('--no-install', 'tsc', '-p', 'tsconfig.src.json') -Label 'NemoClaw CLI TypeScript build' -WorkingDirectory $candidate + Invoke-Checked -FilePath $node -Arguments @('--experimental-strip-types', '--no-warnings', 'scripts/lib/package-blueprint-runner-runtime.mts') -Label 'NemoClaw blueprint runtime packaging' -WorkingDirectory $candidate + Invoke-Checked -FilePath $node -Arguments @('dist/lib/core/generate-build-identity.js') -Label 'NemoClaw build identity generation' -WorkingDirectory $candidate + Invoke-Checked -FilePath $node -Arguments @('dist/lib/inference/serving/generate-catalog.js') -Label 'NemoClaw catalog generation' -WorkingDirectory $candidate + Invoke-Checked -FilePath $node -Arguments @('dist/lib/cli/generate-oclif-metadata-manifest.js') -Label 'NemoClaw command metadata generation' -WorkingDirectory $candidate + + if ((Test-Path -LiteralPath $candidateVersionPath) -or (Test-Path -LiteralPath $candidateRevisionPath)) { + Fail-PayloadPreparation 'Candidate release identity files must not be pre-existing build residue.' + } + $candidateVersion = [string](Get-Content -LiteralPath (Join-Path $candidate 'package.json') -Raw | ConvertFrom-Json).version + $candidateRevision = (& git -C $candidate rev-parse HEAD).Trim() + if ($LASTEXITCODE -ne 0 -or $candidateRevision -cnotmatch '^[a-f0-9]{40}$') { + Fail-PayloadPreparation 'Candidate Git revision could not be resolved.' + } + [IO.File]::WriteAllText($candidateVersionPath, "$candidateVersion`n", [Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($candidateRevisionPath, "$candidateRevision`n", [Text.UTF8Encoding]::new($false)) + $createdCandidateIdentityFiles = $true + + $packOutput = & $npm pack --ignore-scripts --json --pack-destination $workRoot $candidate | Out-String + if ($LASTEXITCODE -ne 0) { Fail-PayloadPreparation 'NemoClaw npm package creation failed.' } + $packReceipt = @($packOutput | ConvertFrom-Json) + if ($packReceipt.Count -ne 1 -or [string]::IsNullOrWhiteSpace([string]$packReceipt[0].filename)) { + Fail-PayloadPreparation 'NemoClaw npm pack output was invalid.' + } + $nemoclawArchive = Join-Path $workRoot ([string]$packReceipt[0].filename) + Remove-Item -LiteralPath $candidateVersionPath, $candidateRevisionPath -Force + $createdCandidateIdentityFiles = $false + + $nemoclawProduction = Join-Path $workRoot 'nemoclaw-production' + [IO.Directory]::CreateDirectory($nemoclawProduction) | Out-Null + Copy-Item -LiteralPath (Join-Path $candidate 'package.json') -Destination $nemoclawProduction + Copy-Item -LiteralPath (Join-Path $candidate 'package-lock.json') -Destination $nemoclawProduction + Invoke-Checked -FilePath $npm -Arguments @('ci', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund') -Label 'NemoClaw production dependency restore' -WorkingDirectory $nemoclawProduction + $nemoclawExtract = Join-Path $workRoot 'nemoclaw-package' + [IO.Directory]::CreateDirectory($nemoclawExtract) | Out-Null + Invoke-Checked -FilePath $tar -Arguments @('-xzf', $nemoclawArchive, '-C', $nemoclawExtract) -Label 'NemoClaw package extraction' + $nemoclawRoot = Join-Path $output 'nemoclaw' + [IO.Directory]::CreateDirectory($nemoclawRoot) | Out-Null + Copy-Item -LiteralPath (Join-Path $nemoclawExtract 'package') -Destination (Join-Path $nemoclawRoot 'app') -Recurse + Copy-Item -LiteralPath (Join-Path $nemoclawProduction 'node_modules') -Destination (Join-Path $nemoclawRoot 'node_modules') -Recurse + + $openClawRoot = Join-Path $output 'openclaw' + [IO.Directory]::CreateDirectory($openClawRoot) | Out-Null + Copy-Item -LiteralPath (Join-Path $candidate 'agents\openclaw\openclaw-runtime\package.json') -Destination $openClawRoot + Copy-Item -LiteralPath (Join-Path $candidate 'agents\openclaw\openclaw-runtime\package-lock.json') -Destination $openClawRoot + Invoke-Checked -FilePath $npm -Arguments @('ci', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund') -Label 'OpenClaw production runtime restore' -WorkingDirectory $openClawRoot + + $nodeArchivePath = Join-Path $workRoot $script:NodeArchive + Invoke-WebRequest -UseBasicParsing -Uri "https://nodejs.org/dist/v$($script:NodeVersion)/$($script:NodeArchive)" -OutFile $nodeArchivePath + Assert-Sha256 -Path $nodeArchivePath -Expected $script:NodeArchiveSha256 -Label 'Node.js ARM64 archive' + $nodeExtract = Join-Path $workRoot 'node' + Expand-Archive -LiteralPath $nodeArchivePath -DestinationPath $nodeExtract + $nodeDistributionRoot = Join-Path $nodeExtract "node-v$($script:NodeVersion)-win-arm64" + + $binRoot = Join-Path $output 'bin' + [IO.Directory]::CreateDirectory($binRoot) | Out-Null + Copy-Item -LiteralPath (Join-Path $nodeDistributionRoot 'node.exe') -Destination (Join-Path $binRoot 'node.exe') + Copy-Item -LiteralPath (Join-Path $nodeDistributionRoot 'LICENSE') -Destination (Join-Path $output 'NODE-LICENSE.txt') + Copy-Item -LiteralPath (Join-Path $openShellPayload 'openshell.exe') -Destination (Join-Path $binRoot 'openshell.exe') + Copy-Item -LiteralPath (Join-Path $openShellPayload 'openshell-gateway.exe') -Destination (Join-Path $binRoot 'openshell-gateway.exe') + + $mxcArchivePath = Join-Path $workRoot "mxc-sdk-$($script:MxcSdkVersion).tgz" + Invoke-WebRequest -UseBasicParsing -Uri "https://registry.npmjs.org/@microsoft/mxc-sdk/-/mxc-sdk-$($script:MxcSdkVersion).tgz" -OutFile $mxcArchivePath + Assert-Sha256 -Path $mxcArchivePath -Expected $script:MxcSdkArchiveSha256 -Label 'Microsoft MXC SDK archive' + $mxcExtract = Join-Path $workRoot 'mxc-sdk' + [IO.Directory]::CreateDirectory($mxcExtract) | Out-Null + Invoke-Checked -FilePath $tar -Arguments @('-xzf', $mxcArchivePath, '-C', $mxcExtract) -Label 'Microsoft MXC SDK extraction' + $mxcRoot = Join-Path $output 'mxc' + [IO.Directory]::CreateDirectory($mxcRoot) | Out-Null + Get-ChildItem -LiteralPath (Join-Path $mxcExtract 'package\bin\arm64') -File | Where-Object { + $_.Extension -in @('.exe', '.dll') + } | ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination (Join-Path $mxcRoot $_.Name) + } + Copy-Item -LiteralPath (Join-Path $candidate 'packaging\windows\MXC-LICENSE.txt') -Destination (Join-Path $output 'MXC-LICENSE.txt') + + $launcher = "@echo off`r`n`"%~dp0node.exe`" `"%~dp0..\nemoclaw\app\bin\nemoclaw.js`" %*`r`n" + [IO.File]::WriteAllText((Join-Path $binRoot 'nemoclaw.cmd'), $launcher, [Text.ASCIIEncoding]::new()) + $openClawLauncher = "@echo off`r`n`"%~dp0node.exe`" `"%~dp0..\openclaw\node_modules\openclaw\openclaw.mjs`" %*`r`n" + [IO.File]::WriteAllText((Join-Path $binRoot 'openclaw.cmd'), $openClawLauncher, [Text.ASCIIEncoding]::new()) + + $configRoot = Join-Path $output 'config' + [IO.Directory]::CreateDirectory($configRoot) | Out-Null + $gatewayConfig = @" +[openshell.drivers.mxc] +wxc_exec_path = "C:\\Program Files\\NVIDIA\\NemoClaw\\mxc\\wxc-exec.exe" +backend = "process_container" +default_configuration_id = "composable" +pc_least_privilege = false +pc_capabilities = ["privateNetworkClientServer"] +debug = false +"@ + [IO.File]::WriteAllText((Join-Path $configRoot 'mxc-gateway.toml'), $gatewayConfig, [Text.UTF8Encoding]::new($false)) + Copy-Item -LiteralPath (Join-Path $candidate 'LICENSE') -Destination (Join-Path $output 'LICENSE.txt') + Copy-Item -LiteralPath (Join-Path $candidate 'packaging\windows\NATIVE-PREVIEW.txt') -Destination (Join-Path $output 'NATIVE-PREVIEW.txt') + + foreach ($portableExecutable in @( + 'bin\node.exe', + 'bin\openshell.exe', + 'bin\openshell-gateway.exe', + 'mxc\wxc-exec.exe', + 'mxc\wxc-host-prep.exe' + )) { + Assert-Arm64PortableExecutable -Path (Join-Path $output $portableExecutable) -Label $portableExecutable + } + foreach ($required in @( + 'bin\nemoclaw.cmd', + 'nemoclaw\app\bin\nemoclaw.js', + 'openclaw\node_modules\openclaw\openclaw.mjs', + 'config\mxc-gateway.toml' + )) { + if (-not (Test-Path -LiteralPath (Join-Path $output $required) -PathType Leaf)) { + Fail-PayloadPreparation "Prepared payload is incomplete: $required" + } + } + + $receipt = [pscustomobject]@{ + schemaVersion = 1 + classification = 'nemoclaw-native-windows-arm64-runtime-payload' + node = [pscustomobject]@{ version = $script:NodeVersion; archiveSha256 = $script:NodeArchiveSha256 } + openClaw = [pscustomobject]@{ version = '2026.7.1' } + openShell = [pscustomobject]@{ pullRequest = 'NVIDIA/OpenShell#2721'; revision = $script:OpenShellRevision } + mxc = [pscustomobject]@{ npmPackage = '@microsoft/mxc-sdk'; version = $script:MxcSdkVersion; archiveSha256 = $script:MxcSdkArchiveSha256 } + } + [IO.File]::WriteAllText( + (Join-Path $output 'runtime-payload-receipt.json'), + (($receipt | ConvertTo-Json -Depth 6) + [Environment]::NewLine), + [Text.UTF8Encoding]::new($false) + ) +} catch { + if (Test-Path -LiteralPath $output) { + [IO.Directory]::Delete($output, $true) + } + throw +} finally { + if ($createdCandidateIdentityFiles) { + Remove-Item -LiteralPath $candidateVersionPath, $candidateRevisionPath -Force -ErrorAction SilentlyContinue + } + if (Test-Path -LiteralPath $workRoot) { + [IO.Directory]::Delete($workRoot, $true) + } +} + +Write-Host "Prepared complete NemoClaw native Windows payload: $output" diff --git a/scripts/checks/run-windows-native-package-qualification.ps1 b/scripts/checks/run-windows-native-package-qualification.ps1 index d27a272dfa0..f4624f9d77f 100644 --- a/scripts/checks/run-windows-native-package-qualification.ps1 +++ b/scripts/checks/run-windows-native-package-qualification.ps1 @@ -157,6 +157,61 @@ function Invoke-NativeVersionProbe { } } +function Invoke-NodeCliVersionProbe { + param( + [Parameter(Mandatory)][string]$NodePath, + [Parameter(Mandatory)][string]$EntryPath, + [Parameter(Mandatory)][string]$ExpectedVersion, + [Parameter(Mandatory)][string]$Label + ) + + Assert-Arm64PortableExecutable -Path $NodePath -Label 'Installed node.exe' + if (-not (Test-Path -LiteralPath $EntryPath -PathType Leaf)) { + Fail-PackageQualification "$Label entrypoint is missing." + } + Write-Host "PS> $Label :: node.exe $(Split-Path -Leaf $EntryPath) --version" + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $NodePath + $startInfo.ArgumentList.Add($EntryPath) + $startInfo.ArgumentList.Add('--version') + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + try { + if (-not $process.Start()) { + Fail-PackageQualification "$Label could not start." + } + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit(30000)) { + $process.Kill() + $process.WaitForExit() + Fail-PackageQualification "$Label exceeded its version-probe timeout." + } + $process.WaitForExit() + $exitCode = $process.ExitCode + $output = (@($stdoutTask.GetAwaiter().GetResult().Trim(), $stderrTask.GetAwaiter().GetResult().Trim()) | Where-Object { + -not [string]::IsNullOrWhiteSpace($_) + }) -join [Environment]::NewLine + } finally { + $process.Dispose() + } + if ($exitCode -ne 0 -or $output -notmatch [regex]::Escape($ExpectedVersion) -or $output.Length -gt 4096) { + Fail-PackageQualification "$Label did not report expected version $ExpectedVersion." + } + Write-Host "OUTPUT> $($output -replace '[\r\n]+', ' | ')" + Write-Host "[PASS] $Label exit=$exitCode" + return [pscustomobject]@{ + file = $EntryPath.Substring($installRoot.Length + 1) + exitCode = $exitCode + output = $output + sha256 = (Get-FileHash -LiteralPath $EntryPath -Algorithm SHA256).Hash.ToLowerInvariant() + } +} + function Get-ArpEntries { param([Parameter(Mandatory)][string]$DisplayName) @@ -212,16 +267,18 @@ function Test-MachinePathContains { function Assert-InstalledTree { param( [Parameter(Mandatory)][string]$Root, - [Parameter(Mandatory)][string]$Phase + [Parameter(Mandatory)][string]$Phase, + [Parameter(Mandatory)][string[]]$ExpectedFiles ) - $expectedFiles = @( - 'bin\openshell-gateway.exe', - 'bin\openshell.exe', - 'LICENSE.txt', - 'NATIVE-PREVIEW.txt' - ) | Sort-Object - $expectedDirectories = @('bin') + $expectedFiles = @($ExpectedFiles | Sort-Object) + $expectedDirectories = @($expectedFiles | ForEach-Object { + $parent = [IO.Path]::GetDirectoryName($_) + while (-not [string]::IsNullOrEmpty($parent)) { + $parent + $parent = [IO.Path]::GetDirectoryName($parent) + } + } | Sort-Object -Unique) $observed = @(Get-ChildItem -LiteralPath $Root -Recurse -Force) foreach ($item in $observed) { if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { @@ -343,10 +400,27 @@ if ($manifest.productVersion -cne $ProductVersion -or $manifest.architecture -cn Fail-PackageQualification 'Package manifest identity is invalid.' } $payloadHashes = @{} +$expectedPayloadFiles = @() foreach ($entry in @($manifest.payload)) { - $payloadHashes[[string]$entry.file] = [string]$entry.sha256 + $relativePath = [string]$entry.relativePath + if ($relativePath -notmatch '^[^:\x00-\x1f]+$' -or [IO.Path]::IsPathRooted($relativePath) -or + $relativePath.Split('\') -contains '..' -or $payloadHashes.ContainsKey($relativePath)) { + Fail-PackageQualification 'Package manifest contains an invalid payload path.' + } + $payloadHashes[$relativePath] = [string]$entry.sha256 + $expectedPayloadFiles += $relativePath } -foreach ($requiredPayload in @('openshell.exe', 'openshell-gateway.exe')) { +foreach ($requiredPayload in @( + 'bin\openshell.exe', + 'bin\openshell-gateway.exe', + 'bin\node.exe', + 'bin\nemoclaw.cmd', + 'nemoclaw\app\bin\nemoclaw.js', + 'openclaw\node_modules\openclaw\openclaw.mjs', + 'mxc\wxc-exec.exe', + 'mxc\wxc-host-prep.exe', + 'config\mxc-gateway.toml' +)) { if (-not $payloadHashes.ContainsKey($requiredPayload) -or $payloadHashes[$requiredPayload] -cnotmatch '^[a-f0-9]{64}$') { Fail-PackageQualification "Package manifest is missing $requiredPayload authority." @@ -357,6 +431,10 @@ $installRoot = Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolde $installBin = Join-Path $installRoot 'bin' $openshellPath = Join-Path $installBin 'openshell.exe' $gatewayPath = Join-Path $installBin 'openshell-gateway.exe' +$nodePath = Join-Path $installBin 'node.exe' +$nemoclawEntryPath = Join-Path $installRoot 'nemoclaw\app\bin\nemoclaw.js' +$openClawEntryPath = Join-Path $installRoot 'openclaw\node_modules\openclaw\openclaw.mjs' +$wxcExecPath = Join-Path $installRoot 'mxc\wxc-exec.exe' $bundleInstallLog = Join-Path $artifactRoot 'bundle-install.log' $msiRepairLog = Join-Path $artifactRoot 'msi-repair.log' $msiReinstallLog = Join-Path $artifactRoot 'msi-reinstall.log' @@ -385,15 +463,22 @@ try { -not (Test-Path -LiteralPath $gatewayPath -PathType Leaf)) { Fail-PackageQualification 'Bundle installation did not publish both payload executables.' } - Assert-InstalledTree -Root $installRoot -Phase 'Initial bundle install' - if ((Get-FileHash -LiteralPath $openshellPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $payloadHashes['openshell.exe'] -or - (Get-FileHash -LiteralPath $gatewayPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $payloadHashes['openshell-gateway.exe']) { + Assert-InstalledTree -Root $installRoot -Phase 'Initial bundle install' -ExpectedFiles $expectedPayloadFiles + if ((Get-FileHash -LiteralPath $openshellPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $payloadHashes['bin\openshell.exe'] -or + (Get-FileHash -LiteralPath $gatewayPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $payloadHashes['bin\openshell-gateway.exe'] -or + (Get-FileHash -LiteralPath $nodePath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $payloadHashes['bin\node.exe'] -or + (Get-FileHash -LiteralPath $wxcExecPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $payloadHashes['mxc\wxc-exec.exe']) { Fail-PackageQualification 'Installed payload digests do not match the package manifest.' } - Write-Host '[PASS] Setup installed the exact MSI-owned four-file tree' + Write-Host "[PASS] Setup installed the exact MSI-owned NemoClaw runtime tree ($($expectedPayloadFiles.Count) files)" $nativeEvidence = @( Invoke-NativeVersionProbe -Path $openshellPath -Label 'Installed openshell.exe' Invoke-NativeVersionProbe -Path $gatewayPath -Label 'Installed openshell-gateway.exe' + Invoke-NativeVersionProbe -Path $nodePath -Label 'Installed node.exe' + ) + $applicationEvidence = @( + Invoke-NodeCliVersionProbe -NodePath $nodePath -EntryPath $nemoclawEntryPath -ExpectedVersion $ProductVersion -Label 'Installed NemoClaw CLI' + Invoke-NodeCliVersionProbe -NodePath $nodePath -EntryPath $openClawEntryPath -ExpectedVersion '2026.7.1' -Label 'Installed OpenClaw runtime' ) $msiArp = @(Get-ArpEntries -DisplayName $script:MsiDisplayName) $bundleArp = @(Get-ArpEntries -DisplayName $script:BundleDisplayName) @@ -415,10 +500,10 @@ try { -Arguments @('/fa', $msi, '/qn', '/norestart', '/l*v', $msiRepairLog) ` -Label 'MSI repair' ` -AllowedExitCodes @(0, 3010) | Out-Null - if ((Get-FileHash -LiteralPath $openshellPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $payloadHashes['openshell.exe']) { + if ((Get-FileHash -LiteralPath $openshellPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $payloadHashes['bin\openshell.exe']) { Fail-PackageQualification 'MSI repair did not restore the corrupted OpenShell CLI.' } - Assert-InstalledTree -Root $installRoot -Phase 'MSI repair' + Assert-InstalledTree -Root $installRoot -Phase 'MSI repair' -ExpectedFiles $expectedPayloadFiles Write-Host '[PASS] MSI repair restored the deliberately corrupted openshell.exe digest' Invoke-BoundedProcess ` @@ -429,7 +514,7 @@ try { if (@(Get-ArpEntries -DisplayName $script:MsiDisplayName).Count -ne 1) { Fail-PackageQualification 'MSI reinstall did not preserve one product registration.' } - Assert-InstalledTree -Root $installRoot -Phase 'MSI reinstall' + Assert-InstalledTree -Root $installRoot -Phase 'MSI reinstall' -ExpectedFiles $expectedPayloadFiles Write-Host '[PASS] MSI reinstall preserved exactly one product registration' Invoke-BoundedProcess ` @@ -510,6 +595,7 @@ try { authenticodeStatus = (Get-AuthenticodeSignature -LiteralPath $setup).Status.ToString() } nativeExecutions = $nativeEvidence + applicationExecutions = $applicationEvidence msiRegistration = $msiArp bundleRegistration = $bundleArp repairRestoredDigest = $true From 5b965df01cd60ae580e30427a5a364753c1de0ff Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 23:22:32 -0700 Subject: [PATCH 039/144] test(windows): require an installed NemoClaw turn Signed-off-by: Aaron Erickson --- ci/env-var-doc-allowlist.json | 4 + packaging/windows/NemoClaw.wixproj | 1 + .../runtime/run-installed-native-turn.mts | 463 ++++++++++++++++++ .../checks/build-windows-native-package.ps1 | 1 + ...prepare-windows-native-package-payload.ps1 | 30 +- ...n-windows-native-package-qualification.ps1 | 27 +- src/commands/debug.ts | 29 ++ 7 files changed, 551 insertions(+), 4 deletions(-) create mode 100644 packaging/windows/runtime/run-installed-native-turn.mts diff --git a/ci/env-var-doc-allowlist.json b/ci/env-var-doc-allowlist.json index b7a4cfc04fa..c842aa64b30 100644 --- a/ci/env-var-doc-allowlist.json +++ b/ci/env-var-doc-allowlist.json @@ -102,5 +102,9 @@ { "name": "NEMOCLAW_VLLM_GPU_DEVICE", "reason": "Internal command-scoped handoff for the public --vllm-gpu-device flag. NemoClaw validates, persists, scopes, and restores it; users should set the CLI flag instead." + }, + { + "name": "NEMOCLAW_NATIVE_INSTALL_ROOT", + "reason": "Internal launcher-owned path identifying the MSI installation root for the hidden native Windows qualification turn. The installed nemoclaw.cmd sets it; users must not set it." } ] diff --git a/packaging/windows/NemoClaw.wixproj b/packaging/windows/NemoClaw.wixproj index 1f2b791b45a..673b7bdc14b 100644 --- a/packaging/windows/NemoClaw.wixproj +++ b/packaging/windows/NemoClaw.wixproj @@ -28,5 +28,6 @@ + diff --git a/packaging/windows/runtime/run-installed-native-turn.mts b/packaging/windows/runtime/run-installed-native-turn.mts new file mode 100644 index 00000000000..866b557f211 --- /dev/null +++ b/packaging/windows/runtime/run-installed-native-turn.mts @@ -0,0 +1,463 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; + +const TIMEOUT_MS = 300_000; +const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +function fail(message) { + throw new Error(`Native Windows MXC turn qualification failed: ${message}`); +} + +function requiredFile(file, label) { + const resolved = path.resolve(file); + const stat = fs.statSync(resolved, { throwIfNoEntry: false }); + if (!stat?.isFile()) fail(`${label} is missing`); + return resolved; +} + +function requiredDirectory(directory, label) { + const resolved = path.resolve(directory); + const stat = fs.statSync(resolved, { throwIfNoEntry: false }); + if (!stat?.isDirectory()) fail(`${label} is missing`); + return resolved; +} + +function argumentValue(name) { + const index = process.argv.indexOf(name); + if (index < 0) return null; + const value = process.argv[index + 1]; + if (!value || value.startsWith("--")) fail(`${name} requires a value`); + return value; +} + +function allowlistedWindowsEnvironment(extra = {}) { + const allowedNames = new Set( + [ + "ComSpec", + "LOCALAPPDATA", + "NUMBER_OF_PROCESSORS", + "OS", + "Path", + "PATHEXT", + "PROCESSOR_ARCHITECTURE", + "PROCESSOR_ARCHITEW6432", + "SystemDrive", + "SystemRoot", + "TEMP", + "TMP", + "windir", + ].map((name) => name.toLowerCase()), + ); + const environment = {}; + for (const [name, value] of Object.entries(process.env)) { + if (value !== undefined && allowedNames.has(name.toLowerCase())) environment[name] = value; + } + return { ...environment, ...extra }; +} + +async function freePort() { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("could not allocate a loopback port"))); + return; + } + server.close(() => resolve(address.port)); + }); + }); +} + +async function waitForPort(port, child) { + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + if (child.exitCode !== null) fail("OpenShell gateway exited before readiness"); + const connected = await new Promise((resolve) => { + const socket = net.createConnection({ host: "127.0.0.1", port }); + socket.setTimeout(500); + socket.once("connect", () => { + socket.destroy(); + resolve(true); + }); + socket.once("error", () => resolve(false)); + socket.once("timeout", () => { + socket.destroy(); + resolve(false); + }); + }); + if (connected) return; + await sleep(500); + } + fail("OpenShell gateway did not become ready"); +} + +async function run(file, args, environment, label, timeout = TIMEOUT_MS) { + console.log(`NEMOCLAW> ${label}`); + const child = spawn(file, args, { + env: environment, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + return await new Promise((resolve, reject) => { + let stdout = ""; + let stderr = ""; + const timer = setTimeout(() => { + child.kill(); + reject(new Error(`${label} timed out`)); + }, timeout); + child.stdout.on("data", (chunk) => { + stdout += chunk.toString("utf8"); + if (stdout.length > 1024 * 1024) child.kill(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString("utf8"); + if (stderr.length > 1024 * 1024) child.kill(); + }); + child.once("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.once("close", (code) => { + clearTimeout(timer); + const result = { exitCode: code ?? 1, stdout, stderr }; + if (result.exitCode !== 0) { + const detail = [stdout.trim(), stderr.trim()].filter(Boolean).join(" | ").slice(0, 1000); + reject(new Error(`${label} exited ${result.exitCode}${detail ? `: ${detail}` : ""}`)); + return; + } + resolve(result); + }); + }); +} + +function quoteYamlPath(value) { + return JSON.stringify(value.replaceAll("\\", "/")); +} + +function probeSource() { + return String.raw`import { execFile, spawn } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const required = (name) => { + const value = process.env[name]; + if (!value) throw new Error(name + " is required"); + return value; +}; +const node = required("NEMOCLAW_MXC_NODE"); +const entry = required("NEMOCLAW_MXC_OPENCLAW_ENTRY"); +const home = required("NEMOCLAW_MXC_HOME"); +const resultPath = required("NEMOCLAW_MXC_RESULT"); +const mockPort = Number(required("NEMOCLAW_MXC_MOCK_PORT")); +const gatewayPort = Number(required("NEMOCLAW_MXC_OPENCLAW_PORT")); +const env = { + ...process.env, + HOME: home, + OPENCLAW_GATEWAY_URL: "ws://127.0.0.1:" + gatewayPort, + OPENCLAW_GATEWAY_TOKEN: "qualification-only-token", + USERPROFILE: home, +}; +const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); +const run = async (args, timeout = 210000) => { + try { + const output = await execFileAsync(node, args, { env, timeout, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }); + return { exitCode: 0, stdout: output.stdout, stderr: output.stderr }; + } catch (error) { + return { exitCode: Number.isInteger(error.code) ? error.code : 1, stdout: error.stdout || "", stderr: error.stderr || "" }; + } +}; +const readBody = async (request) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + return Buffer.concat(chunks).toString("utf8"); +}; +const mock = createServer(async (request, response) => { + if (request.method === "GET" && request.url === "/v1/models") { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ object: "list", data: [{ id: "mock-chat", object: "model" }] })); + return; + } + if (request.method !== "POST" || request.url !== "/v1/chat/completions") { + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: { message: "not found" } })); + return; + } + const body = JSON.parse(await readBody(request)); + if (body?.model !== "mock-chat" || !Array.isArray(body?.messages)) { + response.writeHead(400, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: { message: "unexpected request" } })); + return; + } + const id = "chatcmpl-nemoclaw-native"; + const created = Math.floor(Date.now() / 1000); + if (body.stream === true) { + response.writeHead(200, { "cache-control": "no-cache", connection: "keep-alive", "content-type": "text/event-stream" }); + for (const value of [ + { id, object: "chat.completion.chunk", created, model: "mock-chat", choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] }, + { id, object: "chat.completion.chunk", created, model: "mock-chat", choices: [{ index: 0, delta: { content: "CHAT_OK" }, finish_reason: null }] }, + { id, object: "chat.completion.chunk", created, model: "mock-chat", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }, + ]) response.write("data: " + JSON.stringify(value) + "\n\n"); + response.end("data: [DONE]\n\n"); + return; + } + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ + id, + object: "chat.completion", + created, + model: "mock-chat", + choices: [{ index: 0, message: { role: "assistant", content: "CHAT_OK" }, finish_reason: "stop" }], + })); +}); +await new Promise((resolve, reject) => { + mock.once("error", reject); + mock.listen(mockPort, "127.0.0.1", resolve); +}); +const configDirectory = join(home, ".openclaw"); +mkdirSync(configDirectory, { recursive: true }); +writeFileSync(join(configDirectory, "openclaw.json"), JSON.stringify({ + models: { mode: "merge", providers: { mock: { + baseUrl: "http://127.0.0.1:" + mockPort + "/v1", + apiKey: "unused", + api: "openai-completions", + timeoutSeconds: 180, + models: [{ id: "mock-chat", name: "mock/mock-chat", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 131072, maxTokens: 4096 }], + } } }, + agents: { defaults: { model: { primary: "mock/mock-chat" }, timeoutSeconds: 180, skipBootstrap: true, thinkingDefault: "off" }, list: [{ id: "main", default: true }] }, + gateway: { mode: "local", port: gatewayPort, controlUi: { allowInsecureAuth: true, dangerouslyDisableDeviceAuth: false, allowedOrigins: ["http://127.0.0.1:" + gatewayPort] }, trustedProxies: ["127.0.0.1", "::1"], auth: { token: "" }, reload: { mode: "hot" } }, +}), "utf8"); +const version = await run([entry, "--version"], 30000); +const gateway = spawn(node, [entry, "gateway", "run", "--dev", "--allow-unconfigured", "--auth", "token", "--bind", "loopback", "--port", String(gatewayPort)], { env, stdio: "ignore", windowsHide: true }); +let healthy = false; +for (let attempt = 0; attempt < 120 && gateway.exitCode === null; attempt += 1) { + const health = await run([entry, "gateway", "health", "--json", "--timeout", "5000"], 15000); + if (health.exitCode === 0) { healthy = true; break; } + await sleep(1000); +} +let chat = { exitCode: 1, stdout: "", stderr: "" }; +if (healthy) { + chat = await run([entry, "agent", "--agent", "main", "--message", "Reply exactly: CHAT_OK", "--thinking", "off", "--timeout", "180", "--json"]); +} +let exactReply = false; +try { + const document = JSON.parse(chat.stdout.trim()); + const payloads = document?.result?.payloads ?? document?.payloads; + exactReply = document?.status !== "error" && Array.isArray(payloads) && payloads.length === 1 && payloads[0]?.text === "CHAT_OK"; +} catch {} +const result = { version: version.stdout.trim(), versionExitCode: version.exitCode, healthy, chatExitCode: chat.exitCode, exactReply, reply: exactReply ? "CHAT_OK" : null }; +writeFileSync(resultPath, JSON.stringify(result), "utf8"); +if (gateway.exitCode === null) gateway.kill(); +await Promise.race([new Promise((resolve) => gateway.once("exit", resolve)), sleep(5000)]); +await new Promise((resolve) => mock.close(resolve)); +process.exit(version.exitCode === 0 && healthy && exactReply ? 0 : 1); +`; +} + +async function main() { + if (process.platform !== "win32" || process.arch !== "arm64") { + fail("native Windows ARM64 is required"); + } + const installRoot = requiredDirectory( + process.env.NEMOCLAW_NATIVE_INSTALL_ROOT ?? "", + "NemoClaw installation root", + ); + const binRoot = requiredDirectory(path.join(installRoot, "bin"), "NemoClaw bin directory"); + const node = requiredFile(path.join(binRoot, "node.exe"), "Node.js runtime"); + const openshell = requiredFile(path.join(binRoot, "openshell.exe"), "OpenShell CLI"); + const gatewayExecutable = requiredFile( + path.join(binRoot, "openshell-gateway.exe"), + "OpenShell gateway", + ); + const openClawRoot = requiredDirectory(path.join(installRoot, "openclaw"), "OpenClaw runtime"); + const openClawEntry = requiredFile( + path.join(openClawRoot, "node_modules", "openclaw", "openclaw.mjs"), + "OpenClaw entrypoint", + ); + const gatewayConfig = requiredFile( + path.join(installRoot, "config", "mxc-gateway.toml"), + "MXC gateway configuration", + ); + requiredFile(path.join(installRoot, "mxc", "wxc-exec.exe"), "MXC executor"); + + const systemDrive = process.env.SystemDrive; + if (!systemDrive || !/^[A-Za-z]:$/u.test(systemDrive)) fail("SystemDrive is invalid"); + const runId = randomBytes(5).toString("hex"); + const runRoot = path.join(`${systemDrive}\\`, `NemoClawNativeTurn-${runId}`); + const shareRoot = path.join(`${systemDrive}\\`, `NemoClawNativeShare-${runId}`); + if (fs.existsSync(runRoot) || fs.existsSync(shareRoot)) fail("qualification roots already exist"); + fs.mkdirSync(runRoot); + fs.mkdirSync(shareRoot); + const artifactArgument = argumentValue("--artifact-directory"); + const artifactRoot = path.resolve( + artifactArgument ?? + path.join(process.env.LOCALAPPDATA ?? runRoot, "NVIDIA", "NemoClaw", "evidence"), + ); + fs.mkdirSync(artifactRoot, { recursive: true }); + const receiptPath = path.join(artifactRoot, `native-windows-turn-${runId}.json`); + const gatewayPort = await freePort(); + const mockPort = await freePort(); + const openClawPort = await freePort(); + const sandboxName = `nemoclaw-turn-${runId}`; + const gatewayName = `nemoclaw-gateway-${runId}`; + const stateRoot = path.join(runRoot, "state"); + const configRoot = path.join(runRoot, "config"); + const home = path.join(shareRoot, "home"); + const temp = path.join(shareRoot, "temp"); + for (const directory of [stateRoot, configRoot, home, temp]) + fs.mkdirSync(directory, { recursive: true }); + const probePath = path.join(shareRoot, "probe.mjs"); + const resultPath = path.join(shareRoot, "result.json"); + const policyPath = path.join(runRoot, "policy.yaml"); + fs.writeFileSync(probePath, probeSource(), "utf8"); + fs.writeFileSync( + policyPath, + [ + "version: 1", + "", + "filesystem_policy:", + " include_workdir: false", + " read_only:", + ` - ${quoteYamlPath(node)}`, + ` - ${quoteYamlPath(openClawRoot)}`, + " read_write:", + ` - ${quoteYamlPath(shareRoot)}`, + "", + ].join("\n"), + "utf8", + ); + const gatewayLog = fs.openSync(path.join(runRoot, "openshell-gateway.log"), "w"); + const gatewayError = fs.openSync(path.join(runRoot, "openshell-gateway.err.log"), "w"); + const gatewayEnvironment = allowlistedWindowsEnvironment({ + OPENSHELL_DRIVERS: "mxc", + OPENSHELL_GATEWAY_CONFIG: gatewayConfig, + XDG_CONFIG_HOME: configRoot, + XDG_STATE_HOME: stateRoot, + }); + const gateway = spawn( + gatewayExecutable, + [ + "--port", + String(gatewayPort), + "--disable-tls", + "--db-url", + "sqlite::memory:", + "--log-level", + "info", + ], + { env: gatewayEnvironment, stdio: ["ignore", gatewayLog, gatewayError], windowsHide: true }, + ); + let passed = false; + let result = null; + try { + console.log("NEMOCLAW> Starting installed OpenShell MXC gateway"); + await waitForPort(gatewayPort, gateway); + const cliEnvironment = allowlistedWindowsEnvironment({ + ...gatewayEnvironment, + OPENSHELL_GATEWAY: undefined, + }); + await run( + openshell, + ["gateway", "add", `http://127.0.0.1:${gatewayPort}`, "--local", "--name", gatewayName], + cliEnvironment, + "Registering qualification gateway", + ); + await run( + openshell, + ["gateway", "select", gatewayName], + cliEnvironment, + "Selecting qualification gateway", + ); + const sandboxEnvironment = { + NEMOCLAW_MXC_NODE: node, + NEMOCLAW_MXC_OPENCLAW_ENTRY: openClawEntry, + NEMOCLAW_MXC_HOME: home, + NEMOCLAW_MXC_RESULT: resultPath, + NEMOCLAW_MXC_MOCK_PORT: String(mockPort), + NEMOCLAW_MXC_OPENCLAW_PORT: String(openClawPort), + TEMP: temp, + TMP: temp, + }; + const createArgs = [ + "sandbox", + "create", + "--name", + sandboxName, + "--policy", + policyPath, + "--driver-config-json", + JSON.stringify({ mxc: { command: [node, probePath], cwd: shareRoot } }), + "--no-tty", + ]; + for (const [name, value] of Object.entries(sandboxEnvironment)) + createArgs.push("--env", `${name}=${value}`); + await run(openshell, createArgs, cliEnvironment, "Creating native MXC OpenClaw sandbox"); + console.log("NEMOCLAW> Waiting for the installed OpenClaw agent turn"); + const deadline = Date.now() + TIMEOUT_MS; + while (!fs.existsSync(resultPath) && Date.now() < deadline && gateway.exitCode === null) + await sleep(500); + if (!fs.existsSync(resultPath)) fail("installed OpenClaw turn did not publish a result"); + result = JSON.parse(fs.readFileSync(resultPath, "utf8")); + passed = + result.version === "2026.7.1" && + result.versionExitCode === 0 && + result.healthy === true && + result.chatExitCode === 0 && + result.exactReply === true && + result.reply === "CHAT_OK"; + if (!passed) fail("installed OpenClaw turn result was not exact"); + console.log("AGENT> CHAT_OK"); + await run( + openshell, + ["sandbox", "delete", sandboxName], + cliEnvironment, + "Deleting native MXC sandbox", + ); + fs.writeFileSync( + receiptPath, + `${JSON.stringify({ schemaVersion: 1, classification: "installed-nemoclaw-native-windows-turn", architecture: "arm64", backend: "process_container", openClawVersion: result.version, exactReply: result.reply, sandboxDeleted: true, verdict: "pass" }, null, 2)}\n`, + "utf8", + ); + console.log(`NEMOCLAW> PASS receipt=${receiptPath}`); + } finally { + if (!passed) { + try { + await run( + openshell, + ["sandbox", "delete", sandboxName], + gatewayEnvironment, + "Failure cleanup sandbox delete", + 30_000, + ); + } catch {} + } + if (gateway.exitCode === null) gateway.kill(); + await Promise.race([new Promise((resolve) => gateway.once("exit", resolve)), sleep(5000)]); + fs.closeSync(gatewayLog); + fs.closeSync(gatewayError); + try { + fs.rmSync(runRoot, { recursive: true, force: true }); + } catch {} + try { + fs.rmSync(shareRoot, { recursive: true, force: true }); + } catch {} + } +} + +main().catch((error) => { + console.error( + error instanceof Error ? error.message : "Native Windows turn qualification failed.", + ); + process.exitCode = 1; +}); diff --git a/scripts/checks/build-windows-native-package.ps1 b/scripts/checks/build-windows-native-package.ps1 index 64012f55e5f..c15087bafbe 100644 --- a/scripts/checks/build-windows-native-package.ps1 +++ b/scripts/checks/build-windows-native-package.ps1 @@ -103,6 +103,7 @@ foreach ($requiredPayload in @( 'mxc\wxc-exec.exe', 'mxc\wxc-host-prep.exe', 'config\mxc-gateway.toml', + 'qualification\run-installed-native-turn.mts', 'LICENSE.txt', 'NATIVE-PREVIEW.txt' )) { diff --git a/scripts/checks/prepare-windows-native-package-payload.ps1 b/scripts/checks/prepare-windows-native-package-payload.ps1 index aa16c92c4ad..c4be3a26c8c 100644 --- a/scripts/checks/prepare-windows-native-package-payload.ps1 +++ b/scripts/checks/prepare-windows-native-package-payload.ps1 @@ -123,6 +123,9 @@ $workRoot = Join-Path $env:RUNNER_TEMP ('nemoclaw-native-payload-' + [guid]::New $candidateVersionPath = Join-Path $candidate '.version' $candidateRevisionPath = Join-Path $candidate '.source-revision' $createdCandidateIdentityFiles = $false +$candidatePackageJsonPath = Join-Path $candidate 'package.json' +$candidatePackageJsonBytes = [IO.File]::ReadAllBytes($candidatePackageJsonPath) +$candidatePackageJsonTemporarilyModified = $false try { Invoke-Checked -FilePath $npm -Arguments @('ci', '--ignore-scripts', '--no-audit', '--no-fund') -Label 'NemoClaw dependency restore' -WorkingDirectory $candidate @@ -147,8 +150,21 @@ try { [IO.File]::WriteAllText($candidateRevisionPath, "$candidateRevision`n", [Text.UTF8Encoding]::new($false)) $createdCandidateIdentityFiles = $true - $packOutput = & $npm pack --ignore-scripts --json --pack-destination $workRoot $candidate | Out-String + $packManifest = Get-Content -LiteralPath $candidatePackageJsonPath -Raw | ConvertFrom-Json + if ($null -eq $packManifest.scripts -or [string]::IsNullOrWhiteSpace([string]$packManifest.scripts.prepare)) { + Fail-PayloadPreparation 'Candidate package does not expose the expected prepare lifecycle.' + } + $packManifest.scripts.PSObject.Properties.Remove('prepare') + [IO.File]::WriteAllText( + $candidatePackageJsonPath, + (($packManifest | ConvertTo-Json -Depth 100) + [Environment]::NewLine), + [Text.UTF8Encoding]::new($false) + ) + $candidatePackageJsonTemporarilyModified = $true + $packOutput = & $npm pack --json --pack-destination $workRoot $candidate | Out-String if ($LASTEXITCODE -ne 0) { Fail-PayloadPreparation 'NemoClaw npm package creation failed.' } + [IO.File]::WriteAllBytes($candidatePackageJsonPath, $candidatePackageJsonBytes) + $candidatePackageJsonTemporarilyModified = $false $packReceipt = @($packOutput | ConvertFrom-Json) if ($packReceipt.Count -ne 1 -or [string]::IsNullOrWhiteSpace([string]$packReceipt[0].filename)) { Fail-PayloadPreparation 'NemoClaw npm pack output was invalid.' @@ -168,6 +184,7 @@ try { $nemoclawRoot = Join-Path $output 'nemoclaw' [IO.Directory]::CreateDirectory($nemoclawRoot) | Out-Null Copy-Item -LiteralPath (Join-Path $nemoclawExtract 'package') -Destination (Join-Path $nemoclawRoot 'app') -Recurse + [IO.File]::WriteAllBytes((Join-Path $nemoclawRoot 'app\package.json'), $candidatePackageJsonBytes) Copy-Item -LiteralPath (Join-Path $nemoclawProduction 'node_modules') -Destination (Join-Path $nemoclawRoot 'node_modules') -Recurse $openClawRoot = Join-Path $output 'openclaw' @@ -205,7 +222,7 @@ try { } Copy-Item -LiteralPath (Join-Path $candidate 'packaging\windows\MXC-LICENSE.txt') -Destination (Join-Path $output 'MXC-LICENSE.txt') - $launcher = "@echo off`r`n`"%~dp0node.exe`" `"%~dp0..\nemoclaw\app\bin\nemoclaw.js`" %*`r`n" + $launcher = "@echo off`r`nset `"NEMOCLAW_NATIVE_INSTALL_ROOT=%~dp0..`"`r`n`"%~dp0node.exe`" `"%~dp0..\nemoclaw\app\bin\nemoclaw.js`" %*`r`n" [IO.File]::WriteAllText((Join-Path $binRoot 'nemoclaw.cmd'), $launcher, [Text.ASCIIEncoding]::new()) $openClawLauncher = "@echo off`r`n`"%~dp0node.exe`" `"%~dp0..\openclaw\node_modules\openclaw\openclaw.mjs`" %*`r`n" [IO.File]::WriteAllText((Join-Path $binRoot 'openclaw.cmd'), $openClawLauncher, [Text.ASCIIEncoding]::new()) @@ -222,6 +239,9 @@ pc_capabilities = ["privateNetworkClientServer"] debug = false "@ [IO.File]::WriteAllText((Join-Path $configRoot 'mxc-gateway.toml'), $gatewayConfig, [Text.UTF8Encoding]::new($false)) + $qualificationRoot = Join-Path $output 'qualification' + [IO.Directory]::CreateDirectory($qualificationRoot) | Out-Null + Copy-Item -LiteralPath (Join-Path $candidate 'packaging\windows\runtime\run-installed-native-turn.mts') -Destination $qualificationRoot Copy-Item -LiteralPath (Join-Path $candidate 'LICENSE') -Destination (Join-Path $output 'LICENSE.txt') Copy-Item -LiteralPath (Join-Path $candidate 'packaging\windows\NATIVE-PREVIEW.txt') -Destination (Join-Path $output 'NATIVE-PREVIEW.txt') @@ -238,7 +258,8 @@ debug = false 'bin\nemoclaw.cmd', 'nemoclaw\app\bin\nemoclaw.js', 'openclaw\node_modules\openclaw\openclaw.mjs', - 'config\mxc-gateway.toml' + 'config\mxc-gateway.toml', + 'qualification\run-installed-native-turn.mts' )) { if (-not (Test-Path -LiteralPath (Join-Path $output $required) -PathType Leaf)) { Fail-PayloadPreparation "Prepared payload is incomplete: $required" @@ -264,6 +285,9 @@ debug = false } throw } finally { + if ($candidatePackageJsonTemporarilyModified) { + [IO.File]::WriteAllBytes($candidatePackageJsonPath, $candidatePackageJsonBytes) + } if ($createdCandidateIdentityFiles) { Remove-Item -LiteralPath $candidateVersionPath, $candidateRevisionPath -Force -ErrorAction SilentlyContinue } diff --git a/scripts/checks/run-windows-native-package-qualification.ps1 b/scripts/checks/run-windows-native-package-qualification.ps1 index f4624f9d77f..57fe37ec6b7 100644 --- a/scripts/checks/run-windows-native-package-qualification.ps1 +++ b/scripts/checks/run-windows-native-package-qualification.ps1 @@ -419,7 +419,8 @@ foreach ($requiredPayload in @( 'openclaw\node_modules\openclaw\openclaw.mjs', 'mxc\wxc-exec.exe', 'mxc\wxc-host-prep.exe', - 'config\mxc-gateway.toml' + 'config\mxc-gateway.toml', + 'qualification\run-installed-native-turn.mts' )) { if (-not $payloadHashes.ContainsKey($requiredPayload) -or $payloadHashes[$requiredPayload] -cnotmatch '^[a-f0-9]{64}$') { @@ -435,6 +436,8 @@ $nodePath = Join-Path $installBin 'node.exe' $nemoclawEntryPath = Join-Path $installRoot 'nemoclaw\app\bin\nemoclaw.js' $openClawEntryPath = Join-Path $installRoot 'openclaw\node_modules\openclaw\openclaw.mjs' $wxcExecPath = Join-Path $installRoot 'mxc\wxc-exec.exe' +$wxcHostPrepPath = Join-Path $installRoot 'mxc\wxc-host-prep.exe' +$nemoclawLauncherPath = Join-Path $installBin 'nemoclaw.cmd' $bundleInstallLog = Join-Path $artifactRoot 'bundle-install.log' $msiRepairLog = Join-Path $artifactRoot 'msi-repair.log' $msiReinstallLog = Join-Path $artifactRoot 'msi-reinstall.log' @@ -480,6 +483,27 @@ try { Invoke-NodeCliVersionProbe -NodePath $nodePath -EntryPath $nemoclawEntryPath -ExpectedVersion $ProductVersion -Label 'Installed NemoClaw CLI' Invoke-NodeCliVersionProbe -NodePath $nodePath -EntryPath $openClawEntryPath -ExpectedVersion '2026.7.1' -Label 'Installed OpenClaw runtime' ) + Invoke-BoundedProcess ` + -FilePath $wxcHostPrepPath ` + -Arguments @('prepare-system-drive') ` + -Label 'Prepare ephemeral runner for native MXC qualification' ` + -AllowedExitCodes @(0) | Out-Null + $nativeTurnArtifacts = Join-Path $artifactRoot 'native-turn' + Invoke-BoundedProcess ` + -FilePath $nemoclawLauncherPath ` + -Arguments @('debug', '--native-windows-turn', '--artifact-directory', $nativeTurnArtifacts) ` + -Label 'Installed NemoClaw native MXC agent turn' ` + -AllowedExitCodes @(0) | Out-Null + $nativeTurnReceipts = @(Get-ChildItem -LiteralPath $nativeTurnArtifacts -Filter 'native-windows-turn-*.json' -File) + if ($nativeTurnReceipts.Count -ne 1) { + Fail-PackageQualification 'Installed NemoClaw native turn did not publish exactly one receipt.' + } + $nativeTurnReceipt = Get-Content -LiteralPath $nativeTurnReceipts[0].FullName -Raw | ConvertFrom-Json + if ($nativeTurnReceipt.verdict -cne 'pass' -or $nativeTurnReceipt.exactReply -cne 'CHAT_OK' -or + $nativeTurnReceipt.sandboxDeleted -ne $true) { + Fail-PackageQualification 'Installed NemoClaw native turn receipt is incomplete.' + } + Write-Host '[PASS] Installed nemoclaw command created an MXC sandbox and completed an exact CHAT_OK turn' $msiArp = @(Get-ArpEntries -DisplayName $script:MsiDisplayName) $bundleArp = @(Get-ArpEntries -DisplayName $script:BundleDisplayName) if ($msiArp.Count -ne 1 -or $msiArp[0].displayVersion -cne $ProductVersion) { @@ -596,6 +620,7 @@ try { } nativeExecutions = $nativeEvidence applicationExecutions = $applicationEvidence + nativeTurn = $nativeTurnReceipt msiRegistration = $msiArp bundleRegistration = $bundleArp repairRestoredDigest = $true diff --git a/src/commands/debug.ts b/src/commands/debug.ts index 94341d2faad..59f0dc60801 100644 --- a/src/commands/debug.ts +++ b/src/commands/debug.ts @@ -1,6 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { spawn } from "node:child_process"; +import path from "node:path"; + import { Flags } from "@oclif/core"; import { NemoClawCommand } from "../lib/cli/nemoclaw-oclif-command"; @@ -23,10 +26,36 @@ export default class DebugCliCommand extends NemoClawCommand { quick: Flags.boolean({ char: "q", description: "Only collect minimal diagnostics" }), output: Flags.string({ char: "o", description: "Write a tarball to FILE" }), sandbox: Flags.string({ description: "Target sandbox name" }), + "native-windows-turn": Flags.boolean({ hidden: true }), + "artifact-directory": Flags.string({ hidden: true }), }; public async run(): Promise { const { flags } = await this.parse(DebugCliCommand); + if (flags["native-windows-turn"]) { + const installRoot = process.env.NEMOCLAW_NATIVE_INSTALL_ROOT; + if (!installRoot || process.platform !== "win32") { + this.error("The native Windows qualification turn requires the installed Windows package."); + } + const script = path.join(installRoot, "qualification", "run-installed-native-turn.mts"); + const args = ["--experimental-strip-types", "--no-warnings", script]; + if (flags["artifact-directory"]) { + args.push("--artifact-directory", flags["artifact-directory"]); + } + const exitCode = await new Promise((resolve, reject) => { + const child = spawn(process.execPath, args, { + env: process.env, + stdio: "inherit", + windowsHide: false, + }); + child.once("error", reject); + child.once("close", (code) => resolve(code ?? 1)); + }); + if (exitCode !== 0) { + this.error(`Native Windows MXC qualification turn failed with exit code ${exitCode}.`); + } + return; + } const options: DebugOptions = {}; if (flags.quick) options.quick = true; if (flags.output) options.output = flags.output; From 2a248fc1b056fb260b8b1257ea5f10336d6f0cdf Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 1 Sep 2026 23:58:40 -0700 Subject: [PATCH 040/144] fix(install): package versionless runtime payloads Signed-off-by: Aaron Erickson --- packaging/windows/NemoClaw.wixproj | 4 ++++ packaging/windows/runtime/run-installed-native-turn.mts | 9 ++++++--- .../checks/run-windows-native-package-qualification.ps1 | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packaging/windows/NemoClaw.wixproj b/packaging/windows/NemoClaw.wixproj index 673b7bdc14b..394a439df0e 100644 --- a/packaging/windows/NemoClaw.wixproj +++ b/packaging/windows/NemoClaw.wixproj @@ -10,6 +10,10 @@ false $(DefineConstants);ProductVersion=$(ProductVersion);PayloadRoot=$(PayloadRoot);SourceRoot=$(SourceRoot) true + + ICE60 none diff --git a/packaging/windows/runtime/run-installed-native-turn.mts b/packaging/windows/runtime/run-installed-native-turn.mts index 866b557f211..b90de6ad9c1 100644 --- a/packaging/windows/runtime/run-installed-native-turn.mts +++ b/packaging/windows/runtime/run-installed-native-turn.mts @@ -159,6 +159,7 @@ const required = (name) => { const node = required("NEMOCLAW_MXC_NODE"); const entry = required("NEMOCLAW_MXC_OPENCLAW_ENTRY"); const home = required("NEMOCLAW_MXC_HOME"); +const token = required("NEMOCLAW_MXC_TOKEN"); const resultPath = required("NEMOCLAW_MXC_RESULT"); const mockPort = Number(required("NEMOCLAW_MXC_MOCK_PORT")); const gatewayPort = Number(required("NEMOCLAW_MXC_OPENCLAW_PORT")); @@ -166,7 +167,7 @@ const env = { ...process.env, HOME: home, OPENCLAW_GATEWAY_URL: "ws://127.0.0.1:" + gatewayPort, - OPENCLAW_GATEWAY_TOKEN: "qualification-only-token", + OPENCLAW_GATEWAY_TOKEN: token, USERPROFILE: home, }; const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); @@ -360,10 +361,11 @@ async function main() { ); let passed = false; let result = null; + let cliEnvironment = gatewayEnvironment; try { console.log("NEMOCLAW> Starting installed OpenShell MXC gateway"); await waitForPort(gatewayPort, gateway); - const cliEnvironment = allowlistedWindowsEnvironment({ + cliEnvironment = allowlistedWindowsEnvironment({ ...gatewayEnvironment, OPENSHELL_GATEWAY: undefined, }); @@ -383,6 +385,7 @@ async function main() { NEMOCLAW_MXC_NODE: node, NEMOCLAW_MXC_OPENCLAW_ENTRY: openClawEntry, NEMOCLAW_MXC_HOME: home, + NEMOCLAW_MXC_TOKEN: randomBytes(32).toString("base64url"), NEMOCLAW_MXC_RESULT: resultPath, NEMOCLAW_MXC_MOCK_PORT: String(mockPort), NEMOCLAW_MXC_OPENCLAW_PORT: String(openClawPort), @@ -436,7 +439,7 @@ async function main() { await run( openshell, ["sandbox", "delete", sandboxName], - gatewayEnvironment, + cliEnvironment, "Failure cleanup sandbox delete", 30_000, ); diff --git a/scripts/checks/run-windows-native-package-qualification.ps1 b/scripts/checks/run-windows-native-package-qualification.ps1 index 57fe37ec6b7..69615748f69 100644 --- a/scripts/checks/run-windows-native-package-qualification.ps1 +++ b/scripts/checks/run-windows-native-package-qualification.ps1 @@ -86,7 +86,7 @@ function Invoke-BoundedProcess { if (-not $SuppressProofOutput) { Write-Host "PS> $Label :: $(Split-Path -Leaf $FilePath) $($argumentList -join ' ')" } - $process = Start-Process -FilePath $FilePath -ArgumentList $argumentList -PassThru -ErrorAction Stop + $process = Start-Process -FilePath $FilePath -ArgumentList $argumentList -PassThru -NoNewWindow -ErrorAction Stop try { if (-not $process.WaitForExit($script:OperationTimeoutMilliseconds)) { $process.Kill() From d4471ab3ec3d8dd159b780639f2fdb64f9beafa4 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 00:45:15 -0700 Subject: [PATCH 041/144] fix(test): isolate console execution to NemoClaw Signed-off-by: Aaron Erickson --- .github/workflows/platform-vitest-main.yaml | 32 ++++++++++++++++--- ...n-windows-native-package-qualification.ps1 | 14 ++++---- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index 60063ba2980..12869995dd8 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -309,6 +309,33 @@ jobs: if-no-files-found: error retention-days: 14 + - name: Restore the assembled native NemoClaw runtime payload + id: windows-runtime-payload-cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ${{ runner.temp }}/nemoclaw-native-runtime-payload + key: windows-native-runtime-v1-${{ runner.os }}-${{ runner.arch }}-openshell-bcd517bbe08c-${{ hashFiles('candidate/package.json', 'candidate/package-lock.json', 'candidate/bin/**', 'candidate/src/**', 'candidate/nemoclaw/**', 'candidate/agents/**', 'candidate/scripts/lib/package-blueprint-runner-runtime.mts', 'candidate/scripts/checks/prepare-windows-native-package-payload.ps1', 'candidate/packaging/windows/NATIVE-PREVIEW.txt', 'candidate/packaging/windows/MXC-LICENSE.txt', 'candidate/packaging/windows/runtime/**') }} + + - name: Assemble the native NemoClaw runtime payload + if: ${{ steps.windows-runtime-payload-cache.outputs.cache-hit != 'true' }} + shell: powershell + run: | + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + $candidate = [IO.Path]::GetFullPath("$env:GITHUB_WORKSPACE\candidate") + $openshell = [IO.Path]::GetFullPath("$env:GITHUB_WORKSPACE\openshell") + & "$candidate\scripts\checks\prepare-windows-native-package-payload.ps1" ` + -CandidateCheckout $candidate ` + -OpenShellPayloadRoot "$openshell\target\aarch64-pc-windows-msvc\release" ` + -OutputDirectory "$env:RUNNER_TEMP\nemoclaw-native-runtime-payload" + + - name: Cache the assembled native NemoClaw runtime payload + if: ${{ steps.windows-runtime-payload-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ${{ runner.temp }}/nemoclaw-native-runtime-payload + key: ${{ steps.windows-runtime-payload-cache.outputs.cache-primary-key }} + - name: Build ARM64 MSI and setup executable id: windows-package shell: powershell @@ -316,16 +343,11 @@ jobs: Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $candidate = [IO.Path]::GetFullPath("$env:GITHUB_WORKSPACE\candidate") - $openshell = [IO.Path]::GetFullPath("$env:GITHUB_WORKSPACE\openshell") $version = [string](Get-Content -LiteralPath "$candidate\package.json" -Raw | ConvertFrom-Json).version $packageRoot = [IO.Path]::GetFullPath("$env:RUNNER_TEMP\nemoclaw-windows-package") $payloadRoot = [IO.Path]::GetFullPath("$env:RUNNER_TEMP\nemoclaw-native-runtime-payload") "product_version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "package_root=$packageRoot" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - & "$candidate\scripts\checks\prepare-windows-native-package-payload.ps1" ` - -CandidateCheckout $candidate ` - -OpenShellPayloadRoot "$openshell\target\aarch64-pc-windows-msvc\release" ` - -OutputDirectory $payloadRoot & "$candidate\scripts\checks\build-windows-native-package.ps1" ` -ProductVersion $version ` -PayloadRoot $payloadRoot ` diff --git a/scripts/checks/run-windows-native-package-qualification.ps1 b/scripts/checks/run-windows-native-package-qualification.ps1 index 69615748f69..6acdf57a2e4 100644 --- a/scripts/checks/run-windows-native-package-qualification.ps1 +++ b/scripts/checks/run-windows-native-package-qualification.ps1 @@ -86,7 +86,7 @@ function Invoke-BoundedProcess { if (-not $SuppressProofOutput) { Write-Host "PS> $Label :: $(Split-Path -Leaf $FilePath) $($argumentList -join ' ')" } - $process = Start-Process -FilePath $FilePath -ArgumentList $argumentList -PassThru -NoNewWindow -ErrorAction Stop + $process = Start-Process -FilePath $FilePath -ArgumentList $argumentList -PassThru -ErrorAction Stop try { if (-not $process.WaitForExit($script:OperationTimeoutMilliseconds)) { $process.Kill() @@ -489,11 +489,13 @@ try { -Label 'Prepare ephemeral runner for native MXC qualification' ` -AllowedExitCodes @(0) | Out-Null $nativeTurnArtifacts = Join-Path $artifactRoot 'native-turn' - Invoke-BoundedProcess ` - -FilePath $nemoclawLauncherPath ` - -Arguments @('debug', '--native-windows-turn', '--artifact-directory', $nativeTurnArtifacts) ` - -Label 'Installed NemoClaw native MXC agent turn' ` - -AllowedExitCodes @(0) | Out-Null + Write-Host "PS> Installed NemoClaw native MXC agent turn :: nemoclaw debug --native-windows-turn" + & $nemoclawLauncherPath debug --native-windows-turn --artifact-directory $nativeTurnArtifacts + $nativeTurnExitCode = $LASTEXITCODE + if ($nativeTurnExitCode -ne 0) { + Fail-PackageQualification "Installed NemoClaw native MXC agent turn failed with exit code $nativeTurnExitCode." + } + Write-Host "[PASS] Installed NemoClaw native MXC agent turn exit=$nativeTurnExitCode" $nativeTurnReceipts = @(Get-ChildItem -LiteralPath $nativeTurnArtifacts -Filter 'native-windows-turn-*.json' -File) if ($nativeTurnReceipts.Count -ne 1) { Fail-PackageQualification 'Installed NemoClaw native turn did not publish exactly one receipt.' From 479b36ecc6a80466470f2e72c12055eb0ef848e8 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 01:32:42 -0700 Subject: [PATCH 042/144] fix(test): launch installed CLI on PowerShell 5 Signed-off-by: Aaron Erickson --- .../run-windows-native-package-qualification.ps1 | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/checks/run-windows-native-package-qualification.ps1 b/scripts/checks/run-windows-native-package-qualification.ps1 index 6acdf57a2e4..e9339f7625a 100644 --- a/scripts/checks/run-windows-native-package-qualification.ps1 +++ b/scripts/checks/run-windows-native-package-qualification.ps1 @@ -172,8 +172,9 @@ function Invoke-NodeCliVersionProbe { Write-Host "PS> $Label :: node.exe $(Split-Path -Leaf $EntryPath) --version" $startInfo = [Diagnostics.ProcessStartInfo]::new() $startInfo.FileName = $NodePath - $startInfo.ArgumentList.Add($EntryPath) - $startInfo.ArgumentList.Add('--version') + $startInfo.Arguments = (@($EntryPath, '--version') | ForEach-Object { + ConvertTo-NativeArgument -Value $_ + }) -join ' ' $startInfo.UseShellExecute = $false $startInfo.CreateNoWindow = $true $startInfo.RedirectStandardOutput = $true @@ -488,6 +489,11 @@ try { -Arguments @('prepare-system-drive') ` -Label 'Prepare ephemeral runner for native MXC qualification' ` -AllowedExitCodes @(0) | Out-Null + Invoke-BoundedProcess ` + -FilePath $wxcHostPrepPath ` + -Arguments @('prepare-null-device') ` + -Label 'Prepare the ephemeral runner null device for native MXC qualification' ` + -AllowedExitCodes @(0) | Out-Null $nativeTurnArtifacts = Join-Path $artifactRoot 'native-turn' Write-Host "PS> Installed NemoClaw native MXC agent turn :: nemoclaw debug --native-windows-turn" & $nemoclawLauncherPath debug --native-windows-turn --artifact-directory $nativeTurnArtifacts From c209640d7e15034dfa163017e8ad54d30e07a1e4 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 02:11:40 -0700 Subject: [PATCH 043/144] fix(test): keep MXC host preparation external Signed-off-by: Aaron Erickson --- .../run-windows-native-package-qualification.ps1 | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/scripts/checks/run-windows-native-package-qualification.ps1 b/scripts/checks/run-windows-native-package-qualification.ps1 index e9339f7625a..945a31e1b19 100644 --- a/scripts/checks/run-windows-native-package-qualification.ps1 +++ b/scripts/checks/run-windows-native-package-qualification.ps1 @@ -437,7 +437,6 @@ $nodePath = Join-Path $installBin 'node.exe' $nemoclawEntryPath = Join-Path $installRoot 'nemoclaw\app\bin\nemoclaw.js' $openClawEntryPath = Join-Path $installRoot 'openclaw\node_modules\openclaw\openclaw.mjs' $wxcExecPath = Join-Path $installRoot 'mxc\wxc-exec.exe' -$wxcHostPrepPath = Join-Path $installRoot 'mxc\wxc-host-prep.exe' $nemoclawLauncherPath = Join-Path $installBin 'nemoclaw.cmd' $bundleInstallLog = Join-Path $artifactRoot 'bundle-install.log' $msiRepairLog = Join-Path $artifactRoot 'msi-repair.log' @@ -484,16 +483,6 @@ try { Invoke-NodeCliVersionProbe -NodePath $nodePath -EntryPath $nemoclawEntryPath -ExpectedVersion $ProductVersion -Label 'Installed NemoClaw CLI' Invoke-NodeCliVersionProbe -NodePath $nodePath -EntryPath $openClawEntryPath -ExpectedVersion '2026.7.1' -Label 'Installed OpenClaw runtime' ) - Invoke-BoundedProcess ` - -FilePath $wxcHostPrepPath ` - -Arguments @('prepare-system-drive') ` - -Label 'Prepare ephemeral runner for native MXC qualification' ` - -AllowedExitCodes @(0) | Out-Null - Invoke-BoundedProcess ` - -FilePath $wxcHostPrepPath ` - -Arguments @('prepare-null-device') ` - -Label 'Prepare the ephemeral runner null device for native MXC qualification' ` - -AllowedExitCodes @(0) | Out-Null $nativeTurnArtifacts = Join-Path $artifactRoot 'native-turn' Write-Host "PS> Installed NemoClaw native MXC agent turn :: nemoclaw debug --native-windows-turn" & $nemoclawLauncherPath debug --native-windows-turn --artifact-directory $nativeTurnArtifacts From 5475f6af992017e944366f868bf1f658c7f17993 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 02:46:18 -0700 Subject: [PATCH 044/144] fix(windows): bound the native sandbox name Signed-off-by: Aaron Erickson --- .github/workflows/platform-vitest-main.yaml | 20 +++++++++++++------ .../runtime/run-installed-native-turn.mts | 2 +- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index 12869995dd8..1fb10941ab2 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -314,20 +314,28 @@ jobs: uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: ${{ runner.temp }}/nemoclaw-native-runtime-payload - key: windows-native-runtime-v1-${{ runner.os }}-${{ runner.arch }}-openshell-bcd517bbe08c-${{ hashFiles('candidate/package.json', 'candidate/package-lock.json', 'candidate/bin/**', 'candidate/src/**', 'candidate/nemoclaw/**', 'candidate/agents/**', 'candidate/scripts/lib/package-blueprint-runner-runtime.mts', 'candidate/scripts/checks/prepare-windows-native-package-payload.ps1', 'candidate/packaging/windows/NATIVE-PREVIEW.txt', 'candidate/packaging/windows/MXC-LICENSE.txt', 'candidate/packaging/windows/runtime/**') }} + key: windows-native-runtime-v1-${{ runner.os }}-${{ runner.arch }}-openshell-bcd517bbe08c-${{ hashFiles('candidate/package.json', 'candidate/package-lock.json', 'candidate/bin/**', 'candidate/src/**', 'candidate/nemoclaw/**', 'candidate/agents/**', 'candidate/scripts/lib/package-blueprint-runner-runtime.mts', 'candidate/scripts/checks/prepare-windows-native-package-payload.ps1', 'candidate/packaging/windows/NATIVE-PREVIEW.txt', 'candidate/packaging/windows/MXC-LICENSE.txt') }} + restore-keys: | + windows-native-runtime-v1-${{ runner.os }}-${{ runner.arch }}-openshell-bcd517bbe08c- - name: Assemble the native NemoClaw runtime payload - if: ${{ steps.windows-runtime-payload-cache.outputs.cache-hit != 'true' }} shell: powershell run: | Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $candidate = [IO.Path]::GetFullPath("$env:GITHUB_WORKSPACE\candidate") $openshell = [IO.Path]::GetFullPath("$env:GITHUB_WORKSPACE\openshell") - & "$candidate\scripts\checks\prepare-windows-native-package-payload.ps1" ` - -CandidateCheckout $candidate ` - -OpenShellPayloadRoot "$openshell\target\aarch64-pc-windows-msvc\release" ` - -OutputDirectory "$env:RUNNER_TEMP\nemoclaw-native-runtime-payload" + $payloadRoot = [IO.Path]::GetFullPath("$env:RUNNER_TEMP\nemoclaw-native-runtime-payload") + if (-not (Test-Path -LiteralPath "$payloadRoot\runtime-payload-receipt.json" -PathType Leaf)) { + & "$candidate\scripts\checks\prepare-windows-native-package-payload.ps1" ` + -CandidateCheckout $candidate ` + -OpenShellPayloadRoot "$openshell\target\aarch64-pc-windows-msvc\release" ` + -OutputDirectory $payloadRoot + } + Copy-Item ` + -LiteralPath "$candidate\packaging\windows\runtime\run-installed-native-turn.mts" ` + -Destination "$payloadRoot\qualification\run-installed-native-turn.mts" ` + -Force - name: Cache the assembled native NemoClaw runtime payload if: ${{ steps.windows-runtime-payload-cache.outputs.cache-hit != 'true' }} diff --git a/packaging/windows/runtime/run-installed-native-turn.mts b/packaging/windows/runtime/run-installed-native-turn.mts index b90de6ad9c1..e4e78782ebe 100644 --- a/packaging/windows/runtime/run-installed-native-turn.mts +++ b/packaging/windows/runtime/run-installed-native-turn.mts @@ -310,7 +310,7 @@ async function main() { const gatewayPort = await freePort(); const mockPort = await freePort(); const openClawPort = await freePort(); - const sandboxName = `nemoclaw-turn-${runId}`; + const sandboxName = `nc-${runId}`; const gatewayName = `nemoclaw-gateway-${runId}`; const stateRoot = path.join(runRoot, "state"); const configRoot = path.join(runRoot, "config"); From 6f44a32552c3644e214f4999faf091762bd3ced5 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 03:20:41 -0700 Subject: [PATCH 045/144] fix(windows): stage the native artifact shallowly Signed-off-by: Aaron Erickson --- .../runtime/run-installed-native-turn.mts | 30 +++++++++++++++---- ...n-windows-native-package-qualification.ps1 | 3 +- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/packaging/windows/runtime/run-installed-native-turn.mts b/packaging/windows/runtime/run-installed-native-turn.mts index e4e78782ebe..68c4343e96e 100644 --- a/packaging/windows/runtime/run-installed-native-turn.mts +++ b/packaging/windows/runtime/run-installed-native-turn.mts @@ -275,15 +275,18 @@ async function main() { "NemoClaw installation root", ); const binRoot = requiredDirectory(path.join(installRoot, "bin"), "NemoClaw bin directory"); - const node = requiredFile(path.join(binRoot, "node.exe"), "Node.js runtime"); + const installedNode = requiredFile(path.join(binRoot, "node.exe"), "Node.js runtime"); const openshell = requiredFile(path.join(binRoot, "openshell.exe"), "OpenShell CLI"); const gatewayExecutable = requiredFile( path.join(binRoot, "openshell-gateway.exe"), "OpenShell gateway", ); - const openClawRoot = requiredDirectory(path.join(installRoot, "openclaw"), "OpenClaw runtime"); - const openClawEntry = requiredFile( - path.join(openClawRoot, "node_modules", "openclaw", "openclaw.mjs"), + const installedOpenClawRoot = requiredDirectory( + path.join(installRoot, "openclaw"), + "OpenClaw runtime", + ); + requiredFile( + path.join(installedOpenClawRoot, "node_modules", "openclaw", "openclaw.mjs"), "OpenClaw entrypoint", ); const gatewayConfig = requiredFile( @@ -297,9 +300,21 @@ async function main() { const runId = randomBytes(5).toString("hex"); const runRoot = path.join(`${systemDrive}\\`, `NemoClawNativeTurn-${runId}`); const shareRoot = path.join(`${systemDrive}\\`, `NemoClawNativeShare-${runId}`); - if (fs.existsSync(runRoot) || fs.existsSync(shareRoot)) fail("qualification roots already exist"); + const runtimeRoot = path.join(`${systemDrive}\\`, `NemoClawNativeArtifact-${runId}`); + if (fs.existsSync(runRoot) || fs.existsSync(shareRoot) || fs.existsSync(runtimeRoot)) + fail("qualification roots already exist"); fs.mkdirSync(runRoot); fs.mkdirSync(shareRoot); + fs.mkdirSync(runtimeRoot); + console.log("NEMOCLAW> Staging exact installed Node/OpenClaw bytes at the shallow MXC root"); + const node = path.join(runtimeRoot, "node.exe"); + const openClawRoot = path.join(runtimeRoot, "openclaw"); + fs.copyFileSync(installedNode, node); + fs.cpSync(installedOpenClawRoot, openClawRoot, { recursive: true }); + const openClawEntry = requiredFile( + path.join(openClawRoot, "node_modules", "openclaw", "openclaw.mjs"), + "Staged OpenClaw entrypoint", + ); const artifactArgument = argumentValue("--artifact-directory"); const artifactRoot = path.resolve( artifactArgument ?? @@ -429,7 +444,7 @@ async function main() { ); fs.writeFileSync( receiptPath, - `${JSON.stringify({ schemaVersion: 1, classification: "installed-nemoclaw-native-windows-turn", architecture: "arm64", backend: "process_container", openClawVersion: result.version, exactReply: result.reply, sandboxDeleted: true, verdict: "pass" }, null, 2)}\n`, + `${JSON.stringify({ schemaVersion: 1, classification: "installed-nemoclaw-native-windows-turn", architecture: "arm64", backend: "process_container", artifactStagedAtDriveRoot: true, openClawVersion: result.version, exactReply: result.reply, sandboxDeleted: true, verdict: "pass" }, null, 2)}\n`, "utf8", ); console.log(`NEMOCLAW> PASS receipt=${receiptPath}`); @@ -455,6 +470,9 @@ async function main() { try { fs.rmSync(shareRoot, { recursive: true, force: true }); } catch {} + try { + fs.rmSync(runtimeRoot, { recursive: true, force: true }); + } catch {} } } diff --git a/scripts/checks/run-windows-native-package-qualification.ps1 b/scripts/checks/run-windows-native-package-qualification.ps1 index 945a31e1b19..3847e585ca4 100644 --- a/scripts/checks/run-windows-native-package-qualification.ps1 +++ b/scripts/checks/run-windows-native-package-qualification.ps1 @@ -497,7 +497,8 @@ try { } $nativeTurnReceipt = Get-Content -LiteralPath $nativeTurnReceipts[0].FullName -Raw | ConvertFrom-Json if ($nativeTurnReceipt.verdict -cne 'pass' -or $nativeTurnReceipt.exactReply -cne 'CHAT_OK' -or - $nativeTurnReceipt.sandboxDeleted -ne $true) { + $nativeTurnReceipt.sandboxDeleted -ne $true -or + $nativeTurnReceipt.artifactStagedAtDriveRoot -ne $true) { Fail-PackageQualification 'Installed NemoClaw native turn receipt is incomplete.' } Write-Host '[PASS] Installed nemoclaw command created an MXC sandbox and completed an exact CHAT_OK turn' From 4fb8cd73c4751247a67dc199b447ddedd3476c7b Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 03:55:22 -0700 Subject: [PATCH 046/144] test(windows): retain sanitized MXC diagnostics Signed-off-by: Aaron Erickson --- .../runtime/run-installed-native-turn.mts | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/packaging/windows/runtime/run-installed-native-turn.mts b/packaging/windows/runtime/run-installed-native-turn.mts index 68c4343e96e..1afbcabf9f9 100644 --- a/packaging/windows/runtime/run-installed-native-turn.mts +++ b/packaging/windows/runtime/run-installed-native-turn.mts @@ -143,6 +143,16 @@ function quoteYamlPath(value) { return JSON.stringify(value.replaceAll("\\", "/")); } +function sanitizedDiagnostic(text, replacements) { + let sanitized = text.slice(-64 * 1024); + for (const [value, replacement] of replacements) { + if (value) sanitized = sanitized.replaceAll(value, replacement); + } + return sanitized + .replaceAll(/C:\\Users\\[^\\\r\n]+/giu, "") + .replaceAll(/[A-Za-z0-9_-]{32,}/gu, ""); +} + function probeSource() { return String.raw`import { execFile, spawn } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; @@ -316,12 +326,12 @@ async function main() { "Staged OpenClaw entrypoint", ); const artifactArgument = argumentValue("--artifact-directory"); - const artifactRoot = path.resolve( + const evidenceRoot = path.resolve( artifactArgument ?? path.join(process.env.LOCALAPPDATA ?? runRoot, "NVIDIA", "NemoClaw", "evidence"), ); - fs.mkdirSync(artifactRoot, { recursive: true }); - const receiptPath = path.join(artifactRoot, `native-windows-turn-${runId}.json`); + fs.mkdirSync(evidenceRoot, { recursive: true }); + const receiptPath = path.join(evidenceRoot, `native-windows-turn-${runId}.json`); const gatewayPort = await freePort(); const mockPort = await freePort(); const openClawPort = await freePort(); @@ -355,6 +365,8 @@ async function main() { ); const gatewayLog = fs.openSync(path.join(runRoot, "openshell-gateway.log"), "w"); const gatewayError = fs.openSync(path.join(runRoot, "openshell-gateway.err.log"), "w"); + const gatewayLogPath = path.join(runRoot, "openshell-gateway.log"); + const gatewayErrorPath = path.join(runRoot, "openshell-gateway.err.log"); const gatewayEnvironment = allowlistedWindowsEnvironment({ OPENSHELL_DRIVERS: "mxc", OPENSHELL_GATEWAY_CONFIG: gatewayConfig, @@ -377,6 +389,7 @@ async function main() { let passed = false; let result = null; let cliEnvironment = gatewayEnvironment; + const gatewayToken = randomBytes(32).toString("base64url"); try { console.log("NEMOCLAW> Starting installed OpenShell MXC gateway"); await waitForPort(gatewayPort, gateway); @@ -400,7 +413,7 @@ async function main() { NEMOCLAW_MXC_NODE: node, NEMOCLAW_MXC_OPENCLAW_ENTRY: openClawEntry, NEMOCLAW_MXC_HOME: home, - NEMOCLAW_MXC_TOKEN: randomBytes(32).toString("base64url"), + NEMOCLAW_MXC_TOKEN: gatewayToken, NEMOCLAW_MXC_RESULT: resultPath, NEMOCLAW_MXC_MOCK_PORT: String(mockPort), NEMOCLAW_MXC_OPENCLAW_PORT: String(openClawPort), @@ -464,6 +477,21 @@ async function main() { await Promise.race([new Promise((resolve) => gateway.once("exit", resolve)), sleep(5000)]); fs.closeSync(gatewayLog); fs.closeSync(gatewayError); + if (!passed) { + const diagnostic = sanitizedDiagnostic( + `${fs.readFileSync(gatewayLogPath, "utf8")}\n${fs.readFileSync(gatewayErrorPath, "utf8")}`, + [ + [gatewayToken, ""], + [installRoot, ""], + [runtimeRoot, ""], + [shareRoot, ""], + [runRoot, ""], + ], + ); + const diagnosticPath = path.join(evidenceRoot, `native-windows-turn-diagnostic-${runId}.log`); + fs.writeFileSync(diagnosticPath, diagnostic, "utf8"); + console.error(`NEMOCLAW> Sanitized MXC diagnostic\n${diagnostic}`); + } try { fs.rmSync(runRoot, { recursive: true, force: true }); } catch {} From 8dd76a4728e459def011e724f4208bda5d771361 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 04:28:45 -0700 Subject: [PATCH 047/144] fix(windows): provide the sandbox process environment Signed-off-by: Aaron Erickson --- .../runtime/run-installed-native-turn.mts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packaging/windows/runtime/run-installed-native-turn.mts b/packaging/windows/runtime/run-installed-native-turn.mts index 1afbcabf9f9..e78e7262f8b 100644 --- a/packaging/windows/runtime/run-installed-native-turn.mts +++ b/packaging/windows/runtime/run-installed-native-turn.mts @@ -307,6 +307,11 @@ async function main() { const systemDrive = process.env.SystemDrive; if (!systemDrive || !/^[A-Za-z]:$/u.test(systemDrive)) fail("SystemDrive is invalid"); + const systemRoot = requiredDirectory(process.env.SystemRoot ?? "", "Windows system root"); + const comSpec = requiredFile( + path.join(systemRoot, "System32", "cmd.exe"), + "Windows command host", + ); const runId = randomBytes(5).toString("hex"); const runRoot = path.join(`${systemDrive}\\`, `NemoClawNativeTurn-${runId}`); const shareRoot = path.join(`${systemDrive}\\`, `NemoClawNativeShare-${runId}`); @@ -410,6 +415,9 @@ async function main() { "Selecting qualification gateway", ); const sandboxEnvironment = { + COMSPEC: comSpec, + LOCALAPPDATA: home, + NUMBER_OF_PROCESSORS: process.env.NUMBER_OF_PROCESSORS ?? "1", NEMOCLAW_MXC_NODE: node, NEMOCLAW_MXC_OPENCLAW_ENTRY: openClawEntry, NEMOCLAW_MXC_HOME: home, @@ -417,8 +425,16 @@ async function main() { NEMOCLAW_MXC_RESULT: resultPath, NEMOCLAW_MXC_MOCK_PORT: String(mockPort), NEMOCLAW_MXC_OPENCLAW_PORT: String(openClawPort), + OS: "Windows_NT", + PATH: `${path.join(systemRoot, "System32")};${systemRoot}`, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + PROCESSOR_ARCHITECTURE: "ARM64", + SYSTEMDRIVE: systemDrive, + SYSTEMROOT: systemRoot, TEMP: temp, TMP: temp, + USERPROFILE: home, + WINDIR: systemRoot, }; const createArgs = [ "sandbox", From 2b72dc94ca4bf4c5ce46cd2781f9defa5a2bf9b7 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 05:07:39 -0700 Subject: [PATCH 048/144] fix(install): prepare MXC null device in setup Signed-off-by: Aaron Erickson --- packaging/windows/Bundle.wxs | 9 +++++++++ packaging/windows/NATIVE-PREVIEW.txt | 4 ++++ packaging/windows/NemoClaw.Bundle.wixproj | 3 ++- packaging/windows/README.md | 6 ++++++ scripts/checks/build-windows-native-package.ps1 | 11 ++++++++++- 5 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packaging/windows/Bundle.wxs b/packaging/windows/Bundle.wxs index 28eb69287a6..6547ef44a49 100644 --- a/packaging/windows/Bundle.wxs +++ b/packaging/windows/Bundle.wxs @@ -18,6 +18,15 @@ SuppressRepair="no" /> + diff --git a/packaging/windows/NATIVE-PREVIEW.txt b/packaging/windows/NATIVE-PREVIEW.txt index 3dee696ae8d..427f4884354 100644 --- a/packaging/windows/NATIVE-PREVIEW.txt +++ b/packaging/windows/NATIVE-PREVIEW.txt @@ -5,6 +5,10 @@ native Windows ARM64 builds of openshell.exe and openshell-gateway.exe from NVIDIA/OpenShell#2721 merge commit bcd517bbe08cc80860c9be57699390cd32e8445f, and the pinned Microsoft MXC 0.8.0 ARM64 runtime tools. +The setup executable applies Microsoft MXC's elevated prepare-null-device host +prerequisite before installing the MSI. Windows resets that prerequisite at +reboot; persistent production lifecycle ownership remains deferred. + This is a qualification candidate, not a production support claim. Native MXC execution remains limited by the Windows host build and capabilities. Gateway service registration, supported onboarding, managed inference, local inference, diff --git a/packaging/windows/NemoClaw.Bundle.wixproj b/packaging/windows/NemoClaw.Bundle.wixproj index a967abe8977..69f4fc529d0 100644 --- a/packaging/windows/NemoClaw.Bundle.wixproj +++ b/packaging/windows/NemoClaw.Bundle.wixproj @@ -8,7 +8,7 @@ $(PackageOutputRoot) $(PackageIntermediateRoot)\bundle\ false - $(DefineConstants);ProductVersion=$(ProductVersion);SourceRoot=$(SourceRoot);MsiPath=$(MsiPath) + $(DefineConstants);ProductVersion=$(ProductVersion);SourceRoot=$(SourceRoot);MsiPath=$(MsiPath);WxcHostPrepPath=$(PayloadRoot)\mxc\wxc-host-prep.exe true none $(RestorePackagesPath)\wixtoolset.bootstrapperapplications.wixext\5.0.2\wixext5\WixToolset.BootstrapperApplications.wixext.dll @@ -24,6 +24,7 @@ + diff --git a/packaging/windows/README.md b/packaging/windows/README.md index 0030f4f5288..a2263b9b366 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -16,6 +16,12 @@ and gateway binaries, and pinned Microsoft MXC tools. Windows Installer registers normal Add/Remove Programs metadata and adds the installed `bin` directory to the machine PATH. +The Burn setup runs the pinned Microsoft `wxc-host-prep.exe +prepare-null-device` prerequisite through its per-machine elevated engine before +installing the MSI. This is a native executable prerequisite rather than an MSI +custom action; the setting is required for AppContainer process initialization +and resets when Windows reboots. + The package is a preview distribution boundary. Host qualification, supported onboarding, managed inference, service registration, production activation, and production signing remain separate gates. diff --git a/scripts/checks/build-windows-native-package.ps1 b/scripts/checks/build-windows-native-package.ps1 index c15087bafbe..136197a1e02 100644 --- a/scripts/checks/build-windows-native-package.ps1 +++ b/scripts/checks/build-windows-native-package.ps1 @@ -120,10 +120,19 @@ $authoringText = @( [IO.File]::ReadAllText((Join-Path $sourceRoot 'packaging\windows\Bundle.wxs')) ) -join [Environment]::NewLine if ($authoringText -match '<\s*CustomAction\b' -or - $authoringText -match '<\s*ExePackage\b' -or $authoringText -match '(?i)\b(powershell|pwsh|wsl|bash|ubuntu|docker)\b') { Fail-WindowsPackageBuild 'WiX authoring contains a prohibited custom-action or non-native execution path.' } +$exePackages = @([regex]::Matches($authoringText, '<\s*ExePackage\b[^>]*/>', 'IgnoreCase, Singleline')) +if ($exePackages.Count -ne 1 -or + $exePackages[0].Value -notmatch 'Id="MxcNullDevicePreparation"' -or + $exePackages[0].Value -notmatch 'SourceFile="\$\(var\.WxcHostPrepPath\)"' -or + $exePackages[0].Value -notmatch 'InstallArguments="prepare-null-device"' -or + $exePackages[0].Value -notmatch 'PerMachine="yes"' -or + $exePackages[0].Value -notmatch 'Permanent="yes"' -or + $exePackages[0].Value -notmatch 'Vital="yes"') { + Fail-WindowsPackageBuild 'WiX Burn authoring must contain only the exact pinned MXC null-device prerequisite.' +} [IO.Directory]::CreateDirectory($output) | Out-Null $intermediate = Join-Path $outputParent ('.windows-package-' + [guid]::NewGuid().ToString('N')) From ac48721cfaf24391b10786fe8e4ecf289571f994 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 05:30:59 -0700 Subject: [PATCH 049/144] fix(install): keep null preparation boot-scoped Signed-off-by: Aaron Erickson --- packaging/windows/Bundle.wxs | 1 - 1 file changed, 1 deletion(-) diff --git a/packaging/windows/Bundle.wxs b/packaging/windows/Bundle.wxs index 6547ef44a49..d09dfc70e7c 100644 --- a/packaging/windows/Bundle.wxs +++ b/packaging/windows/Bundle.wxs @@ -22,7 +22,6 @@ Id="MxcNullDevicePreparation" SourceFile="$(var.WxcHostPrepPath)" InstallArguments="prepare-null-device" - RepairArguments="prepare-null-device" PerMachine="yes" Permanent="yes" Compressed="yes" From 428957b782978c50d773b416e994e8e73042f26b Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 05:53:07 -0700 Subject: [PATCH 050/144] fix(install): model boot-ephemeral MXC preparation Signed-off-by: Aaron Erickson --- packaging/windows/NemoClaw.Bundle.wixproj | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packaging/windows/NemoClaw.Bundle.wixproj b/packaging/windows/NemoClaw.Bundle.wixproj index 69f4fc529d0..8218fa3014a 100644 --- a/packaging/windows/NemoClaw.Bundle.wixproj +++ b/packaging/windows/NemoClaw.Bundle.wixproj @@ -10,6 +10,9 @@ false $(DefineConstants);ProductVersion=$(ProductVersion);SourceRoot=$(SourceRoot);MsiPath=$(MsiPath);WxcHostPrepPath=$(PayloadRoot)\mxc\wxc-host-prep.exe true + + 1161 none $(RestorePackagesPath)\wixtoolset.bootstrapperapplications.wixext\5.0.2\wixext5\WixToolset.BootstrapperApplications.wixext.dll From eb5ddee0c9f1724316ba031ac8cb4a990f2b00ae Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 06:30:18 -0700 Subject: [PATCH 051/144] fix(install): prepare the MXC system drive Signed-off-by: Aaron Erickson --- packaging/windows/Bundle.wxs | 8 ++++++ packaging/windows/NATIVE-PREVIEW.txt | 7 +++--- packaging/windows/README.md | 11 ++++---- .../checks/build-windows-native-package.ps1 | 25 +++++++++++++------ 4 files changed, 35 insertions(+), 16 deletions(-) diff --git a/packaging/windows/Bundle.wxs b/packaging/windows/Bundle.wxs index d09dfc70e7c..6661aafb8ae 100644 --- a/packaging/windows/Bundle.wxs +++ b/packaging/windows/Bundle.wxs @@ -18,6 +18,14 @@ SuppressRepair="no" /> + ]*/>', 'IgnoreCase, Singleline')) -if ($exePackages.Count -ne 1 -or - $exePackages[0].Value -notmatch 'Id="MxcNullDevicePreparation"' -or - $exePackages[0].Value -notmatch 'SourceFile="\$\(var\.WxcHostPrepPath\)"' -or - $exePackages[0].Value -notmatch 'InstallArguments="prepare-null-device"' -or - $exePackages[0].Value -notmatch 'PerMachine="yes"' -or - $exePackages[0].Value -notmatch 'Permanent="yes"' -or - $exePackages[0].Value -notmatch 'Vital="yes"') { - Fail-WindowsPackageBuild 'WiX Burn authoring must contain only the exact pinned MXC null-device prerequisite.' +$systemDrivePreparation = @($exePackages | Where-Object { + $_.Value -match 'Id="MxcSystemDrivePreparation"' -and + $_.Value -match 'InstallArguments="prepare-system-drive"' +}) +$nullDevicePreparation = @($exePackages | Where-Object { + $_.Value -match 'Id="MxcNullDevicePreparation"' -and + $_.Value -match 'InstallArguments="prepare-null-device"' +}) +$invalidPrerequisite = @($exePackages | Where-Object { + $_.Value -notmatch 'SourceFile="\$\(var\.WxcHostPrepPath\)"' -or + $_.Value -notmatch 'PerMachine="yes"' -or + $_.Value -notmatch 'Permanent="yes"' -or + $_.Value -notmatch 'Vital="yes"' +}) +if ($exePackages.Count -ne 2 -or $systemDrivePreparation.Count -ne 1 -or + $nullDevicePreparation.Count -ne 1 -or $invalidPrerequisite.Count -ne 0) { + Fail-WindowsPackageBuild 'WiX Burn authoring must contain only the exact pinned MXC host prerequisites.' } [IO.Directory]::CreateDirectory($output) | Out-Null From bffb031002bd150ac19f6f4943d3e79b6b354ae8 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 06:52:24 -0700 Subject: [PATCH 052/144] fix(install): separate MXC prerequisite cache ids Signed-off-by: Aaron Erickson --- packaging/windows/Bundle.wxs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packaging/windows/Bundle.wxs b/packaging/windows/Bundle.wxs index 6661aafb8ae..38d70b9ac35 100644 --- a/packaging/windows/Bundle.wxs +++ b/packaging/windows/Bundle.wxs @@ -20,6 +20,7 @@ Date: Wed, 2 Sep 2026 07:14:49 -0700 Subject: [PATCH 053/144] fix(install): separate MXC prerequisite payloads Signed-off-by: Aaron Erickson --- packaging/windows/Bundle.wxs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packaging/windows/Bundle.wxs b/packaging/windows/Bundle.wxs index 38d70b9ac35..74a01a1cf0f 100644 --- a/packaging/windows/Bundle.wxs +++ b/packaging/windows/Bundle.wxs @@ -21,6 +21,7 @@ Date: Wed, 2 Sep 2026 07:53:56 -0700 Subject: [PATCH 054/144] fix(test): bound full package operations realistically Signed-off-by: Aaron Erickson --- scripts/checks/run-windows-native-package-qualification.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/checks/run-windows-native-package-qualification.ps1 b/scripts/checks/run-windows-native-package-qualification.ps1 index 3847e585ca4..6b6ccc7445a 100644 --- a/scripts/checks/run-windows-native-package-qualification.ps1 +++ b/scripts/checks/run-windows-native-package-qualification.ps1 @@ -24,7 +24,7 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -$script:OperationTimeoutMilliseconds = 300000 +$script:OperationTimeoutMilliseconds = 1200000 $script:ProcessAuditSettleMilliseconds = 3000 $script:MsiDisplayName = 'NemoClaw Native Windows Candidate' $script:BundleDisplayName = 'NemoClaw Native Windows Candidate Setup' From 4aae7e07b6876e89bfeed9652d1a11ddf84ec439 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 08:43:29 -0700 Subject: [PATCH 055/144] fix(windows): enable Node UI compatibility in OpenShell Signed-off-by: Aaron Erickson --- .github/workflows/platform-vitest-main.yaml | 30 +++++++++++++++++++ packaging/windows/NATIVE-PREVIEW.txt | 5 ++++ packaging/windows/README.md | 6 ++++ .../windows/openshell-2721-node-ui.patch | 12 ++++++++ .../checks/build-windows-native-package.ps1 | 1 + ...prepare-windows-native-package-payload.ps1 | 2 ++ ...n-windows-native-package-qualification.ps1 | 3 +- 7 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 packaging/windows/openshell-2721-node-ui.patch diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index 1fb10941ab2..b75c5a7f7aa 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -309,6 +309,20 @@ jobs: if-no-files-found: error retention-days: 14 + - name: Build the pinned Node compatibility derivative + working-directory: openshell + shell: powershell + run: | + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + $patch = [IO.Path]::GetFullPath("$env:GITHUB_WORKSPACE\candidate\packaging\windows\openshell-2721-node-ui.patch") + & git apply --check -- $patch + if ($LASTEXITCODE -ne 0) { throw 'The NVIDIA/OpenShell#2721 Node UI compatibility patch no longer applies exactly.' } + & git apply -- $patch + if ($LASTEXITCODE -ne 0) { throw 'The NVIDIA/OpenShell#2721 Node UI compatibility patch failed.' } + & .\tasks\scripts\windows-msvc.ps1 build aarch64-pc-windows-msvc + & .\tasks\scripts\windows-msvc.ps1 artifacts aarch64-pc-windows-msvc + - name: Restore the assembled native NemoClaw runtime payload id: windows-runtime-payload-cache uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 @@ -336,6 +350,22 @@ jobs: -LiteralPath "$candidate\packaging\windows\runtime\run-installed-native-turn.mts" ` -Destination "$payloadRoot\qualification\run-installed-native-turn.mts" ` -Force + Copy-Item ` + -LiteralPath "$openshell\target\aarch64-pc-windows-msvc\release\openshell.exe" ` + -Destination "$payloadRoot\bin\openshell.exe" ` + -Force + Copy-Item ` + -LiteralPath "$openshell\target\aarch64-pc-windows-msvc\release\openshell-gateway.exe" ` + -Destination "$payloadRoot\bin\openshell-gateway.exe" ` + -Force + Copy-Item ` + -LiteralPath "$candidate\packaging\windows\openshell-2721-node-ui.patch" ` + -Destination "$payloadRoot\OPENSHELL-NODE-UI-COMPATIBILITY.patch" ` + -Force + Copy-Item ` + -LiteralPath "$candidate\packaging\windows\NATIVE-PREVIEW.txt" ` + -Destination "$payloadRoot\NATIVE-PREVIEW.txt" ` + -Force - name: Cache the assembled native NemoClaw runtime payload if: ${{ steps.windows-runtime-payload-cache.outputs.cache-hit != 'true' }} diff --git a/packaging/windows/NATIVE-PREVIEW.txt b/packaging/windows/NATIVE-PREVIEW.txt index 9669b96c96f..053a7d4025b 100644 --- a/packaging/windows/NATIVE-PREVIEW.txt +++ b/packaging/windows/NATIVE-PREVIEW.txt @@ -5,6 +5,11 @@ native Windows ARM64 builds of openshell.exe and openshell-gateway.exe from NVIDIA/OpenShell#2721 merge commit bcd517bbe08cc80860c9be57699390cd32e8445f, and the pinned Microsoft MXC 0.8.0 ARM64 runtime tools. +The packaged OpenShell gateway is a source-built NVIDIA/OpenShell#2721 +derivative with the checked-in OPENSHELL-NODE-UI-COMPATIBILITY.patch applied. +That single compatibility change emits ui.disable=false for the one-shot +ProcessContainer request so the packaged Node.js runtime can initialize. + The setup executable applies Microsoft MXC's elevated prepare-system-drive and prepare-null-device host prerequisites before installing the MSI. Windows resets the null-device prerequisite at reboot; persistent production lifecycle diff --git a/packaging/windows/README.md b/packaging/windows/README.md index 8db62c33fc7..77c524bca19 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -16,6 +16,12 @@ and gateway binaries, and pinned Microsoft MXC tools. Windows Installer registers normal Add/Remove Programs metadata and adds the installed `bin` directory to the machine PATH. +The workflow first builds and qualifies the unmodified NVIDIA/OpenShell#2721 +merge commit, then applies the checked-in one-line Node UI compatibility patch +and rebuilds the packaged derivative. The patch and its exact hash are installed +with the product; it adds `ui.disable=false` to the one-shot ProcessContainer +request and does not bypass OpenShell or call MXC directly from NemoClaw. + The Burn setup runs the pinned Microsoft `wxc-host-prep.exe` system-drive and null-device prerequisites through its per-machine elevated engine before installing the MSI. These are native executable prerequisites rather than MSI diff --git a/packaging/windows/openshell-2721-node-ui.patch b/packaging/windows/openshell-2721-node-ui.patch new file mode 100644 index 00000000000..8611524a8bf --- /dev/null +++ b/packaging/windows/openshell-2721-node-ui.patch @@ -0,0 +1,12 @@ +diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs +index 32d7006d..5bf24476 100644 +--- a/crates/openshell-driver-mxc/src/mxc.rs ++++ b/crates/openshell-driver-mxc/src/mxc.rs +@@ -177,6 +177,7 @@ fn oneshot_config_json( + "timeout": process.timeout, + }, + "processContainer": serde_json::Value::Object(pc_json), ++ "ui": { "disable": false }, + "filesystem": serde_json::Value::Object(filesystem_json), + }); + if let Some(network) = network { diff --git a/scripts/checks/build-windows-native-package.ps1 b/scripts/checks/build-windows-native-package.ps1 index 7ac5868b413..5706f885379 100644 --- a/scripts/checks/build-windows-native-package.ps1 +++ b/scripts/checks/build-windows-native-package.ps1 @@ -104,6 +104,7 @@ foreach ($requiredPayload in @( 'mxc\wxc-host-prep.exe', 'config\mxc-gateway.toml', 'qualification\run-installed-native-turn.mts', + 'OPENSHELL-NODE-UI-COMPATIBILITY.patch', 'LICENSE.txt', 'NATIVE-PREVIEW.txt' )) { diff --git a/scripts/checks/prepare-windows-native-package-payload.ps1 b/scripts/checks/prepare-windows-native-package-payload.ps1 index c4be3a26c8c..5f32fa23810 100644 --- a/scripts/checks/prepare-windows-native-package-payload.ps1 +++ b/scripts/checks/prepare-windows-native-package-payload.ps1 @@ -244,6 +244,7 @@ debug = false Copy-Item -LiteralPath (Join-Path $candidate 'packaging\windows\runtime\run-installed-native-turn.mts') -Destination $qualificationRoot Copy-Item -LiteralPath (Join-Path $candidate 'LICENSE') -Destination (Join-Path $output 'LICENSE.txt') Copy-Item -LiteralPath (Join-Path $candidate 'packaging\windows\NATIVE-PREVIEW.txt') -Destination (Join-Path $output 'NATIVE-PREVIEW.txt') + Copy-Item -LiteralPath (Join-Path $candidate 'packaging\windows\openshell-2721-node-ui.patch') -Destination (Join-Path $output 'OPENSHELL-NODE-UI-COMPATIBILITY.patch') foreach ($portableExecutable in @( 'bin\node.exe', @@ -272,6 +273,7 @@ debug = false node = [pscustomobject]@{ version = $script:NodeVersion; archiveSha256 = $script:NodeArchiveSha256 } openClaw = [pscustomobject]@{ version = '2026.7.1' } openShell = [pscustomobject]@{ pullRequest = 'NVIDIA/OpenShell#2721'; revision = $script:OpenShellRevision } + openShellCompatibilityPatchSha256 = (Get-FileHash -LiteralPath (Join-Path $output 'OPENSHELL-NODE-UI-COMPATIBILITY.patch') -Algorithm SHA256).Hash.ToLowerInvariant() mxc = [pscustomobject]@{ npmPackage = '@microsoft/mxc-sdk'; version = $script:MxcSdkVersion; archiveSha256 = $script:MxcSdkArchiveSha256 } } [IO.File]::WriteAllText( diff --git a/scripts/checks/run-windows-native-package-qualification.ps1 b/scripts/checks/run-windows-native-package-qualification.ps1 index 6b6ccc7445a..8aa2e292f73 100644 --- a/scripts/checks/run-windows-native-package-qualification.ps1 +++ b/scripts/checks/run-windows-native-package-qualification.ps1 @@ -421,7 +421,8 @@ foreach ($requiredPayload in @( 'mxc\wxc-exec.exe', 'mxc\wxc-host-prep.exe', 'config\mxc-gateway.toml', - 'qualification\run-installed-native-turn.mts' + 'qualification\run-installed-native-turn.mts', + 'OPENSHELL-NODE-UI-COMPATIBILITY.patch' )) { if (-not $payloadHashes.ContainsKey($requiredPayload) -or $payloadHashes[$requiredPayload] -cnotmatch '^[a-f0-9]{64}$') { From 8b55f32c04bc53094b77ef92a43ec6ba9e77027e Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 09:27:44 -0700 Subject: [PATCH 056/144] test(windows): report failed sandbox probe results Signed-off-by: Aaron Erickson --- .../windows/runtime/run-installed-native-turn.mts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packaging/windows/runtime/run-installed-native-turn.mts b/packaging/windows/runtime/run-installed-native-turn.mts index e78e7262f8b..cf0b1875447 100644 --- a/packaging/windows/runtime/run-installed-native-turn.mts +++ b/packaging/windows/runtime/run-installed-native-turn.mts @@ -449,7 +449,15 @@ async function main() { ]; for (const [name, value] of Object.entries(sandboxEnvironment)) createArgs.push("--env", `${name}=${value}`); - await run(openshell, createArgs, cliEnvironment, "Creating native MXC OpenClaw sandbox"); + try { + await run(openshell, createArgs, cliEnvironment, "Creating native MXC OpenClaw sandbox"); + } catch (error) { + if (fs.existsSync(resultPath)) { + const failedProbe = JSON.parse(fs.readFileSync(resultPath, "utf8")); + console.error(`NEMOCLAW> Failed sandbox probe result ${JSON.stringify(failedProbe)}`); + } + throw error; + } console.log("NEMOCLAW> Waiting for the installed OpenClaw agent turn"); const deadline = Date.now() + TIMEOUT_MS; while (!fs.existsSync(resultPath) && Date.now() < deadline && gateway.exitCode === null) From 33d01a568e4274747da095a77d7f4238c6959341 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 10:25:10 -0700 Subject: [PATCH 057/144] fix(windows): allow the OpenClaw process tree --- packaging/windows/NATIVE-PREVIEW.txt | 5 ++- packaging/windows/README.md | 8 ++-- .../windows/openshell-2721-node-ui.patch | 39 +++++++++++++++++-- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/packaging/windows/NATIVE-PREVIEW.txt b/packaging/windows/NATIVE-PREVIEW.txt index 053a7d4025b..d5eaac63365 100644 --- a/packaging/windows/NATIVE-PREVIEW.txt +++ b/packaging/windows/NATIVE-PREVIEW.txt @@ -7,8 +7,9 @@ and the pinned Microsoft MXC 0.8.0 ARM64 runtime tools. The packaged OpenShell gateway is a source-built NVIDIA/OpenShell#2721 derivative with the checked-in OPENSHELL-NODE-UI-COMPATIBILITY.patch applied. -That single compatibility change emits ui.disable=false for the one-shot -ProcessContainer request so the packaged Node.js runtime can initialize. +That compatibility change relaxes the one-shot ProcessContainer UI/job policy +so the packaged Node.js/OpenClaw process tree can initialize. MXC filesystem +containment remains active. The setup executable applies Microsoft MXC's elevated prepare-system-drive and prepare-null-device host prerequisites before installing the MSI. Windows diff --git a/packaging/windows/README.md b/packaging/windows/README.md index 77c524bca19..c646b8e1b21 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -17,10 +17,12 @@ registers normal Add/Remove Programs metadata and adds the installed `bin` directory to the machine PATH. The workflow first builds and qualifies the unmodified NVIDIA/OpenShell#2721 -merge commit, then applies the checked-in one-line Node UI compatibility patch +merge commit, then applies the checked-in Node process-tree compatibility patch and rebuilds the packaged derivative. The patch and its exact hash are installed -with the product; it adds `ui.disable=false` to the one-shot ProcessContainer -request and does not bypass OpenShell or call MXC directly from NemoClaw. +with the product; it opts the one-shot ProcessContainer out of the default MXC +UI/job restrictions that prevent the packaged Node/OpenClaw child processes from +initializing. MXC filesystem containment remains active. The package does not +bypass OpenShell or call MXC directly from NemoClaw. The Burn setup runs the pinned Microsoft `wxc-host-prep.exe` system-drive and null-device prerequisites through its per-machine elevated engine before diff --git a/packaging/windows/openshell-2721-node-ui.patch b/packaging/windows/openshell-2721-node-ui.patch index 8611524a8bf..552a5649124 100644 --- a/packaging/windows/openshell-2721-node-ui.patch +++ b/packaging/windows/openshell-2721-node-ui.patch @@ -1,12 +1,45 @@ diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs -index 32d7006d..5bf24476 100644 +index 32d7006d..72175517 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs -@@ -177,6 +177,7 @@ fn oneshot_config_json( +@@ -165,6 +165,12 @@ fn oneshot_config_json( + if !pc.capabilities.is_empty() { + pc_json.insert("capabilities".into(), pc.capabilities.clone().into()); + } ++ pc_json.insert( ++ "ui".into(), ++ serde_json::json!({ ++ "isolation": "desktop", "desktopSystemControl": true, "systemSettings": "all", "ime": true ++ }), ++ ); +- ++ + let mut config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, +@@ -177,6 +183,7 @@ fn oneshot_config_json( "timeout": process.timeout, }, "processContainer": serde_json::Value::Object(pc_json), -+ "ui": { "disable": false }, ++ "ui": { "disable": false, "clipboard": "all", "injection": true }, "filesystem": serde_json::Value::Object(filesystem_json), }); if let Some(network) = network { +@@ -810,6 +817,16 @@ mod tests { + let config = oneshot_config_json("sb-1", &filesystem, &pc, &process, None); +- ++ + assert!(config.get("network").is_none()); ++ assert_eq!(config["ui"]["disable"], false); ++ assert_eq!(config["ui"]["clipboard"], "all"); ++ assert_eq!(config["ui"]["injection"], true); ++ assert_eq!(config["processContainer"]["ui"]["isolation"], "desktop"); ++ assert_eq!( ++ config["processContainer"]["ui"]["desktopSystemControl"], ++ true ++ ); ++ assert_eq!(config["processContainer"]["ui"]["systemSettings"], "all"); ++ assert_eq!(config["processContainer"]["ui"]["ime"], true); + } +- ++ + #[test] From 995d98a6a8d9838d2bdad2f198fb3f8c8220f3ba Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 10:28:41 -0700 Subject: [PATCH 058/144] test(windows): record the installed NemoClaw turn --- .../create-windows-native-proof-video.ps1 | 19 ++++++++++++++++++- ...n-windows-native-package-qualification.ps1 | 3 +++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/scripts/checks/create-windows-native-proof-video.ps1 b/scripts/checks/create-windows-native-proof-video.ps1 index ce485095635..24da78555c6 100644 --- a/scripts/checks/create-windows-native-proof-video.ps1 +++ b/scripts/checks/create-windows-native-proof-video.ps1 @@ -35,7 +35,7 @@ $ErrorActionPreference = 'Stop' $script:CaptureFramesPerSecond = 4 $script:FrameDurationMilliseconds = 250 -$script:MaximumRecordingMilliseconds = 180000 +$script:MaximumRecordingMilliseconds = 1800000 $script:MinimumCaptureFrames = 40 $script:MinimumUniqueFrames = 8 @@ -299,6 +299,9 @@ if (-not $qualification.repairRestoredDigest -or -not $qualification.reinstallPreservedRegistration -or -not $qualification.finalAbsence -or -not $qualification.machinePathRemoved -or + $qualification.nativeTurn.verdict -cne 'pass' -or + $qualification.nativeTurn.exactReply -cne 'CHAT_OK' -or + $qualification.nativeTurn.sandboxDeleted -ne $true -or @($qualification.nativeExecutions).Count -ne 2 -or @($qualification.packageDescendantProhibitedStarts).Count -ne 0 -or @($qualification.newPackageDescendantProhibitedProcesses).Count -ne 0) { @@ -413,6 +416,13 @@ try { (Get-Item -LiteralPath $consoleTranscript).Length -eq 0) { Fail-ProofVideo 'The live console transcript is missing.' } + $consoleTranscriptText = [IO.File]::ReadAllText($consoleTranscript) + if (-not $consoleTranscriptText.Contains('AGENT> CHAT_OK') -or + -not $consoleTranscriptText.Contains( + '[PASS] Installed nemoclaw command created an MXC sandbox and completed an exact CHAT_OK turn' + )) { + Fail-ProofVideo 'The recorded console did not show the installed NemoClaw agent turn.' + } if ($installerWindowFrameCount -lt 4) { Fail-ProofVideo 'The real WiX installer window was not captured for at least one second.' @@ -541,6 +551,12 @@ public static class NemoClawConsoleVideoEncoder $consoleQualificationReceipt = Resolve-RequiredFile ` -Path (Join-Path $consoleQualification 'package-qualification.json') ` -Label 'Recorded console qualification receipt' + $recordedQualification = Get-Content -LiteralPath $consoleQualificationReceipt -Raw | ConvertFrom-Json + if ($recordedQualification.nativeTurn.verdict -cne 'pass' -or + $recordedQualification.nativeTurn.exactReply -cne 'CHAT_OK' -or + $recordedQualification.nativeTurn.sandboxDeleted -ne $true) { + Fail-ProofVideo 'The recorded qualification receipt does not prove the installed NemoClaw turn.' + } $receipt = [pscustomobject]@{ schemaVersion = 2 classification = 'native-windows-candidate-preview-actual-window-recording' @@ -566,6 +582,7 @@ public static class NemoClawConsoleVideoEncoder installerWindowFrameCount = $installerWindowFrameCount recordingWallTimeMilliseconds = $recordingClock.ElapsedMilliseconds qualificationExitCode = $proofExitCode + installedNemoClawTurn = 'CHAT_OK' } video = [pscustomobject]@{ file = $videoName diff --git a/scripts/checks/run-windows-native-package-qualification.ps1 b/scripts/checks/run-windows-native-package-qualification.ps1 index 8aa2e292f73..fd0f33becab 100644 --- a/scripts/checks/run-windows-native-package-qualification.ps1 +++ b/scripts/checks/run-windows-native-package-qualification.ps1 @@ -503,6 +503,9 @@ try { Fail-PackageQualification 'Installed NemoClaw native turn receipt is incomplete.' } Write-Host '[PASS] Installed nemoclaw command created an MXC sandbox and completed an exact CHAT_OK turn' + if ($InteractiveProof) { + Start-Sleep -Seconds 3 + } $msiArp = @(Get-ArpEntries -DisplayName $script:MsiDisplayName) $bundleArp = @(Get-ArpEntries -DisplayName $script:BundleDisplayName) if ($msiArp.Count -ne 1 -or $msiArp[0].displayVersion -cne $ProductVersion) { From df42e2f2da0daeb6ae9626885e68050ed4beeadb Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 10:31:42 -0700 Subject: [PATCH 059/144] test(windows): bind proof to the full runtime --- scripts/checks/create-windows-native-proof-video.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/checks/create-windows-native-proof-video.ps1 b/scripts/checks/create-windows-native-proof-video.ps1 index 24da78555c6..5763b0c0034 100644 --- a/scripts/checks/create-windows-native-proof-video.ps1 +++ b/scripts/checks/create-windows-native-proof-video.ps1 @@ -302,7 +302,8 @@ if (-not $qualification.repairRestoredDigest -or $qualification.nativeTurn.verdict -cne 'pass' -or $qualification.nativeTurn.exactReply -cne 'CHAT_OK' -or $qualification.nativeTurn.sandboxDeleted -ne $true -or - @($qualification.nativeExecutions).Count -ne 2 -or + @($qualification.nativeExecutions).Count -ne 3 -or + @($qualification.applicationExecutions).Count -ne 2 -or @($qualification.packageDescendantProhibitedStarts).Count -ne 0 -or @($qualification.newPackageDescendantProhibitedProcesses).Count -ne 0) { Fail-ProofVideo 'Initial package qualification receipt is not a complete passing lifecycle.' From 32297ad62ced5eeb44461f1943e5d1420e718922 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 10:34:32 -0700 Subject: [PATCH 060/144] fix(windows): normalize the OpenClaw version receipt --- packaging/windows/runtime/run-installed-native-turn.mts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packaging/windows/runtime/run-installed-native-turn.mts b/packaging/windows/runtime/run-installed-native-turn.mts index cf0b1875447..1500681bcb3 100644 --- a/packaging/windows/runtime/run-installed-native-turn.mts +++ b/packaging/windows/runtime/run-installed-native-turn.mts @@ -250,6 +250,7 @@ writeFileSync(join(configDirectory, "openclaw.json"), JSON.stringify({ gateway: { mode: "local", port: gatewayPort, controlUi: { allowInsecureAuth: true, dangerouslyDisableDeviceAuth: false, allowedOrigins: ["http://127.0.0.1:" + gatewayPort] }, trustedProxies: ["127.0.0.1", "::1"], auth: { token: "" }, reload: { mode: "hot" } }, }), "utf8"); const version = await run([entry, "--version"], 30000); +const normalizedVersion = /\b2026\.7\.1\b/u.test(version.stdout) ? "2026.7.1" : version.stdout.trim(); const gateway = spawn(node, [entry, "gateway", "run", "--dev", "--allow-unconfigured", "--auth", "token", "--bind", "loopback", "--port", String(gatewayPort)], { env, stdio: "ignore", windowsHide: true }); let healthy = false; for (let attempt = 0; attempt < 120 && gateway.exitCode === null; attempt += 1) { @@ -267,12 +268,12 @@ try { const payloads = document?.result?.payloads ?? document?.payloads; exactReply = document?.status !== "error" && Array.isArray(payloads) && payloads.length === 1 && payloads[0]?.text === "CHAT_OK"; } catch {} -const result = { version: version.stdout.trim(), versionExitCode: version.exitCode, healthy, chatExitCode: chat.exitCode, exactReply, reply: exactReply ? "CHAT_OK" : null }; +const result = { version: normalizedVersion, versionExitCode: version.exitCode, healthy, chatExitCode: chat.exitCode, exactReply, reply: exactReply ? "CHAT_OK" : null }; writeFileSync(resultPath, JSON.stringify(result), "utf8"); if (gateway.exitCode === null) gateway.kill(); await Promise.race([new Promise((resolve) => gateway.once("exit", resolve)), sleep(5000)]); await new Promise((resolve) => mock.close(resolve)); -process.exit(version.exitCode === 0 && healthy && exactReply ? 0 : 1); +process.exit(version.exitCode === 0 && normalizedVersion === "2026.7.1" && healthy && exactReply ? 0 : 1); `; } From 2c7c2a7aa97a15bd7d9980567aecf73cbb3bfc74 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 10:35:30 -0700 Subject: [PATCH 061/144] fix(ci): bind the runtime cache to the OpenShell patch --- .github/workflows/platform-vitest-main.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index b75c5a7f7aa..77b3fa0fb20 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -328,7 +328,7 @@ jobs: uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: ${{ runner.temp }}/nemoclaw-native-runtime-payload - key: windows-native-runtime-v1-${{ runner.os }}-${{ runner.arch }}-openshell-bcd517bbe08c-${{ hashFiles('candidate/package.json', 'candidate/package-lock.json', 'candidate/bin/**', 'candidate/src/**', 'candidate/nemoclaw/**', 'candidate/agents/**', 'candidate/scripts/lib/package-blueprint-runner-runtime.mts', 'candidate/scripts/checks/prepare-windows-native-package-payload.ps1', 'candidate/packaging/windows/NATIVE-PREVIEW.txt', 'candidate/packaging/windows/MXC-LICENSE.txt') }} + key: windows-native-runtime-v1-${{ runner.os }}-${{ runner.arch }}-openshell-bcd517bbe08c-${{ hashFiles('candidate/package.json', 'candidate/package-lock.json', 'candidate/bin/**', 'candidate/src/**', 'candidate/nemoclaw/**', 'candidate/agents/**', 'candidate/scripts/lib/package-blueprint-runner-runtime.mts', 'candidate/scripts/checks/prepare-windows-native-package-payload.ps1', 'candidate/packaging/windows/NATIVE-PREVIEW.txt', 'candidate/packaging/windows/MXC-LICENSE.txt', 'candidate/packaging/windows/openshell-2721-node-ui.patch') }} restore-keys: | windows-native-runtime-v1-${{ runner.os }}-${{ runner.arch }}-openshell-bcd517bbe08c- From 2253765ad8c37107c0d5efd887ad948d7382f0a6 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 10:50:59 -0700 Subject: [PATCH 062/144] fix(ci): forbid stale native runtime cache fallback --- .github/workflows/platform-vitest-main.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index 77b3fa0fb20..918f944a74e 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -328,9 +328,7 @@ jobs: uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: ${{ runner.temp }}/nemoclaw-native-runtime-payload - key: windows-native-runtime-v1-${{ runner.os }}-${{ runner.arch }}-openshell-bcd517bbe08c-${{ hashFiles('candidate/package.json', 'candidate/package-lock.json', 'candidate/bin/**', 'candidate/src/**', 'candidate/nemoclaw/**', 'candidate/agents/**', 'candidate/scripts/lib/package-blueprint-runner-runtime.mts', 'candidate/scripts/checks/prepare-windows-native-package-payload.ps1', 'candidate/packaging/windows/NATIVE-PREVIEW.txt', 'candidate/packaging/windows/MXC-LICENSE.txt', 'candidate/packaging/windows/openshell-2721-node-ui.patch') }} - restore-keys: | - windows-native-runtime-v1-${{ runner.os }}-${{ runner.arch }}-openshell-bcd517bbe08c- + key: windows-native-runtime-v2-${{ runner.os }}-${{ runner.arch }}-openshell-bcd517bbe08c-${{ hashFiles('candidate/package.json', 'candidate/package-lock.json', 'candidate/bin/**', 'candidate/src/**', 'candidate/nemoclaw/**', 'candidate/agents/**', 'candidate/scripts/lib/package-blueprint-runner-runtime.mts', 'candidate/scripts/checks/prepare-windows-native-package-payload.ps1', 'candidate/packaging/windows/NATIVE-PREVIEW.txt', 'candidate/packaging/windows/MXC-LICENSE.txt', 'candidate/packaging/windows/openshell-2721-node-ui.patch') }} - name: Assemble the native NemoClaw runtime payload shell: powershell From 7fa8f9e70804b10926e7789db474b535333c1e27 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 11:28:05 -0700 Subject: [PATCH 063/144] fix(windows): run OpenClaw inside the MXC process --- packaging/windows/NATIVE-PREVIEW.txt | 6 +- packaging/windows/README.md | 13 +-- .../windows/openshell-2721-node-ui.patch | 37 +-------- .../runtime/run-installed-native-turn.mts | 83 +++++++++---------- .../create-windows-native-proof-video.ps1 | 2 + ...n-windows-native-package-qualification.ps1 | 1 + 6 files changed, 58 insertions(+), 84 deletions(-) diff --git a/packaging/windows/NATIVE-PREVIEW.txt b/packaging/windows/NATIVE-PREVIEW.txt index d5eaac63365..95bc0ffa959 100644 --- a/packaging/windows/NATIVE-PREVIEW.txt +++ b/packaging/windows/NATIVE-PREVIEW.txt @@ -7,9 +7,9 @@ and the pinned Microsoft MXC 0.8.0 ARM64 runtime tools. The packaged OpenShell gateway is a source-built NVIDIA/OpenShell#2721 derivative with the checked-in OPENSHELL-NODE-UI-COMPATIBILITY.patch applied. -That compatibility change relaxes the one-shot ProcessContainer UI/job policy -so the packaged Node.js/OpenClaw process tree can initialize. MXC filesystem -containment remains active. +That compatibility change emits ui.disable=false so the contained Node.js +process can initialize. The qualification turn runs OpenClaw in a worker inside +that same process; MXC filesystem containment remains active. The setup executable applies Microsoft MXC's elevated prepare-system-drive and prepare-null-device host prerequisites before installing the MSI. Windows diff --git a/packaging/windows/README.md b/packaging/windows/README.md index c646b8e1b21..964757cbde5 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -17,12 +17,13 @@ registers normal Add/Remove Programs metadata and adds the installed `bin` directory to the machine PATH. The workflow first builds and qualifies the unmodified NVIDIA/OpenShell#2721 -merge commit, then applies the checked-in Node process-tree compatibility patch -and rebuilds the packaged derivative. The patch and its exact hash are installed -with the product; it opts the one-shot ProcessContainer out of the default MXC -UI/job restrictions that prevent the packaged Node/OpenClaw child processes from -initializing. MXC filesystem containment remains active. The package does not -bypass OpenShell or call MXC directly from NemoClaw. +merge commit, then applies the checked-in Node compatibility patch and rebuilds +the packaged derivative. The patch and its exact hash are installed with the +product; it sets `ui.disable=false` so the contained Node process can initialize. +The qualification turn executes OpenClaw in a worker inside that same contained +Node process, avoiding an unsupported nested-process assumption while retaining +MXC filesystem containment. The package does not bypass OpenShell or call MXC +directly from NemoClaw. The Burn setup runs the pinned Microsoft `wxc-host-prep.exe` system-drive and null-device prerequisites through its per-machine elevated engine before diff --git a/packaging/windows/openshell-2721-node-ui.patch b/packaging/windows/openshell-2721-node-ui.patch index 552a5649124..0ff0ef42af3 100644 --- a/packaging/windows/openshell-2721-node-ui.patch +++ b/packaging/windows/openshell-2721-node-ui.patch @@ -1,45 +1,16 @@ diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs -index 32d7006d..72175517 100644 +index 32d7006d..f6aa58c3 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs -@@ -165,6 +165,12 @@ fn oneshot_config_json( - if !pc.capabilities.is_empty() { - pc_json.insert("capabilities".into(), pc.capabilities.clone().into()); - } -+ pc_json.insert( -+ "ui".into(), -+ serde_json::json!({ -+ "isolation": "desktop", "desktopSystemControl": true, "systemSettings": "all", "ime": true -+ }), -+ ); -- -+ - let mut config = serde_json::json!({ - "version": MXC_SCHEMA_VERSION, -@@ -177,6 +183,7 @@ fn oneshot_config_json( +@@ -177,6 +177,7 @@ fn oneshot_config_json( "timeout": process.timeout, }, "processContainer": serde_json::Value::Object(pc_json), -+ "ui": { "disable": false, "clipboard": "all", "injection": true }, ++ "ui": { "disable": false }, "filesystem": serde_json::Value::Object(filesystem_json), }); if let Some(network) = network { -@@ -810,6 +817,16 @@ mod tests { - let config = oneshot_config_json("sb-1", &filesystem, &pc, &process, None); -- -+ +@@ -812,2 +813,3 @@ mod tests { assert!(config.get("network").is_none()); + assert_eq!(config["ui"]["disable"], false); -+ assert_eq!(config["ui"]["clipboard"], "all"); -+ assert_eq!(config["ui"]["injection"], true); -+ assert_eq!(config["processContainer"]["ui"]["isolation"], "desktop"); -+ assert_eq!( -+ config["processContainer"]["ui"]["desktopSystemControl"], -+ true -+ ); -+ assert_eq!(config["processContainer"]["ui"]["systemSettings"], "all"); -+ assert_eq!(config["processContainer"]["ui"]["ime"], true); } -- -+ - #[test] diff --git a/packaging/windows/runtime/run-installed-native-turn.mts b/packaging/windows/runtime/run-installed-native-turn.mts index 1500681bcb3..ab5c029f0d7 100644 --- a/packaging/windows/runtime/run-installed-native-turn.mts +++ b/packaging/windows/runtime/run-installed-native-turn.mts @@ -154,40 +154,58 @@ function sanitizedDiagnostic(text, replacements) { } function probeSource() { - return String.raw`import { execFile, spawn } from "node:child_process"; + return String.raw`import { Worker } from "node:worker_threads"; import { mkdirSync, writeFileSync } from "node:fs"; import { createServer } from "node:http"; -import { join } from "node:path"; -import { promisify } from "node:util"; +import { dirname, join } from "node:path"; -const execFileAsync = promisify(execFile); const required = (name) => { const value = process.env[name]; if (!value) throw new Error(name + " is required"); return value; }; -const node = required("NEMOCLAW_MXC_NODE"); -const entry = required("NEMOCLAW_MXC_OPENCLAW_ENTRY"); +const launcher = required("NEMOCLAW_MXC_OPENCLAW_ENTRY"); +const entry = join(dirname(launcher), "dist", "entry.js"); const home = required("NEMOCLAW_MXC_HOME"); -const token = required("NEMOCLAW_MXC_TOKEN"); const resultPath = required("NEMOCLAW_MXC_RESULT"); const mockPort = Number(required("NEMOCLAW_MXC_MOCK_PORT")); -const gatewayPort = Number(required("NEMOCLAW_MXC_OPENCLAW_PORT")); const env = { ...process.env, HOME: home, - OPENCLAW_GATEWAY_URL: "ws://127.0.0.1:" + gatewayPort, - OPENCLAW_GATEWAY_TOKEN: token, + NODE_DISABLE_COMPILE_CACHE: "1", + OPENCLAW_HOME: home, + OPENCLAW_NO_RESPAWN: "1", USERPROFILE: home, }; -const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); +const workerSource = [ + 'const { workerData } = require("node:worker_threads");', + 'const { pathToFileURL } = require("node:url");', + 'process.argv = [process.execPath, workerData.entry, ...workerData.args];', + 'import(pathToFileURL(workerData.entry).href).catch((error) => {', + ' console.error(error instanceof Error ? error.stack ?? error.message : String(error));', + ' process.exit(1);', + '});', +].join("\n"); const run = async (args, timeout = 210000) => { - try { - const output = await execFileAsync(node, args, { env, timeout, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }); - return { exitCode: 0, stdout: output.stdout, stderr: output.stderr }; - } catch (error) { - return { exitCode: Number.isInteger(error.code) ? error.code : 1, stdout: error.stdout || "", stderr: error.stderr || "" }; - } + const worker = new Worker(workerSource, { eval: true, env, execArgv: [], resourceLimits: { stackSizeMb: 64 }, stderr: true, stdout: true, workerData: { args, entry } }); + let stdout = ""; + let stderr = ""; + worker.stdout.on("data", (chunk) => { stdout += chunk.toString("utf8"); }); + worker.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); }); + return await new Promise((resolve) => { + const timer = setTimeout(() => { + void worker.terminate(); + resolve({ exitCode: 1, stdout, stderr: stderr + "OpenClaw worker timed out" }); + }, timeout); + worker.once("error", (error) => { + clearTimeout(timer); + resolve({ exitCode: 1, stdout, stderr: stderr + (error instanceof Error ? error.message : String(error)) }); + }); + worker.once("exit", (code) => { + clearTimeout(timer); + resolve({ exitCode: code, stdout, stderr }); + }); + }); }; const readBody = async (request) => { const chunks = []; @@ -247,33 +265,20 @@ writeFileSync(join(configDirectory, "openclaw.json"), JSON.stringify({ models: [{ id: "mock-chat", name: "mock/mock-chat", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 131072, maxTokens: 4096 }], } } }, agents: { defaults: { model: { primary: "mock/mock-chat" }, timeoutSeconds: 180, skipBootstrap: true, thinkingDefault: "off" }, list: [{ id: "main", default: true }] }, - gateway: { mode: "local", port: gatewayPort, controlUi: { allowInsecureAuth: true, dangerouslyDisableDeviceAuth: false, allowedOrigins: ["http://127.0.0.1:" + gatewayPort] }, trustedProxies: ["127.0.0.1", "::1"], auth: { token: "" }, reload: { mode: "hot" } }, }), "utf8"); -const version = await run([entry, "--version"], 30000); +const version = await run(["--version"], 30000); const normalizedVersion = /\b2026\.7\.1\b/u.test(version.stdout) ? "2026.7.1" : version.stdout.trim(); -const gateway = spawn(node, [entry, "gateway", "run", "--dev", "--allow-unconfigured", "--auth", "token", "--bind", "loopback", "--port", String(gatewayPort)], { env, stdio: "ignore", windowsHide: true }); -let healthy = false; -for (let attempt = 0; attempt < 120 && gateway.exitCode === null; attempt += 1) { - const health = await run([entry, "gateway", "health", "--json", "--timeout", "5000"], 15000); - if (health.exitCode === 0) { healthy = true; break; } - await sleep(1000); -} -let chat = { exitCode: 1, stdout: "", stderr: "" }; -if (healthy) { - chat = await run([entry, "agent", "--agent", "main", "--message", "Reply exactly: CHAT_OK", "--thinking", "off", "--timeout", "180", "--json"]); -} +const chat = await run(["agent", "--local", "--agent", "main", "--message", "Reply exactly: CHAT_OK", "--thinking", "off", "--timeout", "180", "--json"]); let exactReply = false; try { const document = JSON.parse(chat.stdout.trim()); const payloads = document?.result?.payloads ?? document?.payloads; exactReply = document?.status !== "error" && Array.isArray(payloads) && payloads.length === 1 && payloads[0]?.text === "CHAT_OK"; } catch {} -const result = { version: normalizedVersion, versionExitCode: version.exitCode, healthy, chatExitCode: chat.exitCode, exactReply, reply: exactReply ? "CHAT_OK" : null }; +const result = { executionMode: "embedded-worker", version: normalizedVersion, versionExitCode: version.exitCode, versionError: version.stderr.slice(-2000), chatExitCode: chat.exitCode, chatError: chat.stderr.slice(-2000), exactReply, reply: exactReply ? "CHAT_OK" : null }; writeFileSync(resultPath, JSON.stringify(result), "utf8"); -if (gateway.exitCode === null) gateway.kill(); -await Promise.race([new Promise((resolve) => gateway.once("exit", resolve)), sleep(5000)]); await new Promise((resolve) => mock.close(resolve)); -process.exit(version.exitCode === 0 && normalizedVersion === "2026.7.1" && healthy && exactReply ? 0 : 1); +process.exit(version.exitCode === 0 && normalizedVersion === "2026.7.1" && chat.exitCode === 0 && exactReply ? 0 : 1); `; } @@ -340,7 +345,6 @@ async function main() { const receiptPath = path.join(evidenceRoot, `native-windows-turn-${runId}.json`); const gatewayPort = await freePort(); const mockPort = await freePort(); - const openClawPort = await freePort(); const sandboxName = `nc-${runId}`; const gatewayName = `nemoclaw-gateway-${runId}`; const stateRoot = path.join(runRoot, "state"); @@ -395,7 +399,6 @@ async function main() { let passed = false; let result = null; let cliEnvironment = gatewayEnvironment; - const gatewayToken = randomBytes(32).toString("base64url"); try { console.log("NEMOCLAW> Starting installed OpenShell MXC gateway"); await waitForPort(gatewayPort, gateway); @@ -419,13 +422,10 @@ async function main() { COMSPEC: comSpec, LOCALAPPDATA: home, NUMBER_OF_PROCESSORS: process.env.NUMBER_OF_PROCESSORS ?? "1", - NEMOCLAW_MXC_NODE: node, NEMOCLAW_MXC_OPENCLAW_ENTRY: openClawEntry, NEMOCLAW_MXC_HOME: home, - NEMOCLAW_MXC_TOKEN: gatewayToken, NEMOCLAW_MXC_RESULT: resultPath, NEMOCLAW_MXC_MOCK_PORT: String(mockPort), - NEMOCLAW_MXC_OPENCLAW_PORT: String(openClawPort), OS: "Windows_NT", PATH: `${path.join(systemRoot, "System32")};${systemRoot}`, PATHEXT: ".COM;.EXE;.BAT;.CMD", @@ -466,9 +466,9 @@ async function main() { if (!fs.existsSync(resultPath)) fail("installed OpenClaw turn did not publish a result"); result = JSON.parse(fs.readFileSync(resultPath, "utf8")); passed = + result.executionMode === "embedded-worker" && result.version === "2026.7.1" && result.versionExitCode === 0 && - result.healthy === true && result.chatExitCode === 0 && result.exactReply === true && result.reply === "CHAT_OK"; @@ -482,7 +482,7 @@ async function main() { ); fs.writeFileSync( receiptPath, - `${JSON.stringify({ schemaVersion: 1, classification: "installed-nemoclaw-native-windows-turn", architecture: "arm64", backend: "process_container", artifactStagedAtDriveRoot: true, openClawVersion: result.version, exactReply: result.reply, sandboxDeleted: true, verdict: "pass" }, null, 2)}\n`, + `${JSON.stringify({ schemaVersion: 1, classification: "installed-nemoclaw-native-windows-turn", architecture: "arm64", backend: "process_container", openClawExecutionMode: result.executionMode, artifactStagedAtDriveRoot: true, openClawVersion: result.version, exactReply: result.reply, sandboxDeleted: true, verdict: "pass" }, null, 2)}\n`, "utf8", ); console.log(`NEMOCLAW> PASS receipt=${receiptPath}`); @@ -506,7 +506,6 @@ async function main() { const diagnostic = sanitizedDiagnostic( `${fs.readFileSync(gatewayLogPath, "utf8")}\n${fs.readFileSync(gatewayErrorPath, "utf8")}`, [ - [gatewayToken, ""], [installRoot, ""], [runtimeRoot, ""], [shareRoot, ""], diff --git a/scripts/checks/create-windows-native-proof-video.ps1 b/scripts/checks/create-windows-native-proof-video.ps1 index 5763b0c0034..02db89c663c 100644 --- a/scripts/checks/create-windows-native-proof-video.ps1 +++ b/scripts/checks/create-windows-native-proof-video.ps1 @@ -301,6 +301,7 @@ if (-not $qualification.repairRestoredDigest -or -not $qualification.machinePathRemoved -or $qualification.nativeTurn.verdict -cne 'pass' -or $qualification.nativeTurn.exactReply -cne 'CHAT_OK' -or + $qualification.nativeTurn.openClawExecutionMode -cne 'embedded-worker' -or $qualification.nativeTurn.sandboxDeleted -ne $true -or @($qualification.nativeExecutions).Count -ne 3 -or @($qualification.applicationExecutions).Count -ne 2 -or @@ -555,6 +556,7 @@ public static class NemoClawConsoleVideoEncoder $recordedQualification = Get-Content -LiteralPath $consoleQualificationReceipt -Raw | ConvertFrom-Json if ($recordedQualification.nativeTurn.verdict -cne 'pass' -or $recordedQualification.nativeTurn.exactReply -cne 'CHAT_OK' -or + $recordedQualification.nativeTurn.openClawExecutionMode -cne 'embedded-worker' -or $recordedQualification.nativeTurn.sandboxDeleted -ne $true) { Fail-ProofVideo 'The recorded qualification receipt does not prove the installed NemoClaw turn.' } diff --git a/scripts/checks/run-windows-native-package-qualification.ps1 b/scripts/checks/run-windows-native-package-qualification.ps1 index fd0f33becab..d565f1f0745 100644 --- a/scripts/checks/run-windows-native-package-qualification.ps1 +++ b/scripts/checks/run-windows-native-package-qualification.ps1 @@ -498,6 +498,7 @@ try { } $nativeTurnReceipt = Get-Content -LiteralPath $nativeTurnReceipts[0].FullName -Raw | ConvertFrom-Json if ($nativeTurnReceipt.verdict -cne 'pass' -or $nativeTurnReceipt.exactReply -cne 'CHAT_OK' -or + $nativeTurnReceipt.openClawExecutionMode -cne 'embedded-worker' -or $nativeTurnReceipt.sandboxDeleted -ne $true -or $nativeTurnReceipt.artifactStagedAtDriveRoot -ne $true) { Fail-PackageQualification 'Installed NemoClaw native turn receipt is incomplete.' From c032a7ff03d72c4c320c55946f0f92d4727d2316 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 11:30:23 -0700 Subject: [PATCH 064/144] chore(windows): remove an unused runtime import --- packaging/windows/runtime/run-installed-native-turn.mts | 1 - 1 file changed, 1 deletion(-) diff --git a/packaging/windows/runtime/run-installed-native-turn.mts b/packaging/windows/runtime/run-installed-native-turn.mts index ab5c029f0d7..2ff3a4396cb 100644 --- a/packaging/windows/runtime/run-installed-native-turn.mts +++ b/packaging/windows/runtime/run-installed-native-turn.mts @@ -5,7 +5,6 @@ import { spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; import fs from "node:fs"; import net from "node:net"; -import os from "node:os"; import path from "node:path"; const TIMEOUT_MS = 300_000; From 2f63b60f151ad7b2265c5ca74281f323fe0eb8c4 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 12:24:55 -0700 Subject: [PATCH 065/144] fix(windows): grant the staged runtime root read-only --- packaging/windows/runtime/run-installed-native-turn.mts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packaging/windows/runtime/run-installed-native-turn.mts b/packaging/windows/runtime/run-installed-native-turn.mts index 2ff3a4396cb..5d7f88967fd 100644 --- a/packaging/windows/runtime/run-installed-native-turn.mts +++ b/packaging/windows/runtime/run-installed-native-turn.mts @@ -364,8 +364,7 @@ async function main() { "filesystem_policy:", " include_workdir: false", " read_only:", - ` - ${quoteYamlPath(node)}`, - ` - ${quoteYamlPath(openClawRoot)}`, + ` - ${quoteYamlPath(runtimeRoot)}`, " read_write:", ` - ${quoteYamlPath(shareRoot)}`, "", From c6d8a32dd5ac0b19622225fc6311a6d5497387ac Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 13:55:47 -0700 Subject: [PATCH 066/144] fix(windows): detach the completed OpenShell watcher --- packaging/windows/NATIVE-PREVIEW.txt | 4 ++ packaging/windows/README.md | 5 +- .../runtime/run-installed-native-turn.mts | 67 +++++++++++++++---- 3 files changed, 61 insertions(+), 15 deletions(-) diff --git a/packaging/windows/NATIVE-PREVIEW.txt b/packaging/windows/NATIVE-PREVIEW.txt index 95bc0ffa959..52f7f5a24a5 100644 --- a/packaging/windows/NATIVE-PREVIEW.txt +++ b/packaging/windows/NATIVE-PREVIEW.txt @@ -11,6 +11,10 @@ That compatibility change emits ui.disable=false so the contained Node.js process can initialize. The qualification turn runs OpenClaw in a worker inside that same process; MXC filesystem containment remains active. +The pinned OpenShell create watcher does not return after the one-shot workload +completes. NemoClaw stops that client-side watcher after receiving the exact +workload result, then deletes the sandbox through OpenShell. + The setup executable applies Microsoft MXC's elevated prepare-system-drive and prepare-null-device host prerequisites before installing the MSI. Windows resets the null-device prerequisite at reboot; persistent production lifecycle diff --git a/packaging/windows/README.md b/packaging/windows/README.md index 964757cbde5..c0bfe6c9d0c 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -23,7 +23,10 @@ product; it sets `ui.disable=false` so the contained Node process can initialize The qualification turn executes OpenClaw in a worker inside that same contained Node process, avoiding an unsupported nested-process assumption while retaining MXC filesystem containment. The package does not bypass OpenShell or call MXC -directly from NemoClaw. +directly from NemoClaw. The pinned OpenShell CLI watch does not return after the +one-shot MXC workload completes, so the qualification command stops that +client-side watcher after receiving the exact workload result and then deletes +the sandbox through OpenShell. The Burn setup runs the pinned Microsoft `wxc-host-prep.exe` system-drive and null-device prerequisites through its per-machine elevated engine before diff --git a/packaging/windows/runtime/run-installed-native-turn.mts b/packaging/windows/runtime/run-installed-native-turn.mts index 5d7f88967fd..c4675c8c90b 100644 --- a/packaging/windows/runtime/run-installed-native-turn.mts +++ b/packaging/windows/runtime/run-installed-native-turn.mts @@ -397,6 +397,9 @@ async function main() { let passed = false; let result = null; let cliEnvironment = gatewayEnvironment; + let create = null; + let createExit = null; + let createWatcherDetached = false; try { console.log("NEMOCLAW> Starting installed OpenShell MXC gateway"); await waitForPort(gatewayPort, gateway); @@ -448,29 +451,62 @@ async function main() { ]; for (const [name, value] of Object.entries(sandboxEnvironment)) createArgs.push("--env", `${name}=${value}`); - try { - await run(openshell, createArgs, cliEnvironment, "Creating native MXC OpenClaw sandbox"); - } catch (error) { - if (fs.existsSync(resultPath)) { - const failedProbe = JSON.parse(fs.readFileSync(resultPath, "utf8")); - console.error(`NEMOCLAW> Failed sandbox probe result ${JSON.stringify(failedProbe)}`); - } - throw error; - } + console.log("NEMOCLAW> Creating native MXC OpenClaw sandbox"); + let createSpawnError = null; + let createClosed = false; + create = spawn(openshell, createArgs, { + env: cliEnvironment, + stdio: "ignore", + windowsHide: true, + }); + create.once("error", (error) => { + createSpawnError = error; + }); + createExit = new Promise((resolve) => + create.once("close", (code) => { + createClosed = true; + resolve(code); + }), + ); console.log("NEMOCLAW> Waiting for the installed OpenClaw agent turn"); const deadline = Date.now() + TIMEOUT_MS; - while (!fs.existsSync(resultPath) && Date.now() < deadline && gateway.exitCode === null) + while ( + !fs.existsSync(resultPath) && + Date.now() < deadline && + gateway.exitCode === null && + !createClosed && + createSpawnError === null + ) await sleep(500); - if (!fs.existsSync(resultPath)) fail("installed OpenClaw turn did not publish a result"); + if (!fs.existsSync(resultPath)) { + if (createSpawnError !== null) throw createSpawnError; + if (createClosed) + fail( + `OpenShell sandbox request exited ${create.exitCode ?? create.signalCode ?? "unknown"} before publishing a result`, + ); + fail("installed OpenClaw turn did not publish a result"); + } + createWatcherDetached = create.exitCode === null; + if (createWatcherDetached) create.kill(); + await Promise.race([createExit, sleep(5000)]); result = JSON.parse(fs.readFileSync(resultPath, "utf8")); - passed = + const turnPassed = result.executionMode === "embedded-worker" && result.version === "2026.7.1" && result.versionExitCode === 0 && result.chatExitCode === 0 && result.exactReply === true && result.reply === "CHAT_OK"; - if (!passed) fail("installed OpenClaw turn result was not exact"); + if (!turnPassed) { + const failedProbe = sanitizedDiagnostic(JSON.stringify(result), [ + [installRoot, ""], + [runtimeRoot, ""], + [shareRoot, ""], + [runRoot, ""], + ]); + console.error(`NEMOCLAW> Failed sandbox probe result ${failedProbe}`); + fail("installed OpenClaw turn result was not exact"); + } console.log("AGENT> CHAT_OK"); await run( openshell, @@ -478,13 +514,16 @@ async function main() { cliEnvironment, "Deleting native MXC sandbox", ); + passed = true; fs.writeFileSync( receiptPath, - `${JSON.stringify({ schemaVersion: 1, classification: "installed-nemoclaw-native-windows-turn", architecture: "arm64", backend: "process_container", openClawExecutionMode: result.executionMode, artifactStagedAtDriveRoot: true, openClawVersion: result.version, exactReply: result.reply, sandboxDeleted: true, verdict: "pass" }, null, 2)}\n`, + `${JSON.stringify({ schemaVersion: 1, classification: "installed-nemoclaw-native-windows-turn", architecture: "arm64", backend: "process_container", openClawExecutionMode: result.executionMode, openShellCreateWatcherDetached: createWatcherDetached, artifactStagedAtDriveRoot: true, openClawVersion: result.version, exactReply: result.reply, sandboxDeleted: true, verdict: "pass" }, null, 2)}\n`, "utf8", ); console.log(`NEMOCLAW> PASS receipt=${receiptPath}`); } finally { + if (create?.exitCode === null) create.kill(); + if (createExit !== null) await Promise.race([createExit, sleep(5000)]); if (!passed) { try { await run( From 27745da72e760e5874a6ffa389e16d5f5fa11f89 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 13:58:09 -0700 Subject: [PATCH 067/144] test(windows): keep the video focused on proof of life --- .../create-windows-native-proof-video.ps1 | 8 +-- ...n-windows-native-package-console-proof.ps1 | 2 +- ...n-windows-native-package-qualification.ps1 | 58 +++++++++++-------- 3 files changed, 40 insertions(+), 28 deletions(-) diff --git a/scripts/checks/create-windows-native-proof-video.ps1 b/scripts/checks/create-windows-native-proof-video.ps1 index 02db89c663c..f7c5e734985 100644 --- a/scripts/checks/create-windows-native-proof-video.ps1 +++ b/scripts/checks/create-windows-native-proof-video.ps1 @@ -6,10 +6,10 @@ Record the live console running the native Windows installer qualification. .DESCRIPTION - Launches the real setup/install/repair/reinstall/uninstall qualification - in a real Windows console, captures the actual console and WiX installer - window pixels four times per second, and encodes those live frames to H.264 - with the Windows Media Foundation-backed Windows.Media.Editing API. + Downloads and launches the real setup, runs the installed NemoClaw turn, + and uninstalls it in a real Windows console. Captures the actual console and + WiX installer window pixels four times per second, then encodes those live + frames to H.264 with Windows Media Foundation. #> [CmdletBinding()] diff --git a/scripts/checks/run-windows-native-package-console-proof.ps1 b/scripts/checks/run-windows-native-package-console-proof.ps1 index 90e1d0fcbcd..dc7d0934bfa 100644 --- a/scripts/checks/run-windows-native-package-console-proof.ps1 +++ b/scripts/checks/run-windows-native-package-console-proof.ps1 @@ -46,7 +46,7 @@ try { $transcriptStarted = $true Clear-Host Write-Host 'NemoClaw Native Windows ARM64 Installer - LIVE CONSOLE PROOF' -ForegroundColor Cyan - Write-Host 'This window is executing the complete setup/install/repair/uninstall flow.' + Write-Host 'This window is downloading, installing, running, and uninstalling NemoClaw.' Write-Host '' Start-Sleep -Seconds 2 diff --git a/scripts/checks/run-windows-native-package-qualification.ps1 b/scripts/checks/run-windows-native-package-qualification.ps1 index d565f1f0745..4cc7cdf1c99 100644 --- a/scripts/checks/run-windows-native-package-qualification.ps1 +++ b/scripts/checks/run-windows-native-package-qualification.ps1 @@ -447,6 +447,8 @@ $bundleUninstallLog = Join-Path $artifactRoot 'bundle-uninstall.log' $preExecution = Get-ProhibitedProcessSnapshot -Phase 'pre-execution' $processAudit = Start-ProhibitedProcessAudit $processAuditStopped = $false +$repairRestoredDigest = $false +$reinstallPreservedRegistration = $false Write-Host "HOST> NemoClaw native Windows ARM64 package qualification" Write-Host "HOST> os=$([Environment]::OSVersion.Version) architecture=$([Runtime.InteropServices.RuntimeInformation]::OSArchitecture) product=$ProductVersion" @@ -521,28 +523,34 @@ try { Write-Host "[PASS] Add/Remove Programs registered MSI=$($msiArp[0].displayVersion) bundle=$($bundleArp[0].displayVersion)" Write-Host '[PASS] Machine PATH contains the installed bin directory exactly once' - [IO.File]::AppendAllText($openshellPath, 'msi-repair-drift', [Text.UTF8Encoding]::new($false)) - Invoke-BoundedProcess ` - -FilePath (Join-Path $env:SystemRoot 'System32\msiexec.exe') ` - -Arguments @('/fa', $msi, '/qn', '/norestart', '/l*v', $msiRepairLog) ` - -Label 'MSI repair' ` - -AllowedExitCodes @(0, 3010) | Out-Null - if ((Get-FileHash -LiteralPath $openshellPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $payloadHashes['bin\openshell.exe']) { - Fail-PackageQualification 'MSI repair did not restore the corrupted OpenShell CLI.' - } - Assert-InstalledTree -Root $installRoot -Phase 'MSI repair' -ExpectedFiles $expectedPayloadFiles - Write-Host '[PASS] MSI repair restored the deliberately corrupted openshell.exe digest' + if ($InteractiveProof) { + Write-Host '[INFO] Repair and reinstall are already proven by the bound initial qualification receipt' + } else { + [IO.File]::AppendAllText($openshellPath, 'msi-repair-drift', [Text.UTF8Encoding]::new($false)) + Invoke-BoundedProcess ` + -FilePath (Join-Path $env:SystemRoot 'System32\msiexec.exe') ` + -Arguments @('/fa', $msi, '/qn', '/norestart', '/l*v', $msiRepairLog) ` + -Label 'MSI repair' ` + -AllowedExitCodes @(0, 3010) | Out-Null + if ((Get-FileHash -LiteralPath $openshellPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $payloadHashes['bin\openshell.exe']) { + Fail-PackageQualification 'MSI repair did not restore the corrupted OpenShell CLI.' + } + Assert-InstalledTree -Root $installRoot -Phase 'MSI repair' -ExpectedFiles $expectedPayloadFiles + $repairRestoredDigest = $true + Write-Host '[PASS] MSI repair restored the deliberately corrupted openshell.exe digest' - Invoke-BoundedProcess ` - -FilePath (Join-Path $env:SystemRoot 'System32\msiexec.exe') ` - -Arguments @('/i', $msi, 'REINSTALL=ALL', 'REINSTALLMODE=vomus', '/qn', '/norestart', '/l*v', $msiReinstallLog) ` - -Label 'MSI reinstall' ` - -AllowedExitCodes @(0, 3010) | Out-Null - if (@(Get-ArpEntries -DisplayName $script:MsiDisplayName).Count -ne 1) { - Fail-PackageQualification 'MSI reinstall did not preserve one product registration.' + Invoke-BoundedProcess ` + -FilePath (Join-Path $env:SystemRoot 'System32\msiexec.exe') ` + -Arguments @('/i', $msi, 'REINSTALL=ALL', 'REINSTALLMODE=vomus', '/qn', '/norestart', '/l*v', $msiReinstallLog) ` + -Label 'MSI reinstall' ` + -AllowedExitCodes @(0, 3010) | Out-Null + if (@(Get-ArpEntries -DisplayName $script:MsiDisplayName).Count -ne 1) { + Fail-PackageQualification 'MSI reinstall did not preserve one product registration.' + } + Assert-InstalledTree -Root $installRoot -Phase 'MSI reinstall' -ExpectedFiles $expectedPayloadFiles + $reinstallPreservedRegistration = $true + Write-Host '[PASS] MSI reinstall preserved exactly one product registration' } - Assert-InstalledTree -Root $installRoot -Phase 'MSI reinstall' -ExpectedFiles $expectedPayloadFiles - Write-Host '[PASS] MSI reinstall preserved exactly one product registration' Invoke-BoundedProcess ` -FilePath (Join-Path $env:SystemRoot 'System32\msiexec.exe') ` @@ -599,7 +607,11 @@ try { } Write-Host "[PASS] Zero prohibited package descendants; runner-wide prohibited starts recorded=$($prohibitedStarts.Count)" - foreach ($logPath in @($bundleInstallLog, $msiRepairLog, $msiReinstallLog, $msiUninstallLog, $bundleUninstallLog)) { + $requiredLogs = @($bundleInstallLog, $msiUninstallLog, $bundleUninstallLog) + if (-not $InteractiveProof) { + $requiredLogs += @($msiRepairLog, $msiReinstallLog) + } + foreach ($logPath in $requiredLogs) { if (-not (Test-Path -LiteralPath $logPath -PathType Leaf) -or (Get-Item -LiteralPath $logPath).Length -eq 0) { Fail-PackageQualification "Installer log is missing: $(Split-Path -Leaf $logPath)" } @@ -626,8 +638,8 @@ try { nativeTurn = $nativeTurnReceipt msiRegistration = $msiArp bundleRegistration = $bundleArp - repairRestoredDigest = $true - reinstallPreservedRegistration = $true + repairRestoredDigest = $repairRestoredDigest + reinstallPreservedRegistration = $reinstallPreservedRegistration finalAbsence = $true machinePathRemoved = $true prohibitedProcessStarts = $prohibitedStarts From ef83c08709284a18ad8295cc8c3c061f97841db2 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 15:26:45 -0700 Subject: [PATCH 068/144] fix(install): exclude unused MXC backends --- packaging/windows/NATIVE-PREVIEW.txt | 3 ++- packaging/windows/README.md | 8 +++++--- .../checks/build-windows-native-package.ps1 | 7 +++++++ .../prepare-windows-native-package-payload.ps1 | 18 +++++++++++++----- ...un-windows-native-package-qualification.ps1 | 8 ++++++++ 5 files changed, 35 insertions(+), 9 deletions(-) diff --git a/packaging/windows/NATIVE-PREVIEW.txt b/packaging/windows/NATIVE-PREVIEW.txt index 52f7f5a24a5..08d8858b986 100644 --- a/packaging/windows/NATIVE-PREVIEW.txt +++ b/packaging/windows/NATIVE-PREVIEW.txt @@ -3,7 +3,8 @@ NemoClaw Native Windows Candidate Preview This package contains the NemoClaw CLI, Node.js 22.22.3, OpenClaw 2026.7.1, native Windows ARM64 builds of openshell.exe and openshell-gateway.exe from NVIDIA/OpenShell#2721 merge commit bcd517bbe08cc80860c9be57699390cd32e8445f, -and the pinned Microsoft MXC 0.8.0 ARM64 runtime tools. +and the pinned Microsoft MXC 0.8.0 ARM64 wxc-exec.exe and wxc-host-prep.exe +ProcessContainer tools. WSLC and other unused backend sidecars are excluded. The packaged OpenShell gateway is a source-built NVIDIA/OpenShell#2721 derivative with the checked-in OPENSHELL-NODE-UI-COMPATIBILITY.patch applied. diff --git a/packaging/windows/README.md b/packaging/windows/README.md index c0bfe6c9d0c..2bf4103fc14 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -12,9 +12,11 @@ custom actions and does not invoke PowerShell, WSL, Bash, Ubuntu, Docker, or a Linux virtual machine. The package installs the exact assembled ARM64 NemoClaw runtime payload under `%ProgramFiles%\NVIDIA\NemoClaw`. That payload contains the NemoClaw CLI, pinned Node.js and OpenClaw runtimes, NVIDIA/OpenShell#2721 CLI -and gateway binaries, and pinned Microsoft MXC tools. Windows Installer -registers normal Add/Remove Programs metadata and adds the installed `bin` -directory to the machine PATH. +and gateway binaries, and only the pinned Microsoft MXC ProcessContainer +executor and host-preparation utility. WSLC, Windows Sandbox, test-proxy, +diagnostic, and learning-mode sidecars from the upstream SDK archive are not +packaged. Windows Installer registers normal Add/Remove Programs metadata and +adds the installed `bin` directory to the machine PATH. The workflow first builds and qualifies the unmodified NVIDIA/OpenShell#2721 merge commit, then applies the checked-in Node compatibility patch and rebuilds diff --git a/scripts/checks/build-windows-native-package.ps1 b/scripts/checks/build-windows-native-package.ps1 index 5706f885379..472c187e4c6 100644 --- a/scripts/checks/build-windows-native-package.ps1 +++ b/scripts/checks/build-windows-native-package.ps1 @@ -112,6 +112,13 @@ foreach ($requiredPayload in @( Fail-WindowsPackageBuild "Required NemoClaw runtime payload is missing: $requiredPayload" } } +$mxcRoot = Join-Path $payload 'mxc' +$mxcPayloadFiles = @(Get-ChildItem -LiteralPath $mxcRoot -Recurse -File | ForEach-Object { + $_.FullName.Substring($mxcRoot.Length + 1) +} | Sort-Object) +if (@(Compare-Object @('wxc-exec.exe', 'wxc-host-prep.exe') $mxcPayloadFiles).Count -ne 0) { + Fail-WindowsPackageBuild 'MXC payload must contain only the ProcessContainer executor and host-preparation utility.' +} Assert-Arm64PortableExecutable -Path (Join-Path $payload 'bin\node.exe') -Label 'node.exe payload' Assert-Arm64PortableExecutable -Path (Join-Path $payload 'mxc\wxc-exec.exe') -Label 'wxc-exec.exe payload' Assert-Arm64PortableExecutable -Path (Join-Path $payload 'mxc\wxc-host-prep.exe') -Label 'wxc-host-prep.exe payload' diff --git a/scripts/checks/prepare-windows-native-package-payload.ps1 b/scripts/checks/prepare-windows-native-package-payload.ps1 index 5f32fa23810..f5f91ea6501 100644 --- a/scripts/checks/prepare-windows-native-package-payload.ps1 +++ b/scripts/checks/prepare-windows-native-package-payload.ps1 @@ -215,10 +215,13 @@ try { Invoke-Checked -FilePath $tar -Arguments @('-xzf', $mxcArchivePath, '-C', $mxcExtract) -Label 'Microsoft MXC SDK extraction' $mxcRoot = Join-Path $output 'mxc' [IO.Directory]::CreateDirectory($mxcRoot) | Out-Null - Get-ChildItem -LiteralPath (Join-Path $mxcExtract 'package\bin\arm64') -File | Where-Object { - $_.Extension -in @('.exe', '.dll') - } | ForEach-Object { - Copy-Item -LiteralPath $_.FullName -Destination (Join-Path $mxcRoot $_.Name) + $mxcDistributionRoot = Join-Path $mxcExtract 'package\bin\arm64' + foreach ($mxcFile in @('wxc-exec.exe', 'wxc-host-prep.exe')) { + $mxcSource = Join-Path $mxcDistributionRoot $mxcFile + if (-not (Test-Path -LiteralPath $mxcSource -PathType Leaf)) { + Fail-PayloadPreparation "Pinned Microsoft MXC archive is missing $mxcFile." + } + Copy-Item -LiteralPath $mxcSource -Destination (Join-Path $mxcRoot $mxcFile) } Copy-Item -LiteralPath (Join-Path $candidate 'packaging\windows\MXC-LICENSE.txt') -Destination (Join-Path $output 'MXC-LICENSE.txt') @@ -274,7 +277,12 @@ debug = false openClaw = [pscustomobject]@{ version = '2026.7.1' } openShell = [pscustomobject]@{ pullRequest = 'NVIDIA/OpenShell#2721'; revision = $script:OpenShellRevision } openShellCompatibilityPatchSha256 = (Get-FileHash -LiteralPath (Join-Path $output 'OPENSHELL-NODE-UI-COMPATIBILITY.patch') -Algorithm SHA256).Hash.ToLowerInvariant() - mxc = [pscustomobject]@{ npmPackage = '@microsoft/mxc-sdk'; version = $script:MxcSdkVersion; archiveSha256 = $script:MxcSdkArchiveSha256 } + mxc = [pscustomobject]@{ + npmPackage = '@microsoft/mxc-sdk' + version = $script:MxcSdkVersion + archiveSha256 = $script:MxcSdkArchiveSha256 + packagedFiles = @('wxc-exec.exe', 'wxc-host-prep.exe') + } } [IO.File]::WriteAllText( (Join-Path $output 'runtime-payload-receipt.json'), diff --git a/scripts/checks/run-windows-native-package-qualification.ps1 b/scripts/checks/run-windows-native-package-qualification.ps1 index 4cc7cdf1c99..6b48f704f9d 100644 --- a/scripts/checks/run-windows-native-package-qualification.ps1 +++ b/scripts/checks/run-windows-native-package-qualification.ps1 @@ -429,6 +429,14 @@ foreach ($requiredPayload in @( Fail-PackageQualification "Package manifest is missing $requiredPayload authority." } } +$mxcManifestFiles = @($expectedPayloadFiles | Where-Object { + $_.StartsWith('mxc\', [StringComparison]::OrdinalIgnoreCase) +} | ForEach-Object { + $_.Substring(4) +} | Sort-Object) +if (@(Compare-Object @('wxc-exec.exe', 'wxc-host-prep.exe') $mxcManifestFiles).Count -ne 0) { + Fail-PackageQualification 'Package manifest contains an unused MXC backend or sidecar.' +} $installRoot = Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles)) 'NVIDIA\NemoClaw' $installBin = Join-Path $installRoot 'bin' From 2b68f38ce503322ee2237b83966b9a79f4f61512 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 16:45:09 -0700 Subject: [PATCH 069/144] fix(test): make process lineage lifecycle-aware --- ...n-windows-native-package-qualification.ps1 | 59 +++++++++++++------ 1 file changed, 40 insertions(+), 19 deletions(-) diff --git a/scripts/checks/run-windows-native-package-qualification.ps1 b/scripts/checks/run-windows-native-package-qualification.ps1 index 6b48f704f9d..4af63a6c951 100644 --- a/scripts/checks/run-windows-native-package-qualification.ps1 +++ b/scripts/checks/run-windows-native-package-qualification.ps1 @@ -312,40 +312,61 @@ function Get-ProhibitedProcessSnapshot { } function Start-ProhibitedProcessAudit { - $sourceIdentifier = 'NemoClawNativePackage-' + [guid]::NewGuid().ToString('N') - Register-WmiEvent -Class Win32_ProcessStartTrace -SourceIdentifier $sourceIdentifier | Out-Null - return $sourceIdentifier + $auditId = [guid]::NewGuid().ToString('N') + $startSourceIdentifier = "NemoClawNativePackageStart-$auditId" + $stopSourceIdentifier = "NemoClawNativePackageStop-$auditId" + Register-WmiEvent -Class Win32_ProcessStartTrace -SourceIdentifier $startSourceIdentifier | Out-Null + Register-WmiEvent -Class Win32_ProcessStopTrace -SourceIdentifier $stopSourceIdentifier | Out-Null + return [pscustomobject]@{ + startSourceIdentifier = $startSourceIdentifier + stopSourceIdentifier = $stopSourceIdentifier + } } function Stop-ProhibitedProcessAudit { param( - [Parameter(Mandatory)][string]$SourceIdentifier, + [Parameter(Mandatory)][object]$Audit, [Parameter(Mandatory)][int]$RootProcessId ) Start-Sleep -Milliseconds $script:ProcessAuditSettleMilliseconds $records = @() - foreach ($auditEvent in @(Get-Event -SourceIdentifier $SourceIdentifier -ErrorAction SilentlyContinue)) { - $processEvent = $auditEvent.SourceEventArgs.NewEvent - $records += [pscustomobject]@{ - processId = [int]$processEvent.ProcessID - parentProcessId = [int]$processEvent.ParentProcessID - processName = [string]$processEvent.ProcessName + foreach ($source in @( + [pscustomobject]@{ identifier = $Audit.startSourceIdentifier; kind = 'start' } + [pscustomobject]@{ identifier = $Audit.stopSourceIdentifier; kind = 'stop' } + )) { + foreach ($auditEvent in @(Get-Event -SourceIdentifier $source.identifier -ErrorAction SilentlyContinue)) { + $processEvent = $auditEvent.SourceEventArgs.NewEvent + $parentProcessId = 0 + if ($source.kind -ceq 'start') { + $parentProcessId = [int]$processEvent.ParentProcessID + } + $records += [pscustomobject]@{ + eventIdentifier = $auditEvent.EventIdentifier + kind = $source.kind + parentProcessId = $parentProcessId + processId = [int]$processEvent.ProcessID + processName = [string]$processEvent.ProcessName + timeGenerated = $auditEvent.TimeGenerated + } + Remove-Event -EventIdentifier $auditEvent.EventIdentifier } - Remove-Event -EventIdentifier $auditEvent.EventIdentifier + Unregister-Event -SourceIdentifier $source.identifier -ErrorAction SilentlyContinue } - Unregister-Event -SourceIdentifier $SourceIdentifier -ErrorAction SilentlyContinue $tracked = @{} $tracked[[string]$RootProcessId] = $true $descendantStarts = @() - foreach ($record in $records) { - if ($tracked.ContainsKey([string]$record.parentProcessId)) { + foreach ($record in @($records | Sort-Object timeGenerated, eventIdentifier)) { + if ($record.kind -ceq 'stop') { + [void]$tracked.Remove([string]$record.processId) + } elseif ($tracked.ContainsKey([string]$record.parentProcessId)) { $descendantStarts += $record $tracked[[string]$record.processId] = $true } } - $prohibitedStarts = @($records | Where-Object { + $startRecords = @($records | Where-Object { $_.kind -ceq 'start' }) + $prohibitedStarts = @($startRecords | Where-Object { $name = $_.processName.ToLowerInvariant() $name -in @('bash.exe', 'docker.exe', 'dockerd.exe', 'wsl.exe') -or $name.StartsWith('com.docker') -or $name.StartsWith('ubuntu') @@ -356,7 +377,7 @@ function Stop-ProhibitedProcessAudit { $name.StartsWith('com.docker') -or $name.StartsWith('ubuntu') }) return [pscustomobject]@{ - allStarts = $records + allStarts = $startRecords descendantStarts = $descendantStarts prohibitedStarts = $prohibitedStarts packageDescendantProhibitedStarts = $packageDescendantProhibitedStarts @@ -583,7 +604,7 @@ try { } Write-Host '[PASS] Windows Installer uninstall removed files, registrations, and PATH' - $auditResult = Stop-ProhibitedProcessAudit -SourceIdentifier $processAudit -RootProcessId $PID + $auditResult = Stop-ProhibitedProcessAudit -Audit $processAudit -RootProcessId $PID $processAuditStopped = $true $setupProcessName = (Split-Path -Leaf $setup).ToLowerInvariant() if (@($auditResult.descendantStarts | Where-Object { @@ -595,7 +616,7 @@ try { $packageDescendantProhibitedStarts = @($auditResult.packageDescendantProhibitedStarts) if ($packageDescendantProhibitedStarts.Count -ne 0) { $names = @($packageDescendantProhibitedStarts | ForEach-Object { - $_.processName + "$($_.processName)(pid=$($_.processId),parent=$($_.parentProcessId))" } | Sort-Object -Unique) -join ', ' Fail-PackageQualification "Package operations started a prohibited descendant process: $names" } @@ -668,7 +689,7 @@ try { } finally { if (-not $processAuditStopped) { try { - Stop-ProhibitedProcessAudit -SourceIdentifier $processAudit -RootProcessId $PID | Out-Null + Stop-ProhibitedProcessAudit -Audit $processAudit -RootProcessId $PID | Out-Null } catch { Write-Warning "Could not stop prohibited-process audit during cleanup: $($_.Exception.Message)" } From 91a447c80da8e8cf821a4e27b43afe2cd7376014 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 19:13:18 -0700 Subject: [PATCH 070/144] fix(windows): bind exact payload and cleanup evidence --- .github/workflows/platform-vitest-main.yaml | 6 +- packaging/windows/Product.wxs | 1 + .../runtime/run-installed-native-turn.mts | 121 +++++++++++++----- .../checks/build-windows-native-package.ps1 | 3 + .../create-windows-native-proof-video.ps1 | 12 +- ...prepare-windows-native-package-payload.ps1 | 1 + ...n-windows-native-package-qualification.ps1 | 5 + 7 files changed, 113 insertions(+), 36 deletions(-) diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index 918f944a74e..bdbdd67dc87 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -328,7 +328,7 @@ jobs: uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: ${{ runner.temp }}/nemoclaw-native-runtime-payload - key: windows-native-runtime-v2-${{ runner.os }}-${{ runner.arch }}-openshell-bcd517bbe08c-${{ hashFiles('candidate/package.json', 'candidate/package-lock.json', 'candidate/bin/**', 'candidate/src/**', 'candidate/nemoclaw/**', 'candidate/agents/**', 'candidate/scripts/lib/package-blueprint-runner-runtime.mts', 'candidate/scripts/checks/prepare-windows-native-package-payload.ps1', 'candidate/packaging/windows/NATIVE-PREVIEW.txt', 'candidate/packaging/windows/MXC-LICENSE.txt', 'candidate/packaging/windows/openshell-2721-node-ui.patch') }} + key: windows-native-runtime-v2-${{ runner.os }}-${{ runner.arch }}-${{ github.sha }}-openshell-bcd517bbe08c-${{ hashFiles('candidate/package.json', 'candidate/package-lock.json', 'candidate/bin/**', 'candidate/src/**', 'candidate/nemoclaw/**', 'candidate/agents/**', 'candidate/scripts/lib/package-blueprint-runner-runtime.mts', 'candidate/scripts/checks/prepare-windows-native-package-payload.ps1', 'candidate/packaging/windows/NATIVE-PREVIEW.txt', 'candidate/packaging/windows/MXC-LICENSE.txt', 'candidate/packaging/windows/openshell-2721-node-ui.patch') }} - name: Assemble the native NemoClaw runtime payload shell: powershell @@ -344,6 +344,10 @@ jobs: -OpenShellPayloadRoot "$openshell\target\aarch64-pc-windows-msvc\release" ` -OutputDirectory $payloadRoot } + $payloadReceipt = Get-Content -LiteralPath "$payloadRoot\runtime-payload-receipt.json" -Raw | ConvertFrom-Json + if ($payloadReceipt.nemoclaw.revision -cne $env:GITHUB_SHA) { + throw 'The cached native NemoClaw runtime payload does not match the exact candidate revision.' + } Copy-Item ` -LiteralPath "$candidate\packaging\windows\runtime\run-installed-native-turn.mts" ` -Destination "$payloadRoot\qualification\run-installed-native-turn.mts" ` diff --git a/packaging/windows/Product.wxs b/packaging/windows/Product.wxs index 5f3e63d94bd..b0b195fda04 100644 --- a/packaging/windows/Product.wxs +++ b/packaging/windows/Product.wxs @@ -15,6 +15,7 @@ Description="NemoClaw native Windows ARM64 candidate payload" Manufacturer="NVIDIA Corporation" /> diff --git a/packaging/windows/runtime/run-installed-native-turn.mts b/packaging/windows/runtime/run-installed-native-turn.mts index c4675c8c90b..5ab4a5d9f5e 100644 --- a/packaging/windows/runtime/run-installed-native-turn.mts +++ b/packaging/windows/runtime/run-installed-native-turn.mts @@ -138,6 +138,41 @@ async function run(file, args, environment, label, timeout = TIMEOUT_MS) { }); } +async function stopChild(child) { + if (child.exitCode !== null || child.signalCode !== null) return true; + const exited = new Promise((resolve) => child.once("exit", () => resolve(true))); + child.kill(); + return await Promise.race([exited, sleep(5000).then(() => false)]); +} + +async function removeDirectory(directory) { + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + fs.rmSync(directory, { recursive: true, force: true }); + } catch {} + if (!fs.existsSync(directory)) return true; + await sleep(1000); + } + return false; +} + +async function waitForFileText(file, expected, timeout = 30_000) { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + if (fs.existsSync(file) && fs.readFileSync(file, "utf8").includes(expected)) return true; + await sleep(250); + } + return false; +} + +function jsonContainsExactValue(value, target) { + if (value === target) return true; + if (Array.isArray(value)) return value.some((item) => jsonContainsExactValue(item, target)); + if (value !== null && typeof value === "object") + return Object.values(value).some((item) => jsonContainsExactValue(item, target)); + return false; +} + function quoteYamlPath(value) { return JSON.stringify(value.replaceAll("\\", "/")); } @@ -398,8 +433,8 @@ async function main() { let result = null; let cliEnvironment = gatewayEnvironment; let create = null; - let createExit = null; let createWatcherDetached = false; + let logsClosed = false; try { console.log("NEMOCLAW> Starting installed OpenShell MXC gateway"); await waitForPort(gatewayPort, gateway); @@ -462,12 +497,9 @@ async function main() { create.once("error", (error) => { createSpawnError = error; }); - createExit = new Promise((resolve) => - create.once("close", (code) => { - createClosed = true; - resolve(code); - }), - ); + create.once("close", () => { + createClosed = true; + }); console.log("NEMOCLAW> Waiting for the installed OpenClaw agent turn"); const deadline = Date.now() + TIMEOUT_MS; while ( @@ -487,8 +519,7 @@ async function main() { fail("installed OpenClaw turn did not publish a result"); } createWatcherDetached = create.exitCode === null; - if (createWatcherDetached) create.kill(); - await Promise.race([createExit, sleep(5000)]); + if (!(await stopChild(create))) fail("OpenShell sandbox request watcher did not stop"); result = JSON.parse(fs.readFileSync(resultPath, "utf8")); const turnPassed = result.executionMode === "embedded-worker" && @@ -507,6 +538,8 @@ async function main() { console.error(`NEMOCLAW> Failed sandbox probe result ${failedProbe}`); fail("installed OpenClaw turn result was not exact"); } + if (!(await waitForFileText(gatewayLogPath, "MXC agent exec completed successfully"))) + fail("OpenShell did not report successful MXC workload termination"); console.log("AGENT> CHAT_OK"); await run( openshell, @@ -514,16 +547,37 @@ async function main() { cliEnvironment, "Deleting native MXC sandbox", ); - passed = true; + const sandboxList = await run( + openshell, + ["sandbox", "list", "-o", "json"], + cliEnvironment, + "Verifying native MXC sandbox registry cleanup", + ); + let sandboxRegistry; + try { + sandboxRegistry = JSON.parse(sandboxList.stdout.trim()); + } catch { + fail("OpenShell sandbox registry output was not JSON"); + } + if (jsonContainsExactValue(sandboxRegistry, sandboxName)) + fail("native MXC sandbox remained registered after deletion"); + if (!(await stopChild(gateway))) fail("OpenShell MXC gateway did not stop"); + fs.closeSync(gatewayLog); + fs.closeSync(gatewayError); + logsClosed = true; + for (const directory of [runRoot, shareRoot, runtimeRoot]) { + if (!(await removeDirectory(directory))) + fail(`qualification root remained after cleanup: ${path.basename(directory)}`); + } fs.writeFileSync( receiptPath, - `${JSON.stringify({ schemaVersion: 1, classification: "installed-nemoclaw-native-windows-turn", architecture: "arm64", backend: "process_container", openClawExecutionMode: result.executionMode, openShellCreateWatcherDetached: createWatcherDetached, artifactStagedAtDriveRoot: true, openClawVersion: result.version, exactReply: result.reply, sandboxDeleted: true, verdict: "pass" }, null, 2)}\n`, + `${JSON.stringify({ schemaVersion: 1, classification: "installed-nemoclaw-native-windows-turn", architecture: "arm64", backend: "process_container", openClawExecutionMode: result.executionMode, openShellCreateWatcherDetached: createWatcherDetached, createWatcherStopped: true, workloadStopped: true, gatewayStopped: true, artifactStagedAtDriveRoot: true, openClawVersion: result.version, exactReply: result.reply, sandboxDeleted: true, sandboxRegistryAbsent: true, qualificationRootsRemoved: true, verdict: "pass" }, null, 2)}\n`, "utf8", ); + passed = true; console.log(`NEMOCLAW> PASS receipt=${receiptPath}`); } finally { - if (create?.exitCode === null) create.kill(); - if (createExit !== null) await Promise.race([createExit, sleep(5000)]); + if (create !== null) await stopChild(create); if (!passed) { try { await run( @@ -535,33 +589,32 @@ async function main() { ); } catch {} } - if (gateway.exitCode === null) gateway.kill(); - await Promise.race([new Promise((resolve) => gateway.once("exit", resolve)), sleep(5000)]); - fs.closeSync(gatewayLog); - fs.closeSync(gatewayError); + await stopChild(gateway); + if (!logsClosed) { + fs.closeSync(gatewayLog); + fs.closeSync(gatewayError); + logsClosed = true; + } if (!passed) { - const diagnostic = sanitizedDiagnostic( - `${fs.readFileSync(gatewayLogPath, "utf8")}\n${fs.readFileSync(gatewayErrorPath, "utf8")}`, - [ + const diagnosticParts = [gatewayLogPath, gatewayErrorPath] + .filter((file) => fs.existsSync(file)) + .map((file) => fs.readFileSync(file, "utf8")); + if (diagnosticParts.length > 0) { + const diagnostic = sanitizedDiagnostic(diagnosticParts.join("\n"), [ [installRoot, ""], [runtimeRoot, ""], [shareRoot, ""], [runRoot, ""], - ], - ); - const diagnosticPath = path.join(evidenceRoot, `native-windows-turn-diagnostic-${runId}.log`); - fs.writeFileSync(diagnosticPath, diagnostic, "utf8"); - console.error(`NEMOCLAW> Sanitized MXC diagnostic\n${diagnostic}`); + ]); + const diagnosticPath = path.join( + evidenceRoot, + `native-windows-turn-diagnostic-${runId}.log`, + ); + fs.writeFileSync(diagnosticPath, diagnostic, "utf8"); + console.error(`NEMOCLAW> Sanitized MXC diagnostic\n${diagnostic}`); + } } - try { - fs.rmSync(runRoot, { recursive: true, force: true }); - } catch {} - try { - fs.rmSync(shareRoot, { recursive: true, force: true }); - } catch {} - try { - fs.rmSync(runtimeRoot, { recursive: true, force: true }); - } catch {} + for (const directory of [runRoot, shareRoot, runtimeRoot]) await removeDirectory(directory); } } diff --git a/scripts/checks/build-windows-native-package.ps1 b/scripts/checks/build-windows-native-package.ps1 index 472c187e4c6..b7c6d129fda 100644 --- a/scripts/checks/build-windows-native-package.ps1 +++ b/scripts/checks/build-windows-native-package.ps1 @@ -131,6 +131,9 @@ if ($authoringText -match '<\s*CustomAction\b' -or $authoringText -match '(?i)\b(powershell|pwsh|wsl|bash|ubuntu|docker)\b') { Fail-WindowsPackageBuild 'WiX authoring contains a prohibited custom-action or non-native execution path.' } +if ($authoringText -notmatch '<\s*MajorUpgrade\b[^>]*Schedule="afterInstallInitialize"') { + Fail-WindowsPackageBuild 'Major-upgrade removal must remain inside MSI rollback protection.' +} $exePackages = @([regex]::Matches($authoringText, '<\s*ExePackage\b[^>]*/>', 'IgnoreCase, Singleline')) $systemDrivePreparation = @($exePackages | Where-Object { $_.Value -match 'Id="MxcSystemDrivePreparation"' -and diff --git a/scripts/checks/create-windows-native-proof-video.ps1 b/scripts/checks/create-windows-native-proof-video.ps1 index f7c5e734985..3f289fcf1e0 100644 --- a/scripts/checks/create-windows-native-proof-video.ps1 +++ b/scripts/checks/create-windows-native-proof-video.ps1 @@ -302,7 +302,12 @@ if (-not $qualification.repairRestoredDigest -or $qualification.nativeTurn.verdict -cne 'pass' -or $qualification.nativeTurn.exactReply -cne 'CHAT_OK' -or $qualification.nativeTurn.openClawExecutionMode -cne 'embedded-worker' -or + $qualification.nativeTurn.createWatcherStopped -ne $true -or + $qualification.nativeTurn.workloadStopped -ne $true -or + $qualification.nativeTurn.gatewayStopped -ne $true -or $qualification.nativeTurn.sandboxDeleted -ne $true -or + $qualification.nativeTurn.sandboxRegistryAbsent -ne $true -or + $qualification.nativeTurn.qualificationRootsRemoved -ne $true -or @($qualification.nativeExecutions).Count -ne 3 -or @($qualification.applicationExecutions).Count -ne 2 -or @($qualification.packageDescendantProhibitedStarts).Count -ne 0 -or @@ -557,7 +562,12 @@ public static class NemoClawConsoleVideoEncoder if ($recordedQualification.nativeTurn.verdict -cne 'pass' -or $recordedQualification.nativeTurn.exactReply -cne 'CHAT_OK' -or $recordedQualification.nativeTurn.openClawExecutionMode -cne 'embedded-worker' -or - $recordedQualification.nativeTurn.sandboxDeleted -ne $true) { + $recordedQualification.nativeTurn.createWatcherStopped -ne $true -or + $recordedQualification.nativeTurn.workloadStopped -ne $true -or + $recordedQualification.nativeTurn.gatewayStopped -ne $true -or + $recordedQualification.nativeTurn.sandboxDeleted -ne $true -or + $recordedQualification.nativeTurn.sandboxRegistryAbsent -ne $true -or + $recordedQualification.nativeTurn.qualificationRootsRemoved -ne $true) { Fail-ProofVideo 'The recorded qualification receipt does not prove the installed NemoClaw turn.' } $receipt = [pscustomobject]@{ diff --git a/scripts/checks/prepare-windows-native-package-payload.ps1 b/scripts/checks/prepare-windows-native-package-payload.ps1 index f5f91ea6501..81075716ef2 100644 --- a/scripts/checks/prepare-windows-native-package-payload.ps1 +++ b/scripts/checks/prepare-windows-native-package-payload.ps1 @@ -273,6 +273,7 @@ debug = false $receipt = [pscustomobject]@{ schemaVersion = 1 classification = 'nemoclaw-native-windows-arm64-runtime-payload' + nemoclaw = [pscustomobject]@{ version = $candidateVersion; revision = $candidateRevision } node = [pscustomobject]@{ version = $script:NodeVersion; archiveSha256 = $script:NodeArchiveSha256 } openClaw = [pscustomobject]@{ version = '2026.7.1' } openShell = [pscustomobject]@{ pullRequest = 'NVIDIA/OpenShell#2721'; revision = $script:OpenShellRevision } diff --git a/scripts/checks/run-windows-native-package-qualification.ps1 b/scripts/checks/run-windows-native-package-qualification.ps1 index 4af63a6c951..4b31c65efae 100644 --- a/scripts/checks/run-windows-native-package-qualification.ps1 +++ b/scripts/checks/run-windows-native-package-qualification.ps1 @@ -530,7 +530,12 @@ try { $nativeTurnReceipt = Get-Content -LiteralPath $nativeTurnReceipts[0].FullName -Raw | ConvertFrom-Json if ($nativeTurnReceipt.verdict -cne 'pass' -or $nativeTurnReceipt.exactReply -cne 'CHAT_OK' -or $nativeTurnReceipt.openClawExecutionMode -cne 'embedded-worker' -or + $nativeTurnReceipt.createWatcherStopped -ne $true -or + $nativeTurnReceipt.workloadStopped -ne $true -or + $nativeTurnReceipt.gatewayStopped -ne $true -or $nativeTurnReceipt.sandboxDeleted -ne $true -or + $nativeTurnReceipt.sandboxRegistryAbsent -ne $true -or + $nativeTurnReceipt.qualificationRootsRemoved -ne $true -or $nativeTurnReceipt.artifactStagedAtDriveRoot -ne $true) { Fail-PackageQualification 'Installed NemoClaw native turn receipt is incomplete.' } From d97041ebc5b21ad084713e4c9168d15313f169e4 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 19:35:38 -0700 Subject: [PATCH 071/144] fix(windows): remove dead cleanup assignment --- packaging/windows/runtime/run-installed-native-turn.mts | 1 - 1 file changed, 1 deletion(-) diff --git a/packaging/windows/runtime/run-installed-native-turn.mts b/packaging/windows/runtime/run-installed-native-turn.mts index 5ab4a5d9f5e..6c1272e15c0 100644 --- a/packaging/windows/runtime/run-installed-native-turn.mts +++ b/packaging/windows/runtime/run-installed-native-turn.mts @@ -593,7 +593,6 @@ async function main() { if (!logsClosed) { fs.closeSync(gatewayLog); fs.closeSync(gatewayError); - logsClosed = true; } if (!passed) { const diagnosticParts = [gatewayLogPath, gatewayErrorPath] From d6d2e8a75da56db90f9e02ec2f21ac074965d751 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 22:04:04 -0700 Subject: [PATCH 072/144] feat(windows): add branded three-turn web proof --- .github/workflows/platform-vitest-main.yaml | 6 +- packaging/windows/Bundle.wxs | 14 +- packaging/windows/NemoClaw.Bundle.wixproj | 4 + packaging/windows/NemoClaw.wixproj | 3 + packaging/windows/Product.wxs | 33 +- packaging/windows/Theme.wxl | 69 +++ packaging/windows/assets/NemoClaw.ico | Bin 0 -> 9771 bytes packaging/windows/assets/NemoClawLogo.png | Bin 0 -> 3644 bytes packaging/windows/assets/NemoClawSidebar.png | Bin 0 -> 16929 bytes .../runtime/run-installed-native-turn.mts | 51 +- .../runtime/run-installed-native-web-ui.mts | 485 ++++++++++++++++++ .../checks/build-windows-native-package.ps1 | 12 + .../create-windows-native-proof-video.ps1 | 75 ++- ...prepare-windows-native-package-payload.ps1 | 7 +- ...n-windows-native-package-qualification.ps1 | 47 +- 15 files changed, 759 insertions(+), 47 deletions(-) create mode 100644 packaging/windows/Theme.wxl create mode 100644 packaging/windows/assets/NemoClaw.ico create mode 100644 packaging/windows/assets/NemoClawLogo.png create mode 100644 packaging/windows/assets/NemoClawSidebar.png create mode 100644 packaging/windows/runtime/run-installed-native-web-ui.mts diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index bdbdd67dc87..1d636012bc4 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -452,12 +452,12 @@ jobs: -OutputDirectory "$packageRoot\proof-video" - name: Upload native Windows proof-of-life video - if: success() + if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: windows-native-proof-video-${{ github.sha }} + name: windows-native-raw-proof-video-${{ github.sha }} path: ${{ runner.temp }}/nemoclaw-windows-package/proof-video/ - if-no-files-found: error + if-no-files-found: warn retention-days: 14 - name: Upload downloadable Windows native package diff --git a/packaging/windows/Bundle.wxs b/packaging/windows/Bundle.wxs index 74a01a1cf0f..5e9a3bd560d 100644 --- a/packaging/windows/Bundle.wxs +++ b/packaging/windows/Bundle.wxs @@ -4,16 +4,24 @@ xmlns="http://wixtoolset.org/schemas/v4/wxs" xmlns:bal="http://wixtoolset.org/schemas/v4/wxs/bal"> diff --git a/packaging/windows/NemoClaw.Bundle.wixproj b/packaging/windows/NemoClaw.Bundle.wixproj index 8218fa3014a..e0a4cb119ea 100644 --- a/packaging/windows/NemoClaw.Bundle.wixproj +++ b/packaging/windows/NemoClaw.Bundle.wixproj @@ -28,6 +28,10 @@ + + + + diff --git a/packaging/windows/NemoClaw.wixproj b/packaging/windows/NemoClaw.wixproj index 394a439df0e..3b3a714815f 100644 --- a/packaging/windows/NemoClaw.wixproj +++ b/packaging/windows/NemoClaw.wixproj @@ -27,11 +27,14 @@ + + + diff --git a/packaging/windows/Product.wxs b/packaging/windows/Product.wxs index b0b195fda04..c647ce859e4 100644 --- a/packaging/windows/Product.wxs +++ b/packaging/windows/Product.wxs @@ -2,7 +2,7 @@ - + + + @@ -49,9 +51,32 @@ - + + + + + + + + + + + + diff --git a/packaging/windows/Theme.wxl b/packaging/windows/Theme.wxl new file mode 100644 index 00000000000..8566e5d01ca --- /dev/null +++ b/packaging/windows/Theme.wxl @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/assets/NemoClaw.ico b/packaging/windows/assets/NemoClaw.ico new file mode 100644 index 0000000000000000000000000000000000000000..7d8eb86af53d97bfc944d4a930ddff90203d745f GIT binary patch literal 9771 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 YPKT!aDh}a){ebdTSV}1Wwa(}N0ab?fO8@`> literal 0 HcmV?d00001 diff --git a/packaging/windows/runtime/run-installed-native-turn.mts b/packaging/windows/runtime/run-installed-native-turn.mts index 6c1272e15c0..508d5a0e182 100644 --- a/packaging/windows/runtime/run-installed-native-turn.mts +++ b/packaging/windows/runtime/run-installed-native-turn.mts @@ -6,6 +6,7 @@ import { randomBytes } from "node:crypto"; import fs from "node:fs"; import net from "node:net"; import path from "node:path"; +import { pathToFileURL } from "node:url"; const TIMEOUT_MS = 300_000; const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); @@ -14,21 +15,21 @@ function fail(message) { throw new Error(`Native Windows MXC turn qualification failed: ${message}`); } -function requiredFile(file, label) { +export function requiredFile(file, label) { const resolved = path.resolve(file); const stat = fs.statSync(resolved, { throwIfNoEntry: false }); if (!stat?.isFile()) fail(`${label} is missing`); return resolved; } -function requiredDirectory(directory, label) { +export function requiredDirectory(directory, label) { const resolved = path.resolve(directory); const stat = fs.statSync(resolved, { throwIfNoEntry: false }); if (!stat?.isDirectory()) fail(`${label} is missing`); return resolved; } -function argumentValue(name) { +export function argumentValue(name) { const index = process.argv.indexOf(name); if (index < 0) return null; const value = process.argv[index + 1]; @@ -36,7 +37,7 @@ function argumentValue(name) { return value; } -function allowlistedWindowsEnvironment(extra = {}) { +export function allowlistedWindowsEnvironment(extra = {}) { const allowedNames = new Set( [ "ComSpec", @@ -61,7 +62,7 @@ function allowlistedWindowsEnvironment(extra = {}) { return { ...environment, ...extra }; } -async function freePort() { +export async function freePort() { return await new Promise((resolve, reject) => { const server = net.createServer(); server.once("error", reject); @@ -76,10 +77,10 @@ async function freePort() { }); } -async function waitForPort(port, child) { +export async function waitForPort(port, child, label = "OpenShell gateway") { const deadline = Date.now() + 60_000; while (Date.now() < deadline) { - if (child.exitCode !== null) fail("OpenShell gateway exited before readiness"); + if (child.exitCode !== null) fail(`${label} exited before readiness`); const connected = await new Promise((resolve) => { const socket = net.createConnection({ host: "127.0.0.1", port }); socket.setTimeout(500); @@ -96,10 +97,10 @@ async function waitForPort(port, child) { if (connected) return; await sleep(500); } - fail("OpenShell gateway did not become ready"); + fail(`${label} did not become ready`); } -async function run(file, args, environment, label, timeout = TIMEOUT_MS) { +export async function run(file, args, environment, label, timeout = TIMEOUT_MS) { console.log(`NEMOCLAW> ${label}`); const child = spawn(file, args, { env: environment, @@ -138,14 +139,14 @@ async function run(file, args, environment, label, timeout = TIMEOUT_MS) { }); } -async function stopChild(child) { +export async function stopChild(child) { if (child.exitCode !== null || child.signalCode !== null) return true; const exited = new Promise((resolve) => child.once("exit", () => resolve(true))); child.kill(); return await Promise.race([exited, sleep(5000).then(() => false)]); } -async function removeDirectory(directory) { +export async function removeDirectory(directory) { for (let attempt = 0; attempt < 5; attempt += 1) { try { fs.rmSync(directory, { recursive: true, force: true }); @@ -156,7 +157,7 @@ async function removeDirectory(directory) { return false; } -async function waitForFileText(file, expected, timeout = 30_000) { +export async function waitForFileText(file, expected, timeout = 30_000) { const deadline = Date.now() + timeout; while (Date.now() < deadline) { if (fs.existsSync(file) && fs.readFileSync(file, "utf8").includes(expected)) return true; @@ -165,7 +166,7 @@ async function waitForFileText(file, expected, timeout = 30_000) { return false; } -function jsonContainsExactValue(value, target) { +export function jsonContainsExactValue(value, target) { if (value === target) return true; if (Array.isArray(value)) return value.some((item) => jsonContainsExactValue(item, target)); if (value !== null && typeof value === "object") @@ -173,11 +174,11 @@ function jsonContainsExactValue(value, target) { return false; } -function quoteYamlPath(value) { +export function quoteYamlPath(value) { return JSON.stringify(value.replaceAll("\\", "/")); } -function sanitizedDiagnostic(text, replacements) { +export function sanitizedDiagnostic(text, replacements) { let sanitized = text.slice(-64 * 1024); for (const [value, replacement] of replacements) { if (value) sanitized = sanitized.replaceAll(value, replacement); @@ -427,7 +428,11 @@ async function main() { "--log-level", "info", ], - { env: gatewayEnvironment, stdio: ["ignore", gatewayLog, gatewayError], windowsHide: true }, + { + env: gatewayEnvironment, + stdio: ["ignore", gatewayLog, gatewayError], + windowsHide: true, + }, ); let passed = false; let result = null; @@ -617,9 +622,11 @@ async function main() { } } -main().catch((error) => { - console.error( - error instanceof Error ? error.message : "Native Windows turn qualification failed.", - ); - process.exitCode = 1; -}); +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + main().catch((error) => { + console.error( + error instanceof Error ? error.message : "Native Windows turn qualification failed.", + ); + process.exitCode = 1; + }); +} diff --git a/packaging/windows/runtime/run-installed-native-web-ui.mts b/packaging/windows/runtime/run-installed-native-web-ui.mts new file mode 100644 index 00000000000..515653b10e0 --- /dev/null +++ b/packaging/windows/runtime/run-installed-native-web-ui.mts @@ -0,0 +1,485 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; + +import { + allowlistedWindowsEnvironment, + argumentValue, + freePort, + jsonContainsExactValue, + quoteYamlPath, + removeDirectory, + requiredDirectory, + requiredFile, + run, + stopChild, + waitForPort, +} from "./run-installed-native-turn.mts"; + +const TURN_PROOFS = [ + ["Reply exactly with NATIVE_WINDOWS_TURN_1_OK", "NATIVE_WINDOWS_TURN_1_OK"], + ["Reply exactly with NATIVE_WINDOWS_TURN_2_OK", "NATIVE_WINDOWS_TURN_2_OK"], + ["Reply exactly with NATIVE_WINDOWS_TURN_3_OK", "NATIVE_WINDOWS_TURN_3_OK"], +]; + +const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +function fail(message) { + throw new Error(`Native Windows OpenClaw UI qualification failed: ${message}`); +} + +function gatewaySource() { + return String.raw`import { mkdirSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +const required = (name) => { + const value = process.env[name]; + if (!value) throw new Error(name + " is required"); + return value; +}; +const launcher = required("NEMOCLAW_MXC_OPENCLAW_ENTRY"); +const home = required("NEMOCLAW_MXC_HOME"); +const mockPort = Number(required("NEMOCLAW_MXC_MOCK_PORT")); +const uiPort = Number(required("NEMOCLAW_MXC_UI_PORT")); +const readBody = async (request) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + return Buffer.concat(chunks).toString("utf8"); +}; +const contentText = (value) => { + if (typeof value === "string") return value; + if (!Array.isArray(value)) return ""; + return value.map((part) => typeof part === "string" ? part : part?.text ?? "").join(" "); +}; +const responseFor = (body) => { + const text = body.messages.map((message) => contentText(message?.content)).join("\n"); + const turn = text.match(/NATIVE_WINDOWS_TURN_([123])_OK/u)?.[1]; + return turn ? "NATIVE_WINDOWS_TURN_" + turn + "_OK" : "NEMOCLAW_NATIVE_PREVIEW_OK"; +}; +const mock = createServer(async (request, response) => { + if (request.method === "GET" && request.url === "/v1/models") { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ object: "list", data: [{ id: "native-preview", object: "model" }] })); + return; + } + if (request.method !== "POST" || request.url !== "/v1/chat/completions") { + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: { message: "not found" } })); + return; + } + const body = JSON.parse(await readBody(request)); + if (body?.model !== "native-preview" || !Array.isArray(body?.messages)) { + response.writeHead(400, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: { message: "unexpected request" } })); + return; + } + const content = responseFor(body); + const id = "chatcmpl-nemoclaw-native-ui"; + const created = Math.floor(Date.now() / 1000); + if (body.stream === true) { + response.writeHead(200, { "cache-control": "no-cache", connection: "keep-alive", "content-type": "text/event-stream" }); + for (const value of [ + { id, object: "chat.completion.chunk", created, model: "native-preview", choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] }, + { id, object: "chat.completion.chunk", created, model: "native-preview", choices: [{ index: 0, delta: { content }, finish_reason: null }] }, + { id, object: "chat.completion.chunk", created, model: "native-preview", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }, + ]) response.write("data: " + JSON.stringify(value) + "\n\n"); + response.end("data: [DONE]\n\n"); + return; + } + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ + id, + object: "chat.completion", + created, + model: "native-preview", + choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], + })); +}); +await new Promise((resolve, reject) => { + mock.once("error", reject); + mock.listen(mockPort, "127.0.0.1", resolve); +}); +const configDirectory = join(home, ".openclaw"); +mkdirSync(configDirectory, { recursive: true }); +writeFileSync(join(configDirectory, "openclaw.json"), JSON.stringify({ + gateway: { + mode: "local", + bind: "loopback", + auth: { mode: "none" }, + controlUi: { allowedOrigins: ["http://127.0.0.1:" + uiPort, "http://localhost:" + uiPort] }, + }, + models: { mode: "merge", providers: { nemoclawNativePreview: { + baseUrl: "http://127.0.0.1:" + mockPort + "/v1", + apiKey: "unused", + api: "openai-completions", + timeoutSeconds: 180, + models: [{ id: "native-preview", name: "NemoClaw Native Preview", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 131072, maxTokens: 4096 }], + } } }, + agents: { defaults: { model: { primary: "nemoclawNativePreview/native-preview" }, timeoutSeconds: 180, skipBootstrap: true, thinkingDefault: "off" }, list: [{ id: "main", default: true }] }, +}), "utf8"); +Object.assign(process.env, { + HOME: home, + NODE_DISABLE_COMPILE_CACHE: "1", + OPENCLAW_HOME: home, + OPENCLAW_NO_RESPAWN: "1", + USERPROFILE: home, +}); +process.argv = [process.execPath, launcher, "gateway", "run", "--allow-unconfigured", "--port", String(uiPort), "--bind", "loopback", "--auth", "none"]; +await import(pathToFileURL(launcher).href); +`; +} + +function resolveEdge() { + const candidates = [ + process.env["ProgramFiles(x86)"], + process.env.ProgramFiles, + process.env.LOCALAPPDATA, + ] + .filter(Boolean) + .map((root) => path.join(root, "Microsoft", "Edge", "Application", "msedge.exe")); + for (const candidate of candidates) { + if (fs.statSync(candidate, { throwIfNoEntry: false })?.isFile()) return candidate; + } + fail("Microsoft Edge is required for the visible Control UI proof"); +} + +async function driveBrowser(openClawRoot, url, evidenceRoot, qualification) { + const playwrightRoot = requiredDirectory( + path.join(openClawRoot, "node_modules", "openclaw", "node_modules", "playwright-core"), + "installed Playwright browser driver", + ); + const require = createRequire(import.meta.url); + const { chromium } = require(playwrightRoot); + const browser = await chromium.launch({ + executablePath: resolveEdge(), + headless: false, + args: ["--no-first-run", "--no-default-browser-check", "--start-maximized"], + }); + let browserVersion = "unknown"; + try { + browserVersion = browser.version(); + const context = await browser.newContext({ + viewport: { width: 1280, height: 720 }, + }); + const page = await context.newPage(); + await page.goto(`${url}/chat`, { + waitUntil: "domcontentloaded", + timeout: 90_000, + }); + await page.evaluate(() => { + document.title = "NemoClaw Native Windows · OpenClaw Control UI"; + }); + const composer = page.locator(".agent-chat__composer-combobox > textarea").first(); + await composer.waitFor({ state: "visible", timeout: 90_000 }); + await page.waitForFunction( + () => { + const input = document.querySelector(".agent-chat__composer-combobox > textarea"); + return input instanceof HTMLTextAreaElement && !input.disabled; + }, + undefined, + { timeout: 90_000 }, + ); + await page.evaluate(() => { + document.title = "NemoClaw Native Windows · OpenClaw Control UI"; + }); + await page.screenshot({ + path: path.join(evidenceRoot, "web-ui-ready.png"), + }); + if (!qualification) { + console.log(`WEB UI> READY ${url}/chat`); + await new Promise((resolve) => browser.once("disconnected", resolve)); + return { browserVersion, turns: [] }; + } + const turns = []; + for (let index = 0; index < TURN_PROOFS.length; index += 1) { + const [prompt, expected] = TURN_PROOFS[index]; + console.log(`WEB UI> TURN ${index + 1} typing in the real OpenClaw Control UI`); + await composer.fill(""); + await composer.pressSequentially(prompt, { delay: 20 }); + await sleep(750); + await composer.press("Enter"); + await page.getByText(expected, { exact: true }).last().waitFor({ + state: "visible", + timeout: 120_000, + }); + await page.screenshot({ + path: path.join(evidenceRoot, `web-ui-turn-${index + 1}.png`), + fullPage: false, + }); + console.log(`WEB UI> TURN ${index + 1} PASS ${expected}`); + turns.push({ prompt, expected, visible: true }); + await sleep(2000); + } + await sleep(3000); + return { browserVersion, turns }; + } finally { + if (browser.isConnected()) await browser.close(); + } +} + +async function main() { + if (process.platform !== "win32" || process.arch !== "arm64") + fail("native Windows ARM64 is required"); + const qualification = process.argv.includes("--qualification"); + const installRoot = requiredDirectory( + process.env.NEMOCLAW_NATIVE_INSTALL_ROOT ?? "", + "NemoClaw installation root", + ); + const binRoot = requiredDirectory(path.join(installRoot, "bin"), "NemoClaw bin directory"); + const installedNode = requiredFile(path.join(binRoot, "node.exe"), "Node.js runtime"); + const openshell = requiredFile(path.join(binRoot, "openshell.exe"), "OpenShell CLI"); + const gatewayExecutable = requiredFile( + path.join(binRoot, "openshell-gateway.exe"), + "OpenShell gateway", + ); + const installedOpenClawRoot = requiredDirectory( + path.join(installRoot, "openclaw"), + "OpenClaw runtime", + ); + const installedOpenClawEntry = requiredFile( + path.join(installedOpenClawRoot, "node_modules", "openclaw", "openclaw.mjs"), + "OpenClaw entrypoint", + ); + const gatewayConfig = requiredFile( + path.join(installRoot, "config", "mxc-gateway.toml"), + "MXC gateway configuration", + ); + requiredFile(path.join(installRoot, "mxc", "wxc-exec.exe"), "MXC executor"); + + const systemDrive = process.env.SystemDrive; + if (!systemDrive || !/^[A-Za-z]:$/u.test(systemDrive)) fail("SystemDrive is invalid"); + const systemRoot = requiredDirectory(process.env.SystemRoot ?? "", "Windows system root"); + const runId = randomBytes(5).toString("hex"); + const runRoot = path.join(`${systemDrive}\\`, `NemoClawNativeUi-${runId}`); + const shareRoot = path.join(`${systemDrive}\\`, `NemoClawNativeUiShare-${runId}`); + const runtimeRoot = path.join(`${systemDrive}\\`, `NemoClawNativeUiRuntime-${runId}`); + for (const directory of [runRoot, shareRoot, runtimeRoot]) { + if (fs.existsSync(directory)) fail("qualification root already exists"); + fs.mkdirSync(directory); + } + const evidenceRoot = path.resolve( + argumentValue("--artifact-directory") ?? + path.join(process.env.LOCALAPPDATA ?? runRoot, "NVIDIA", "NemoClaw", "evidence"), + ); + fs.mkdirSync(evidenceRoot, { recursive: true }); + const node = path.join(runtimeRoot, "node.exe"); + const openClawRoot = path.join(runtimeRoot, "openclaw"); + console.log("WEB UI> Staging the exact installed OpenClaw runtime for MXC"); + fs.copyFileSync(installedNode, node); + fs.cpSync(installedOpenClawRoot, openClawRoot, { recursive: true }); + const openClawEntry = requiredFile( + path.join(openClawRoot, "node_modules", "openclaw", "openclaw.mjs"), + "staged OpenClaw entrypoint", + ); + if (!fs.readFileSync(openClawEntry).equals(fs.readFileSync(installedOpenClawEntry))) + fail("staged OpenClaw entrypoint does not match the installed payload"); + const gatewayScript = path.join(shareRoot, "openclaw-native-ui.mjs"); + fs.writeFileSync(gatewayScript, gatewaySource(), "utf8"); + const policyPath = path.join(runRoot, "policy.yaml"); + fs.writeFileSync( + policyPath, + [ + "version: 1", + "", + "filesystem_policy:", + " include_workdir: false", + " read_only:", + ` - ${quoteYamlPath(runtimeRoot)}`, + " read_write:", + ` - ${quoteYamlPath(shareRoot)}`, + "", + ].join("\n"), + "utf8", + ); + const configRoot = path.join(runRoot, "config"); + const stateRoot = path.join(runRoot, "state"); + const home = path.join(shareRoot, "home"); + const temp = path.join(shareRoot, "temp"); + for (const directory of [configRoot, stateRoot, home, temp]) + fs.mkdirSync(directory, { recursive: true }); + const openShellPort = await freePort(); + const uiPort = await freePort(); + const mockPort = await freePort(); + const sandboxName = `nc-ui-${runId}`; + const gatewayName = `nemoclaw-ui-${runId}`; + const gatewayLogPath = path.join(runRoot, "openshell-gateway.log"); + const gatewayErrorPath = path.join(runRoot, "openshell-gateway.err.log"); + const gatewayLog = fs.openSync(gatewayLogPath, "w"); + const gatewayError = fs.openSync(gatewayErrorPath, "w"); + const gatewayEnvironment = allowlistedWindowsEnvironment({ + OPENSHELL_DRIVERS: "mxc", + OPENSHELL_GATEWAY_CONFIG: gatewayConfig, + XDG_CONFIG_HOME: configRoot, + XDG_STATE_HOME: stateRoot, + }); + const gateway = spawn( + gatewayExecutable, + [ + "--port", + String(openShellPort), + "--disable-tls", + "--db-url", + "sqlite::memory:", + "--log-level", + "info", + ], + { + env: gatewayEnvironment, + stdio: ["ignore", gatewayLog, gatewayError], + windowsHide: true, + }, + ); + let cliEnvironment = gatewayEnvironment; + let create = null; + let passed = false; + let logsClosed = false; + try { + console.log("WEB UI> Starting the installed OpenShell MXC gateway"); + await waitForPort(openShellPort, gateway); + cliEnvironment = allowlistedWindowsEnvironment({ + ...gatewayEnvironment, + OPENSHELL_GATEWAY: undefined, + }); + await run( + openshell, + ["gateway", "add", `http://127.0.0.1:${openShellPort}`, "--local", "--name", gatewayName], + cliEnvironment, + "Registering the native UI gateway", + ); + await run( + openshell, + ["gateway", "select", gatewayName], + cliEnvironment, + "Selecting the native UI gateway", + ); + const sandboxEnvironment = { + LOCALAPPDATA: home, + NEMOCLAW_MXC_HOME: home, + NEMOCLAW_MXC_MOCK_PORT: String(mockPort), + NEMOCLAW_MXC_OPENCLAW_ENTRY: openClawEntry, + NEMOCLAW_MXC_UI_PORT: String(uiPort), + NODE_DISABLE_COMPILE_CACHE: "1", + NUMBER_OF_PROCESSORS: process.env.NUMBER_OF_PROCESSORS ?? "1", + OS: "Windows_NT", + PATH: `${path.join(systemRoot, "System32")};${systemRoot}`, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + PROCESSOR_ARCHITECTURE: "ARM64", + SYSTEMDRIVE: systemDrive, + SYSTEMROOT: systemRoot, + TEMP: temp, + TMP: temp, + USERPROFILE: home, + WINDIR: systemRoot, + }; + const createArgs = [ + "sandbox", + "create", + "--name", + sandboxName, + "--policy", + policyPath, + "--driver-config-json", + JSON.stringify({ + mxc: { command: [node, gatewayScript], cwd: shareRoot }, + }), + "--no-tty", + ]; + for (const [name, value] of Object.entries(sandboxEnvironment)) + createArgs.push("--env", `${name}=${value}`); + console.log("WEB UI> Creating the native MXC OpenClaw Control UI sandbox"); + create = spawn(openshell, createArgs, { + env: cliEnvironment, + stdio: "ignore", + windowsHide: true, + }); + console.log("WEB UI> Waiting for the real OpenClaw Control UI"); + await waitForPort(uiPort, create, "OpenClaw Control UI"); + const uiUrl = `http://127.0.0.1:${uiPort}`; + console.log(`WEB UI> Launching Microsoft Edge at ${uiUrl}/chat`); + const browserProof = await driveBrowser(openClawRoot, uiUrl, evidenceRoot, qualification); + await run( + openshell, + ["sandbox", "delete", sandboxName], + cliEnvironment, + "Deleting the native Control UI sandbox", + ); + if (create !== null && !(await stopChild(create))) + fail("OpenShell sandbox request watcher did not stop"); + const sandboxList = await run( + openshell, + ["sandbox", "list", "-o", "json"], + cliEnvironment, + "Verifying Control UI sandbox registry cleanup", + ); + if (jsonContainsExactValue(JSON.parse(sandboxList.stdout.trim()), sandboxName)) + fail("Control UI sandbox remained registered after deletion"); + if (!(await stopChild(gateway))) fail("OpenShell MXC gateway did not stop"); + fs.closeSync(gatewayLog); + fs.closeSync(gatewayError); + logsClosed = true; + for (const directory of [runRoot, shareRoot, runtimeRoot]) { + if (!(await removeDirectory(directory))) + fail(`runtime root remained after cleanup: ${path.basename(directory)}`); + } + const receipt = { + schemaVersion: 1, + classification: "installed-nemoclaw-native-windows-openclaw-control-ui", + architecture: "arm64", + backend: "process_container", + browser: "Microsoft Edge", + browserVersion: browserProof.browserVersion, + deterministicLocalModel: qualification, + turnCount: browserProof.turns.length, + turns: browserProof.turns, + sandboxDeleted: true, + sandboxRegistryAbsent: true, + gatewayStopped: true, + qualificationRootsRemoved: true, + verdict: "pass", + }; + fs.writeFileSync( + path.join(evidenceRoot, `native-windows-web-ui-${runId}.json`), + `${JSON.stringify(receipt, null, 2)}\n`, + "utf8", + ); + passed = true; + console.log( + qualification + ? "WEB UI> PASS three real OpenClaw Control UI agent turns" + : "WEB UI> NemoClaw preview session closed cleanly", + ); + } finally { + if (create !== null) await stopChild(create); + if (!passed) { + try { + await run( + openshell, + ["sandbox", "delete", sandboxName], + cliEnvironment, + "Failure cleanup Control UI sandbox delete", + 30_000, + ); + } catch {} + } + await stopChild(gateway); + if (!logsClosed) { + fs.closeSync(gatewayLog); + fs.closeSync(gatewayError); + } + for (const directory of [runRoot, shareRoot, runtimeRoot]) await removeDirectory(directory); + } +} + +main().catch((error) => { + console.error( + error instanceof Error ? error.message : "Native Windows OpenClaw UI qualification failed.", + ); + process.exitCode = 1; +}); diff --git a/scripts/checks/build-windows-native-package.ps1 b/scripts/checks/build-windows-native-package.ps1 index b7c6d129fda..232d999b7fa 100644 --- a/scripts/checks/build-windows-native-package.ps1 +++ b/scripts/checks/build-windows-native-package.ps1 @@ -98,12 +98,14 @@ Assert-Arm64PortableExecutable -Path $gateway -Label 'openshell-gateway.exe payl foreach ($requiredPayload in @( 'bin\node.exe', 'bin\nemoclaw.cmd', + 'bin\nemoclaw-ui.cmd', 'nemoclaw\app\bin\nemoclaw.js', 'openclaw\node_modules\openclaw\openclaw.mjs', 'mxc\wxc-exec.exe', 'mxc\wxc-host-prep.exe', 'config\mxc-gateway.toml', 'qualification\run-installed-native-turn.mts', + 'qualification\run-installed-native-web-ui.mts', 'OPENSHELL-NODE-UI-COMPATIBILITY.patch', 'LICENSE.txt', 'NATIVE-PREVIEW.txt' @@ -134,6 +136,16 @@ if ($authoringText -match '<\s*CustomAction\b' -or if ($authoringText -notmatch '<\s*MajorUpgrade\b[^>]*Schedule="afterInstallInitialize"') { Fail-WindowsPackageBuild 'Major-upgrade removal must remain inside MSI rollback protection.' } +foreach ($requiredAsset in @( + 'packaging\windows\assets\NemoClaw.ico', + 'packaging\windows\assets\NemoClawLogo.png', + 'packaging\windows\assets\NemoClawSidebar.png', + 'packaging\windows\Theme.wxl' +)) { + if (-not (Test-Path -LiteralPath (Join-Path $sourceRoot $requiredAsset) -PathType Leaf)) { + Fail-WindowsPackageBuild "Required branded setup asset is missing: $requiredAsset" + } +} $exePackages = @([regex]::Matches($authoringText, '<\s*ExePackage\b[^>]*/>', 'IgnoreCase, Singleline')) $systemDrivePreparation = @($exePackages | Where-Object { $_.Value -match 'Id="MxcSystemDrivePreparation"' -and diff --git a/scripts/checks/create-windows-native-proof-video.ps1 b/scripts/checks/create-windows-native-proof-video.ps1 index 3f289fcf1e0..6f0929c35f9 100644 --- a/scripts/checks/create-windows-native-proof-video.ps1 +++ b/scripts/checks/create-windows-native-proof-video.ps1 @@ -7,9 +7,9 @@ .DESCRIPTION Downloads and launches the real setup, runs the installed NemoClaw turn, - and uninstalls it in a real Windows console. Captures the actual console and - WiX installer window pixels four times per second, then encodes those live - frames to H.264 with Windows Media Foundation. + and uninstalls it in a real Windows console. Captures the actual console, + WiX installer, and OpenClaw Control UI window pixels four times per second, + then encodes those live frames to H.264 with Windows Media Foundation. #> [CmdletBinding()] @@ -182,12 +182,15 @@ function Save-ActualWindowFrame { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][IntPtr]$ConsoleWindow, - [Parameter(Mandatory)][IntPtr]$InstallerWindow + [Parameter(Mandatory)][IntPtr]$InstallerWindow, + [Parameter(Mandatory)][IntPtr]$BrowserWindow ) $consoleBitmap = [NemoClawNativeWindowCapture]::Capture($ConsoleWindow) $installerBitmap = $null $installerCaptured = $false + $browserBitmap = $null + $browserCaptured = $false if ($InstallerWindow -ne [IntPtr]::Zero) { try { $installerBitmap = [NemoClawNativeWindowCapture]::Capture($InstallerWindow) @@ -196,6 +199,14 @@ function Save-ActualWindowFrame { # Burn can close its progress window between enumeration and capture. } } + if ($BrowserWindow -ne [IntPtr]::Zero) { + try { + $browserBitmap = [NemoClawNativeWindowCapture]::Capture($BrowserWindow) + $browserCaptured = $true + } catch { + # Edge can close a page between enumeration and capture. + } + } $frame = [Drawing.Bitmap]::new(1280, 720, [Drawing.Imaging.PixelFormat]::Format24bppRgb) $graphics = [Drawing.Graphics]::FromImage($frame) try { @@ -216,6 +227,15 @@ function Save-ActualWindowFrame { $graphics.FillRectangle([Drawing.Brushes]::Black, $installerX - 8, $installerY - 8, $installerWidth + 16, $installerHeight + 16) $graphics.DrawImage($installerBitmap, $installerX, $installerY, $installerWidth, $installerHeight) } + if ($null -ne $browserBitmap) { + $browserScale = [Math]::Min(1280 / $browserBitmap.Width, 720 / $browserBitmap.Height) + $browserWidth = [int]($browserBitmap.Width * $browserScale) + $browserHeight = [int]($browserBitmap.Height * $browserScale) + $browserX = [int]((1280 - $browserWidth) / 2) + $browserY = [int]((720 - $browserHeight) / 2) + $graphics.Clear([Drawing.Color]::Black) + $graphics.DrawImage($browserBitmap, $browserX, $browserY, $browserWidth, $browserHeight) + } $frame.Save($Path, [Drawing.Imaging.ImageFormat]::Png) } finally { $graphics.Dispose() @@ -223,9 +243,15 @@ function Save-ActualWindowFrame { if ($null -ne $installerBitmap) { $installerBitmap.Dispose() } + if ($null -ne $browserBitmap) { + $browserBitmap.Dispose() + } $consoleBitmap.Dispose() } - return $installerCaptured + return [pscustomobject]@{ + installer = $installerCaptured + browser = $browserCaptured + } } if ($ProductVersion -cnotmatch '^[0-9]{1,3}\.[0-9]{1,5}\.[0-9]{1,5}$') { @@ -308,6 +334,9 @@ if (-not $qualification.repairRestoredDigest -or $qualification.nativeTurn.sandboxDeleted -ne $true -or $qualification.nativeTurn.sandboxRegistryAbsent -ne $true -or $qualification.nativeTurn.qualificationRootsRemoved -ne $true -or + $qualification.webUi.verdict -cne 'pass' -or + [int]$qualification.webUi.turnCount -ne 3 -or + @($qualification.webUi.turns).Count -ne 3 -or @($qualification.nativeExecutions).Count -ne 3 -or @($qualification.applicationExecutions).Count -ne 2 -or @($qualification.packageDescendantProhibitedStarts).Count -ne 0 -or @@ -368,6 +397,7 @@ try { $recordingClock = [Diagnostics.Stopwatch]::StartNew() $framePaths = @() $installerWindowFrameCount = 0 + $browserWindowFrameCount = 0 while (-not $proofProcess.HasExited) { if ($recordingClock.ElapsedMilliseconds -gt $script:MaximumRecordingMilliseconds) { $proofProcess.Kill() @@ -375,15 +405,20 @@ try { Fail-ProofVideo 'Real console qualification exceeded its recording timeout.' } $installerWindow = [NemoClawNativeWindowCapture]::FindWindowContaining( - 'NemoClaw Native Windows Candidate Setup', + 'NemoClaw Setup', + $consoleWindow + ) + $browserWindow = [NemoClawNativeWindowCapture]::FindWindowContaining( + 'NemoClaw Native Windows · OpenClaw Control UI', $consoleWindow ) $framePath = Join-Path $frameRoot ('frame-{0:D5}.png' -f ($framePaths.Count + 1)) try { - $installerCaptured = Save-ActualWindowFrame ` + $capturedWindows = Save-ActualWindowFrame ` -Path $framePath ` -ConsoleWindow $consoleWindow ` - -InstallerWindow $installerWindow + -InstallerWindow $installerWindow ` + -BrowserWindow $browserWindow } catch { $proofProcess.Refresh() if ($proofProcess.HasExited) { @@ -398,9 +433,12 @@ try { } continue } - if ($installerCaptured) { + if ($capturedWindows.installer) { $installerWindowFrameCount++ } + if ($capturedWindows.browser) { + $browserWindowFrameCount++ + } $framePaths += $framePath Start-Sleep -Milliseconds $script:FrameDurationMilliseconds $proofProcess.Refresh() @@ -427,13 +465,19 @@ try { if (-not $consoleTranscriptText.Contains('AGENT> CHAT_OK') -or -not $consoleTranscriptText.Contains( '[PASS] Installed nemoclaw command created an MXC sandbox and completed an exact CHAT_OK turn' - )) { - Fail-ProofVideo 'The recorded console did not show the installed NemoClaw agent turn.' + ) -or + -not $consoleTranscriptText.Contains('WEB UI> TURN 1 PASS NATIVE_WINDOWS_TURN_1_OK') -or + -not $consoleTranscriptText.Contains('WEB UI> TURN 2 PASS NATIVE_WINDOWS_TURN_2_OK') -or + -not $consoleTranscriptText.Contains('WEB UI> TURN 3 PASS NATIVE_WINDOWS_TURN_3_OK')) { + Fail-ProofVideo 'The recorded console did not show the installed NemoClaw CLI and web UI turns.' } if ($installerWindowFrameCount -lt 4) { Fail-ProofVideo 'The real WiX installer window was not captured for at least one second.' } + if ($browserWindowFrameCount -lt 8) { + Fail-ProofVideo 'The real OpenClaw Control UI window was not captured for at least two seconds.' + } $frameHashes = @($framePaths | ForEach-Object { (Get-FileHash -LiteralPath $_ -Algorithm SHA256).Hash }) @@ -570,6 +614,11 @@ public static class NemoClawConsoleVideoEncoder $recordedQualification.nativeTurn.qualificationRootsRemoved -ne $true) { Fail-ProofVideo 'The recorded qualification receipt does not prove the installed NemoClaw turn.' } + if ($recordedQualification.webUi.verdict -cne 'pass' -or + [int]$recordedQualification.webUi.turnCount -ne 3 -or + @($recordedQualification.webUi.turns).Count -ne 3) { + Fail-ProofVideo 'The recorded qualification receipt does not prove three OpenClaw Control UI turns.' + } $receipt = [pscustomobject]@{ schemaVersion = 2 classification = 'native-windows-candidate-preview-actual-window-recording' @@ -585,7 +634,7 @@ public static class NemoClawConsoleVideoEncoder consoleTranscriptSha256 = (Get-FileHash -LiteralPath $consoleTranscript -Algorithm SHA256).Hash.ToLowerInvariant() } capture = [pscustomobject]@{ - kind = 'actual PrintWindow capture of real PowerShell console and WiX installer windows' + kind = 'actual PrintWindow capture of real PowerShell console, WiX installer, and OpenClaw Control UI windows' sourceWidth = 1280 sourceHeight = 720 requestedFramesPerSecond = $script:CaptureFramesPerSecond @@ -593,9 +642,11 @@ public static class NemoClawConsoleVideoEncoder frameCount = $framePaths.Count uniqueFrameCount = $uniqueFrameCount installerWindowFrameCount = $installerWindowFrameCount + browserWindowFrameCount = $browserWindowFrameCount recordingWallTimeMilliseconds = $recordingClock.ElapsedMilliseconds qualificationExitCode = $proofExitCode installedNemoClawTurn = 'CHAT_OK' + installedWebUiTurns = 3 } video = [pscustomobject]@{ file = $videoName diff --git a/scripts/checks/prepare-windows-native-package-payload.ps1 b/scripts/checks/prepare-windows-native-package-payload.ps1 index 81075716ef2..99da924f91e 100644 --- a/scripts/checks/prepare-windows-native-package-payload.ps1 +++ b/scripts/checks/prepare-windows-native-package-payload.ps1 @@ -227,6 +227,8 @@ try { $launcher = "@echo off`r`nset `"NEMOCLAW_NATIVE_INSTALL_ROOT=%~dp0..`"`r`n`"%~dp0node.exe`" `"%~dp0..\nemoclaw\app\bin\nemoclaw.js`" %*`r`n" [IO.File]::WriteAllText((Join-Path $binRoot 'nemoclaw.cmd'), $launcher, [Text.ASCIIEncoding]::new()) + $uiLauncher = "@echo off`r`nset `"NEMOCLAW_NATIVE_INSTALL_ROOT=%~dp0..`"`r`n`"%~dp0node.exe`" --experimental-strip-types --no-warnings `"%~dp0..\qualification\run-installed-native-web-ui.mts`" %*`r`n" + [IO.File]::WriteAllText((Join-Path $binRoot 'nemoclaw-ui.cmd'), $uiLauncher, [Text.ASCIIEncoding]::new()) $openClawLauncher = "@echo off`r`n`"%~dp0node.exe`" `"%~dp0..\openclaw\node_modules\openclaw\openclaw.mjs`" %*`r`n" [IO.File]::WriteAllText((Join-Path $binRoot 'openclaw.cmd'), $openClawLauncher, [Text.ASCIIEncoding]::new()) @@ -245,6 +247,7 @@ debug = false $qualificationRoot = Join-Path $output 'qualification' [IO.Directory]::CreateDirectory($qualificationRoot) | Out-Null Copy-Item -LiteralPath (Join-Path $candidate 'packaging\windows\runtime\run-installed-native-turn.mts') -Destination $qualificationRoot + Copy-Item -LiteralPath (Join-Path $candidate 'packaging\windows\runtime\run-installed-native-web-ui.mts') -Destination $qualificationRoot Copy-Item -LiteralPath (Join-Path $candidate 'LICENSE') -Destination (Join-Path $output 'LICENSE.txt') Copy-Item -LiteralPath (Join-Path $candidate 'packaging\windows\NATIVE-PREVIEW.txt') -Destination (Join-Path $output 'NATIVE-PREVIEW.txt') Copy-Item -LiteralPath (Join-Path $candidate 'packaging\windows\openshell-2721-node-ui.patch') -Destination (Join-Path $output 'OPENSHELL-NODE-UI-COMPATIBILITY.patch') @@ -260,10 +263,12 @@ debug = false } foreach ($required in @( 'bin\nemoclaw.cmd', + 'bin\nemoclaw-ui.cmd', 'nemoclaw\app\bin\nemoclaw.js', 'openclaw\node_modules\openclaw\openclaw.mjs', 'config\mxc-gateway.toml', - 'qualification\run-installed-native-turn.mts' + 'qualification\run-installed-native-turn.mts', + 'qualification\run-installed-native-web-ui.mts' )) { if (-not (Test-Path -LiteralPath (Join-Path $output $required) -PathType Leaf)) { Fail-PayloadPreparation "Prepared payload is incomplete: $required" diff --git a/scripts/checks/run-windows-native-package-qualification.ps1 b/scripts/checks/run-windows-native-package-qualification.ps1 index 4b31c65efae..f4a4ecfcd0f 100644 --- a/scripts/checks/run-windows-native-package-qualification.ps1 +++ b/scripts/checks/run-windows-native-package-qualification.ps1 @@ -26,8 +26,8 @@ $ErrorActionPreference = 'Stop' $script:OperationTimeoutMilliseconds = 1200000 $script:ProcessAuditSettleMilliseconds = 3000 -$script:MsiDisplayName = 'NemoClaw Native Windows Candidate' -$script:BundleDisplayName = 'NemoClaw Native Windows Candidate Setup' +$script:MsiDisplayName = 'NemoClaw Runtime' +$script:BundleDisplayName = 'NemoClaw' function Fail-PackageQualification { param([Parameter(Mandatory)][string]$Message) @@ -437,12 +437,14 @@ foreach ($requiredPayload in @( 'bin\openshell-gateway.exe', 'bin\node.exe', 'bin\nemoclaw.cmd', + 'bin\nemoclaw-ui.cmd', 'nemoclaw\app\bin\nemoclaw.js', 'openclaw\node_modules\openclaw\openclaw.mjs', 'mxc\wxc-exec.exe', 'mxc\wxc-host-prep.exe', 'config\mxc-gateway.toml', 'qualification\run-installed-native-turn.mts', + 'qualification\run-installed-native-web-ui.mts', 'OPENSHELL-NODE-UI-COMPATIBILITY.patch' )) { if (-not $payloadHashes.ContainsKey($requiredPayload) -or @@ -468,6 +470,7 @@ $nemoclawEntryPath = Join-Path $installRoot 'nemoclaw\app\bin\nemoclaw.js' $openClawEntryPath = Join-Path $installRoot 'openclaw\node_modules\openclaw\openclaw.mjs' $wxcExecPath = Join-Path $installRoot 'mxc\wxc-exec.exe' $nemoclawLauncherPath = Join-Path $installBin 'nemoclaw.cmd' +$nemoclawUiLauncherPath = Join-Path $installBin 'nemoclaw-ui.cmd' $bundleInstallLog = Join-Path $artifactRoot 'bundle-install.log' $msiRepairLog = Join-Path $artifactRoot 'msi-repair.log' $msiReinstallLog = Join-Path $artifactRoot 'msi-reinstall.log' @@ -540,6 +543,45 @@ try { Fail-PackageQualification 'Installed NemoClaw native turn receipt is incomplete.' } Write-Host '[PASS] Installed nemoclaw command created an MXC sandbox and completed an exact CHAT_OK turn' + $webUiArtifacts = Join-Path $artifactRoot 'web-ui' + Write-Host 'PS> Launch installed NemoClaw OpenClaw web UI and complete three agent turns' + & $nemoclawUiLauncherPath --qualification --artifact-directory $webUiArtifacts + $webUiExitCode = $LASTEXITCODE + if ($webUiExitCode -ne 0) { + Fail-PackageQualification "Installed NemoClaw OpenClaw web UI qualification failed with exit code $webUiExitCode." + } + $webUiReceipts = @(Get-ChildItem -LiteralPath $webUiArtifacts -Filter 'native-windows-web-ui-*.json' -File) + if ($webUiReceipts.Count -ne 1) { + Fail-PackageQualification 'Installed NemoClaw web UI did not publish exactly one receipt.' + } + $webUiReceipt = Get-Content -LiteralPath $webUiReceipts[0].FullName -Raw | ConvertFrom-Json + $expectedWebUiReplies = @( + 'NATIVE_WINDOWS_TURN_1_OK', + 'NATIVE_WINDOWS_TURN_2_OK', + 'NATIVE_WINDOWS_TURN_3_OK' + ) + if ($webUiReceipt.verdict -cne 'pass' -or + $webUiReceipt.backend -cne 'process_container' -or + $webUiReceipt.browser -cne 'Microsoft Edge' -or + $webUiReceipt.deterministicLocalModel -ne $true -or + [int]$webUiReceipt.turnCount -ne 3 -or + @($webUiReceipt.turns).Count -ne 3 -or + $webUiReceipt.sandboxDeleted -ne $true -or + $webUiReceipt.sandboxRegistryAbsent -ne $true -or + $webUiReceipt.gatewayStopped -ne $true -or + $webUiReceipt.qualificationRootsRemoved -ne $true) { + Fail-PackageQualification 'Installed NemoClaw web UI receipt is incomplete.' + } + for ($index = 0; $index -lt $expectedWebUiReplies.Count; $index++) { + if ($webUiReceipt.turns[$index].expected -cne $expectedWebUiReplies[$index] -or + $webUiReceipt.turns[$index].visible -ne $true) { + Fail-PackageQualification "Installed NemoClaw web UI turn $($index + 1) is not exact." + } + } + if (@(Get-ChildItem -LiteralPath $webUiArtifacts -Filter 'web-ui-turn-*.png' -File).Count -ne 3) { + Fail-PackageQualification 'Installed NemoClaw web UI did not capture three turn screenshots.' + } + Write-Host '[PASS] Installed NemoClaw launched the real OpenClaw Control UI and completed three exact agent turns' if ($InteractiveProof) { Start-Sleep -Seconds 3 } @@ -670,6 +712,7 @@ try { nativeExecutions = $nativeEvidence applicationExecutions = $applicationEvidence nativeTurn = $nativeTurnReceipt + webUi = $webUiReceipt msiRegistration = $msiArp bundleRegistration = $bundleArp repairRestoredDigest = $repairRestoredDigest From 5862b734560a03bd6660ddbae26bbcba3f57341f Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 22:31:46 -0700 Subject: [PATCH 073/144] feat(windows): add graphical agent onboarding --- .github/workflows/platform-vitest-main.yaml | 2 + packaging/windows/Bundle.wxs | 2 +- packaging/windows/NATIVE-PREVIEW.txt | 10 + packaging/windows/NemoClaw.Bundle.wixproj | 2 +- packaging/windows/NemoClaw.wixproj | 3 +- packaging/windows/Product.wxs | 6 +- packaging/windows/README.md | 22 +- packaging/windows/Theme.wxl | 4 +- packaging/windows/launcher/Cargo.lock | 7 + packaging/windows/launcher/Cargo.toml | 19 + packaging/windows/launcher/src/main.rs | 90 ++++ packaging/windows/onboarding/ASSET-SOURCES.md | 16 + packaging/windows/onboarding/app.ts | 97 +++++ .../windows/onboarding/assets/deepagents.png | Bin 0 -> 1592 bytes .../windows/onboarding/assets/hermes.png | Bin 0 -> 12333 bytes .../windows/onboarding/assets/nemocua.png | Bin 0 -> 3644 bytes .../windows/onboarding/assets/nvidia.svg | 26 ++ .../windows/onboarding/assets/openclaw.png | Bin 0 -> 5920 bytes packaging/windows/onboarding/assets/pi.svg | 28 ++ packaging/windows/onboarding/index.html | 306 ++++++++++++++ packaging/windows/onboarding/styles.css | 390 ++++++++++++++++++ .../runtime/run-installed-native-web-ui.mts | 164 +++++++- .../checks/build-windows-native-package.ps1 | 6 +- .../create-windows-native-proof-video.ps1 | 111 +++-- ...prepare-windows-native-package-payload.ps1 | 38 +- ...n-windows-native-package-qualification.ps1 | 16 +- 26 files changed, 1301 insertions(+), 64 deletions(-) create mode 100644 packaging/windows/launcher/Cargo.lock create mode 100644 packaging/windows/launcher/Cargo.toml create mode 100644 packaging/windows/launcher/src/main.rs create mode 100644 packaging/windows/onboarding/ASSET-SOURCES.md create mode 100644 packaging/windows/onboarding/app.ts create mode 100644 packaging/windows/onboarding/assets/deepagents.png create mode 100644 packaging/windows/onboarding/assets/hermes.png create mode 100644 packaging/windows/onboarding/assets/nemocua.png create mode 100644 packaging/windows/onboarding/assets/nvidia.svg create mode 100644 packaging/windows/onboarding/assets/openclaw.png create mode 100644 packaging/windows/onboarding/assets/pi.svg create mode 100644 packaging/windows/onboarding/index.html create mode 100644 packaging/windows/onboarding/styles.css diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index 1d636012bc4..cb6e5326720 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -415,6 +415,7 @@ jobs: -ArtifactDirectory "$packageRoot\qualification" - name: Upload installer for the recorded GitHub download + if: ${{ always() && steps.windows-package.outcome == 'success' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: windows-native-download-source-${{ github.sha }} @@ -426,6 +427,7 @@ jobs: retention-days: 14 - name: Create native Windows proof-of-life video + if: ${{ always() && steps.windows-package.outcome == 'success' }} shell: powershell env: GH_TOKEN: ${{ github.token }} diff --git a/packaging/windows/Bundle.wxs b/packaging/windows/Bundle.wxs index 5e9a3bd560d..1ab0d5bd02e 100644 --- a/packaging/windows/Bundle.wxs +++ b/packaging/windows/Bundle.wxs @@ -18,7 +18,7 @@ LogoFile="$(var.SourceRoot)\packaging\windows\assets\NemoClawLogo.png" LogoSideFile="$(var.SourceRoot)\packaging\windows\assets\NemoClawSidebar.png" LocalizationFile="$(var.SourceRoot)\packaging\windows\Theme.wxl" - LaunchTarget="[ProgramFiles6432Folder]NVIDIA\NemoClaw\bin\nemoclaw-ui.cmd" + LaunchTarget="[ProgramFiles6432Folder]NVIDIA\NemoClaw\bin\NemoClaw.exe" LaunchWorkingFolder="[ProgramFiles6432Folder]NVIDIA\NemoClaw" LaunchHidden="yes" ShowVersion="yes" diff --git a/packaging/windows/NATIVE-PREVIEW.txt b/packaging/windows/NATIVE-PREVIEW.txt index 08d8858b986..1762c500c28 100644 --- a/packaging/windows/NATIVE-PREVIEW.txt +++ b/packaging/windows/NATIVE-PREVIEW.txt @@ -16,6 +16,16 @@ The pinned OpenShell create watcher does not return after the one-shot workload completes. NemoClaw stops that client-side watcher after receiving the exact workload result, then deletes the sandbox through OpenShell. +The NVIDIA-branded setup installs a native ARM64 NemoClaw GUI launcher and a +graphical first-run experience. The interface names OpenClaw, Hermes Agent, +LangChain Deep Agents Code, Pi, and NemoCUA, with Pi and NemoCUA explicitly +marked experimental. Only OpenClaw activation is qualified in this package +slice; other selections fail closed until their exact native payloads pass. + +CI drives three deterministic turns through the real OpenClaw Control UI and +MXC-contained gateway. The deterministic loopback model proves transport and +runtime wiring without a pull-request secret; it is not production inference. + The setup executable applies Microsoft MXC's elevated prepare-system-drive and prepare-null-device host prerequisites before installing the MSI. Windows resets the null-device prerequisite at reboot; persistent production lifecycle diff --git a/packaging/windows/NemoClaw.Bundle.wixproj b/packaging/windows/NemoClaw.Bundle.wixproj index e0a4cb119ea..2e8fd7fc346 100644 --- a/packaging/windows/NemoClaw.Bundle.wixproj +++ b/packaging/windows/NemoClaw.Bundle.wixproj @@ -28,7 +28,7 @@ - + diff --git a/packaging/windows/NemoClaw.wixproj b/packaging/windows/NemoClaw.wixproj index 3b3a714815f..d241e05a679 100644 --- a/packaging/windows/NemoClaw.wixproj +++ b/packaging/windows/NemoClaw.wixproj @@ -26,10 +26,11 @@ + - + diff --git a/packaging/windows/Product.wxs b/packaging/windows/Product.wxs index c647ce859e4..e209b9efbaf 100644 --- a/packaging/windows/Product.wxs +++ b/packaging/windows/Product.wxs @@ -19,7 +19,7 @@ DowngradeErrorMessage="A newer NemoClaw native Windows candidate is already installed." /> - + @@ -51,14 +51,14 @@ - + diff --git a/packaging/windows/README.md b/packaging/windows/README.md index 2bf4103fc14..3c40dfcd661 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -37,6 +37,22 @@ custom actions. System-drive preparation supplies shallow-root traversal; the null-device setting is required for AppContainer process initialization and resets when Windows reboots. -The package is a preview distribution boundary. Host qualification, supported -onboarding, managed inference, service registration, production activation, and -production signing remain separate gates. +The setup uses a restrained NVIDIA-branded WiX interface and installs a native +ARM64 `NemoClaw.exe` GUI launcher. Launching NemoClaw opens the local graphical +onboarder without PowerShell or a visible console. The onboarder presents the +three supported agent identities (OpenClaw, Hermes Agent, and LangChain Deep +Agents Code) plus explicitly experimental Pi and NemoCUA choices. This slice +activates only the pinned OpenClaw runtime; the other selections fail closed +with a native-qualification explanation until their ARM64 payloads land. + +Package qualification launches Microsoft Edge through the installed GUI +launcher, walks all four graphical onboarding screens, and submits three turns +through the real OpenClaw Control UI to the MXC-contained OpenClaw gateway. A +deterministic loopback model endpoint makes the transport assertion repeatable +without exposing a PR credential; it is evidence for UI/gateway/runtime wiring, +not production inference quality. The workflow always attempts to upload the +raw actual-window recording so a failed UI run retains visual diagnostics. + +The package is a preview distribution boundary. Host qualification, +credential-backed onboarding parity, managed inference, service registration, +production activation, and production signing remain separate gates. diff --git a/packaging/windows/Theme.wxl b/packaging/windows/Theme.wxl index 8566e5d01ca..0a85c160e67 100644 --- a/packaging/windows/Theme.wxl +++ b/packaging/windows/Theme.wxl @@ -7,8 +7,8 @@ - - + + diff --git a/packaging/windows/launcher/Cargo.lock b/packaging/windows/launcher/Cargo.lock new file mode 100644 index 00000000000..f90a3904b46 --- /dev/null +++ b/packaging/windows/launcher/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "nemoclaw-windows-launcher" +version = "0.1.0" diff --git a/packaging/windows/launcher/Cargo.toml b/packaging/windows/launcher/Cargo.toml new file mode 100644 index 00000000000..2d7a482d328 --- /dev/null +++ b/packaging/windows/launcher/Cargo.toml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "nemoclaw-windows-launcher" +version = "0.1.0" +edition = "2024" +publish = false + +[[bin]] +name = "NemoClaw" +path = "src/main.rs" + +[profile.release] +codegen-units = 1 +lto = true +opt-level = "s" +panic = "abort" +strip = "symbols" diff --git a/packaging/windows/launcher/src/main.rs b/packaging/windows/launcher/src/main.rs new file mode 100644 index 00000000000..8679361adc7 --- /dev/null +++ b/packaging/windows/launcher/src/main.rs @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg_attr(target_os = "windows", windows_subsystem = "windows")] + +#[cfg(not(target_os = "windows"))] +compile_error!("The NemoClaw launcher is Windows-only."); + +use std::env; +use std::ffi::OsStr; +use std::iter; +use std::os::windows::ffi::OsStrExt; +use std::os::windows::process::CommandExt; +use std::path::PathBuf; +use std::process::{Command, exit}; + +const CREATE_NO_WINDOW: u32 = 0x0800_0000; +const DETACHED_PROCESS: u32 = 0x0000_0008; + +#[link(name = "user32")] +unsafe extern "system" { + fn MessageBoxW(window: isize, text: *const u16, caption: *const u16, kind: u32) -> i32; +} + +fn wide(value: &str) -> Vec { + OsStr::new(value) + .encode_wide() + .chain(iter::once(0)) + .collect() +} + +fn fail(message: &str) -> ! { + let text = wide(message); + let caption = wide("NemoClaw could not start"); + unsafe { + MessageBoxW(0, text.as_ptr(), caption.as_ptr(), 0x10); + } + exit(1); +} + +fn main() { + let executable = + env::current_exe().unwrap_or_else(|_| fail("The launcher path is unavailable.")); + let bin = executable + .parent() + .map(PathBuf::from) + .unwrap_or_else(|| fail("The NemoClaw bin directory is unavailable.")); + let install = bin + .parent() + .map(PathBuf::from) + .unwrap_or_else(|| fail("The NemoClaw installation directory is unavailable.")); + let node = bin.join("node.exe"); + let entry = install + .join("qualification") + .join("run-installed-native-web-ui.mts"); + if !node.is_file() || !entry.is_file() { + fail( + "The installed NemoClaw runtime is incomplete. Run Repair from Apps > Installed apps.", + ); + } + + let mut forwarded = env::args_os().skip(1).collect::>(); + let wait = forwarded.first().is_some_and(|value| value == "--wait"); + if wait { + forwarded.remove(0); + } + let mut command = Command::new(node); + command + .arg("--experimental-strip-types") + .arg("--no-warnings") + .arg(entry) + .args(forwarded) + .current_dir(install) + .env("NEMOCLAW_NATIVE_INSTALL_ROOT", install); + command.creation_flags(if wait { + CREATE_NO_WINDOW + } else { + CREATE_NO_WINDOW | DETACHED_PROCESS + }); + + if wait { + let status = command + .status() + .unwrap_or_else(|_| fail("The installed NemoClaw runtime could not be started.")); + exit(status.code().unwrap_or(1)); + } + command + .spawn() + .unwrap_or_else(|_| fail("The installed NemoClaw runtime could not be started.")); +} diff --git a/packaging/windows/onboarding/ASSET-SOURCES.md b/packaging/windows/onboarding/ASSET-SOURCES.md new file mode 100644 index 00000000000..70fd7cfea3a --- /dev/null +++ b/packaging/windows/onboarding/ASSET-SOURCES.md @@ -0,0 +1,16 @@ + + + +# Agent branding sources + +These marks identify third-party agent choices; they do not imply endorsement. +The UI keeps marks visually subordinate to NVIDIA NemoClaw branding. + +- `nvidia.svg` and `nemocua.png`: existing NemoClaw repository NVIDIA artwork. +- `openclaw.png`: OpenClaw 2026.7.1 `dist/control-ui/apple-touch-icon.png`, from the exact locked npm package (`sha512-ge/Xss99CHAjPL/ikmH/UFoiOrjcxDB4sW3y9mhyCD+dYW3wzV7TKbAVdkrXFgAG2d2BjpJofP97zUZ+umxo8g==`). +- `hermes.png`: `NousResearch/hermes-agent` commit `db23c79bbeb945397e5ca5aca00bb2a834e51b31`, `assets/banner.png` (`sha256-75e85ef6fecf5a6227985f2082e5b35331fceff4d8db03720c82a7d9ee8b7eea`). +- `deepagents.png`: square mark cropped from `langchain-ai/deepagents` commit `54fe91fd3745e285899961bfe74380c837674164`, `.github/images/logo-light.svg` (`sha256-dd6d676149c64df2af80fa24c6c34d1511d6fab1f6012ff61b6acd7dbd000e7f`). +- `pi.svg`: `https://pi.dev/logo-auto.svg`, retrieved 2026-09-02 (`sha256-03d509c104b9570063fa268fd3235ed7e0e41dafd93124ca94cae3726f58f117`). + +OpenClaw, Hermes Agent, Deep Agents, LangChain, Pi, and their marks remain the +property of their respective owners. diff --git a/packaging/windows/onboarding/app.ts b/packaging/windows/onboarding/app.ts new file mode 100644 index 00000000000..db214290234 --- /dev/null +++ b/packaging/windows/onboarding/app.ts @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const agentNames = { + openclaw: "OpenClaw", + hermes: "Hermes Agent", + "langchain-deepagents-code": "Deep Agents Code", + pi: "Pi", + nemocua: "NemoCUA", +}; + +const inferenceNames = { + nvidia: "NVIDIA hosted inference", + openrouter: "OpenRouter", + compatible: "Compatible endpoint", + local: "Local NVIDIA GPU", +}; + +const state = { step: 1, agent: "openclaw", inference: "nvidia" }; +const panels = [...document.querySelectorAll("[data-step]")]; +const steps = [...document.querySelectorAll("[data-step-target]")]; +const next = document.querySelector("#next"); +const back = document.querySelector("#back"); +const launch = document.querySelector("#launch"); +const form = document.querySelector("#onboarding-form"); +const error = document.querySelector("#submit-error"); + +function render() { + for (const panel of panels) + panel.classList.toggle("active", Number(panel.dataset.step) === state.step); + for (const step of steps) + step.classList.toggle("active", Number(step.dataset.stepTarget) === state.step); + back.disabled = state.step === 1; + next.hidden = state.step === 4; + launch.hidden = state.step !== 4; + document.querySelector("#review-agent").textContent = agentNames[state.agent]; + document.querySelector("#review-inference").textContent = inferenceNames[state.inference]; + document.querySelector("#experimental-notice").hidden = !["pi", "nemocua"].includes(state.agent); +} + +function select(selector, attribute, value) { + for (const card of document.querySelectorAll(selector)) { + const selected = card.dataset[attribute] === value; + card.classList.toggle("selected", selected); + card.setAttribute("aria-checked", String(selected)); + } +} + +document.querySelectorAll("[data-agent]").forEach((card) => { + card.addEventListener("click", () => { + state.agent = card.dataset.agent; + select("[data-agent]", "agent", state.agent); + }); +}); + +document.querySelectorAll("[data-inference]").forEach((choice) => { + choice.addEventListener("click", () => { + state.inference = choice.dataset.inference; + select("[data-inference]", "inference", state.inference); + }); +}); + +next.addEventListener("click", () => { + state.step = Math.min(4, state.step + 1); + render(); +}); + +back.addEventListener("click", () => { + state.step = Math.max(1, state.step - 1); + render(); +}); + +form.addEventListener("submit", async (event) => { + event.preventDefault(); + error.hidden = true; + launch.disabled = true; + launch.textContent = "Preparing…"; + const options = Object.fromEntries(new FormData(form).entries()); + try { + const response = await fetch("/api/configure", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ...state, options }), + }); + const result = await response.json(); + if (!response.ok || typeof result.redirect !== "string") + throw new Error(result.message || "Setup could not continue."); + window.location.assign(result.redirect); + } catch (cause) { + error.textContent = cause instanceof Error ? cause.message : "Setup could not continue."; + error.hidden = false; + launch.disabled = false; + launch.textContent = "Finish & launch"; + } +}); + +render(); diff --git a/packaging/windows/onboarding/assets/deepagents.png b/packaging/windows/onboarding/assets/deepagents.png new file mode 100644 index 0000000000000000000000000000000000000000..5845c18bf9421ffae8b9b1b5f5eb97391a8378c6 GIT binary patch literal 1592 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�!X5p5sf&LloNSTYkUfT-^- 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 O0000 + + + + + + + + + + diff --git a/packaging/windows/onboarding/assets/openclaw.png b/packaging/windows/onboarding/assets/openclaw.png new file mode 100644 index 0000000000000000000000000000000000000000..71781843f857e274449a0e7a2b151c06d92b5e4f GIT binary patch literal 5920 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 literal 0 HcmV?d00001 diff --git a/packaging/windows/onboarding/assets/pi.svg b/packaging/windows/onboarding/assets/pi.svg new file mode 100644 index 00000000000..c403a4ea012 --- /dev/null +++ b/packaging/windows/onboarding/assets/pi.svg @@ -0,0 +1,28 @@ + + + + + + + + diff --git a/packaging/windows/onboarding/index.html b/packaging/windows/onboarding/index.html new file mode 100644 index 00000000000..05141f2f173 --- /dev/null +++ b/packaging/windows/onboarding/index.html @@ -0,0 +1,306 @@ + + + + + + NemoClaw Native Windows · Setup + + + +
+
+ NVIDIA + + NemoClaw + Native Windows ARM64 preview +
+ + + +
+
+

Choose your agent

+

Start with the experience that fits your work.

+

+ NemoClaw places each qualified agent behind native OpenShell and + Microsoft MXC isolation. You can add another sandbox later. +

+ +
+ + + + + + + + + +
+
+ +
+

Choose inference

+

Connect the model you trust.

+

+ Credentials are requested only after the selected endpoint passes + validation and are never written to Windows Installer logs. +

+
+ + + + +
+
+ +
+

Shape the experience

+

Begin simple. Add integrations when you need them.

+

+ These choices mirror the core onboarding decisions and remain + editable after setup. +

+
+ + + + +
+
+ +
+

Review

+

Ready to launch NemoClaw.

+

+ Your runtime stays under Program Files. Agent state, credentials, + and workspaces stay in user-owned data directories. +

+
+
+
Agent
+
OpenClaw
+
+
+
Inference
+
NVIDIA hosted inference
+
+
+
Isolation
+
OpenShell + Microsoft MXC ProcessContainer
+
+
+
Platform
+
Native Windows ARM64 · no WSL
+
+
+ + +
+ +
+ + No configuration secret is passed through MSI. + + +
+
+
+ + + diff --git a/packaging/windows/onboarding/styles.css b/packaging/windows/onboarding/styles.css new file mode 100644 index 00000000000..78674ebaadb --- /dev/null +++ b/packaging/windows/onboarding/styles.css @@ -0,0 +1,390 @@ +:root { + color-scheme: light; + font-family: "Segoe UI", Inter, system-ui, sans-serif; + color: #202020; + background: #f2f2f2; + --green: #76b900; + --green-dark: #4f7d00; + --ink: #202020; + --muted: #666; + --line: #dddddd; + --surface: #ffffff; +} + +* { + box-sizing: border-box; +} +body { + margin: 0; + min-width: 920px; + min-height: 100vh; + background: linear-gradient(145deg, #f7f7f7, #ececec); +} +button, +input { + font: inherit; +} +button { + color: inherit; +} + +.shell { + width: min(1120px, calc(100vw - 64px)); + min-height: calc(100vh - 64px); + margin: 32px auto; + overflow: hidden; + background: var(--surface); + border: 1px solid #d8d8d8; + border-radius: 14px; + box-shadow: 0 22px 60px rgba(0, 0, 0, 0.09); +} + +.masthead { + display: flex; + align-items: center; + gap: 14px; + height: 72px; + padding: 0 32px; + border-bottom: 1px solid var(--line); +} +.masthead img { + width: 112px; + height: auto; +} +.masthead strong { + font-size: 20px; + letter-spacing: -0.02em; +} +.divider { + width: 1px; + height: 24px; + background: #c9c9c9; +} +.preview { + margin-left: auto; + color: var(--muted); + font-size: 13px; +} + +.steps { + display: flex; + gap: 8px; + padding: 20px 32px 0; +} +.step { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 12px; + border: 0; + border-radius: 18px; + background: transparent; + color: #777; +} +.step span { + display: grid; + place-items: center; + width: 22px; + height: 22px; + border: 1px solid #bbb; + border-radius: 50%; + font-size: 12px; +} +.step.active { + color: var(--ink); + background: #f3f6ee; +} +.step.active span { + border-color: var(--green); + background: var(--green); + color: #fff; +} + +.panel { + display: none; + padding: 28px 48px 24px; +} +.panel.active { + display: block; +} +.eyebrow { + margin: 0 0 8px; + color: var(--green-dark); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; +} +h1 { + margin: 0; + font-size: 34px; + line-height: 1.15; + letter-spacing: -0.035em; + font-weight: 650; +} +.lead { + max-width: 760px; + margin: 12px 0 26px; + color: var(--muted); + font-size: 16px; + line-height: 1.55; +} + +.agent-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} +.agent-card { + display: flex; + gap: 16px; + min-height: 116px; + padding: 16px; + text-align: left; + background: #fff; + border: 1px solid var(--line); + border-radius: 10px; + cursor: pointer; + transition: + border-color 120ms, + box-shadow 120ms, + transform 120ms; +} +.agent-card:hover { + border-color: #aaa; + transform: translateY(-1px); +} +.agent-card.selected { + border-color: var(--green); + box-shadow: 0 0 0 2px rgba(118, 185, 0, 0.14); +} +.agent-card.experimental { + background: #fbfbfb; +} +.agent-mark { + display: grid; + place-items: center; + flex: 0 0 62px; + height: 62px; + overflow: hidden; + border-radius: 12px; + background: #f3f3f3; +} +.agent-mark img { + max-width: 52px; + max-height: 52px; + object-fit: contain; +} +.agent-mark.openclaw { + background: #fff1f1; +} +.agent-mark.hermes { + background: #1c1c1c; +} +.agent-mark.hermes img { + max-width: 58px; + max-height: 40px; +} +.agent-mark.deepagents img { + width: 56px; +} +.agent-mark.pi img { + width: 48px; +} +.agent-mark.nemocua { + background: #f2f7e9; +} +.agent-copy { + display: flex; + min-width: 0; + flex-direction: column; +} +.card-heading { + font-size: 17px; + font-weight: 650; +} +.vendor { + margin-top: 2px; + color: #777; + font-size: 12px; +} +.description { + margin-top: 8px; + color: #555; + font-size: 13px; + line-height: 1.38; +} +.badge { + display: inline-block; + margin-left: 7px; + padding: 2px 7px; + border-radius: 10px; + background: #ededed; + color: #666; + font-size: 10px; + font-weight: 700; + vertical-align: 2px; +} +.badge.recommended { + background: #eff7e3; + color: var(--green-dark); +} + +.choice-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + max-width: 860px; +} +.choice { + position: relative; + display: flex; + min-height: 112px; + flex-direction: column; + gap: 8px; + padding: 20px; + text-align: left; + border: 1px solid var(--line); + border-radius: 10px; + background: #fff; + cursor: pointer; +} +.choice strong { + font-size: 17px; +} +.choice span { + color: var(--muted); + line-height: 1.4; +} +.choice em { + position: absolute; + top: 18px; + right: 18px; + color: var(--green-dark); + font-size: 11px; + font-style: normal; + font-weight: 700; +} +.choice.selected { + border-color: var(--green); + box-shadow: 0 0 0 2px rgba(118, 185, 0, 0.14); +} + +.toggle-list { + max-width: 820px; + border: 1px solid var(--line); + border-radius: 10px; + overflow: hidden; +} +.toggle-list label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 18px 20px; + border-bottom: 1px solid var(--line); +} +.toggle-list label:last-child { + border-bottom: 0; +} +.toggle-list span { + display: flex; + flex-direction: column; + gap: 4px; +} +.toggle-list small { + color: var(--muted); + font-size: 13px; +} +.toggle-list input { + width: 18px; + height: 18px; + accent-color: var(--green); +} + +.review { + max-width: 760px; + margin: 28px 0; + border-top: 1px solid var(--line); +} +.review div { + display: grid; + grid-template-columns: 170px 1fr; + padding: 15px 4px; + border-bottom: 1px solid var(--line); +} +.review dt { + color: var(--muted); +} +.review dd { + margin: 0; + font-weight: 600; +} +.notice { + max-width: 760px; + padding: 14px 16px; + border-left: 3px solid #d49d00; + background: #fff9e8; + color: #5c4a16; + line-height: 1.45; +} +.notice strong { + display: block; + margin-bottom: 2px; +} +.error { + max-width: 760px; + padding: 12px 14px; + border-radius: 6px; + background: #fff0f0; + color: #8a2222; +} + +.actions { + display: flex; + align-items: center; + gap: 10px; + padding: 18px 32px; + border-top: 1px solid var(--line); + background: #fafafa; +} +.actions button { + min-width: 104px; + padding: 10px 17px; + border-radius: 6px; + cursor: pointer; +} +.secondary { + border: 1px solid #bdbdbd; + background: #fff; +} +.primary { + border: 1px solid var(--green-dark); + background: var(--green); + color: #fff; + font-weight: 650; +} +.actions button:disabled { + cursor: default; + opacity: 0.45; +} +.privacy { + margin-right: auto; + color: #777; + font-size: 12px; +} + +@media (max-width: 980px) { + body { + min-width: 760px; + } + .shell { + width: calc(100vw - 28px); + margin: 14px; + min-height: calc(100vh - 28px); + } + .agent-grid, + .choice-grid { + grid-template-columns: 1fr; + } +} diff --git a/packaging/windows/runtime/run-installed-native-web-ui.mts b/packaging/windows/runtime/run-installed-native-web-ui.mts index 515653b10e0..a6297f3aacd 100644 --- a/packaging/windows/runtime/run-installed-native-web-ui.mts +++ b/packaging/windows/runtime/run-installed-native-web-ui.mts @@ -4,6 +4,7 @@ import { spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; import fs from "node:fs"; +import { createServer } from "node:http"; import { createRequire } from "node:module"; import path from "node:path"; @@ -150,7 +151,110 @@ function resolveEdge() { fail("Microsoft Edge is required for the visible Control UI proof"); } -async function driveBrowser(openClawRoot, url, evidenceRoot, qualification) { +async function startOnboardingServer(installRoot, openClawUrl, evidenceRoot) { + const onboardingRoot = requiredDirectory( + path.join(installRoot, "onboarding"), + "NemoClaw graphical onboarder", + ); + const files = new Map([ + ["/", ["index.html", "text/html; charset=utf-8"]], + ["/index.html", ["index.html", "text/html; charset=utf-8"]], + ["/styles.css", ["styles.css", "text/css; charset=utf-8"]], + ["/app.ts", ["app.ts", "text/javascript; charset=utf-8"]], + ["/assets/nvidia.svg", ["assets/nvidia.svg", "image/svg+xml"]], + ["/assets/openclaw.png", ["assets/openclaw.png", "image/png"]], + ["/assets/hermes.png", ["assets/hermes.png", "image/png"]], + ["/assets/deepagents.png", ["assets/deepagents.png", "image/png"]], + ["/assets/pi.svg", ["assets/pi.svg", "image/svg+xml"]], + ["/assets/nemocua.png", ["assets/nemocua.png", "image/png"]], + ]); + let selection = null; + const server = createServer(async (request, response) => { + try { + const pathname = new URL(request.url ?? "/", "http://127.0.0.1").pathname; + if (request.method === "POST" && pathname === "/api/configure") { + const chunks = []; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > 64 * 1024) throw new Error("onboarding request is too large"); + chunks.push(chunk); + } + const submitted = JSON.parse(Buffer.concat(chunks).toString("utf8")); + const agents = new Set([ + "openclaw", + "hermes", + "langchain-deepagents-code", + "pi", + "nemocua", + ]); + const inference = new Set(["nvidia", "openrouter", "compatible", "local"]); + if (!agents.has(submitted?.agent) || !inference.has(submitted?.inference)) + throw new Error("onboarding selection is invalid"); + if (submitted.agent !== "openclaw") { + response.writeHead(409, { "content-type": "application/json" }); + response.end( + JSON.stringify({ + message: + "This agent is visible for native Windows planning, but its pinned ARM64 runtime has not passed qualification yet. Choose OpenClaw for this candidate.", + }), + ); + return; + } + selection = { + schemaVersion: 1, + agent: submitted.agent, + inference: submitted.inference, + options: submitted.options ?? {}, + }; + fs.writeFileSync( + path.join(evidenceRoot, "onboarding-selection.json"), + `${JSON.stringify(selection, null, 2)}\n`, + "utf8", + ); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ redirect: `${openClawUrl}/chat` })); + return; + } + const file = files.get(pathname); + if (request.method !== "GET" || !file) { + response.writeHead(404, { + "content-type": "text/plain; charset=utf-8", + }); + response.end("Not found"); + return; + } + const [relative, contentType] = file; + response.writeHead(200, { + "cache-control": "no-store", + "content-security-policy": + "default-src 'self'; img-src 'self'; style-src 'self'; script-src 'self'; connect-src 'self'", + "content-type": contentType, + "x-content-type-options": "nosniff", + }); + response.end(fs.readFileSync(requiredFile(path.join(onboardingRoot, relative), relative))); + } catch (error) { + response.writeHead(400, { "content-type": "application/json" }); + response.end( + JSON.stringify({ + message: error instanceof Error ? error.message : "Invalid request", + }), + ); + } + }); + const port = await freePort(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", resolve); + }); + return { + server, + url: `http://127.0.0.1:${port}`, + selection: () => selection, + }; +} + +async function driveBrowser(openClawRoot, onboardingUrl, openClawUrl, evidenceRoot, qualification) { const playwrightRoot = requiredDirectory( path.join(openClawRoot, "node_modules", "openclaw", "node_modules", "playwright-core"), "installed Playwright browser driver", @@ -169,13 +273,37 @@ async function driveBrowser(openClawRoot, url, evidenceRoot, qualification) { viewport: { width: 1280, height: 720 }, }); const page = await context.newPage(); - await page.goto(`${url}/chat`, { + await page.goto(onboardingUrl, { waitUntil: "domcontentloaded", timeout: 90_000, }); - await page.evaluate(() => { - document.title = "NemoClaw Native Windows · OpenClaw Control UI"; + await page.locator("[data-agent='openclaw']").waitFor({ + state: "visible", + timeout: 30_000, + }); + await page.screenshot({ + path: path.join(evidenceRoot, "onboarding-agent.png"), + }); + if (!qualification) { + console.log(`WEB UI> READY ${onboardingUrl}`); + await new Promise((resolve) => browser.once("disconnected", resolve)); + return { browserVersion, turns: [] }; + } + await page.locator("[data-agent='openclaw']").click(); + await page.locator("#next").click(); + await page.screenshot({ + path: path.join(evidenceRoot, "onboarding-inference.png"), + }); + await page.locator("#next").click(); + await page.screenshot({ + path: path.join(evidenceRoot, "onboarding-experience.png"), + }); + await page.locator("#next").click(); + await page.screenshot({ + path: path.join(evidenceRoot, "onboarding-review.png"), }); + await page.locator("#launch").click(); + await page.waitForURL(`${openClawUrl}/chat`, { timeout: 30_000 }); const composer = page.locator(".agent-chat__composer-combobox > textarea").first(); await composer.waitFor({ state: "visible", timeout: 90_000 }); await page.waitForFunction( @@ -192,11 +320,6 @@ async function driveBrowser(openClawRoot, url, evidenceRoot, qualification) { await page.screenshot({ path: path.join(evidenceRoot, "web-ui-ready.png"), }); - if (!qualification) { - console.log(`WEB UI> READY ${url}/chat`); - await new Promise((resolve) => browser.once("disconnected", resolve)); - return { browserVersion, turns: [] }; - } const turns = []; for (let index = 0; index < TURN_PROOFS.length; index += 1) { const [prompt, expected] = TURN_PROOFS[index]; @@ -338,6 +461,7 @@ async function main() { ); let cliEnvironment = gatewayEnvironment; let create = null; + let onboarding = null; let passed = false; let logsClosed = false; try { @@ -402,8 +526,22 @@ async function main() { console.log("WEB UI> Waiting for the real OpenClaw Control UI"); await waitForPort(uiPort, create, "OpenClaw Control UI"); const uiUrl = `http://127.0.0.1:${uiPort}`; - console.log(`WEB UI> Launching Microsoft Edge at ${uiUrl}/chat`); - const browserProof = await driveBrowser(openClawRoot, uiUrl, evidenceRoot, qualification); + onboarding = await startOnboardingServer(installRoot, uiUrl, evidenceRoot); + console.log(`WEB UI> Launching the NemoClaw graphical onboarder at ${onboarding.url}`); + const browserProof = await driveBrowser( + openClawRoot, + onboarding.url, + uiUrl, + evidenceRoot, + qualification, + ); + const onboardingSelection = onboarding.selection(); + await new Promise((resolve, reject) => { + onboarding.server.close((error) => (error ? reject(error) : resolve())); + }); + onboarding = null; + if (qualification && onboardingSelection?.agent !== "openclaw") + fail("graphical onboarding did not select OpenClaw"); await run( openshell, ["sandbox", "delete", sandboxName], @@ -436,6 +574,7 @@ async function main() { browser: "Microsoft Edge", browserVersion: browserProof.browserVersion, deterministicLocalModel: qualification, + onboardingSelection, turnCount: browserProof.turns.length, turns: browserProof.turns, sandboxDeleted: true, @@ -456,6 +595,9 @@ async function main() { : "WEB UI> NemoClaw preview session closed cleanly", ); } finally { + if (onboarding !== null) { + await new Promise((resolve) => onboarding.server.close(() => resolve())); + } if (create !== null) await stopChild(create); if (!passed) { try { diff --git a/scripts/checks/build-windows-native-package.ps1 b/scripts/checks/build-windows-native-package.ps1 index 232d999b7fa..0b0969d1742 100644 --- a/scripts/checks/build-windows-native-package.ps1 +++ b/scripts/checks/build-windows-native-package.ps1 @@ -97,10 +97,13 @@ Assert-Arm64PortableExecutable -Path $openshell -Label 'openshell.exe payload' Assert-Arm64PortableExecutable -Path $gateway -Label 'openshell-gateway.exe payload' foreach ($requiredPayload in @( 'bin\node.exe', + 'bin\NemoClaw.exe', 'bin\nemoclaw.cmd', - 'bin\nemoclaw-ui.cmd', 'nemoclaw\app\bin\nemoclaw.js', 'openclaw\node_modules\openclaw\openclaw.mjs', + 'onboarding\index.html', + 'onboarding\styles.css', + 'onboarding\app.ts', 'mxc\wxc-exec.exe', 'mxc\wxc-host-prep.exe', 'config\mxc-gateway.toml', @@ -122,6 +125,7 @@ if (@(Compare-Object @('wxc-exec.exe', 'wxc-host-prep.exe') $mxcPayloadFiles).Co Fail-WindowsPackageBuild 'MXC payload must contain only the ProcessContainer executor and host-preparation utility.' } Assert-Arm64PortableExecutable -Path (Join-Path $payload 'bin\node.exe') -Label 'node.exe payload' +Assert-Arm64PortableExecutable -Path (Join-Path $payload 'bin\NemoClaw.exe') -Label 'NemoClaw.exe payload' Assert-Arm64PortableExecutable -Path (Join-Path $payload 'mxc\wxc-exec.exe') -Label 'wxc-exec.exe payload' Assert-Arm64PortableExecutable -Path (Join-Path $payload 'mxc\wxc-host-prep.exe') -Label 'wxc-host-prep.exe payload' diff --git a/scripts/checks/create-windows-native-proof-video.ps1 b/scripts/checks/create-windows-native-proof-video.ps1 index 6f0929c35f9..ed783aa2a61 100644 --- a/scripts/checks/create-windows-native-proof-video.ps1 +++ b/scripts/checks/create-windows-native-proof-video.ps1 @@ -277,7 +277,7 @@ if (-not (Test-Path -LiteralPath $candidate -PathType Container)) { $msi = Resolve-RequiredFile -Path $MsiPath -Label 'MsiPath' $setup = Resolve-RequiredFile -Path $SetupPath -Label 'SetupPath' $manifestPath = Resolve-RequiredFile -Path $PackageManifestPath -Label 'PackageManifestPath' -$qualificationPath = Resolve-RequiredFile -Path $QualificationReceiptPath -Label 'QualificationReceiptPath' +$qualificationPath = [IO.Path]::GetFullPath($QualificationReceiptPath) $hostPath = Resolve-RequiredFile -Path $HostReceiptPath -Label 'HostReceiptPath' $openshellPath = Resolve-RequiredFile -Path $OpenShellReceiptPath -Label 'OpenShellReceiptPath' $qualificationScript = Resolve-RequiredFile ` @@ -305,12 +305,15 @@ if (Test-Path -LiteralPath $desktopDownload) { } $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json -$qualification = Get-Content -LiteralPath $qualificationPath -Raw | ConvertFrom-Json +$qualification = if (Test-Path -LiteralPath $qualificationPath -PathType Leaf) { + Get-Content -LiteralPath $qualificationPath -Raw | ConvertFrom-Json +} else { + $null +} $hostReceipt = Get-Content -LiteralPath $hostPath -Raw | ConvertFrom-Json $openshellReceipt = Get-Content -LiteralPath $openshellPath -Raw | ConvertFrom-Json -if ($manifest.productVersion -cne $ProductVersion -or $manifest.architecture -cne 'arm64' -or - $qualification.productVersion -cne $ProductVersion -or $qualification.architecture -cne 'arm64') { - Fail-ProofVideo 'Package and qualification receipt identity do not match.' +if ($manifest.productVersion -cne $ProductVersion -or $manifest.architecture -cne 'arm64') { + Fail-ProofVideo 'Package manifest identity does not match.' } if ($hostReceipt.osArchitecture -cne 'Arm64' -or $hostReceipt.processArchitecture -cne 'Arm64' -or $hostReceipt.runnerArchitecture -cne 'ARM64') { @@ -321,7 +324,7 @@ if ($openshellReceipt.repository -cne 'https://github.com/NVIDIA/OpenShell.git' $openshellReceipt.revision -cne 'bcd517bbe08cc80860c9be57699390cd32e8445f') { Fail-ProofVideo 'OpenShell source authority does not match NVIDIA/OpenShell#2721.' } -if (-not $qualification.repairRestoredDigest -or +if ($null -ne $qualification -and (-not $qualification.repairRestoredDigest -or -not $qualification.reinstallPreservedRegistration -or -not $qualification.finalAbsence -or -not $qualification.machinePathRemoved -or @@ -336,11 +339,12 @@ if (-not $qualification.repairRestoredDigest -or $qualification.nativeTurn.qualificationRootsRemoved -ne $true -or $qualification.webUi.verdict -cne 'pass' -or [int]$qualification.webUi.turnCount -ne 3 -or + $qualification.webUi.onboardingSelection.agent -cne 'openclaw' -or @($qualification.webUi.turns).Count -ne 3 -or @($qualification.nativeExecutions).Count -ne 3 -or @($qualification.applicationExecutions).Count -ne 2 -or @($qualification.packageDescendantProhibitedStarts).Count -ne 0 -or - @($qualification.newPackageDescendantProhibitedProcesses).Count -ne 0) { + @($qualification.newPackageDescendantProhibitedProcesses).Count -ne 0)) { Fail-ProofVideo 'Initial package qualification receipt is not a complete passing lifecycle.' } @@ -351,6 +355,7 @@ $consoleTranscript = Join-Path $output 'live-console-transcript.txt' $frameRoot = Join-Path $env:RUNNER_TEMP ('nemoclaw-console-frames-' + [guid]::NewGuid().ToString('N')) [IO.Directory]::CreateDirectory($frameRoot) | Out-Null $proofProcess = $null +$captureFailures = [Collections.Generic.List[string]]::new() try { $powershell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' $proofArguments = @( @@ -402,14 +407,15 @@ try { if ($recordingClock.ElapsedMilliseconds -gt $script:MaximumRecordingMilliseconds) { $proofProcess.Kill() $proofProcess.WaitForExit() - Fail-ProofVideo 'Real console qualification exceeded its recording timeout.' + $captureFailures.Add('Real console qualification exceeded its recording timeout.') + break } $installerWindow = [NemoClawNativeWindowCapture]::FindWindowContaining( 'NemoClaw Setup', $consoleWindow ) $browserWindow = [NemoClawNativeWindowCapture]::FindWindowContaining( - 'NemoClaw Native Windows · OpenClaw Control UI', + 'NemoClaw Native Windows', $consoleWindow ) $framePath = Join-Path $frameRoot ('frame-{0:D5}.png' -f ($framePaths.Count + 1)) @@ -429,7 +435,8 @@ try { [IntPtr]::Zero ) if ($consoleWindow -eq [IntPtr]::Zero) { - Fail-ProofVideo "The real console window disappeared during qualification: $($_.Exception.Message)" + $captureFailures.Add("The real console window disappeared during qualification: $($_.Exception.Message)") + break } continue } @@ -452,16 +459,20 @@ try { } else { '' } - Fail-ProofVideo "Real console qualification failed with exit code $proofExitCode. $failureText" + $captureFailures.Add("Real console qualification failed with exit code $proofExitCode. $failureText") } if ($framePaths.Count -lt $script:MinimumCaptureFrames) { - Fail-ProofVideo "The live console recording captured too few frames: $($framePaths.Count)." + $captureFailures.Add("The live console recording captured too few frames: $($framePaths.Count).") } if (-not (Test-Path -LiteralPath $consoleTranscript -PathType Leaf) -or (Get-Item -LiteralPath $consoleTranscript).Length -eq 0) { - Fail-ProofVideo 'The live console transcript is missing.' + $captureFailures.Add('The live console transcript is missing.') + } + $consoleTranscriptText = if (Test-Path -LiteralPath $consoleTranscript -PathType Leaf) { + [IO.File]::ReadAllText($consoleTranscript) + } else { + '' } - $consoleTranscriptText = [IO.File]::ReadAllText($consoleTranscript) if (-not $consoleTranscriptText.Contains('AGENT> CHAT_OK') -or -not $consoleTranscriptText.Contains( '[PASS] Installed nemoclaw command created an MXC sandbox and completed an exact CHAT_OK turn' @@ -469,21 +480,24 @@ try { -not $consoleTranscriptText.Contains('WEB UI> TURN 1 PASS NATIVE_WINDOWS_TURN_1_OK') -or -not $consoleTranscriptText.Contains('WEB UI> TURN 2 PASS NATIVE_WINDOWS_TURN_2_OK') -or -not $consoleTranscriptText.Contains('WEB UI> TURN 3 PASS NATIVE_WINDOWS_TURN_3_OK')) { - Fail-ProofVideo 'The recorded console did not show the installed NemoClaw CLI and web UI turns.' + $captureFailures.Add('The recorded console did not show the installed NemoClaw CLI and web UI turns.') } if ($installerWindowFrameCount -lt 4) { - Fail-ProofVideo 'The real WiX installer window was not captured for at least one second.' + $captureFailures.Add('The real WiX installer window was not captured for at least one second.') } if ($browserWindowFrameCount -lt 8) { - Fail-ProofVideo 'The real OpenClaw Control UI window was not captured for at least two seconds.' + $captureFailures.Add('The real OpenClaw Control UI window was not captured for at least two seconds.') + } + if ($framePaths.Count -eq 0) { + Fail-ProofVideo 'The proof process produced no actual-window frames to encode.' } $frameHashes = @($framePaths | ForEach-Object { (Get-FileHash -LiteralPath $_ -Algorithm SHA256).Hash }) $uniqueFrameCount = @($frameHashes | Sort-Object -Unique).Count if ($uniqueFrameCount -lt $script:MinimumUniqueFrames) { - Fail-ProofVideo "The real window recording changed too little: only $uniqueFrameCount unique frames." + $captureFailures.Add("The real window recording changed too little: only $uniqueFrameCount unique frames.") } $middleIndex = [int][Math]::Floor(($framePaths.Count - 1) / 2) @@ -599,11 +613,14 @@ public static class NemoClawConsoleVideoEncoder Fail-ProofVideo 'Rendered console proof is not an ISO base media file.' } - $consoleQualificationReceipt = Resolve-RequiredFile ` - -Path (Join-Path $consoleQualification 'package-qualification.json') ` - -Label 'Recorded console qualification receipt' - $recordedQualification = Get-Content -LiteralPath $consoleQualificationReceipt -Raw | ConvertFrom-Json - if ($recordedQualification.nativeTurn.verdict -cne 'pass' -or + $consoleQualificationReceipt = Join-Path $consoleQualification 'package-qualification.json' + $recordedQualification = if (Test-Path -LiteralPath $consoleQualificationReceipt -PathType Leaf) { + Get-Content -LiteralPath $consoleQualificationReceipt -Raw | ConvertFrom-Json + } else { + $captureFailures.Add('The recorded console qualification receipt is missing.') + $null + } + if ($null -ne $recordedQualification -and ($recordedQualification.nativeTurn.verdict -cne 'pass' -or $recordedQualification.nativeTurn.exactReply -cne 'CHAT_OK' -or $recordedQualification.nativeTurn.openClawExecutionMode -cne 'embedded-worker' -or $recordedQualification.nativeTurn.createWatcherStopped -ne $true -or @@ -611,27 +628,48 @@ public static class NemoClawConsoleVideoEncoder $recordedQualification.nativeTurn.gatewayStopped -ne $true -or $recordedQualification.nativeTurn.sandboxDeleted -ne $true -or $recordedQualification.nativeTurn.sandboxRegistryAbsent -ne $true -or - $recordedQualification.nativeTurn.qualificationRootsRemoved -ne $true) { - Fail-ProofVideo 'The recorded qualification receipt does not prove the installed NemoClaw turn.' + $recordedQualification.nativeTurn.qualificationRootsRemoved -ne $true)) { + $captureFailures.Add('The recorded qualification receipt does not prove the installed NemoClaw turn.') } - if ($recordedQualification.webUi.verdict -cne 'pass' -or + if ($null -ne $recordedQualification -and ($recordedQualification.webUi.verdict -cne 'pass' -or [int]$recordedQualification.webUi.turnCount -ne 3 -or - @($recordedQualification.webUi.turns).Count -ne 3) { - Fail-ProofVideo 'The recorded qualification receipt does not prove three OpenClaw Control UI turns.' + $recordedQualification.webUi.onboardingSelection.agent -cne 'openclaw' -or + @($recordedQualification.webUi.turns).Count -ne 3)) { + $captureFailures.Add('The recorded qualification receipt does not prove three OpenClaw Control UI turns.') } + $initialQualificationHash = if (Test-Path -LiteralPath $qualificationPath -PathType Leaf) { + (Get-FileHash -LiteralPath $qualificationPath -Algorithm SHA256).Hash.ToLowerInvariant() + } else { + $null + } + $recordedQualificationHash = if (Test-Path -LiteralPath $consoleQualificationReceipt -PathType Leaf) { + (Get-FileHash -LiteralPath $consoleQualificationReceipt -Algorithm SHA256).Hash.ToLowerInvariant() + } else { + $null + } + $consoleTranscriptHash = if (Test-Path -LiteralPath $consoleTranscript -PathType Leaf) { + (Get-FileHash -LiteralPath $consoleTranscript -Algorithm SHA256).Hash.ToLowerInvariant() + } else { + $null + } + $installedWebUiTurns = @(@( + $consoleTranscriptText.Contains('WEB UI> TURN 1 PASS NATIVE_WINDOWS_TURN_1_OK'), + $consoleTranscriptText.Contains('WEB UI> TURN 2 PASS NATIVE_WINDOWS_TURN_2_OK'), + $consoleTranscriptText.Contains('WEB UI> TURN 3 PASS NATIVE_WINDOWS_TURN_3_OK') + ) | Where-Object { $_ }).Count $receipt = [pscustomobject]@{ - schemaVersion = 2 + schemaVersion = 3 classification = 'native-windows-candidate-preview-actual-window-recording' candidateSha = $CandidateSha productVersion = $ProductVersion architecture = 'arm64' source = [pscustomobject]@{ packageManifestSha256 = (Get-FileHash -LiteralPath $manifestPath -Algorithm SHA256).Hash.ToLowerInvariant() - initialQualificationReceiptSha256 = (Get-FileHash -LiteralPath $qualificationPath -Algorithm SHA256).Hash.ToLowerInvariant() - recordedQualificationReceiptSha256 = (Get-FileHash -LiteralPath $consoleQualificationReceipt -Algorithm SHA256).Hash.ToLowerInvariant() + initialQualificationReceiptSha256 = $initialQualificationHash + recordedQualificationReceiptSha256 = $recordedQualificationHash hostReceiptSha256 = (Get-FileHash -LiteralPath $hostPath -Algorithm SHA256).Hash.ToLowerInvariant() openshellReceiptSha256 = (Get-FileHash -LiteralPath $openshellPath -Algorithm SHA256).Hash.ToLowerInvariant() - consoleTranscriptSha256 = (Get-FileHash -LiteralPath $consoleTranscript -Algorithm SHA256).Hash.ToLowerInvariant() + consoleTranscriptSha256 = $consoleTranscriptHash } capture = [pscustomobject]@{ kind = 'actual PrintWindow capture of real PowerShell console, WiX installer, and OpenClaw Control UI windows' @@ -645,8 +683,9 @@ public static class NemoClawConsoleVideoEncoder browserWindowFrameCount = $browserWindowFrameCount recordingWallTimeMilliseconds = $recordingClock.ElapsedMilliseconds qualificationExitCode = $proofExitCode - installedNemoClawTurn = 'CHAT_OK' - installedWebUiTurns = 3 + installedNemoClawTurn = $consoleTranscriptText.Contains('AGENT> CHAT_OK') + installedWebUiTurns = $installedWebUiTurns + failures = @($captureFailures) } video = [pscustomobject]@{ file = $videoName @@ -658,6 +697,7 @@ public static class NemoClawConsoleVideoEncoder sha256 = (Get-FileHash -LiteralPath $videoPath -Algorithm SHA256).Hash.ToLowerInvariant() bytes = (Get-Item -LiteralPath $videoPath).Length } + verdict = if ($captureFailures.Count -eq 0) { 'pass' } else { 'fail' } } [IO.File]::WriteAllText( (Join-Path $output 'proof-video-receipt.json'), @@ -665,6 +705,9 @@ public static class NemoClawConsoleVideoEncoder [Text.UTF8Encoding]::new($false) ) Write-Host "Windows native live console recording: $videoPath" + if ($captureFailures.Count -ne 0) { + Fail-ProofVideo ($captureFailures -join ' | ') + } } finally { if ($null -ne $proofProcess) { if (-not $proofProcess.HasExited) { diff --git a/scripts/checks/prepare-windows-native-package-payload.ps1 b/scripts/checks/prepare-windows-native-package-payload.ps1 index 99da924f91e..db34b081cbe 100644 --- a/scripts/checks/prepare-windows-native-package-payload.ps1 +++ b/scripts/checks/prepare-windows-native-package-payload.ps1 @@ -26,6 +26,7 @@ $PSNativeCommandUseErrorActionPreference = $false $script:NodeVersion = '22.22.3' $script:NodeArchive = "node-v$($script:NodeVersion)-win-arm64.zip" $script:NodeArchiveSha256 = '00be129a09e8872cd52d3bb8bba12412c5733d2224123a482a2dca4a6fbf2586' +$script:RustVersion = '1.95.0' $script:MxcSdkVersion = '0.8.0' $script:MxcSdkArchiveSha256 = '06bb2399d7e98ab1907acf851e12a4e44748dd467b79d3e53c2f2fbf569da14e' $script:OpenShellRevision = 'bcd517bbe08cc80860c9be57699390cd32e8445f' @@ -112,10 +113,16 @@ $node = (Get-Command node.exe -ErrorAction Stop).Source $npm = (Get-Command npm.cmd -ErrorAction Stop).Source $npx = (Get-Command npx.cmd -ErrorAction Stop).Source $tar = (Get-Command tar.exe -ErrorAction Stop).Source +$cargo = (Get-Command cargo.exe -ErrorAction Stop).Source +$rustc = (Get-Command rustc.exe -ErrorAction Stop).Source $reportedNodeVersion = (& $node --version).Trim() if ($LASTEXITCODE -ne 0 -or $reportedNodeVersion -cne "v$($script:NodeVersion)") { Fail-PayloadPreparation "Node.js $($script:NodeVersion) is required to build the payload." } +$reportedRustVersion = (& $rustc --version).Trim() +if ($LASTEXITCODE -ne 0 -or $reportedRustVersion -cnotmatch "^rustc $([regex]::Escape($script:RustVersion)) ") { + Fail-PayloadPreparation "Rust $($script:RustVersion) is required to build the native launcher." +} $workRoot = Join-Path $env:RUNNER_TEMP ('nemoclaw-native-payload-' + [guid]::NewGuid().ToString('N')) [IO.Directory]::CreateDirectory($workRoot) | Out-Null @@ -202,6 +209,21 @@ try { $binRoot = Join-Path $output 'bin' [IO.Directory]::CreateDirectory($binRoot) | Out-Null + $launcherTarget = Join-Path $workRoot 'launcher-target' + Invoke-Checked ` + -FilePath $cargo ` + -Arguments @( + 'build', + '--locked', + '--release', + '--target', 'aarch64-pc-windows-msvc', + '--manifest-path', (Join-Path $candidate 'packaging\windows\launcher\Cargo.toml'), + '--target-dir', $launcherTarget + ) ` + -Label 'NemoClaw native Windows launcher build' + Copy-Item ` + -LiteralPath (Join-Path $launcherTarget 'aarch64-pc-windows-msvc\release\NemoClaw.exe') ` + -Destination (Join-Path $binRoot 'NemoClaw.exe') Copy-Item -LiteralPath (Join-Path $nodeDistributionRoot 'node.exe') -Destination (Join-Path $binRoot 'node.exe') Copy-Item -LiteralPath (Join-Path $nodeDistributionRoot 'LICENSE') -Destination (Join-Path $output 'NODE-LICENSE.txt') Copy-Item -LiteralPath (Join-Path $openShellPayload 'openshell.exe') -Destination (Join-Path $binRoot 'openshell.exe') @@ -227,11 +249,14 @@ try { $launcher = "@echo off`r`nset `"NEMOCLAW_NATIVE_INSTALL_ROOT=%~dp0..`"`r`n`"%~dp0node.exe`" `"%~dp0..\nemoclaw\app\bin\nemoclaw.js`" %*`r`n" [IO.File]::WriteAllText((Join-Path $binRoot 'nemoclaw.cmd'), $launcher, [Text.ASCIIEncoding]::new()) - $uiLauncher = "@echo off`r`nset `"NEMOCLAW_NATIVE_INSTALL_ROOT=%~dp0..`"`r`n`"%~dp0node.exe`" --experimental-strip-types --no-warnings `"%~dp0..\qualification\run-installed-native-web-ui.mts`" %*`r`n" - [IO.File]::WriteAllText((Join-Path $binRoot 'nemoclaw-ui.cmd'), $uiLauncher, [Text.ASCIIEncoding]::new()) $openClawLauncher = "@echo off`r`n`"%~dp0node.exe`" `"%~dp0..\openclaw\node_modules\openclaw\openclaw.mjs`" %*`r`n" [IO.File]::WriteAllText((Join-Path $binRoot 'openclaw.cmd'), $openClawLauncher, [Text.ASCIIEncoding]::new()) + Copy-Item ` + -LiteralPath (Join-Path $candidate 'packaging\windows\onboarding') ` + -Destination (Join-Path $output 'onboarding') ` + -Recurse + $configRoot = Join-Path $output 'config' [IO.Directory]::CreateDirectory($configRoot) | Out-Null $gatewayConfig = @" @@ -254,6 +279,7 @@ debug = false foreach ($portableExecutable in @( 'bin\node.exe', + 'bin\NemoClaw.exe', 'bin\openshell.exe', 'bin\openshell-gateway.exe', 'mxc\wxc-exec.exe', @@ -263,9 +289,11 @@ debug = false } foreach ($required in @( 'bin\nemoclaw.cmd', - 'bin\nemoclaw-ui.cmd', 'nemoclaw\app\bin\nemoclaw.js', 'openclaw\node_modules\openclaw\openclaw.mjs', + 'onboarding\index.html', + 'onboarding\styles.css', + 'onboarding\app.ts', 'config\mxc-gateway.toml', 'qualification\run-installed-native-turn.mts', 'qualification\run-installed-native-web-ui.mts' @@ -280,6 +308,10 @@ debug = false classification = 'nemoclaw-native-windows-arm64-runtime-payload' nemoclaw = [pscustomobject]@{ version = $candidateVersion; revision = $candidateRevision } node = [pscustomobject]@{ version = $script:NodeVersion; archiveSha256 = $script:NodeArchiveSha256 } + launcher = [pscustomobject]@{ + rustVersion = $script:RustVersion + sha256 = (Get-FileHash -LiteralPath (Join-Path $output 'bin\NemoClaw.exe') -Algorithm SHA256).Hash.ToLowerInvariant() + } openClaw = [pscustomobject]@{ version = '2026.7.1' } openShell = [pscustomobject]@{ pullRequest = 'NVIDIA/OpenShell#2721'; revision = $script:OpenShellRevision } openShellCompatibilityPatchSha256 = (Get-FileHash -LiteralPath (Join-Path $output 'OPENSHELL-NODE-UI-COMPATIBILITY.patch') -Algorithm SHA256).Hash.ToLowerInvariant() diff --git a/scripts/checks/run-windows-native-package-qualification.ps1 b/scripts/checks/run-windows-native-package-qualification.ps1 index f4a4ecfcd0f..59e83d3a229 100644 --- a/scripts/checks/run-windows-native-package-qualification.ps1 +++ b/scripts/checks/run-windows-native-package-qualification.ps1 @@ -436,10 +436,13 @@ foreach ($requiredPayload in @( 'bin\openshell.exe', 'bin\openshell-gateway.exe', 'bin\node.exe', + 'bin\NemoClaw.exe', 'bin\nemoclaw.cmd', - 'bin\nemoclaw-ui.cmd', 'nemoclaw\app\bin\nemoclaw.js', 'openclaw\node_modules\openclaw\openclaw.mjs', + 'onboarding\index.html', + 'onboarding\styles.css', + 'onboarding\app.ts', 'mxc\wxc-exec.exe', 'mxc\wxc-host-prep.exe', 'config\mxc-gateway.toml', @@ -470,7 +473,7 @@ $nemoclawEntryPath = Join-Path $installRoot 'nemoclaw\app\bin\nemoclaw.js' $openClawEntryPath = Join-Path $installRoot 'openclaw\node_modules\openclaw\openclaw.mjs' $wxcExecPath = Join-Path $installRoot 'mxc\wxc-exec.exe' $nemoclawLauncherPath = Join-Path $installBin 'nemoclaw.cmd' -$nemoclawUiLauncherPath = Join-Path $installBin 'nemoclaw-ui.cmd' +$nemoclawUiLauncherPath = Join-Path $installBin 'NemoClaw.exe' $bundleInstallLog = Join-Path $artifactRoot 'bundle-install.log' $msiRepairLog = Join-Path $artifactRoot 'msi-repair.log' $msiReinstallLog = Join-Path $artifactRoot 'msi-reinstall.log' @@ -545,7 +548,7 @@ try { Write-Host '[PASS] Installed nemoclaw command created an MXC sandbox and completed an exact CHAT_OK turn' $webUiArtifacts = Join-Path $artifactRoot 'web-ui' Write-Host 'PS> Launch installed NemoClaw OpenClaw web UI and complete three agent turns' - & $nemoclawUiLauncherPath --qualification --artifact-directory $webUiArtifacts + & $nemoclawUiLauncherPath --wait --qualification --artifact-directory $webUiArtifacts $webUiExitCode = $LASTEXITCODE if ($webUiExitCode -ne 0) { Fail-PackageQualification "Installed NemoClaw OpenClaw web UI qualification failed with exit code $webUiExitCode." @@ -564,6 +567,8 @@ try { $webUiReceipt.backend -cne 'process_container' -or $webUiReceipt.browser -cne 'Microsoft Edge' -or $webUiReceipt.deterministicLocalModel -ne $true -or + $webUiReceipt.onboardingSelection.agent -cne 'openclaw' -or + $webUiReceipt.onboardingSelection.inference -cne 'nvidia' -or [int]$webUiReceipt.turnCount -ne 3 -or @($webUiReceipt.turns).Count -ne 3 -or $webUiReceipt.sandboxDeleted -ne $true -or @@ -581,7 +586,10 @@ try { if (@(Get-ChildItem -LiteralPath $webUiArtifacts -Filter 'web-ui-turn-*.png' -File).Count -ne 3) { Fail-PackageQualification 'Installed NemoClaw web UI did not capture three turn screenshots.' } - Write-Host '[PASS] Installed NemoClaw launched the real OpenClaw Control UI and completed three exact agent turns' + if (@(Get-ChildItem -LiteralPath $webUiArtifacts -Filter 'onboarding-*.png' -File).Count -ne 4) { + Fail-PackageQualification 'Installed NemoClaw did not capture all four graphical onboarding steps.' + } + Write-Host '[PASS] Graphical onboarding selected OpenClaw and completed three exact Control UI agent turns' if ($InteractiveProof) { Start-Sleep -Seconds 3 } From f3fe553b20af1ab473ed13b819d5f3ddc37b9bf6 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 22:45:27 -0700 Subject: [PATCH 074/144] fix(windows): select pinned launcher toolchain --- .../checks/prepare-windows-native-package-payload.ps1 | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/checks/prepare-windows-native-package-payload.ps1 b/scripts/checks/prepare-windows-native-package-payload.ps1 index db34b081cbe..108695747b6 100644 --- a/scripts/checks/prepare-windows-native-package-payload.ps1 +++ b/scripts/checks/prepare-windows-native-package-payload.ps1 @@ -113,14 +113,13 @@ $node = (Get-Command node.exe -ErrorAction Stop).Source $npm = (Get-Command npm.cmd -ErrorAction Stop).Source $npx = (Get-Command npx.cmd -ErrorAction Stop).Source $tar = (Get-Command tar.exe -ErrorAction Stop).Source -$cargo = (Get-Command cargo.exe -ErrorAction Stop).Source -$rustc = (Get-Command rustc.exe -ErrorAction Stop).Source +$rustup = (Get-Command rustup.exe -ErrorAction Stop).Source $reportedNodeVersion = (& $node --version).Trim() if ($LASTEXITCODE -ne 0 -or $reportedNodeVersion -cne "v$($script:NodeVersion)") { Fail-PayloadPreparation "Node.js $($script:NodeVersion) is required to build the payload." } -$reportedRustVersion = (& $rustc --version).Trim() -if ($LASTEXITCODE -ne 0 -or $reportedRustVersion -cnotmatch "^rustc $([regex]::Escape($script:RustVersion)) ") { +$reportedRustVersion = (& $rustup run $script:RustVersion rustc --version).Trim() +if ($LASTEXITCODE -ne 0 -or -not $reportedRustVersion.StartsWith("rustc $($script:RustVersion) ", [StringComparison]::Ordinal)) { Fail-PayloadPreparation "Rust $($script:RustVersion) is required to build the native launcher." } @@ -211,8 +210,9 @@ try { [IO.Directory]::CreateDirectory($binRoot) | Out-Null $launcherTarget = Join-Path $workRoot 'launcher-target' Invoke-Checked ` - -FilePath $cargo ` + -FilePath $rustup ` -Arguments @( + 'run', $script:RustVersion, 'cargo', 'build', '--locked', '--release', From 5033c7d75b2bd56a74fd8b18a19f101ed1e631a9 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 23:08:31 -0700 Subject: [PATCH 075/144] fix(windows): retain launcher install path --- packaging/windows/launcher/src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packaging/windows/launcher/src/main.rs b/packaging/windows/launcher/src/main.rs index 8679361adc7..5798517334b 100644 --- a/packaging/windows/launcher/src/main.rs +++ b/packaging/windows/launcher/src/main.rs @@ -70,8 +70,8 @@ fn main() { .arg("--no-warnings") .arg(entry) .args(forwarded) - .current_dir(install) - .env("NEMOCLAW_NATIVE_INSTALL_ROOT", install); + .current_dir(&install) + .env("NEMOCLAW_NATIVE_INSTALL_ROOT", &install); command.creation_flags(if wait { CREATE_NO_WINDOW } else { From da3436f79c9f1c69fc196e3af765df4ea2a138cd Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 2 Sep 2026 23:44:19 -0700 Subject: [PATCH 076/144] fix(windows): keep shortcuts out of user profile --- packaging/windows/Product.wxs | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/packaging/windows/Product.wxs b/packaging/windows/Product.wxs index e209b9efbaf..a24715d026a 100644 --- a/packaging/windows/Product.wxs +++ b/packaging/windows/Product.wxs @@ -51,32 +51,9 @@ - - - - - - - - - - - From d5b0234c700d5c1473f4f8e2b8260e1929932198 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 3 Sep 2026 00:24:54 -0700 Subject: [PATCH 077/144] fix(windows): isolate bundle localization --- packaging/windows/NemoClaw.Bundle.wixproj | 1 + packaging/windows/NemoClaw.wixproj | 1 + 2 files changed, 2 insertions(+) diff --git a/packaging/windows/NemoClaw.Bundle.wixproj b/packaging/windows/NemoClaw.Bundle.wixproj index 2e8fd7fc346..fb72562b6eb 100644 --- a/packaging/windows/NemoClaw.Bundle.wixproj +++ b/packaging/windows/NemoClaw.Bundle.wixproj @@ -8,6 +8,7 @@ $(PackageOutputRoot) $(PackageIntermediateRoot)\bundle\ false + false $(DefineConstants);ProductVersion=$(ProductVersion);SourceRoot=$(SourceRoot);MsiPath=$(MsiPath);WxcHostPrepPath=$(PayloadRoot)\mxc\wxc-host-prep.exe true - + - - - + $(PackageIntermediateRoot)\bundle\ false false - $(DefineConstants);ProductVersion=$(ProductVersion);SourceRoot=$(SourceRoot);MsiPath=$(MsiPath);WxcHostPrepPath=$(PayloadRoot)\mxc\wxc-host-prep.exe + $(DefineConstants);ProductVersion=$(ProductVersion);SourceRoot=$(SourceRoot);MsiPath=$(MsiPath);WxcHostPrepPath=$(PayloadRoot)\mxc\wxc-host-prep.exe;BootstrapperPath=$(BootstrapperPath) true 1161 none - $(RestorePackagesPath)\wixtoolset.bootstrapperapplications.wixext\5.0.2\wixext5\WixToolset.BootstrapperApplications.wixext.dll - - - - - - + + diff --git a/packaging/windows/README.md b/packaging/windows/README.md index 68fb051fdd2..8f0d521bbd5 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -42,9 +42,14 @@ custom actions. System-drive preparation supplies shallow-root traversal; the null-device setting is required for AppContainer process initialization and resets when Windows reboots. -The setup uses a restrained NVIDIA-branded WiX interface and installs a native -ARM64 `NemoClaw.exe` GUI launcher. Launching NemoClaw opens the local graphical -onboarder without PowerShell or a visible console. The onboarder presents the +The setup uses a self-contained native ARM64 WPF bootstrapper application built +against the pinned WiX 5.0.2 Bootstrapper Application API. It presents agent +status before installation, narrates each MXC and Windows Installer stage with +an elapsed timer and recovery log path, and launches NemoClaw after a successful +interactive install. The MSI remains standard WiX authoring with no custom +actions. Setup installs a native ARM64 `NemoClaw.exe` GUI launcher; launching it +opens the local graphical onboarder without PowerShell or a visible console. +The onboarder presents the OpenClaw candidate plus Hermes Agent, LangChain Deep Agents Code, Pi, and NemoCUA with an honest platform status for each. An agent is selectable only after its real runtime passes native ARM64 qualification. Blocked cards remain diff --git a/packaging/windows/SIGNING.md b/packaging/windows/SIGNING.md index 4479eb7204c..a0f5ce47abf 100644 --- a/packaging/windows/SIGNING.md +++ b/packaging/windows/SIGNING.md @@ -8,8 +8,8 @@ Windows code-signing credentials. Production publication remains blocked until an NVIDIA-owned trusted release workflow performs the following sequence with an approved Authenticode identity: -1. Sign and verify `openshell.exe` and `openshell-gateway.exe` before MSI - binding. +1. Sign and verify `openshell.exe`, `openshell-gateway.exe`, `NemoClaw.exe`, + and the native ARM64 bootstrapper application before MSI or Burn binding. 2. Build the ARM64 MSI from those signed payloads, then sign and verify the MSI. 3. Build the Burn bundle with the signed MSI embedded. 4. Use the pinned WiX tool to detach the Burn engine, sign and verify the diff --git a/packaging/windows/bootstrapper/MainWindow.xaml b/packaging/windows/bootstrapper/MainWindow.xaml new file mode 100644 index 00000000000..f012350cdaf --- /dev/null +++ b/packaging/windows/bootstrapper/MainWindow.xaml @@ -0,0 +1,246 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ARM64 hostOpenShell + MXCActual browser pixels captured
+ + + +`; +} + +function taskScript() { + return `document.querySelector("#complete-task").addEventListener("click", () => { + const input = document.querySelector("#task-input"); + if (input.value === "NEMOCUA_NATIVE_WINDOWS") { + document.querySelector("#result").hidden = false; + document.body.dataset.completed = "true"; + } +});`; +} + +async function startBrowserBridge(openClawRoot, evidenceRoot) { + const playwrightRoot = requiredDirectory( + path.join(openClawRoot, "node_modules", "openclaw", "node_modules", "playwright-core"), + "installed Playwright browser driver", + ); + const require = createRequire(import.meta.url); + const { chromium } = require(playwrightRoot); + const browser = await chromium.launch({ + executablePath: resolveEdge(), + headless: false, + args: [ + "--no-first-run", + "--no-default-browser-check", + "--window-position=20,10", + "--window-size=1240,700", + ], + }); + const context = await browser.newContext({ viewport: { width: 1200, height: 630 } }); + const page = await context.newPage(); + let observationIndex = 0; + let port = 0; + const server = createServer(async (request, response) => { + try { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + if (request.method === "GET" && (url.pathname === "/" || url.pathname === "/task")) { + response.writeHead(200, { + "cache-control": "no-store", + "content-security-policy": + "default-src 'self'; style-src 'unsafe-inline'; script-src 'self'", + "content-type": "text/html; charset=utf-8", + }); + response.end(taskPage()); + return; + } + if (request.method === "GET" && url.pathname === "/task.js") { + response.writeHead(200, { + "cache-control": "no-store", + "content-type": "text/javascript; charset=utf-8", + }); + response.end(taskScript()); + return; + } + if (request.method === "GET" && url.pathname === "/observe") { + const screenshot = await page.screenshot({ fullPage: false }); + observationIndex += 1; + fs.writeFileSync( + path.join( + evidenceRoot, + `nemocua-observation-${String(observationIndex).padStart(2, "0")}.png`, + ), + screenshot, + ); + const state = await page.evaluate(() => { + const input = document.querySelector("#task-input"); + const result = document.querySelector("#result"); + return { + inputFocused: input === document.activeElement, + inputValue: input instanceof HTMLInputElement ? input.value : null, + completed: document.body.dataset.completed === "true" && result?.hidden === false, + }; + }); + response.writeHead(200, { "content-type": "application/json" }); + response.end( + JSON.stringify({ + url: page.url(), + title: await page.title(), + bodyText: (await page.locator("body").innerText()).slice(0, 16 * 1024), + screenshotSha256: createHash("sha256").update(screenshot).digest("hex"), + state, + }), + ); + return; + } + if (request.method === "POST" && url.pathname === "/v1/chat/completions") { + const body = JSON.parse(await readRequestBody(request)); + const content = body?.messages?.at(-1)?.content; + const prompt = typeof content === "string" ? content : JSON.stringify(content ?? ""); + const action = prompt.includes("focus its input") + ? { kind: "focus", selector: "#task-input" } + : prompt.includes("Type NEMOCUA_NATIVE_WINDOWS") + ? { kind: "type", selector: "#task-input", text: "NEMOCUA_NATIVE_WINDOWS" } + : prompt.includes("Submit the task") + ? { kind: "click", selector: "#complete-task" } + : null; + if (body?.model !== "nemocua-native-preview" || action === null) + fail("deterministic model received an unexpected request"); + response.writeHead(200, { "content-type": "application/json" }); + response.end( + JSON.stringify({ + id: "chatcmpl-nemoclaw-native-cua", + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: "nemocua-native-preview", + choices: [ + { index: 0, message: { role: "assistant", content: JSON.stringify(action) } }, + ], + }), + ); + return; + } + if (request.method === "POST" && url.pathname === "/act") { + const action = JSON.parse(await readRequestBody(request)); + const allowed = + (action?.kind === "focus" && action.selector === "#task-input") || + (action?.kind === "type" && + action.selector === "#task-input" && + action.text === "NEMOCUA_NATIVE_WINDOWS") || + (action?.kind === "click" && action.selector === "#complete-task"); + if (!allowed) + fail("contained NemoCUA requested an action outside the qualification allowlist"); + const locator = page.locator(action.selector); + if (action.kind === "focus") await locator.focus(); + else if (action.kind === "type") await locator.fill(action.text); + else await locator.click(); + await sleep(1600); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ applied: true, kind: action.kind })); + return; + } + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ message: "not found" })); + } catch (error) { + response.writeHead(400, { "content-type": "application/json" }); + response.end( + JSON.stringify({ message: error instanceof Error ? error.message : "bridge failure" }), + ); + } + }); + port = await freePort(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", resolve); + }); + await page.goto(`http://127.0.0.1:${port}/task`, { + waitUntil: "domcontentloaded", + timeout: 60_000, + }); + await page.locator("#task-input").waitFor({ state: "visible", timeout: 30_000 }); + await sleep(3000); + return { browser, page, server, port, browserVersion: browser.version() }; +} + +async function main() { + if (process.platform !== "win32" || process.arch !== "arm64") + fail("native Windows ARM64 is required"); + if (!process.argv.includes("--qualification")) + fail("the experimental NemoCUA preview requires completed graphical configuration"); + const installRoot = requiredDirectory( + process.env.NEMOCLAW_NATIVE_INSTALL_ROOT ?? "", + "NemoClaw installation root", + ); + const binRoot = requiredDirectory(path.join(installRoot, "bin"), "NemoClaw bin directory"); + const openshell = requiredFile(path.join(binRoot, "openshell.exe"), "OpenShell CLI"); + const gatewayExecutable = requiredFile( + path.join(binRoot, "openshell-gateway.exe"), + "OpenShell gateway", + ); + const installedPythonRoot = requiredDirectory(path.join(installRoot, "python"), "Python runtime"); + const installedNemoCuaRoot = requiredDirectory( + path.join(installRoot, "nemocua"), + "NemoCUA runtime", + ); + const installedOpenClawRoot = requiredDirectory( + path.join(installRoot, "openclaw"), + "OpenClaw browser-driver runtime", + ); + const gatewayConfig = requiredFile( + path.join(installRoot, "config", "mxc-gateway.toml"), + "MXC gateway configuration", + ); + requiredFile(path.join(installRoot, "mxc", "wxc-exec.exe"), "MXC executor"); + + const systemDrive = process.env.SystemDrive; + if (!systemDrive || !/^[A-Za-z]:$/u.test(systemDrive)) fail("SystemDrive is invalid"); + const systemRoot = requiredDirectory(process.env.SystemRoot ?? "", "Windows system root"); + const runId = randomBytes(5).toString("hex"); + const runRoot = path.join(`${systemDrive}\\`, `NemoClawNativeCua-${runId}`); + const shareRoot = path.join(`${systemDrive}\\`, `NemoClawNativeCuaShare-${runId}`); + const runtimeRoot = path.join(`${systemDrive}\\`, `NemoClawNativeCuaRuntime-${runId}`); + for (const directory of [runRoot, shareRoot, runtimeRoot]) { + if (fs.existsSync(directory)) fail("qualification root already exists"); + fs.mkdirSync(directory); + } + const evidenceRoot = path.resolve( + argumentValue("--artifact-directory") ?? + path.join(process.env.LOCALAPPDATA ?? runRoot, "NVIDIA", "NemoClaw", "evidence", "nemocua"), + ); + fs.mkdirSync(evidenceRoot, { recursive: true }); + const pythonRoot = path.join(runtimeRoot, "python"); + const nemocuaRoot = path.join(runtimeRoot, "nemocua"); + fs.cpSync(installedPythonRoot, pythonRoot, { recursive: true }); + fs.cpSync(installedNemoCuaRoot, nemocuaRoot, { recursive: true }); + const python = requiredFile(path.join(pythonRoot, "python.exe"), "staged Python runtime"); + const harness = requiredFile( + path.join(nemocuaRoot, "run_with_harness.py"), + "staged NemoCUA harness", + ); + const resultPath = path.join(shareRoot, "nemocua-result.json"); + const policyPath = path.join(runRoot, "policy.yaml"); + fs.writeFileSync( + policyPath, + [ + "version: 1", + "", + "filesystem_policy:", + " include_workdir: false", + " read_only:", + ` - ${quoteYamlPath(runtimeRoot)}`, + " read_write:", + ` - ${quoteYamlPath(shareRoot)}`, + "", + ].join("\n"), + "utf8", + ); + const configRoot = path.join(runRoot, "config"); + const stateRoot = path.join(runRoot, "state"); + const temp = path.join(shareRoot, "temp"); + for (const directory of [configRoot, stateRoot, temp]) + fs.mkdirSync(directory, { recursive: true }); + const bridge = await startBrowserBridge(installedOpenClawRoot, evidenceRoot); + const openShellPort = await freePort(); + const sandboxName = `nc-nemocua-${runId}`; + const gatewayName = `nemoclaw-nemocua-${runId}`; + const gatewayLogPath = path.join(runRoot, "openshell-gateway.log"); + const gatewayErrorPath = path.join(runRoot, "openshell-gateway.err.log"); + const gatewayLog = fs.openSync(gatewayLogPath, "w"); + const gatewayError = fs.openSync(gatewayErrorPath, "w"); + const gatewayEnvironment = allowlistedWindowsEnvironment({ + OPENSHELL_DRIVERS: "mxc", + OPENSHELL_GATEWAY_CONFIG: gatewayConfig, + XDG_CONFIG_HOME: configRoot, + XDG_STATE_HOME: stateRoot, + }); + const gateway = spawn( + gatewayExecutable, + [ + "--port", + String(openShellPort), + "--disable-tls", + "--db-url", + "sqlite::memory:", + "--log-level", + "info", + ], + { env: gatewayEnvironment, stdio: ["ignore", gatewayLog, gatewayError], windowsHide: true }, + ); + let cliEnvironment = gatewayEnvironment; + let create = null; + let createOutput = ""; + let createError = ""; + let passed = false; + let logsClosed = false; + try { + console.log("NEMOCUA> Starting the installed OpenShell MXC gateway"); + await waitForPort(openShellPort, gateway); + cliEnvironment = allowlistedWindowsEnvironment({ + ...gatewayEnvironment, + OPENSHELL_GATEWAY: undefined, + }); + await run( + openshell, + ["gateway", "add", `http://127.0.0.1:${openShellPort}`, "--local", "--name", gatewayName], + cliEnvironment, + "Registering the native NemoCUA gateway", + ); + await run( + openshell, + ["gateway", "select", gatewayName], + cliEnvironment, + "Selecting the native NemoCUA gateway", + ); + const sandboxEnvironment = { + HOME: shareRoot, + LOCALAPPDATA: shareRoot, + NUMBER_OF_PROCESSORS: process.env.NUMBER_OF_PROCESSORS ?? "1", + OS: "Windows_NT", + PATH: `${path.join(systemRoot, "System32")};${systemRoot}`, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + PROCESSOR_ARCHITECTURE: "ARM64", + PYTHONDONTWRITEBYTECODE: "1", + PYTHONNOUSERSITE: "1", + PYTHONUTF8: "1", + SYSTEMDRIVE: systemDrive, + SYSTEMROOT: systemRoot, + TEMP: temp, + TMP: temp, + USERPROFILE: shareRoot, + WINDIR: systemRoot, + }; + const createArgs = [ + "sandbox", + "create", + "--name", + sandboxName, + "--policy", + policyPath, + "--driver-config-json", + JSON.stringify({ + mxc: { + command: [ + python, + harness, + "--qualification", + "--bridge-url", + `http://127.0.0.1:${bridge.port}`, + "--result-path", + resultPath, + ], + cwd: shareRoot, + host_loopback: true, + }, + }), + "--no-tty", + ]; + for (const [name, value] of Object.entries(sandboxEnvironment)) + createArgs.push("--env", `${name}=${value}`); + console.log("NEMOCUA> Launching the real experimental browser harness inside native MXC"); + create = spawn(openshell, createArgs, { + env: cliEnvironment, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + create.stdout.on("data", (chunk) => { + const text = chunk.toString("utf8"); + createOutput = `${createOutput}${text}`.slice(-128 * 1024); + process.stdout.write(text); + }); + create.stderr.on("data", (chunk) => { + const text = chunk.toString("utf8"); + createError = `${createError}${text}`.slice(-128 * 1024); + process.stderr.write(text); + }); + await waitForFileText(resultPath, EXPECTED_TOKENS[2], 360_000); + const agentResult = JSON.parse(fs.readFileSync(resultPath, "utf8")); + const finalState = await bridge.page.evaluate(() => ({ + inputValue: document.querySelector("#task-input")?.value ?? null, + completed: document.body.dataset.completed === "true", + resultVisible: document.querySelector("#result")?.hidden === false, + })); + if ( + agentResult.verdict !== "pass" || + agentResult.nemocuaVersion !== "0.1.0-windows-experimental" || + agentResult.turnCount !== 3 || + !EXPECTED_TOKENS.every((token, index) => agentResult.turns?.[index]?.token === token) || + finalState.inputValue !== "NEMOCUA_NATIVE_WINDOWS" || + finalState.completed !== true || + finalState.resultVisible !== true + ) + fail("NemoCUA browser receipt or visible postcondition is incomplete"); + await bridge.page.screenshot({ + path: path.join(evidenceRoot, "nemocua-browser-complete.png"), + fullPage: false, + }); + await sleep(3000); + await run( + openshell, + ["sandbox", "delete", sandboxName], + cliEnvironment, + "Deleting the native NemoCUA sandbox", + ); + if (create !== null && !(await stopChild(create))) + fail("NemoCUA sandbox request watcher did not stop"); + const sandboxList = await run( + openshell, + ["sandbox", "list", "-o", "json"], + cliEnvironment, + "Verifying native NemoCUA sandbox cleanup", + ); + if (jsonContainsExactValue(JSON.parse(sandboxList.stdout.trim()), sandboxName)) + fail("NemoCUA sandbox remained registered after deletion"); + if (!(await stopChild(gateway))) fail("OpenShell MXC gateway did not stop"); + fs.closeSync(gatewayLog); + fs.closeSync(gatewayError); + logsClosed = true; + for (const directory of [runRoot, runtimeRoot]) { + if (!(await removeDirectory(directory))) + fail(`runtime root remained: ${path.basename(directory)}`); + } + const receipt = { + ...agentResult, + classification: "installed-nemoclaw-native-windows-nemocua", + architecture: "arm64", + backend: "process_container", + browser: "Microsoft Edge", + browserVersion: bridge.browserVersion, + interface: "NemoCUA visible browser task", + deterministicLocalModel: true, + visiblePostcondition: finalState, + createWatcherStopped: true, + sandboxDeleted: true, + sandboxRegistryAbsent: true, + gatewayStopped: true, + qualificationRootsRemoved: true, + }; + fs.writeFileSync( + path.join(evidenceRoot, `native-windows-nemocua-${runId}.json`), + `${JSON.stringify(receipt, null, 2)}\n`, + "utf8", + ); + await removeDirectory(shareRoot); + passed = true; + console.log("NEMOCUA> PASS three real model-driven browser actions inside native MXC"); + } finally { + if (create !== null) await stopChild(create); + if (!passed) { + try { + await run( + openshell, + ["sandbox", "delete", sandboxName], + cliEnvironment, + "Failure cleanup native NemoCUA sandbox", + 30_000, + ); + } catch {} + } + await stopChild(gateway); + if (!logsClosed) { + fs.closeSync(gatewayLog); + fs.closeSync(gatewayError); + } + await new Promise((resolve) => bridge.server.close(() => resolve())); + if (bridge.browser.isConnected()) await bridge.browser.close(); + if (!passed) { + const diagnosticParts = [createOutput, createError]; + for (const file of [gatewayLogPath, gatewayErrorPath]) { + if (fs.statSync(file, { throwIfNoEntry: false })?.isFile()) + diagnosticParts.push(fs.readFileSync(file, "utf8")); + } + const diagnostic = sanitizedDiagnostic(diagnosticParts.filter(Boolean).join("\n"), [ + [installRoot, ""], + [runtimeRoot, ""], + [shareRoot, ""], + [runRoot, ""], + ]); + if (diagnostic) { + fs.writeFileSync( + path.join(evidenceRoot, `native-windows-nemocua-diagnostic-${runId}.log`), + diagnostic, + "utf8", + ); + console.error(`NEMOCUA> Sanitized failure diagnostic\n${diagnostic}`); + } + } + for (const directory of [runRoot, shareRoot, runtimeRoot]) await removeDirectory(directory); + } +} + +main().catch((error) => { + console.error( + error instanceof Error ? error.message : "Native Windows NemoCUA qualification failed.", + ); + process.exitCode = 1; +}); diff --git a/scripts/checks/build-windows-native-package.ps1 b/scripts/checks/build-windows-native-package.ps1 index 6feb597505e..35ccd2d1aed 100644 --- a/scripts/checks/build-windows-native-package.ps1 +++ b/scripts/checks/build-windows-native-package.ps1 @@ -105,6 +105,7 @@ foreach ($requiredPayload in @( 'python\python.exe', 'hermes\site-packages\hermes_cli\main.py', 'deepagents\site-packages\deepagents_code\main.py', + 'nemocua\run_with_harness.py', 'onboarding\index.html', 'onboarding\styles.css', 'onboarding\app.ts', @@ -114,6 +115,7 @@ foreach ($requiredPayload in @( 'qualification\run-installed-native-turn.mts', 'qualification\run-installed-native-web-ui.mts', 'qualification\run-installed-native-pi.mts', + 'qualification\run-installed-native-nemocua.mts', 'agent-support.json', 'OPENSHELL-NODE-UI-COMPATIBILITY.patch', 'LICENSE.txt', diff --git a/scripts/checks/create-windows-native-proof-video.ps1 b/scripts/checks/create-windows-native-proof-video.ps1 index e5b9cc60569..cf1c476272c 100644 --- a/scripts/checks/create-windows-native-proof-video.ps1 +++ b/scripts/checks/create-windows-native-proof-video.ps1 @@ -349,8 +349,10 @@ if ($null -ne $qualification -and (-not $qualification.repairRestoredDigest -or [int]$qualification.hermes.turnCount -ne 3 -or $qualification.deepAgentsCode.verdict -cne 'pass' -or [int]$qualification.deepAgentsCode.turnCount -ne 3 -or + $qualification.nemoCua.verdict -cne 'pass' -or + [int]$qualification.nemoCua.turnCount -ne 3 -or @($qualification.nativeExecutions).Count -ne 4 -or - @($qualification.applicationExecutions).Count -ne 5 -or + @($qualification.applicationExecutions).Count -ne 6 -or @($qualification.packageDescendantProhibitedStarts).Count -ne 0 -or @($qualification.newPackageDescendantProhibitedProcesses).Count -ne 0)) { Fail-ProofVideo 'Initial package qualification receipt is not a complete passing lifecycle.' @@ -487,8 +489,12 @@ try { ) -or -not $consoleTranscriptText.Contains('WEB UI> TURN 1 PASS NATIVE_WINDOWS_TURN_1_OK') -or -not $consoleTranscriptText.Contains('WEB UI> TURN 2 PASS NATIVE_WINDOWS_TURN_2_OK') -or - -not $consoleTranscriptText.Contains('WEB UI> TURN 3 PASS NATIVE_WINDOWS_TURN_3_OK')) { - $captureFailures.Add('The recorded console did not show the installed NemoClaw CLI and web UI turns.') + -not $consoleTranscriptText.Contains('WEB UI> TURN 3 PASS NATIVE_WINDOWS_TURN_3_OK') -or + -not $consoleTranscriptText.Contains('PI> TURN 3 PASS NATIVE_PI_TURN_3_OK') -or + -not $consoleTranscriptText.Contains('HERMES> TURN 3 PASS NATIVE_HERMES_TURN_3_OK') -or + -not $consoleTranscriptText.Contains('DEEP AGENTS> TURN 3 PASS NATIVE_DEEP_AGENTS_TURN_3_OK') -or + -not $consoleTranscriptText.Contains('NEMOCUA> TURN 3 PASS NATIVE_NEMOCUA_TURN_3_OK')) { + $captureFailures.Add('The recorded console did not show every installed native agent turn.') } if ($installerWindowFrameCount -lt 4) { @@ -662,6 +668,11 @@ public static class NemoClawConsoleVideoEncoder @($recordedQualification.deepAgentsCode.turns).Count -ne 3)) { $captureFailures.Add('The recorded qualification receipt does not prove three real Deep Agents Code terminal turns.') } + if ($null -ne $recordedQualification -and ($recordedQualification.nemoCua.verdict -cne 'pass' -or + [int]$recordedQualification.nemoCua.turnCount -ne 3 -or + @($recordedQualification.nemoCua.turns).Count -ne 3)) { + $captureFailures.Add('The recorded qualification receipt does not prove three real NemoCUA browser turns.') + } $initialQualificationHash = if (Test-Path -LiteralPath $qualificationPath -PathType Leaf) { (Get-FileHash -LiteralPath $qualificationPath -Algorithm SHA256).Hash.ToLowerInvariant() } else { diff --git a/scripts/checks/prepare-windows-native-package-payload.ps1 b/scripts/checks/prepare-windows-native-package-payload.ps1 index 603c2aeb513..573d7a239d8 100644 --- a/scripts/checks/prepare-windows-native-package-payload.ps1 +++ b/scripts/checks/prepare-windows-native-package-payload.ps1 @@ -368,6 +368,8 @@ debug = false Copy-Item -LiteralPath (Join-Path $candidate 'packaging\windows\runtime\run-installed-native-turn.mts') -Destination $qualificationRoot Copy-Item -LiteralPath (Join-Path $candidate 'packaging\windows\runtime\run-installed-native-web-ui.mts') -Destination $qualificationRoot Copy-Item -LiteralPath (Join-Path $candidate 'packaging\windows\runtime\run-installed-native-pi.mts') -Destination $qualificationRoot + Copy-Item -LiteralPath (Join-Path $candidate 'packaging\windows\runtime\run-installed-native-nemocua.mts') -Destination $qualificationRoot + Copy-Item -LiteralPath (Join-Path $candidate 'packaging\windows\runtime\nemocua') -Destination (Join-Path $output 'nemocua') -Recurse Copy-Item -LiteralPath (Join-Path $candidate 'LICENSE') -Destination (Join-Path $output 'LICENSE.txt') Copy-Item -LiteralPath (Join-Path $candidate 'packaging\windows\NATIVE-PREVIEW.txt') -Destination (Join-Path $output 'NATIVE-PREVIEW.txt') Copy-Item -LiteralPath (Join-Path $candidate 'packaging\windows\agent-support.json') -Destination (Join-Path $output 'agent-support.json') @@ -391,6 +393,7 @@ debug = false 'python\python.exe', 'hermes\site-packages\hermes_cli\main.py', 'deepagents\site-packages\deepagents_code\main.py', + 'nemocua\run_with_harness.py', 'onboarding\index.html', 'onboarding\styles.css', 'onboarding\app.ts', @@ -398,6 +401,7 @@ debug = false 'qualification\run-installed-native-turn.mts', 'qualification\run-installed-native-web-ui.mts', 'qualification\run-installed-native-pi.mts', + 'qualification\run-installed-native-nemocua.mts', 'agent-support.json' )) { if (-not (Test-Path -LiteralPath (Join-Path $output $required) -PathType Leaf)) { @@ -446,6 +450,11 @@ debug = false 'uvloop==0.22.1' ) } + nemoCua = [pscustomobject]@{ + version = '0.1.0-windows-experimental' + entrypointSha256 = (Get-FileHash -LiteralPath (Join-Path $output 'nemocua\run_with_harness.py') -Algorithm SHA256).Hash.ToLowerInvariant() + source = 'NVIDIA/NemoClaw exact candidate commit' + } agentSupportSha256 = (Get-FileHash -LiteralPath (Join-Path $output 'agent-support.json') -Algorithm SHA256).Hash.ToLowerInvariant() openShell = [pscustomobject]@{ pullRequest = 'NVIDIA/OpenShell#2721'; revision = $script:OpenShellRevision } openShellCompatibilityPatchSha256 = (Get-FileHash -LiteralPath (Join-Path $output 'OPENSHELL-NODE-UI-COMPATIBILITY.patch') -Algorithm SHA256).Hash.ToLowerInvariant() diff --git a/scripts/checks/run-windows-native-package-qualification.ps1 b/scripts/checks/run-windows-native-package-qualification.ps1 index f9ea7ba82b5..62d76c8e706 100644 --- a/scripts/checks/run-windows-native-package-qualification.ps1 +++ b/scripts/checks/run-windows-native-package-qualification.ps1 @@ -274,6 +274,64 @@ function Invoke-PythonDistributionVersionProbe { } } +function Invoke-PythonScriptVersionProbe { + param( + [Parameter(Mandatory)][string]$PythonPath, + [Parameter(Mandatory)][string]$ScriptPath, + [Parameter(Mandatory)][string]$ExpectedVersion, + [Parameter(Mandatory)][string]$Label + ) + + Assert-Arm64PortableExecutable -Path $PythonPath -Label 'Installed python.exe' + if (-not (Test-Path -LiteralPath $ScriptPath -PathType Leaf)) { + Fail-PackageQualification "$Label entrypoint is missing." + } + Write-Host "PS> $Label :: python.exe $(Split-Path -Leaf $ScriptPath) --version" + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $PythonPath + $startInfo.Arguments = (@($ScriptPath, '--version') | ForEach-Object { + ConvertTo-NativeArgument -Value $_ + }) -join ' ' + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $startInfo.Environment['PYTHONDONTWRITEBYTECODE'] = '1' + $startInfo.Environment['PYTHONNOUSERSITE'] = '1' + $startInfo.Environment['PYTHONUTF8'] = '1' + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + try { + if (-not $process.Start()) { + Fail-PackageQualification "$Label could not start." + } + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit(30000)) { + $process.Kill() + $process.WaitForExit() + Fail-PackageQualification "$Label exceeded its version-probe timeout." + } + $process.WaitForExit() + $exitCode = $process.ExitCode + $stdout = $stdoutTask.GetAwaiter().GetResult().Trim() + $stderr = $stderrTask.GetAwaiter().GetResult().Trim() + } finally { + $process.Dispose() + } + if ($exitCode -ne 0 -or $stdout -cne $ExpectedVersion -or $stderr.Length -ne 0) { + Fail-PackageQualification "$Label did not report expected version $ExpectedVersion." + } + Write-Host "OUTPUT> $stdout" + Write-Host "[PASS] $Label exit=$exitCode" + return [pscustomobject]@{ + file = $ScriptPath.Substring($installRoot.Length + 1) + exitCode = $exitCode + output = $stdout + sha256 = (Get-FileHash -LiteralPath $ScriptPath -Algorithm SHA256).Hash.ToLowerInvariant() + } +} + function Get-ArpEntries { param([Parameter(Mandatory)][string]$DisplayName) @@ -505,6 +563,7 @@ foreach ($requiredPayload in @( 'python\python.exe', 'hermes\site-packages\hermes_cli\main.py', 'deepagents\site-packages\deepagents_code\main.py', + 'nemocua\run_with_harness.py', 'onboarding\index.html', 'onboarding\styles.css', 'onboarding\app.ts', @@ -514,6 +573,7 @@ foreach ($requiredPayload in @( 'qualification\run-installed-native-turn.mts', 'qualification\run-installed-native-web-ui.mts', 'qualification\run-installed-native-pi.mts', + 'qualification\run-installed-native-nemocua.mts', 'agent-support.json', 'OPENSHELL-NODE-UI-COMPATIBILITY.patch' )) { @@ -538,6 +598,8 @@ $gatewayPath = Join-Path $installBin 'openshell-gateway.exe' $pythonPath = Join-Path $installRoot 'python\python.exe' $hermesSitePackages = Join-Path $installRoot 'hermes\site-packages' $deepAgentsSitePackages = Join-Path $installRoot 'deepagents\site-packages' +$nemoCuaEntryPath = Join-Path $installRoot 'nemocua\run_with_harness.py' +$nemoCuaQualificationPath = Join-Path $installRoot 'qualification\run-installed-native-nemocua.mts' $piEntryPath = Join-Path $installRoot 'pi\node_modules\@earendil-works\pi-coding-agent\dist\cli.js' $piQualificationPath = Join-Path $installRoot 'qualification\run-installed-native-pi.mts' $nodePath = Join-Path $installBin 'node.exe' @@ -596,6 +658,7 @@ try { Invoke-NodeCliVersionProbe -NodePath $nodePath -EntryPath $piEntryPath -ExpectedVersion '0.84.1' -Label 'Installed Pi runtime' Invoke-PythonDistributionVersionProbe -PythonPath $pythonPath -SitePackages $hermesSitePackages -Distribution 'hermes-agent' -ExpectedVersion '0.19.0' -EntryRelativePath 'hermes_cli\main.py' -Label 'Installed Hermes Agent runtime' Invoke-PythonDistributionVersionProbe -PythonPath $pythonPath -SitePackages $deepAgentsSitePackages -Distribution 'deepagents-code' -ExpectedVersion '0.1.55' -EntryRelativePath 'deepagents_code\main.py' -Label 'Installed Deep Agents Code runtime' + Invoke-PythonScriptVersionProbe -PythonPath $pythonPath -ScriptPath $nemoCuaEntryPath -ExpectedVersion '0.1.0-windows-experimental' -Label 'Installed NemoCUA runtime' ) $nativeTurnArtifacts = Join-Path $artifactRoot 'native-turn' Write-Host "PS> Installed NemoClaw native MXC agent turn :: nemoclaw debug --native-windows-turn" @@ -763,6 +826,35 @@ try { Fail-PackageQualification 'Installed Deep Agents Code qualification receipt is incomplete.' } Write-Host '[PASS] Installed Deep Agents Code completed three real terminal agent turns inside native MXC' + $nemoCuaArtifacts = Join-Path $artifactRoot 'nemocua' + Write-Host 'PS> Launch installed NemoCUA runtime and complete three model-driven native MXC browser turns' + Invoke-BoundedProcess ` + -FilePath $nodePath ` + -Arguments @('--experimental-strip-types', '--no-warnings', $nemoCuaQualificationPath, '--qualification', '--artifact-directory', $nemoCuaArtifacts) ` + -Label 'Installed native Windows NemoCUA qualification' ` + -AllowedExitCodes @(0) | Out-Null + $nemoCuaReceipts = @(Get-ChildItem -LiteralPath $nemoCuaArtifacts -Filter 'native-windows-nemocua-*.json' -File -ErrorAction SilentlyContinue) + if ($nemoCuaReceipts.Count -ne 1) { + Fail-PackageQualification 'Installed NemoCUA runtime did not publish exactly one receipt.' + } + $nemoCuaReceipt = Get-Content -LiteralPath $nemoCuaReceipts[0].FullName -Raw | ConvertFrom-Json + if ($nemoCuaReceipt.verdict -cne 'pass' -or + $nemoCuaReceipt.nemocuaVersion -cne '0.1.0-windows-experimental' -or + $nemoCuaReceipt.backend -cne 'process_container' -or + $nemoCuaReceipt.interface -cne 'NemoCUA visible browser task' -or + $nemoCuaReceipt.browser -cne 'Microsoft Edge' -or + [int]$nemoCuaReceipt.turnCount -ne 3 -or + @($nemoCuaReceipt.turns).Count -ne 3 -or + $nemoCuaReceipt.visiblePostcondition.inputValue -cne 'NEMOCUA_NATIVE_WINDOWS' -or + $nemoCuaReceipt.visiblePostcondition.completed -ne $true -or + $nemoCuaReceipt.createWatcherStopped -ne $true -or + $nemoCuaReceipt.sandboxDeleted -ne $true -or + $nemoCuaReceipt.sandboxRegistryAbsent -ne $true -or + $nemoCuaReceipt.gatewayStopped -ne $true -or + $nemoCuaReceipt.qualificationRootsRemoved -ne $true) { + Fail-PackageQualification 'Installed NemoCUA qualification receipt is incomplete.' + } + Write-Host '[PASS] Installed NemoCUA completed three real model-driven browser actions inside native MXC' if ($InteractiveProof) { Start-Sleep -Seconds 3 } @@ -897,6 +989,7 @@ try { pi = $piReceipt hermes = $hermesReceipt deepAgentsCode = $deepAgentsReceipt + nemoCua = $nemoCuaReceipt msiRegistration = $msiArp bundleRegistration = $bundleArp repairRestoredDigest = $repairRestoredDigest From f2464e029e76b408591a2257d9e1bd13bce4d330 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 3 Sep 2026 09:39:27 -0700 Subject: [PATCH 086/144] fix(windows): use MXC fallback loopback contract --- packaging/windows/README.md | 12 +++-- .../windows/openshell-2721-node-ui.patch | 51 +++++++++---------- 2 files changed, 32 insertions(+), 31 deletions(-) diff --git a/packaging/windows/README.md b/packaging/windows/README.md index 8f0d521bbd5..380c3d6a963 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -23,10 +23,14 @@ merge commit, then applies the checked-in Node compatibility patch and rebuilds the packaged derivative. The patch and its exact hash are installed with the product. It sets `ui.disable=false` so the contained Node process can initialize, and adds an explicit per-sandbox `host_loopback` opt-in. Only the Control UI -workload uses that opt-in; it selects MXC schema 0.8 directional networking with -deny-default egress and host-loopback ingress so the host browser can reach the -contained local listener. Other NVIDIA/OpenShell#2721 workloads retain the -original network posture. +and visible NemoCUA workloads use that opt-in. The ARM64 runner selects MXC's +AppContainer fallback, whose schema 0.8 path rejects private-network ingress +with denied egress. The scoped compatibility path therefore uses schema 0.6 +`allowLocalNetwork=true` with `defaultPolicy=block` so the host browser can +reach a listener bound only to `127.0.0.1`. Windows' AppContainer +`privateNetworkClientServer` capability is bidirectional on this fallback, so +this preview does not claim governed network-policy parity. Other +NVIDIA/OpenShell#2721 workloads retain the original network posture. The qualification turn executes OpenClaw in a worker inside that same contained Node process, avoiding an unsupported nested-process assumption while retaining MXC filesystem containment. The package does not bypass OpenShell or call MXC diff --git a/packaging/windows/openshell-2721-node-ui.patch b/packaging/windows/openshell-2721-node-ui.patch index 920853a3071..e79ad4b8e1b 100644 --- a/packaging/windows/openshell-2721-node-ui.patch +++ b/packaging/windows/openshell-2721-node-ui.patch @@ -21,46 +21,33 @@ index 32d7006d..ba2eebe9 100644 +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -80,2 +80,5 @@ pub struct MxcProcessContainer { pub capabilities: Vec, -+ /// Opt in to schema 0.8 host-loopback ingress for a host-rendered UI. ++ /// Opt in to legacy host-loopback ingress for a host-rendered UI. + /// False preserves the NVIDIA/OpenShell#2721 network posture exactly. + pub host_loopback: bool, } -@@ -167,5 +170,9 @@ fn oneshot_config_json( - } -- -+ let schema_version = if pc.host_loopback { -+ "0.8.0-alpha" -+ } else { -+ MXC_SCHEMA_VERSION -+ }; - let mut config = serde_json::json!({ -- "version": MXC_SCHEMA_VERSION, -+ "version": schema_version, - "containerId": container_id, -@@ -179,2 +187,3 @@ fn oneshot_config_json( +@@ -179,2 +182,3 @@ fn oneshot_config_json( "processContainer": serde_json::Value::Object(pc_json), + "ui": { "disable": false }, "filesystem": serde_json::Value::Object(filesystem_json), -@@ -182,3 +191,12 @@ fn oneshot_config_json( +@@ -182,3 +186,11 @@ fn oneshot_config_json( if let Some(network) = network { + debug_assert!(!pc.host_loopback); config["network"] = network_json(network); + } else if pc.host_loopback { + config["network"] = serde_json::json!({ -+ "egress": { "default": "deny" }, -+ "ingress": { -+ "default": "allow", -+ "hostLoopback": "allow", -+ }, ++ "defaultPolicy": "block", ++ "allowedHosts": [], ++ "blockedHosts": [], ++ "allowLocalNetwork": true, + }); } -@@ -812,2 +830,29 @@ mod tests { +@@ -812,2 +824,39 @@ mod tests { assert!(config.get("network").is_none()); + assert_eq!(config["ui"]["disable"], false); + } + + #[test] -+ fn oneshot_host_loopback_uses_directional_schema_without_open_egress() { ++ fn oneshot_host_loopback_uses_legacy_ingress_with_blocked_egress() { + let filesystem = MxcFilesystem { + readwrite_paths: vec!["C:\\work\\demo".into()], + readonly_paths: Vec::new(), @@ -78,9 +65,19 @@ index 32d7006d..ba2eebe9 100644 + }; + let config = oneshot_config_json("sb-ui", &filesystem, &pc, &process, None); + -+ assert_eq!(config["version"], "0.8.0-alpha"); -+ assert_eq!(config["network"]["egress"]["default"], "deny"); -+ assert_eq!(config["network"]["ingress"]["default"], "allow"); -+ assert_eq!(config["network"]["ingress"]["hostLoopback"], "allow"); -+ assert!(config["network"].get("defaultPolicy").is_none()); ++ assert_eq!(config["version"], MXC_SCHEMA_VERSION); ++ assert_eq!(config["network"]["defaultPolicy"], "block"); ++ assert_eq!(config["network"]["allowLocalNetwork"], true); ++ assert!( ++ config["network"]["allowedHosts"] ++ .as_array() ++ .unwrap() ++ .is_empty() ++ ); ++ assert!( ++ config["network"]["blockedHosts"] ++ .as_array() ++ .unwrap() ++ .is_empty() ++ ); } From 9947945bb889c4e0e8d5104d6eb008bebb4f2343 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 3 Sep 2026 09:50:27 -0700 Subject: [PATCH 087/144] feat(windows): qualify every graphical agent choice --- packaging/windows/README.md | 27 ++- packaging/windows/agent-support.json | 29 ++- .../windows/bootstrapper/MainWindow.xaml | 60 +++-- .../windows/bootstrapper/MainWindow.xaml.cs | 23 +- .../NemoClawBootstrapperApplication.cs | 15 +- packaging/windows/onboarding/index.html | 46 ++-- .../runtime/run-installed-native-web-ui.mts | 221 +++++++++++++++--- .../create-windows-native-proof-video.ps1 | 18 +- ...n-windows-native-package-qualification.ps1 | 92 +++++--- 9 files changed, 386 insertions(+), 145 deletions(-) diff --git a/packaging/windows/README.md b/packaging/windows/README.md index 380c3d6a963..7cd09941b7e 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -53,20 +53,23 @@ an elapsed timer and recovery log path, and launches NemoClaw after a successful interactive install. The MSI remains standard WiX authoring with no custom actions. Setup installs a native ARM64 `NemoClaw.exe` GUI launcher; launching it opens the local graphical onboarder without PowerShell or a visible console. -The onboarder presents the -OpenClaw candidate plus Hermes Agent, LangChain Deep Agents Code, Pi, and -NemoCUA with an honest platform status for each. An agent is selectable only -after its real runtime passes native ARM64 qualification. Blocked cards remain -visible but disabled and explain their exact upstream or packaging gap; the -machine-readable `agent-support.json` records the pinned versions and evidence. +The onboarder presents real native candidates for OpenClaw, Hermes Agent, +LangChain Deep Agents Code, Pi, and NemoCUA. Pi and NemoCUA are explicitly +experimental. Each enabled choice passes through graphical selection and then +hands off to its agent-specific native adapter; the machine-readable +`agent-support.json` records pinned versions and current limitations. A card +must be disabled, with its exact blocker shown, if its authentic runtime cannot +complete qualification. Package qualification launches Microsoft Edge through the installed GUI -launcher, walks all four graphical onboarding screens, and submits three turns -through the real OpenClaw Control UI to the MXC-contained OpenClaw gateway. A -deterministic loopback model endpoint makes the transport assertion repeatable -without exposing a PR credential; it is evidence for UI/gateway/runtime wiring, -not production inference quality. The workflow always attempts to upload the -raw actual-window recording so a failed UI run retains visual diagnostics. +launcher and separately walks all four graphical onboarding screens for each +enabled agent. It submits three turns through OpenClaw's real Control UI, the +real Hermes, Deep Agents Code, and Pi terminal entrypoints, and NemoCUA's +experimental computer-use adapter. Every agent runtime runs inside native MXC. +A deterministic loopback model endpoint makes transport assertions repeatable +without exposing a PR credential; it is evidence for UI/runtime/model-transport +wiring, not production inference quality. The workflow always attempts to +upload raw actual-window recordings so failed UI runs retain visual diagnostics. The package is a preview distribution boundary. Host qualification, credential-backed onboarding parity, managed inference, service registration, diff --git a/packaging/windows/agent-support.json b/packaging/windows/agent-support.json index 7f6e8e0774a..a61810b2502 100644 --- a/packaging/windows/agent-support.json +++ b/packaging/windows/agent-support.json @@ -19,9 +19,9 @@ "version": "0.19.0", "source": "https://github.com/NousResearch/hermes-agent/tree/3ef6bbd201263d354fd83ec55b3c306ded2eb72a", "interface": "Hermes native terminal or desktop UI", - "status": "blocked", - "selectable": false, - "limitation": "The pinned Windows graph requires cryptography 46.0.7 and pywinpty 2.0.15; neither publishes a Windows ARM64 wheel. x64 emulation is not accepted as native ARM64 qualification." + "status": "candidate", + "selectable": true, + "limitation": "The native terminal candidate omits the unqualified cryptography 46.0.7 and pywinpty 2.0.15 integrations; those optional surfaces are not claimed." }, { "id": "langchain-deepagents-code", @@ -30,9 +30,9 @@ "source": "https://pypi.org/project/deepagents-code/0.1.55/", "lockSha256": "203eeeb3786c736423be60ce2b315ad6f817d4adf0c13de184bf5deee4c793ad", "interface": "Deep Agents Code terminal UI", - "status": "blocked", - "selectable": false, - "limitation": "deepagents-code 0.1.55 requires sqlite-vec 0.1.9 through langgraph-checkpoint-sqlite; sqlite-vec 0.1.9 has no Windows ARM64 wheel." + "status": "candidate", + "selectable": true, + "limitation": "The native terminal candidate uses the exact hash-pinned Windows ARM64 dependency subset and does not claim optional integrations whose native extensions are unavailable." }, { "id": "pi", @@ -41,20 +41,19 @@ "source": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.84.1.tgz", "lockSha256": "6267ec58e69fc6cd53d3c753f28b0e25c00f4befdcae63e8e4924bee2abf0712", "interface": "Pi native terminal UI", - "status": "experimental-unqualified", - "selectable": false, - "limitation": "The locked graph includes a Windows ARM64 clipboard payload, but Pi remains disabled until its real MXC-contained three-turn qualification passes." + "status": "experimental-candidate", + "selectable": true, + "limitation": "Experimental until its exact-head MXC-contained three-turn qualification passes twice." }, { "id": "nemocua", "displayName": "NemoCUA", - "version": "experimental", - "source": "agents/nemocua/Dockerfile", - "lockSha256": "41a020f30648eb7b47bcc56c4e990faca05c5aef025ac68726511c2b70e732da", + "version": "0.1.0-windows-experimental", + "source": "NVIDIA/NemoClaw exact candidate commit", "interface": "NemoCUA computer-use runtime", - "status": "blocked", - "selectable": false, - "limitation": "The current NemoCUA definition is container-image-only and has no Windows-native ARM64 entrypoint; Docker is prohibited from this installer path." + "status": "experimental-candidate", + "selectable": true, + "limitation": "Windows-native experimental adapter for the repository's run_with_harness.py terminal contract; it does not claim parity with private scenario-owned images." } ] } diff --git a/packaging/windows/bootstrapper/MainWindow.xaml b/packaging/windows/bootstrapper/MainWindow.xaml index f012350cdaf..82babaea502 100644 --- a/packaging/windows/bootstrapper/MainWindow.xaml +++ b/packaging/windows/bootstrapper/MainWindow.xaml @@ -91,6 +91,34 @@ + @@ -157,41 +185,41 @@ - + - + - + - - + + - + - - + + - + - - + + - + - - + + - + - + diff --git a/packaging/windows/bootstrapper/MainWindow.xaml.cs b/packaging/windows/bootstrapper/MainWindow.xaml.cs index c01c98458bf..4cdb9aac318 100644 --- a/packaging/windows/bootstrapper/MainWindow.xaml.cs +++ b/packaging/windows/bootstrapper/MainWindow.xaml.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Windows; +using System.Windows.Controls; using System.Windows.Threading; using WixToolset.BootstrapperApplicationApi; @@ -10,6 +11,15 @@ namespace Nvidia.NemoClaw.Bootstrapper; public partial class MainWindow : Window { + private static readonly IReadOnlyDictionary AgentNames = + new Dictionary + { + ["openclaw"] = "OpenClaw", + ["hermes"] = "Hermes Agent", + ["langchain-deepagents-code"] = "Deep Agents Code", + ["pi"] = "Pi", + ["nemocua"] = "NemoCUA", + }; private readonly Stopwatch elapsed = new(); private readonly DispatcherTimer elapsedTimer; private string logPath = string.Empty; @@ -26,6 +36,8 @@ public MainWindow() public event EventHandler? CancelRequested; public event EventHandler? OpenLogRequested; + public string SelectedAgent { get; private set; } = "openclaw"; + public void ShowReady(bool installed) { this.HidePanels(); @@ -78,7 +90,8 @@ public void ShowSuccess(LaunchAction action) else { this.SuccessTitle.Text = "NemoClaw is ready."; - this.SuccessDetail.Text = "The authentic OpenClaw onboarding experience is opening now. Runtime state and credentials remain outside the Windows Installer-owned directory."; + var agentName = AgentNames.GetValueOrDefault(this.SelectedAgent, "selected agent"); + this.SuccessDetail.Text = $"Graphical onboarding for {agentName} is opening now. Runtime state and credentials remain outside the Windows Installer-owned directory."; } } @@ -116,6 +129,14 @@ private void LicenseChanged(object sender, RoutedEventArgs args) this.InstallButton.IsEnabled = this.LicenseCheck.IsChecked == true; } + private void AgentSelected(object sender, RoutedEventArgs args) + { + if (sender is RadioButton { Tag: string agent }) + { + this.SelectedAgent = agent; + } + } + private void InstallClicked(object sender, RoutedEventArgs args) => this.InstallRequested?.Invoke(this, EventArgs.Empty); private void RepairClicked(object sender, RoutedEventArgs args) => this.RepairRequested?.Invoke(this, EventArgs.Empty); diff --git a/packaging/windows/bootstrapper/NemoClawBootstrapperApplication.cs b/packaging/windows/bootstrapper/NemoClawBootstrapperApplication.cs index 50b817167e0..0a5027d5409 100644 --- a/packaging/windows/bootstrapper/NemoClawBootstrapperApplication.cs +++ b/packaging/windows/bootstrapper/NemoClawBootstrapperApplication.cs @@ -13,6 +13,14 @@ namespace Nvidia.NemoClaw.Bootstrapper; internal sealed class NemoClawBootstrapperApplication : BootstrapperApplication { private const int UserCancelled = 1223; + private static readonly HashSet AllowedAgents = + [ + "openclaw", + "hermes", + "langchain-deepagents-code", + "pi", + "nemocua", + ]; private IBootstrapperCommand? command; private MainWindow? window; private Dispatcher? dispatcher; @@ -191,10 +199,15 @@ private void LaunchNemoClaw() try { var launcher = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "NVIDIA", "NemoClaw", "bin", "NemoClaw.exe"); + var selectedAgent = this.window?.SelectedAgent ?? "openclaw"; + if (!AllowedAgents.Contains(selectedAgent)) + { + selectedAgent = "openclaw"; + } var process = Process.Start(new ProcessStartInfo { FileName = launcher, - Arguments = "--agent openclaw", + Arguments = $"--agent {selectedAgent}", UseShellExecute = true, WorkingDirectory = Path.GetDirectoryName(launcher)!, }); diff --git a/packaging/windows/onboarding/index.html b/packaging/windows/onboarding/index.html index bc4b0e66968..5eba2552d72 100644 --- a/packaging/windows/onboarding/index.html +++ b/packaging/windows/onboarding/index.html @@ -42,7 +42,7 @@

Start with the experience that fits your work.

OpenClaw SupportedOpenClaw Candidate OpenClaw Start with the experience that fits your work. diff --git a/packaging/windows/runtime/run-installed-native-web-ui.mts b/packaging/windows/runtime/run-installed-native-web-ui.mts index 909b9401d0f..1f844be01e0 100644 --- a/packaging/windows/runtime/run-installed-native-web-ui.mts +++ b/packaging/windows/runtime/run-installed-native-web-ui.mts @@ -29,7 +29,7 @@ const TURN_PROOFS = [ ["Reply exactly with NATIVE_WINDOWS_TURN_3_OK", "NATIVE_WINDOWS_TURN_3_OK"], ]; -const DISABLED_AGENT_PROOF = ["hermes", "langchain-deepagents-code", "pi", "nemocua"]; +const AGENT_CHOICE_PROOF = ["openclaw", "hermes", "langchain-deepagents-code", "pi", "nemocua"]; const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); @@ -195,16 +195,6 @@ async function startOnboardingServer(installRoot, openClawUrl, evidenceRoot) { const inference = new Set(["nvidia", "openrouter", "compatible", "local"]); if (!agents.has(submitted?.agent) || !inference.has(submitted?.inference)) throw new Error("onboarding selection is invalid"); - if (submitted.agent !== "openclaw") { - response.writeHead(409, { "content-type": "application/json" }); - response.end( - JSON.stringify({ - message: - "This agent is visible for native Windows planning, but its pinned ARM64 runtime has not passed qualification yet. Choose OpenClaw for this candidate.", - }), - ); - return; - } selection = { schemaVersion: 1, agent: submitted.agent, @@ -217,7 +207,27 @@ async function startOnboardingServer(installRoot, openClawUrl, evidenceRoot) { "utf8", ); response.writeHead(200, { "content-type": "application/json" }); - response.end(JSON.stringify({ redirect: `${openClawUrl}/chat` })); + response.end( + JSON.stringify({ + redirect: + submitted.agent === "openclaw" + ? `${openClawUrl}/chat` + : `/launching.html?agent=${encodeURIComponent(submitted.agent)}`, + }), + ); + return; + } + if (request.method === "GET" && pathname === "/launching.html") { + const agent = new URL(request.url ?? "/", "http://127.0.0.1").searchParams.get("agent"); + const displayName = agentNamesForLaunch[agent] ?? "selected agent"; + response.writeHead(200, { + "cache-control": "no-store", + "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'", + "content-type": "text/html; charset=utf-8", + }); + response.end( + `NemoClaw Native Windows · ${displayName}

Starting ${displayName}

NemoClaw is creating a native OpenShell/MXC sandbox and opening the agent's authentic Windows surface.

Native ARM64 · no WSL · no Docker
`, + ); return; } const file = files.get(pathname); @@ -258,7 +268,22 @@ async function startOnboardingServer(installRoot, openClawUrl, evidenceRoot) { }; } -async function driveBrowser(openClawRoot, onboardingUrl, openClawUrl, evidenceRoot, qualification) { +const agentNamesForLaunch = { + openclaw: "OpenClaw", + hermes: "Hermes Agent", + "langchain-deepagents-code": "Deep Agents Code", + pi: "Pi", + nemocua: "NemoCUA", +}; + +async function driveBrowser( + openClawRoot, + onboardingUrl, + openClawUrl, + evidenceRoot, + qualification, + targetAgent = "openclaw", +) { const playwrightRoot = requiredDirectory( path.join(openClawRoot, "node_modules", "openclaw", "node_modules", "playwright-core"), "installed Playwright browser driver", @@ -305,20 +330,11 @@ async function driveBrowser(openClawRoot, onboardingUrl, openClawUrl, evidenceRo } const demonstratedAgentChoices = []; const disabledAgentChoices = []; - console.log("WEB UI> Showing supported, experimental, and unavailable agent choices"); + console.log("WEB UI> Showing every real native agent choice"); await sleep(3000); - for (const agent of DISABLED_AGENT_PROOF) { - const card = page.locator(`[data-agent='${agent}']`); - if (!(await card.isDisabled())) fail(`unqualified agent ${agent} remained selectable`); - const blocker = await card.getAttribute("data-blocker"); - if (!blocker) fail(`unqualified agent ${agent} did not explain its blocker`); - await card.scrollIntoViewIfNeeded(); - disabledAgentChoices.push({ agent, blocker }); - console.log(`WEB UI> UNAVAILABLE ${agent}: ${blocker}`); - await sleep(1800); - } - for (const agent of ["openclaw"]) { + for (const agent of AGENT_CHOICE_PROOF) { const card = page.locator(`[data-agent='${agent}']`); + if (await card.isDisabled()) fail(`native agent ${agent} is not selectable`); await card.click(); if ((await card.getAttribute("aria-checked")) !== "true") fail(`graphical onboarding did not select ${agent}`); @@ -326,6 +342,7 @@ async function driveBrowser(openClawRoot, onboardingUrl, openClawUrl, evidenceRo console.log(`WEB UI> AGENT CHOICE selected ${agent}`); await sleep(1500); } + await page.locator(`[data-agent='${targetAgent}']`).click(); await page.screenshot({ path: path.join(evidenceRoot, "onboarding-agent.png"), fullPage: false, @@ -348,6 +365,17 @@ async function driveBrowser(openClawRoot, onboardingUrl, openClawUrl, evidenceRo }); await sleep(2500); await page.locator("#launch").click(); + if (targetAgent !== "openclaw") { + await page.waitForURL(`${onboardingUrl}/launching.html?agent=${targetAgent}`, { + timeout: 30_000, + }); + await page.screenshot({ + path: path.join(evidenceRoot, `onboarding-${targetAgent}-launching.png`), + fullPage: false, + }); + await sleep(3000); + return { browserVersion, demonstratedAgentChoices, disabledAgentChoices, turns: [] }; + } await page.waitForURL(`${openClawUrl}/chat`, { timeout: 30_000 }); const composer = page.locator(".agent-chat__composer-combobox > textarea").first(); await composer.waitFor({ state: "visible", timeout: 90_000 }); @@ -392,10 +420,123 @@ async function driveBrowser(openClawRoot, onboardingUrl, openClawUrl, evidenceRo } } +async function runSelectedNonOpenClaw( + installRoot, + installedNode, + installedOpenClawRoot, + targetAgent, + qualification, + evidenceRoot, +) { + const onboarding = await startOnboardingServer(installRoot, "", evidenceRoot); + let browserProof; + try { + console.log(`WEB UI> Launching graphical onboarding for ${agentNamesForLaunch[targetAgent]}`); + browserProof = await driveBrowser( + installedOpenClawRoot, + onboarding.url, + "", + evidenceRoot, + qualification, + targetAgent, + ); + } finally { + await new Promise((resolve) => onboarding.server.close(() => resolve())); + } + const onboardingSelection = onboarding.selection(); + if (onboardingSelection?.agent !== targetAgent) + fail(`graphical onboarding did not select ${targetAgent}`); + if (!qualification) + fail(`${agentNamesForLaunch[targetAgent]} requires completed provider configuration`); + + const runtimeEvidence = path.join(evidenceRoot, "runtime"); + fs.mkdirSync(runtimeEvidence, { recursive: true }); + const isNemoCua = targetAgent === "nemocua"; + const runner = requiredFile( + path.join( + installRoot, + "qualification", + isNemoCua ? "run-installed-native-nemocua.mts" : "run-installed-native-pi.mts", + ), + `${agentNamesForLaunch[targetAgent]} native adapter`, + ); + const arguments_ = [ + "--experimental-strip-types", + "--no-warnings", + runner, + "--qualification", + "--artifact-directory", + runtimeEvidence, + ]; + if (!isNemoCua) arguments_.push("--agent", targetAgent); + const environment = allowlistedWindowsEnvironment({ + NEMOCLAW_NATIVE_INSTALL_ROOT: installRoot, + }); + console.log( + `WEB UI> Handing off to the authentic ${agentNamesForLaunch[targetAgent]} native surface`, + ); + const exitCode = await new Promise((resolve, reject) => { + const child = spawn(installedNode, arguments_, { + cwd: installRoot, + env: environment, + stdio: "inherit", + windowsHide: false, + }); + child.once("error", reject); + child.once("close", (code) => resolve(code ?? 1)); + }); + if (exitCode !== 0) fail(`${agentNamesForLaunch[targetAgent]} native adapter exited ${exitCode}`); + const receiptPrefixes = { + hermes: "native-windows-hermes-", + "langchain-deepagents-code": "native-windows-langchain-deepagents-code-", + pi: "native-windows-pi-", + nemocua: "native-windows-nemocua-", + }; + const runtimeReceipts = fs + .readdirSync(runtimeEvidence) + .filter( + (name) => + name.startsWith(receiptPrefixes[targetAgent]) && + name.endsWith(".json") && + fs.statSync(path.join(runtimeEvidence, name)).isFile(), + ); + if (runtimeReceipts.length !== 1) + fail(`${agentNamesForLaunch[targetAgent]} did not publish exactly one runtime receipt`); + const runtimeReceipt = JSON.parse( + fs.readFileSync(path.join(runtimeEvidence, runtimeReceipts[0]), "utf8"), + ); + if (runtimeReceipt.verdict !== "pass" || runtimeReceipt.turnCount !== 3) + fail(`${agentNamesForLaunch[targetAgent]} runtime receipt is incomplete`); + const receipt = { + schemaVersion: 1, + classification: "installed-nemoclaw-native-windows-graphical-agent-launch", + architecture: "arm64", + selectedAgent: targetAgent, + onboardingSelection, + demonstratedAgentChoices: browserProof.demonstratedAgentChoices, + disabledAgentChoices: browserProof.disabledAgentChoices, + browser: "Microsoft Edge", + browserVersion: browserProof.browserVersion, + runtimeReceipt, + turnCount: runtimeReceipt.turnCount, + verdict: "pass", + }; + fs.writeFileSync( + path.join(evidenceRoot, `native-windows-agent-launch-${targetAgent}.json`), + `${JSON.stringify(receipt, null, 2)}\n`, + "utf8", + ); + console.log( + `WEB UI> PASS graphical onboarding and three ${agentNamesForLaunch[targetAgent]} turns`, + ); +} + async function main() { if (process.platform !== "win32" || process.arch !== "arm64") fail("native Windows ARM64 is required"); const qualification = process.argv.includes("--qualification"); + const targetAgent = argumentValue("--agent") ?? "openclaw"; + if (!Object.hasOwn(agentNamesForLaunch, targetAgent)) fail(`unknown agent ${targetAgent}`); const installRoot = requiredDirectory( process.env.NEMOCLAW_NATIVE_INSTALL_ROOT ?? "", "NemoClaw installation root", @@ -421,6 +562,29 @@ async function main() { ); requiredFile(path.join(installRoot, "mxc", "wxc-exec.exe"), "MXC executor"); + const selectedEvidenceRoot = path.resolve( + argumentValue("--artifact-directory") ?? + path.join( + process.env.LOCALAPPDATA ?? installRoot, + "NVIDIA", + "NemoClaw", + "evidence", + targetAgent, + ), + ); + fs.mkdirSync(selectedEvidenceRoot, { recursive: true }); + if (targetAgent !== "openclaw") { + await runSelectedNonOpenClaw( + installRoot, + installedNode, + installedOpenClawRoot, + targetAgent, + qualification, + selectedEvidenceRoot, + ); + return; + } + const systemDrive = process.env.SystemDrive; if (!systemDrive || !/^[A-Za-z]:$/u.test(systemDrive)) fail("SystemDrive is invalid"); const systemRoot = requiredDirectory(process.env.SystemRoot ?? "", "Windows system root"); @@ -432,11 +596,7 @@ async function main() { if (fs.existsSync(directory)) fail("qualification root already exists"); fs.mkdirSync(directory); } - const evidenceRoot = path.resolve( - argumentValue("--artifact-directory") ?? - path.join(process.env.LOCALAPPDATA ?? runRoot, "NVIDIA", "NemoClaw", "evidence"), - ); - fs.mkdirSync(evidenceRoot, { recursive: true }); + const evidenceRoot = selectedEvidenceRoot; const node = path.join(runtimeRoot, "node.exe"); const openClawRoot = path.join(runtimeRoot, "openclaw"); console.log("WEB UI> Staging the exact installed OpenClaw runtime for MXC"); @@ -587,6 +747,7 @@ async function main() { uiUrl, evidenceRoot, qualification, + targetAgent, ); const onboardingSelection = onboarding.selection(); await new Promise((resolve, reject) => { diff --git a/scripts/checks/create-windows-native-proof-video.ps1 b/scripts/checks/create-windows-native-proof-video.ps1 index cf1c476272c..7fe730ac6ec 100644 --- a/scripts/checks/create-windows-native-proof-video.ps1 +++ b/scripts/checks/create-windows-native-proof-video.ps1 @@ -340,8 +340,8 @@ if ($null -ne $qualification -and (-not $qualification.repairRestoredDigest -or $qualification.webUi.verdict -cne 'pass' -or [int]$qualification.webUi.turnCount -ne 3 -or $qualification.webUi.onboardingSelection.agent -cne 'openclaw' -or - (@($qualification.webUi.demonstratedAgentChoices) -join ',') -cne 'openclaw' -or - (@($qualification.webUi.disabledAgentChoices | ForEach-Object { $_.agent }) -join ',') -cne 'hermes,langchain-deepagents-code,pi,nemocua' -or + (@($qualification.webUi.demonstratedAgentChoices) -join ',') -cne 'openclaw,hermes,langchain-deepagents-code,pi,nemocua' -or + @($qualification.webUi.disabledAgentChoices).Count -ne 0 -or @($qualification.webUi.turns).Count -ne 3 -or $qualification.pi.verdict -cne 'pass' -or [int]$qualification.pi.turnCount -ne 3 -or @@ -351,6 +351,10 @@ if ($null -ne $qualification -and (-not $qualification.repairRestoredDigest -or [int]$qualification.deepAgentsCode.turnCount -ne 3 -or $qualification.nemoCua.verdict -cne 'pass' -or [int]$qualification.nemoCua.turnCount -ne 3 -or + $qualification.agentLaunches.pi.onboardingSelection.agent -cne 'pi' -or + $qualification.agentLaunches.hermes.onboardingSelection.agent -cne 'hermes' -or + $qualification.agentLaunches.deepAgentsCode.onboardingSelection.agent -cne 'langchain-deepagents-code' -or + $qualification.agentLaunches.nemoCua.onboardingSelection.agent -cne 'nemocua' -or @($qualification.nativeExecutions).Count -ne 4 -or @($qualification.applicationExecutions).Count -ne 6 -or @($qualification.packageDescendantProhibitedStarts).Count -ne 0 -or @@ -648,8 +652,8 @@ public static class NemoClawConsoleVideoEncoder if ($null -ne $recordedQualification -and ($recordedQualification.webUi.verdict -cne 'pass' -or [int]$recordedQualification.webUi.turnCount -ne 3 -or $recordedQualification.webUi.onboardingSelection.agent -cne 'openclaw' -or - (@($recordedQualification.webUi.demonstratedAgentChoices) -join ',') -cne 'openclaw' -or - (@($recordedQualification.webUi.disabledAgentChoices | ForEach-Object { $_.agent }) -join ',') -cne 'hermes,langchain-deepagents-code,pi,nemocua' -or + (@($recordedQualification.webUi.demonstratedAgentChoices) -join ',') -cne 'openclaw,hermes,langchain-deepagents-code,pi,nemocua' -or + @($recordedQualification.webUi.disabledAgentChoices).Count -ne 0 -or @($recordedQualification.webUi.turns).Count -ne 3)) { $captureFailures.Add('The recorded qualification receipt does not prove visible agent choices and three OpenClaw Control UI turns.') } @@ -670,7 +674,11 @@ public static class NemoClawConsoleVideoEncoder } if ($null -ne $recordedQualification -and ($recordedQualification.nemoCua.verdict -cne 'pass' -or [int]$recordedQualification.nemoCua.turnCount -ne 3 -or - @($recordedQualification.nemoCua.turns).Count -ne 3)) { + @($recordedQualification.nemoCua.turns).Count -ne 3 -or + $recordedQualification.agentLaunches.pi.onboardingSelection.agent -cne 'pi' -or + $recordedQualification.agentLaunches.hermes.onboardingSelection.agent -cne 'hermes' -or + $recordedQualification.agentLaunches.deepAgentsCode.onboardingSelection.agent -cne 'langchain-deepagents-code' -or + $recordedQualification.agentLaunches.nemoCua.onboardingSelection.agent -cne 'nemocua')) { $captureFailures.Add('The recorded qualification receipt does not prove three real NemoCUA browser turns.') } $initialQualificationHash = if (Test-Path -LiteralPath $qualificationPath -PathType Leaf) { diff --git a/scripts/checks/run-windows-native-package-qualification.ps1 b/scripts/checks/run-windows-native-package-qualification.ps1 index 62d76c8e706..121e6d31ff0 100644 --- a/scripts/checks/run-windows-native-package-qualification.ps1 +++ b/scripts/checks/run-windows-native-package-qualification.ps1 @@ -599,9 +599,7 @@ $pythonPath = Join-Path $installRoot 'python\python.exe' $hermesSitePackages = Join-Path $installRoot 'hermes\site-packages' $deepAgentsSitePackages = Join-Path $installRoot 'deepagents\site-packages' $nemoCuaEntryPath = Join-Path $installRoot 'nemocua\run_with_harness.py' -$nemoCuaQualificationPath = Join-Path $installRoot 'qualification\run-installed-native-nemocua.mts' $piEntryPath = Join-Path $installRoot 'pi\node_modules\@earendil-works\pi-coding-agent\dist\cli.js' -$piQualificationPath = Join-Path $installRoot 'qualification\run-installed-native-pi.mts' $nodePath = Join-Path $installBin 'node.exe' $nemoclawEntryPath = Join-Path $installRoot 'nemoclaw\app\bin\nemoclaw.js' $openClawEntryPath = Join-Path $installRoot 'openclaw\node_modules\openclaw\openclaw.mjs' @@ -706,8 +704,8 @@ try { 'NATIVE_WINDOWS_TURN_2_OK', 'NATIVE_WINDOWS_TURN_3_OK' ) - $expectedAgentChoices = @('openclaw') - $expectedDisabledAgentChoices = @('hermes', 'langchain-deepagents-code', 'pi', 'nemocua') + $expectedAgentChoices = @('openclaw', 'hermes', 'langchain-deepagents-code', 'pi', 'nemocua') + $expectedDisabledAgentChoices = @() if ($webUiReceipt.verdict -cne 'pass' -or $webUiReceipt.backend -cne 'process_container' -or $webUiReceipt.browser -cne 'Microsoft Edge' -or @@ -749,18 +747,22 @@ try { } Write-Host '[PASS] Graphical onboarding selected OpenClaw and completed three exact Control UI agent turns' $piArtifacts = Join-Path $artifactRoot 'pi' - Write-Host 'PS> Launch installed Pi runtime and complete three native MXC agent turns' + Write-Host 'PS> Launch NemoClaw, select Pi graphically, and complete three native MXC agent turns' Invoke-BoundedProcess ` - -FilePath $nodePath ` - -Arguments @('--experimental-strip-types', '--no-warnings', $piQualificationPath, '--qualification', '--artifact-directory', $piArtifacts) ` - -Label 'Installed native Windows Pi qualification' ` + -FilePath $nemoclawUiLauncherPath ` + -Arguments @('--wait', '--qualification', '--agent', 'pi', '--artifact-directory', $piArtifacts) ` + -Label 'Installed graphical native Windows Pi qualification' ` -AllowedExitCodes @(0) | Out-Null - $piReceipts = @(Get-ChildItem -LiteralPath $piArtifacts -Filter 'native-windows-pi-*.json' -File -ErrorAction SilentlyContinue) - if ($piReceipts.Count -ne 1) { - Fail-PackageQualification 'Installed Pi runtime did not publish exactly one receipt.' + $piLaunchReceipts = @(Get-ChildItem -LiteralPath $piArtifacts -Filter 'native-windows-agent-launch-pi.json' -File -ErrorAction SilentlyContinue) + if ($piLaunchReceipts.Count -ne 1) { + Fail-PackageQualification 'Installed Pi graphical launch did not publish exactly one receipt.' } - $piReceipt = Get-Content -LiteralPath $piReceipts[0].FullName -Raw | ConvertFrom-Json + $piLaunchReceipt = Get-Content -LiteralPath $piLaunchReceipts[0].FullName -Raw | ConvertFrom-Json + $piReceipt = $piLaunchReceipt.runtimeReceipt if ($piReceipt.verdict -cne 'pass' -or + $piLaunchReceipt.selectedAgent -cne 'pi' -or + $piLaunchReceipt.onboardingSelection.agent -cne 'pi' -or + (@($piLaunchReceipt.demonstratedAgentChoices) -join ',') -cne 'openclaw,hermes,langchain-deepagents-code,pi,nemocua' -or $piReceipt.piVersion -cne '0.84.1' -or $piReceipt.backend -cne 'process_container' -or $piReceipt.interface -cne 'Pi terminal one-shot mode' -or @@ -775,18 +777,22 @@ try { } Write-Host '[PASS] Installed Pi completed three real terminal agent turns inside native MXC' $hermesArtifacts = Join-Path $artifactRoot 'hermes' - Write-Host 'PS> Launch installed Hermes Agent runtime and complete three native MXC agent turns' + Write-Host 'PS> Launch NemoClaw, select Hermes Agent graphically, and complete three native MXC agent turns' Invoke-BoundedProcess ` - -FilePath $nodePath ` - -Arguments @('--experimental-strip-types', '--no-warnings', $piQualificationPath, '--qualification', '--agent', 'hermes', '--artifact-directory', $hermesArtifacts) ` - -Label 'Installed native Windows Hermes qualification' ` + -FilePath $nemoclawUiLauncherPath ` + -Arguments @('--wait', '--qualification', '--agent', 'hermes', '--artifact-directory', $hermesArtifacts) ` + -Label 'Installed graphical native Windows Hermes qualification' ` -AllowedExitCodes @(0) | Out-Null - $hermesReceipts = @(Get-ChildItem -LiteralPath $hermesArtifacts -Filter 'native-windows-hermes-*.json' -File -ErrorAction SilentlyContinue) - if ($hermesReceipts.Count -ne 1) { - Fail-PackageQualification 'Installed Hermes runtime did not publish exactly one receipt.' + $hermesLaunchReceipts = @(Get-ChildItem -LiteralPath $hermesArtifacts -Filter 'native-windows-agent-launch-hermes.json' -File -ErrorAction SilentlyContinue) + if ($hermesLaunchReceipts.Count -ne 1) { + Fail-PackageQualification 'Installed Hermes graphical launch did not publish exactly one receipt.' } - $hermesReceipt = Get-Content -LiteralPath $hermesReceipts[0].FullName -Raw | ConvertFrom-Json + $hermesLaunchReceipt = Get-Content -LiteralPath $hermesLaunchReceipts[0].FullName -Raw | ConvertFrom-Json + $hermesReceipt = $hermesLaunchReceipt.runtimeReceipt if ($hermesReceipt.verdict -cne 'pass' -or + $hermesLaunchReceipt.selectedAgent -cne 'hermes' -or + $hermesLaunchReceipt.onboardingSelection.agent -cne 'hermes' -or + (@($hermesLaunchReceipt.demonstratedAgentChoices) -join ',') -cne 'openclaw,hermes,langchain-deepagents-code,pi,nemocua' -or $hermesReceipt.hermesVersion -cne '0.19.0' -or $hermesReceipt.backend -cne 'process_container' -or $hermesReceipt.interface -cne 'Hermes terminal one-shot mode' -or @@ -801,18 +807,22 @@ try { } Write-Host '[PASS] Installed Hermes completed three real terminal agent turns inside native MXC' $deepAgentsArtifacts = Join-Path $artifactRoot 'deepagents' - Write-Host 'PS> Launch installed Deep Agents Code runtime and complete three native MXC agent turns' + Write-Host 'PS> Launch NemoClaw, select Deep Agents Code graphically, and complete three native MXC agent turns' Invoke-BoundedProcess ` - -FilePath $nodePath ` - -Arguments @('--experimental-strip-types', '--no-warnings', $piQualificationPath, '--qualification', '--agent', 'langchain-deepagents-code', '--artifact-directory', $deepAgentsArtifacts) ` - -Label 'Installed native Windows Deep Agents Code qualification' ` + -FilePath $nemoclawUiLauncherPath ` + -Arguments @('--wait', '--qualification', '--agent', 'langchain-deepagents-code', '--artifact-directory', $deepAgentsArtifacts) ` + -Label 'Installed graphical native Windows Deep Agents Code qualification' ` -AllowedExitCodes @(0) | Out-Null - $deepAgentsReceipts = @(Get-ChildItem -LiteralPath $deepAgentsArtifacts -Filter 'native-windows-langchain-deepagents-code-*.json' -File -ErrorAction SilentlyContinue) - if ($deepAgentsReceipts.Count -ne 1) { - Fail-PackageQualification 'Installed Deep Agents Code runtime did not publish exactly one receipt.' + $deepAgentsLaunchReceipts = @(Get-ChildItem -LiteralPath $deepAgentsArtifacts -Filter 'native-windows-agent-launch-langchain-deepagents-code.json' -File -ErrorAction SilentlyContinue) + if ($deepAgentsLaunchReceipts.Count -ne 1) { + Fail-PackageQualification 'Installed Deep Agents Code graphical launch did not publish exactly one receipt.' } - $deepAgentsReceipt = Get-Content -LiteralPath $deepAgentsReceipts[0].FullName -Raw | ConvertFrom-Json + $deepAgentsLaunchReceipt = Get-Content -LiteralPath $deepAgentsLaunchReceipts[0].FullName -Raw | ConvertFrom-Json + $deepAgentsReceipt = $deepAgentsLaunchReceipt.runtimeReceipt if ($deepAgentsReceipt.verdict -cne 'pass' -or + $deepAgentsLaunchReceipt.selectedAgent -cne 'langchain-deepagents-code' -or + $deepAgentsLaunchReceipt.onboardingSelection.agent -cne 'langchain-deepagents-code' -or + (@($deepAgentsLaunchReceipt.demonstratedAgentChoices) -join ',') -cne 'openclaw,hermes,langchain-deepagents-code,pi,nemocua' -or $deepAgentsReceipt.deepAgentsCodeVersion -cne '0.1.55' -or $deepAgentsReceipt.backend -cne 'process_container' -or $deepAgentsReceipt.interface -cne 'Deep Agents Code terminal one-shot mode' -or @@ -827,18 +837,22 @@ try { } Write-Host '[PASS] Installed Deep Agents Code completed three real terminal agent turns inside native MXC' $nemoCuaArtifacts = Join-Path $artifactRoot 'nemocua' - Write-Host 'PS> Launch installed NemoCUA runtime and complete three model-driven native MXC browser turns' + Write-Host 'PS> Launch NemoClaw, select NemoCUA graphically, and complete three model-driven native MXC browser turns' Invoke-BoundedProcess ` - -FilePath $nodePath ` - -Arguments @('--experimental-strip-types', '--no-warnings', $nemoCuaQualificationPath, '--qualification', '--artifact-directory', $nemoCuaArtifacts) ` - -Label 'Installed native Windows NemoCUA qualification' ` + -FilePath $nemoclawUiLauncherPath ` + -Arguments @('--wait', '--qualification', '--agent', 'nemocua', '--artifact-directory', $nemoCuaArtifacts) ` + -Label 'Installed graphical native Windows NemoCUA qualification' ` -AllowedExitCodes @(0) | Out-Null - $nemoCuaReceipts = @(Get-ChildItem -LiteralPath $nemoCuaArtifacts -Filter 'native-windows-nemocua-*.json' -File -ErrorAction SilentlyContinue) - if ($nemoCuaReceipts.Count -ne 1) { - Fail-PackageQualification 'Installed NemoCUA runtime did not publish exactly one receipt.' + $nemoCuaLaunchReceipts = @(Get-ChildItem -LiteralPath $nemoCuaArtifacts -Filter 'native-windows-agent-launch-nemocua.json' -File -ErrorAction SilentlyContinue) + if ($nemoCuaLaunchReceipts.Count -ne 1) { + Fail-PackageQualification 'Installed NemoCUA graphical launch did not publish exactly one receipt.' } - $nemoCuaReceipt = Get-Content -LiteralPath $nemoCuaReceipts[0].FullName -Raw | ConvertFrom-Json + $nemoCuaLaunchReceipt = Get-Content -LiteralPath $nemoCuaLaunchReceipts[0].FullName -Raw | ConvertFrom-Json + $nemoCuaReceipt = $nemoCuaLaunchReceipt.runtimeReceipt if ($nemoCuaReceipt.verdict -cne 'pass' -or + $nemoCuaLaunchReceipt.selectedAgent -cne 'nemocua' -or + $nemoCuaLaunchReceipt.onboardingSelection.agent -cne 'nemocua' -or + (@($nemoCuaLaunchReceipt.demonstratedAgentChoices) -join ',') -cne 'openclaw,hermes,langchain-deepagents-code,pi,nemocua' -or $nemoCuaReceipt.nemocuaVersion -cne '0.1.0-windows-experimental' -or $nemoCuaReceipt.backend -cne 'process_container' -or $nemoCuaReceipt.interface -cne 'NemoCUA visible browser task' -or @@ -990,6 +1004,12 @@ try { hermes = $hermesReceipt deepAgentsCode = $deepAgentsReceipt nemoCua = $nemoCuaReceipt + agentLaunches = [pscustomobject]@{ + pi = $piLaunchReceipt + hermes = $hermesLaunchReceipt + deepAgentsCode = $deepAgentsLaunchReceipt + nemoCua = $nemoCuaLaunchReceipt + } msiRegistration = $msiArp bundleRegistration = $bundleArp repairRestoredDigest = $repairRestoredDigest From 63a9619a99456dc0ed7543d8b52294e13e900ac0 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 3 Sep 2026 09:56:14 -0700 Subject: [PATCH 088/144] feat(windows): emit per-agent raw proof videos --- .../create-windows-native-proof-video.ps1 | 68 +++++++++++++++++++ ...n-windows-native-package-qualification.ps1 | 32 +++++++++ 2 files changed, 100 insertions(+) diff --git a/scripts/checks/create-windows-native-proof-video.ps1 b/scripts/checks/create-windows-native-proof-video.ps1 index 7fe730ac6ec..0b02f38947e 100644 --- a/scripts/checks/create-windows-native-proof-video.ps1 +++ b/scripts/checks/create-windows-native-proof-video.ps1 @@ -38,6 +38,13 @@ $script:FrameDurationMilliseconds = 250 $script:MaximumRecordingMilliseconds = 1800000 $script:MinimumCaptureFrames = 40 $script:MinimumUniqueFrames = 8 +$script:AgentVideoSegments = @( + 'openclaw', + 'hermes', + 'langchain-deepagents-code', + 'pi', + 'nemocua' +) function Fail-ProofVideo { param([Parameter(Mandatory)][string]$Message) @@ -415,6 +422,10 @@ try { $recordingClock = [Diagnostics.Stopwatch]::StartNew() $framePaths = @() + $agentSegmentFrames = [ordered]@{} + foreach ($agent in $script:AgentVideoSegments) { + $agentSegmentFrames[$agent] = [ordered]@{ start = $null; end = $null } + } $installerWindowFrameCount = 0 $browserWindowFrameCount = 0 while (-not $proofProcess.HasExited) { @@ -461,6 +472,17 @@ try { $browserWindowFrameCount++ } $framePaths += $framePath + foreach ($agent in $script:AgentVideoSegments) { + $segment = $agentSegmentFrames[$agent] + if ($null -eq $segment.start -and + (Test-Path -LiteralPath (Join-Path $consoleQualification "video-segment-$agent-start.json") -PathType Leaf)) { + $segment.start = $framePaths.Count - 1 + } + if ($null -ne $segment.start -and $null -eq $segment.end -and + (Test-Path -LiteralPath (Join-Path $consoleQualification "video-segment-$agent-end.json") -PathType Leaf)) { + $segment.end = $framePaths.Count - 1 + } + } Start-Sleep -Milliseconds $script:FrameDurationMilliseconds $proofProcess.Refresh() } @@ -631,6 +653,51 @@ public static class NemoClawConsoleVideoEncoder Fail-ProofVideo 'Rendered console proof is not an ISO base media file.' } + $agentVideos = [ordered]@{} + foreach ($agent in $script:AgentVideoSegments) { + $segment = $agentSegmentFrames[$agent] + if ($null -eq $segment.start -or $null -eq $segment.end -or $segment.end -lt $segment.start) { + $captureFailures.Add("The recording is missing a complete $agent agent segment.") + continue + } + $segmentStart = [Math]::Max(0, [int]$segment.start - (2 * $script:CaptureFramesPerSecond)) + $segmentEnd = [Math]::Min($framePaths.Count - 1, [int]$segment.end + (2 * $script:CaptureFramesPerSecond)) + $segmentFrames = [string[]]$framePaths[$segmentStart..$segmentEnd] + $agentVideoName = "NemoClaw-$ProductVersion-windows-arm64-$agent-raw-proof-$($CandidateSha.Substring(0, 12)).mp4" + $agentVideoPath = Join-Path $output $agentVideoName + $agentRenderTask = [NemoClawConsoleVideoEncoder]::RenderAsync( + $segmentFrames, + $script:FrameDurationMilliseconds, + $agentVideoPath + ) + $agentRenderedPath = $agentRenderTask.GetAwaiter().GetResult() + if ($agentRenderedPath -cne $agentVideoPath -or + -not (Test-Path -LiteralPath $agentVideoPath -PathType Leaf) -or + (Get-Item -LiteralPath $agentVideoPath).Length -lt 65536) { + $captureFailures.Add("Media Foundation did not produce the expected $agent raw proof MP4.") + continue + } + $agentVideoBytes = [IO.File]::ReadAllBytes($agentVideoPath) + $agentHeader = [Text.Encoding]::ASCII.GetString( + $agentVideoBytes, + 0, + [Math]::Min(64, $agentVideoBytes.Length) + ) + if ($agentHeader -notmatch 'ftyp') { + $captureFailures.Add("The $agent raw proof is not an ISO base media file.") + continue + } + $agentVideos[$agent] = [pscustomobject]@{ + file = $agentVideoName + firstCombinedFrame = $segmentStart + lastCombinedFrame = $segmentEnd + frameCount = $segmentFrames.Count + expectedDurationMilliseconds = $segmentFrames.Count * $script:FrameDurationMilliseconds + sha256 = (Get-FileHash -LiteralPath $agentVideoPath -Algorithm SHA256).Hash.ToLowerInvariant() + bytes = (Get-Item -LiteralPath $agentVideoPath).Length + } + } + $consoleQualificationReceipt = Join-Path $consoleQualification 'package-qualification.json' $recordedQualification = if (Test-Path -LiteralPath $consoleQualificationReceipt -PathType Leaf) { Get-Content -LiteralPath $consoleQualificationReceipt -Raw | ConvertFrom-Json @@ -741,6 +808,7 @@ public static class NemoClawConsoleVideoEncoder sha256 = (Get-FileHash -LiteralPath $videoPath -Algorithm SHA256).Hash.ToLowerInvariant() bytes = (Get-Item -LiteralPath $videoPath).Length } + agentVideos = [pscustomobject]$agentVideos verdict = if ($captureFailures.Count -eq 0) { 'pass' } else { 'fail' } } [IO.File]::WriteAllText( diff --git a/scripts/checks/run-windows-native-package-qualification.ps1 b/scripts/checks/run-windows-native-package-qualification.ps1 index 121e6d31ff0..c744a5a1a20 100644 --- a/scripts/checks/run-windows-native-package-qualification.ps1 +++ b/scripts/checks/run-windows-native-package-qualification.ps1 @@ -34,6 +34,28 @@ function Fail-PackageQualification { throw "Windows native package qualification failed: $Message" } +function Write-InteractiveVideoMarker { + param( + [Parameter(Mandatory)][ValidateSet('openclaw', 'hermes', 'langchain-deepagents-code', 'pi', 'nemocua')][string]$Agent, + [Parameter(Mandatory)][ValidateSet('start', 'end')][string]$Phase + ) + + if (-not $InteractiveProof) { + return + } + $marker = [pscustomobject]@{ + schemaVersion = 1 + agent = $Agent + phase = $Phase + recordedAtUtc = [DateTime]::UtcNow.ToString('O') + } + [IO.File]::WriteAllText( + (Join-Path $artifactRoot "video-segment-$Agent-$Phase.json"), + (($marker | ConvertTo-Json -Compress) + [Environment]::NewLine), + [Text.UTF8Encoding]::new($false) + ) +} + function Assert-Arm64PortableExecutable { param( [Parameter(Mandatory)][string]$Path, @@ -685,6 +707,7 @@ try { Write-Host '[PASS] Installed nemoclaw command created an MXC sandbox and completed an exact CHAT_OK turn' $webUiArtifacts = Join-Path $artifactRoot 'web-ui' Write-Host 'PS> Launch installed NemoClaw OpenClaw web UI and complete three agent turns' + Write-InteractiveVideoMarker -Agent 'openclaw' -Phase 'start' Invoke-BoundedProcess ` -FilePath $nemoclawUiLauncherPath ` -Arguments @('--wait', '--qualification', '--artifact-directory', $webUiArtifacts) ` @@ -746,8 +769,10 @@ try { Fail-PackageQualification 'Installed NemoClaw did not capture all four graphical onboarding steps.' } Write-Host '[PASS] Graphical onboarding selected OpenClaw and completed three exact Control UI agent turns' + Write-InteractiveVideoMarker -Agent 'openclaw' -Phase 'end' $piArtifacts = Join-Path $artifactRoot 'pi' Write-Host 'PS> Launch NemoClaw, select Pi graphically, and complete three native MXC agent turns' + Write-InteractiveVideoMarker -Agent 'pi' -Phase 'start' Invoke-BoundedProcess ` -FilePath $nemoclawUiLauncherPath ` -Arguments @('--wait', '--qualification', '--agent', 'pi', '--artifact-directory', $piArtifacts) ` @@ -776,8 +801,10 @@ try { Fail-PackageQualification 'Installed Pi qualification receipt is incomplete.' } Write-Host '[PASS] Installed Pi completed three real terminal agent turns inside native MXC' + Write-InteractiveVideoMarker -Agent 'pi' -Phase 'end' $hermesArtifacts = Join-Path $artifactRoot 'hermes' Write-Host 'PS> Launch NemoClaw, select Hermes Agent graphically, and complete three native MXC agent turns' + Write-InteractiveVideoMarker -Agent 'hermes' -Phase 'start' Invoke-BoundedProcess ` -FilePath $nemoclawUiLauncherPath ` -Arguments @('--wait', '--qualification', '--agent', 'hermes', '--artifact-directory', $hermesArtifacts) ` @@ -806,8 +833,10 @@ try { Fail-PackageQualification 'Installed Hermes qualification receipt is incomplete.' } Write-Host '[PASS] Installed Hermes completed three real terminal agent turns inside native MXC' + Write-InteractiveVideoMarker -Agent 'hermes' -Phase 'end' $deepAgentsArtifacts = Join-Path $artifactRoot 'deepagents' Write-Host 'PS> Launch NemoClaw, select Deep Agents Code graphically, and complete three native MXC agent turns' + Write-InteractiveVideoMarker -Agent 'langchain-deepagents-code' -Phase 'start' Invoke-BoundedProcess ` -FilePath $nemoclawUiLauncherPath ` -Arguments @('--wait', '--qualification', '--agent', 'langchain-deepagents-code', '--artifact-directory', $deepAgentsArtifacts) ` @@ -836,8 +865,10 @@ try { Fail-PackageQualification 'Installed Deep Agents Code qualification receipt is incomplete.' } Write-Host '[PASS] Installed Deep Agents Code completed three real terminal agent turns inside native MXC' + Write-InteractiveVideoMarker -Agent 'langchain-deepagents-code' -Phase 'end' $nemoCuaArtifacts = Join-Path $artifactRoot 'nemocua' Write-Host 'PS> Launch NemoClaw, select NemoCUA graphically, and complete three model-driven native MXC browser turns' + Write-InteractiveVideoMarker -Agent 'nemocua' -Phase 'start' Invoke-BoundedProcess ` -FilePath $nemoclawUiLauncherPath ` -Arguments @('--wait', '--qualification', '--agent', 'nemocua', '--artifact-directory', $nemoCuaArtifacts) ` @@ -869,6 +900,7 @@ try { Fail-PackageQualification 'Installed NemoCUA qualification receipt is incomplete.' } Write-Host '[PASS] Installed NemoCUA completed three real model-driven browser actions inside native MXC' + Write-InteractiveVideoMarker -Agent 'nemocua' -Phase 'end' if ($InteractiveProof) { Start-Sleep -Seconds 3 } From 48151840444e4c47100dbc4a78b96fa32383f0e6 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 3 Sep 2026 10:14:35 -0700 Subject: [PATCH 089/144] fix(windows): group payload below MSI component limit --- packaging/windows/NemoClaw.wixproj | 3 + packaging/windows/Product.wxs | 2 +- .../checks/build-windows-native-package.ps1 | 121 ++++++++++++++++++ 3 files changed, 125 insertions(+), 1 deletion(-) diff --git a/packaging/windows/NemoClaw.wixproj b/packaging/windows/NemoClaw.wixproj index 6c551cf21d2..c3e13d79cd5 100644 --- a/packaging/windows/NemoClaw.wixproj +++ b/packaging/windows/NemoClaw.wixproj @@ -19,11 +19,14 @@ + + + diff --git a/packaging/windows/Product.wxs b/packaging/windows/Product.wxs index a24715d026a..2513b832eba 100644 --- a/packaging/windows/Product.wxs +++ b/packaging/windows/Product.wxs @@ -52,7 +52,7 @@ - + diff --git a/scripts/checks/build-windows-native-package.ps1 b/scripts/checks/build-windows-native-package.ps1 index 35ccd2d1aed..41ba9923d38 100644 --- a/scripts/checks/build-windows-native-package.ps1 +++ b/scripts/checks/build-windows-native-package.ps1 @@ -76,6 +76,124 @@ function Assert-Arm64PortableExecutable { } } +function Get-StableWixIdentifier { + param( + [Parameter(Mandatory)][string]$Prefix, + [Parameter(Mandatory)][string]$Value + ) + + $bytes = [Text.Encoding]::UTF8.GetBytes($Value.ToLowerInvariant()) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + $digest = $sha256.ComputeHash($bytes) + } finally { + $sha256.Dispose() + } + $hex = ($digest | ForEach-Object { $_.ToString('x2') }) -join '' + return "$Prefix$($hex.Substring(0, 32))" +} + +function New-GroupedPayloadAuthoring { + param( + [Parameter(Mandatory)][string]$PayloadRoot, + [Parameter(Mandatory)][string]$OutputPath + ) + + $settings = [Xml.XmlWriterSettings]::new() + $settings.Encoding = [Text.UTF8Encoding]::new($false) + $settings.Indent = $true + $settings.NewLineChars = [Environment]::NewLine + $settings.NewLineHandling = [Xml.NewLineHandling]::Replace + $writer = [Xml.XmlWriter]::Create($OutputPath, $settings) + $componentIds = [Collections.Generic.List[string]]::new() + $stats = @{ fileCount = 0 } + $namespace = 'http://wixtoolset.org/schemas/v4/wxs' + $writeDirectory = $null + $writeDirectory = { + param( + [Parameter(Mandatory)][string]$DirectoryPath, + [Parameter(Mandatory)][AllowEmptyString()][string]$RelativeDirectory, + [Parameter(Mandatory)][bool]$Root + ) + + if ($Root) { + $writer.WriteStartElement('DirectoryRef', $namespace) + $writer.WriteAttributeString('Id', 'INSTALLFOLDER') + } else { + $writer.WriteStartElement('Directory', $namespace) + $writer.WriteAttributeString('Id', (Get-StableWixIdentifier -Prefix 'Dir_' -Value $RelativeDirectory)) + $writer.WriteAttributeString('Name', (Split-Path -Leaf $DirectoryPath)) + } + + $files = @(Get-ChildItem -LiteralPath $DirectoryPath -File -Force | Sort-Object Name) + if ($files.Count -gt 0) { + $componentIdentity = if ($Root) { '' } else { $RelativeDirectory } + $componentId = Get-StableWixIdentifier -Prefix 'Cmp_' -Value $componentIdentity + $componentIds.Add($componentId) + $writer.WriteStartElement('Component', $namespace) + $writer.WriteAttributeString('Id', $componentId) + $writer.WriteAttributeString('Guid', '*') + $writer.WriteAttributeString('Bitness', 'always64') + for ($index = 0; $index -lt $files.Count; $index++) { + $file = $files[$index] + $relativeFile = $file.FullName.Substring($PayloadRoot.Length + 1) + $writer.WriteStartElement('File', $namespace) + $writer.WriteAttributeString('Id', (Get-StableWixIdentifier -Prefix 'Fil_' -Value $relativeFile)) + $writer.WriteAttributeString('Name', $file.Name) + $writer.WriteAttributeString('Source', $file.FullName) + if ($index -eq 0) { + $writer.WriteAttributeString('KeyPath', 'yes') + } + $writer.WriteEndElement() + $stats.fileCount++ + } + $writer.WriteEndElement() + } + + foreach ($child in @(Get-ChildItem -LiteralPath $DirectoryPath -Directory -Force | Sort-Object Name)) { + if (($child.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + Fail-WindowsPackageBuild "Payload contains a reparse-point directory: $($child.FullName)" + } + $childRelative = if ([string]::IsNullOrEmpty($RelativeDirectory)) { + $child.Name + } else { + "$RelativeDirectory\$($child.Name)" + } + & $writeDirectory $child.FullName $childRelative $false + } + $writer.WriteEndElement() + } + + try { + $writer.WriteStartDocument() + $writer.WriteStartElement('Wix', $namespace) + $writer.WriteStartElement('Fragment', $namespace) + & $writeDirectory $PayloadRoot '' $true + $writer.WriteEndElement() + $writer.WriteStartElement('Fragment', $namespace) + $writer.WriteStartElement('ComponentGroup', $namespace) + $writer.WriteAttributeString('Id', 'PayloadComponents') + foreach ($componentId in $componentIds) { + $writer.WriteStartElement('ComponentRef', $namespace) + $writer.WriteAttributeString('Id', $componentId) + $writer.WriteEndElement() + } + $writer.WriteEndElement() + $writer.WriteEndElement() + $writer.WriteEndElement() + $writer.WriteEndDocument() + } finally { + $writer.Dispose() + } + if ($stats.fileCount -eq 0) { + Fail-WindowsPackageBuild 'Grouped payload authoring did not contain any files.' + } + if ($componentIds.Count -ge 65536) { + Fail-WindowsPackageBuild "Grouped payload still exceeds the MSI component limit: $($componentIds.Count)." + } + Write-Host "Grouped WiX payload authoring: files=$($stats.fileCount) components=$($componentIds.Count)" +} + if ($ProductVersion -cnotmatch '^[0-9]{1,3}\.[0-9]{1,5}\.[0-9]{1,5}$') { Fail-WindowsPackageBuild 'ProductVersion must be a strict three-part MSI version.' } @@ -201,6 +319,8 @@ $bootstrapperOutput = Join-Path $intermediate 'bootstrapper\publish' $bootstrapperPath = Join-Path $bootstrapperOutput 'NemoClaw.Bootstrapper.exe' $bootstrapperSha256 = $null $bootstrapperAuthenticodeStatus = $null +$payloadAuthoring = Join-Path $intermediate 'GroupedPayload.wxs' +New-GroupedPayloadAuthoring -PayloadRoot $payload -OutputPath $payloadAuthoring Push-Location $wixRoot try { $dotnetVersion = (& dotnet --version).Trim() @@ -216,6 +336,7 @@ try { "-p:SourceRoot=$sourceRoot", "-p:PackageOutputRoot=$output", "-p:PackageIntermediateRoot=$intermediate", + "-p:GeneratedPayloadAuthoring=$payloadAuthoring", "-p:RestorePackagesPath=$restorePackages", '-p:ContinuousIntegrationBuild=true', '-p:RestoreIgnoreFailedSources=false' From 484974973e35fa33568c9e6432b1805c356390c2 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 3 Sep 2026 10:28:19 -0700 Subject: [PATCH 090/144] feat(windows): launch every configured native agent --- packaging/windows/NATIVE-PREVIEW.txt | 30 +- packaging/windows/NemoClaw.wixproj | 1 + packaging/windows/README.md | 12 +- packaging/windows/agent-support.json | 8 +- .../windows/bootstrapper/MainWindow.xaml | 8 +- .../windows/bootstrapper/MainWindow.xaml.cs | 21 + packaging/windows/launcher/src/main.rs | 186 +++++- packaging/windows/onboarding/app.ts | 126 +++- packaging/windows/onboarding/index.html | 84 ++- packaging/windows/onboarding/styles.css | 101 +++- .../runtime/nemocua/run_with_harness.py | 45 +- .../run-installed-native-console-agent.mts | 551 ++++++++++++++++++ .../runtime/run-installed-native-nemocua.mts | 152 ++++- .../runtime/run-installed-native-pi.mts | 3 - .../runtime/run-installed-native-web-ui.mts | 538 +++++++++++++++-- .../checks/build-windows-native-package.ps1 | 1 + .../create-windows-native-proof-video.ps1 | 17 +- ...prepare-windows-native-package-payload.ps1 | 11 + ...n-windows-native-package-qualification.ps1 | 82 ++- 19 files changed, 1852 insertions(+), 125 deletions(-) create mode 100644 packaging/windows/runtime/run-installed-native-console-agent.mts diff --git a/packaging/windows/NATIVE-PREVIEW.txt b/packaging/windows/NATIVE-PREVIEW.txt index 1762c500c28..e58e187c2f4 100644 --- a/packaging/windows/NATIVE-PREVIEW.txt +++ b/packaging/windows/NATIVE-PREVIEW.txt @@ -19,12 +19,24 @@ workload result, then deletes the sandbox through OpenShell. The NVIDIA-branded setup installs a native ARM64 NemoClaw GUI launcher and a graphical first-run experience. The interface names OpenClaw, Hermes Agent, LangChain Deep Agents Code, Pi, and NemoCUA, with Pi and NemoCUA explicitly -marked experimental. Only OpenClaw activation is qualified in this package -slice; other selections fail closed until their exact native payloads pass. - -CI drives three deterministic turns through the real OpenClaw Control UI and -MXC-contained gateway. The deterministic loopback model proves transport and -runtime wiring without a pull-request secret; it is not production inference. +marked experimental. Every enabled choice has an agent-specific executable +adapter: OpenClaw opens its authentic Control UI, Hermes Agent, Deep Agents +Code, and Pi open their native terminal surfaces, and NemoCUA runs its bounded +experimental visible-browser implementation. A choice is not a production +support claim until its exact package qualification passes. + +CI separately drives three deterministic turns through each real agent runtime +inside native MXC. OpenClaw uses its Control UI; terminal agents use their +genuine non-interactive CLI modes; NemoCUA observes and acts on real Edge +pixels. The deterministic loopback model proves transport and runtime wiring +without a pull-request secret; it is not production inference quality. + +Graphical onboarding collects the selected provider endpoint and model. +Provider API keys are written by the native ARM64 launcher to Windows +Credential Manager and are not stored in MSI-owned files or JSON. Agent state +and secret-free configuration live below the current user's Local AppData. +Runtime processes receive only an authenticated, ephemeral loopback broker +route; the provider credential remains on the Windows host. The setup executable applies Microsoft MXC's elevated prepare-system-drive and prepare-null-device host prerequisites before installing the MSI. Windows @@ -33,9 +45,9 @@ ownership remains deferred. This is a qualification candidate, not a production support claim. Native MXC execution remains limited by the Windows host build and capabilities. Gateway -service registration, supported onboarding, managed inference, local inference, -and production activation remain incomplete. Mutable runtime state must remain -outside this MSI-owned installation directory. +service registration, messaging, web-search integration, managed local-model +lifecycle, and production activation remain incomplete. Mutable runtime state +stays outside this MSI-owned installation directory. PR qualification artifacts are unsigned. Production use remains gated on Authenticode signing of the NemoClaw, OpenClaw, OpenShell, MXC, MSI, and setup diff --git a/packaging/windows/NemoClaw.wixproj b/packaging/windows/NemoClaw.wixproj index c3e13d79cd5..6e26190a50d 100644 --- a/packaging/windows/NemoClaw.wixproj +++ b/packaging/windows/NemoClaw.wixproj @@ -45,6 +45,7 @@ + diff --git a/packaging/windows/README.md b/packaging/windows/README.md index 7cd09941b7e..c6f1a7c7f0b 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -53,6 +53,11 @@ an elapsed timer and recovery log path, and launches NemoClaw after a successful interactive install. The MSI remains standard WiX authoring with no custom actions. Setup installs a native ARM64 `NemoClaw.exe` GUI launcher; launching it opens the local graphical onboarder without PowerShell or a visible console. +The onboarder stores secret-free provider/model configuration below the +current user's Local AppData and sends API keys over its loopback-only request +to the native launcher, which writes them to Windows Credential Manager. Agent +processes receive an ephemeral authenticated loopback broker instead of the +provider credential. The onboarder presents real native candidates for OpenClaw, Hermes Agent, LangChain Deep Agents Code, Pi, and NemoCUA. Pi and NemoCUA are explicitly experimental. Each enabled choice passes through graphical selection and then @@ -71,6 +76,7 @@ without exposing a PR credential; it is evidence for UI/runtime/model-transport wiring, not production inference quality. The workflow always attempts to upload raw actual-window recordings so failed UI runs retain visual diagnostics. -The package is a preview distribution boundary. Host qualification, -credential-backed onboarding parity, managed inference, service registration, -production activation, and production signing remain separate gates. +The package is a preview distribution boundary. Host qualification, managed +local-model lifecycle, gateway service registration, messaging and web-search +integration, production activation, and production signing remain separate +gates. diff --git a/packaging/windows/agent-support.json b/packaging/windows/agent-support.json index a61810b2502..af6a45a422e 100644 --- a/packaging/windows/agent-support.json +++ b/packaging/windows/agent-support.json @@ -11,17 +11,17 @@ "interface": "OpenClaw Control UI", "status": "candidate", "selectable": true, - "limitation": "Requires a passing exact-head three-turn native MXC qualification before the candidate is proven." + "limitation": "Candidate Control UI uses a host-side credential broker and requires two consecutive exact-head native MXC qualifications before it is called proven." }, { "id": "hermes", "displayName": "Hermes Agent", "version": "0.19.0", "source": "https://github.com/NousResearch/hermes-agent/tree/3ef6bbd201263d354fd83ec55b3c306ded2eb72a", - "interface": "Hermes native terminal or desktop UI", + "interface": "Hermes native terminal UI", "status": "candidate", "selectable": true, - "limitation": "The native terminal candidate omits the unqualified cryptography 46.0.7 and pywinpty 2.0.15 integrations; those optional surfaces are not claimed." + "limitation": "The native terminal candidate omits unqualified cryptography 46.0.7 and pywinpty 2.0.15 integrations; optional encrypted integration and PTY surfaces are not claimed." }, { "id": "langchain-deepagents-code", @@ -53,7 +53,7 @@ "interface": "NemoCUA computer-use runtime", "status": "experimental-candidate", "selectable": true, - "limitation": "Windows-native experimental adapter for the repository's run_with_harness.py terminal contract; it does not claim parity with private scenario-owned images." + "limitation": "Windows-native bounded visible-browser adapter for the repository's run_with_harness.py contract; it is experimental and does not claim parity with private scenario-owned images." } ] } diff --git a/packaging/windows/bootstrapper/MainWindow.xaml b/packaging/windows/bootstrapper/MainWindow.xaml index 82babaea502..888251faff3 100644 --- a/packaging/windows/bootstrapper/MainWindow.xaml +++ b/packaging/windows/bootstrapper/MainWindow.xaml @@ -151,22 +151,22 @@ - + - + - + - + diff --git a/packaging/windows/bootstrapper/MainWindow.xaml.cs b/packaging/windows/bootstrapper/MainWindow.xaml.cs index 4cdb9aac318..62f173b3bd8 100644 --- a/packaging/windows/bootstrapper/MainWindow.xaml.cs +++ b/packaging/windows/bootstrapper/MainWindow.xaml.cs @@ -40,6 +40,7 @@ public MainWindow() public void ShowReady(bool installed) { + this.SetJourneyStage(installed ? 4 : 1); this.HidePanels(); if (installed) { @@ -59,6 +60,7 @@ public void ShowMaintenance() public void ShowProgress(string title, string detail, int percentage) { + this.SetJourneyStage(percentage >= 60 ? 3 : 2); this.HidePanels(); this.ProgressPanel.Visibility = Visibility.Visible; this.ProgressTitle.Text = title; @@ -74,6 +76,7 @@ public void ShowProgress(string title, string detail, int percentage) public void ShowSuccess(LaunchAction action) { + this.SetJourneyStage(4); this.StopElapsed(); this.HidePanels(); this.SuccessPanel.Visibility = Visibility.Visible; @@ -124,6 +127,24 @@ private void HidePanels() this.MaintenancePanel.Visibility = Visibility.Collapsed; } + private void SetJourneyStage(int stage) + { + var inactive = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0x62, 0x65, 0x5F)); + var active = (System.Windows.Media.Brush)this.FindResource("NvidiaGreen"); + var dots = new[] { this.ChooseStageDot, this.ProtectStageDot, this.InstallStageDot, this.LaunchStageDot }; + for (var index = 0; index < dots.Length; index++) + { + var reached = index < stage; + dots[index].Background = reached ? active : System.Windows.Media.Brushes.Transparent; + dots[index].BorderBrush = reached ? active : inactive; + if (dots[index].Child is TextBlock number) + { + number.Foreground = reached ? System.Windows.Media.Brushes.White : new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0xB9, 0xBB, 0xB7)); + number.FontWeight = reached ? FontWeights.Bold : FontWeights.Normal; + } + } + } + private void LicenseChanged(object sender, RoutedEventArgs args) { this.InstallButton.IsEnabled = this.LicenseCheck.IsChecked == true; diff --git a/packaging/windows/launcher/src/main.rs b/packaging/windows/launcher/src/main.rs index 5798517334b..5136e8f87af 100644 --- a/packaging/windows/launcher/src/main.rs +++ b/packaging/windows/launcher/src/main.rs @@ -8,20 +8,62 @@ compile_error!("The NemoClaw launcher is Windows-only."); use std::env; use std::ffi::OsStr; +use std::ffi::c_void; +use std::io::{Read, Write}; use std::iter; use std::os::windows::ffi::OsStrExt; use std::os::windows::process::CommandExt; use std::path::PathBuf; use std::process::{Command, exit}; +use std::ptr; const CREATE_NO_WINDOW: u32 = 0x0800_0000; const DETACHED_PROCESS: u32 = 0x0000_0008; +const CREATE_NEW_CONSOLE: u32 = 0x0000_0010; +const CRED_TYPE_GENERIC: u32 = 1; +const CRED_PERSIST_LOCAL_MACHINE: u32 = 2; +const MAX_CREDENTIAL_BYTES: usize = 2048; + +#[repr(C)] +struct FileTime { + low_date_time: u32, + high_date_time: u32, +} + +#[repr(C)] +struct CredentialW { + flags: u32, + credential_type: u32, + target_name: *mut u16, + comment: *mut u16, + last_written: FileTime, + credential_blob_size: u32, + credential_blob: *mut u8, + persist: u32, + attribute_count: u32, + attributes: *mut c_void, + target_alias: *mut u16, + user_name: *mut u16, +} #[link(name = "user32")] unsafe extern "system" { fn MessageBoxW(window: isize, text: *const u16, caption: *const u16, kind: u32) -> i32; } +#[link(name = "advapi32")] +unsafe extern "system" { + fn CredWriteW(credential: *const CredentialW, flags: u32) -> i32; + fn CredReadW( + target: *const u16, + credential_type: u32, + flags: u32, + credential: *mut *mut CredentialW, + ) -> i32; + fn CredDeleteW(target: *const u16, credential_type: u32, flags: u32) -> i32; + fn CredFree(buffer: *mut c_void); +} + fn wide(value: &str) -> Vec { OsStr::new(value) .encode_wide() @@ -38,6 +80,85 @@ fn fail(message: &str) -> ! { exit(1); } +fn credential_target(provider: &str) -> Option<&'static str> { + match provider { + "nvidia" => Some("NVIDIA/NemoClaw/inference/nvidia"), + "openrouter" => Some("NVIDIA/NemoClaw/inference/openrouter"), + "compatible" => Some("NVIDIA/NemoClaw/inference/compatible"), + "local" => Some("NVIDIA/NemoClaw/inference/local"), + _ => None, + } +} + +fn credential_error(message: &str) -> ! { + let _ = writeln!(std::io::stderr(), "{message}"); + exit(2); +} + +fn credential_write(provider: &str) { + let target = credential_target(provider) + .unwrap_or_else(|| credential_error("The credential provider is invalid.")); + let mut secret = Vec::new(); + std::io::stdin() + .take((MAX_CREDENTIAL_BYTES + 1) as u64) + .read_to_end(&mut secret) + .unwrap_or_else(|_| credential_error("The credential could not be read.")); + if secret.is_empty() || secret.len() > MAX_CREDENTIAL_BYTES || secret.contains(&0) { + credential_error("The credential length is invalid."); + } + let mut target_wide = wide(target); + let mut username = wide("NemoClaw inference"); + let credential = CredentialW { + flags: 0, + credential_type: CRED_TYPE_GENERIC, + target_name: target_wide.as_mut_ptr(), + comment: ptr::null_mut(), + last_written: FileTime { + low_date_time: 0, + high_date_time: 0, + }, + credential_blob_size: secret.len() as u32, + credential_blob: secret.as_mut_ptr(), + persist: CRED_PERSIST_LOCAL_MACHINE, + attribute_count: 0, + attributes: ptr::null_mut(), + target_alias: ptr::null_mut(), + user_name: username.as_mut_ptr(), + }; + let written = unsafe { CredWriteW(&credential, 0) }; + secret.fill(0); + if written == 0 { + credential_error("Windows Credential Manager rejected the credential."); + } +} + +fn credential_read(provider: &str) { + let target = credential_target(provider) + .unwrap_or_else(|| credential_error("The credential provider is invalid.")); + let target_wide = wide(target); + let mut credential = ptr::null_mut(); + let found = unsafe { CredReadW(target_wide.as_ptr(), CRED_TYPE_GENERIC, 0, &mut credential) }; + if found == 0 || credential.is_null() { + credential_error("No credential is stored for this provider."); + } + let bytes = unsafe { + let value = &*credential; + std::slice::from_raw_parts(value.credential_blob, value.credential_blob_size as usize) + }; + let write_result = std::io::stdout().write_all(bytes); + unsafe { CredFree(credential.cast()) }; + if write_result.is_err() { + credential_error("The credential could not be returned."); + } +} + +fn credential_delete(provider: &str) { + let target = credential_target(provider) + .unwrap_or_else(|| credential_error("The credential provider is invalid.")); + let target_wide = wide(target); + let _ = unsafe { CredDeleteW(target_wide.as_ptr(), CRED_TYPE_GENERIC, 0) }; +} + fn main() { let executable = env::current_exe().unwrap_or_else(|_| fail("The launcher path is unavailable.")); @@ -50,16 +171,67 @@ fn main() { .map(PathBuf::from) .unwrap_or_else(|| fail("The NemoClaw installation directory is unavailable.")); let node = bin.join("node.exe"); - let entry = install - .join("qualification") - .join("run-installed-native-web-ui.mts"); + let mut forwarded = env::args_os().skip(1).collect::>(); + if forwarded + .first() + .is_some_and(|value| value == "--credential-write") + { + let provider = forwarded + .get(1) + .and_then(|value| value.to_str()) + .unwrap_or_else(|| credential_error("A credential provider is required.")); + credential_write(provider); + return; + } + if forwarded + .first() + .is_some_and(|value| value == "--credential-read") + { + let provider = forwarded + .get(1) + .and_then(|value| value.to_str()) + .unwrap_or_else(|| credential_error("A credential provider is required.")); + credential_read(provider); + return; + } + if forwarded + .first() + .is_some_and(|value| value == "--credential-delete") + { + let provider = forwarded + .get(1) + .and_then(|value| value.to_str()) + .unwrap_or_else(|| credential_error("A credential provider is required.")); + credential_delete(provider); + return; + } + let new_console = forwarded.first().is_some_and(|value| value == "--console"); + if new_console { + forwarded.remove(0); + } + let configured = forwarded.iter().any(|value| value == "--configured"); + let configured_nemocua = configured + && forwarded + .windows(2) + .any(|values| values[0] == "--agent" && values[1] == "nemocua"); + let configured_openclaw = configured + && forwarded + .windows(2) + .any(|values| values[0] == "--agent" && values[1] == "openclaw"); + let entry = install.join("qualification").join(if configured_nemocua { + "run-installed-native-nemocua.mts" + } else if configured_openclaw { + "run-installed-native-web-ui.mts" + } else if new_console { + "run-installed-native-console-agent.mts" + } else { + "run-installed-native-web-ui.mts" + }); if !node.is_file() || !entry.is_file() { fail( "The installed NemoClaw runtime is incomplete. Run Repair from Apps > Installed apps.", ); } - - let mut forwarded = env::args_os().skip(1).collect::>(); let wait = forwarded.first().is_some_and(|value| value == "--wait"); if wait { forwarded.remove(0); @@ -72,7 +244,9 @@ fn main() { .args(forwarded) .current_dir(&install) .env("NEMOCLAW_NATIVE_INSTALL_ROOT", &install); - command.creation_flags(if wait { + command.creation_flags(if new_console { + CREATE_NEW_CONSOLE + } else if wait { CREATE_NO_WINDOW } else { CREATE_NO_WINDOW | DETACHED_PROCESS diff --git a/packaging/windows/onboarding/app.ts b/packaging/windows/onboarding/app.ts index 6661a6067c5..7bb9bca3345 100644 --- a/packaging/windows/onboarding/app.ts +++ b/packaging/windows/onboarding/app.ts @@ -13,10 +13,52 @@ const inferenceNames = { nvidia: "NVIDIA hosted inference", openrouter: "OpenRouter", compatible: "Compatible endpoint", - local: "Local NVIDIA GPU", + local: "Local compatible endpoint (experimental)", + qualification: "Deterministic local qualification", }; -const state = { step: 1, agent: "openclaw", inference: "nvidia" }; +const providerDefaults = { + nvidia: { + endpoint: "https://integrate.api.nvidia.com/v1", + model: "nvidia/nemotron-3-super-120b-a12b", + credentialLabel: "NVIDIA API key", + credentialPlaceholder: "nvapi-…", + endpointHelp: "The reviewed NVIDIA API endpoint.", + credentialRequired: true, + }, + openrouter: { + endpoint: "https://openrouter.ai/api/v1", + model: "nvidia/nemotron-3-super-120b-a12b", + credentialLabel: "OpenRouter API key", + credentialPlaceholder: "sk-or-…", + endpointHelp: "NemoClaw identifies itself to OpenRouter on every request.", + credentialRequired: true, + }, + compatible: { + endpoint: "https://", + model: "", + credentialLabel: "API key", + credentialPlaceholder: "Provider credential", + endpointHelp: "Enter the HTTPS base URL ending at the provider's v1 API root.", + credentialRequired: false, + }, + local: { + endpoint: "http://127.0.0.1:8000/v1", + model: "", + credentialLabel: "Bearer token (optional)", + credentialPlaceholder: "Leave blank when the local endpoint has no auth", + endpointHelp: "Connects to an already-running native OpenAI-compatible endpoint.", + credentialRequired: false, + }, +}; + +const qualification = new URLSearchParams(window.location.search).get("qualification") === "1"; +const requestedAgent = new URLSearchParams(window.location.search).get("agent"); +const state = { + step: 1, + agent: Object.hasOwn(agentNames, requestedAgent) ? requestedAgent : "openclaw", + inference: qualification ? "qualification" : "nvidia", +}; const panels = [...document.querySelectorAll("[data-step]")]; const steps = [...document.querySelectorAll("[data-step-target]")]; const next = document.querySelector("#next"); @@ -24,6 +66,74 @@ const back = document.querySelector("#back"); const launch = document.querySelector("#launch"); const form = document.querySelector("#onboarding-form"); const error = document.querySelector("#submit-error"); +const inferenceError = document.querySelector("#inference-error"); +const endpoint = document.querySelector("#endpoint"); +const model = document.querySelector("#model"); +const credential = document.querySelector("#credential"); +const credentialField = document.querySelector("#credential-field"); +const providerHeading = document.querySelector("#provider-heading"); +const credentialLabel = document.querySelector("#credential-label"); +const endpointHelp = document.querySelector("#endpoint-help"); + +function renderProvider() { + const config = providerDefaults[state.inference]; + document.querySelector("#qualification-note").hidden = !qualification; + document.querySelector("#inference-configuration").hidden = qualification; + document.querySelector(".choice-grid").hidden = qualification; + if (qualification) return; + providerHeading.textContent = inferenceNames[state.inference]; + endpoint.value = config.endpoint; + endpoint.readOnly = ["nvidia", "openrouter"].includes(state.inference); + model.value = config.model; + credential.value = ""; + credentialLabel.textContent = config.credentialLabel; + credential.placeholder = config.credentialPlaceholder; + credential.required = config.credentialRequired; + endpointHelp.textContent = config.endpointHelp; + credentialField.querySelector("small").textContent = config.credentialRequired + ? "Stored for your Windows account in Credential Manager; never in MSI logs." + : "Optional. If supplied, Windows Credential Manager protects it for this account."; + inferenceError.hidden = true; +} + +function validateInference() { + if (qualification) return true; + inferenceError.hidden = true; + let parsed; + try { + parsed = new URL(endpoint.value.trim()); + } catch { + inferenceError.textContent = "Enter a complete inference endpoint URL."; + inferenceError.hidden = false; + endpoint.focus(); + return false; + } + if ( + (state.inference === "local" && parsed.protocol !== "http:" && parsed.protocol !== "https:") || + (state.inference !== "local" && parsed.protocol !== "https:") + ) { + inferenceError.textContent = + state.inference === "local" + ? "The local endpoint must use HTTP or HTTPS." + : "Hosted inference endpoints must use HTTPS."; + inferenceError.hidden = false; + endpoint.focus(); + return false; + } + if (!model.value.trim()) { + inferenceError.textContent = "Enter the exact model ID served by this endpoint."; + inferenceError.hidden = false; + model.focus(); + return false; + } + if (providerDefaults[state.inference].credentialRequired && !credential.value.trim()) { + inferenceError.textContent = `${providerDefaults[state.inference].credentialLabel} is required.`; + inferenceError.hidden = false; + credential.focus(); + return false; + } + return true; +} function render() { for (const panel of panels) @@ -35,6 +145,14 @@ function render() { launch.hidden = state.step !== 4; document.querySelector("#review-agent").textContent = agentNames[state.agent]; document.querySelector("#review-inference").textContent = inferenceNames[state.inference]; + document.querySelector("#review-model").textContent = qualification + ? "native-preview" + : model.value.trim() || "Not selected"; + document.querySelector("#review-credential").textContent = qualification + ? "No credential used" + : credential.value.trim() + ? "Windows Credential Manager" + : "No credential required"; document.querySelector("#experimental-notice").hidden = !["pi", "nemocua"].includes(state.agent); } @@ -58,10 +176,12 @@ document.querySelectorAll("[data-inference]").forEach((choice) => { choice.addEventListener("click", () => { state.inference = choice.dataset.inference; select("[data-inference]", "inference", state.inference); + renderProvider(); }); }); next.addEventListener("click", () => { + if (state.step === 2 && !validateInference()) return; state.step = Math.min(4, state.step + 1); render(); }); @@ -95,4 +215,6 @@ form.addEventListener("submit", async (event) => { } }); +renderProvider(); +select("[data-agent]", "agent", state.agent); render(); diff --git a/packaging/windows/onboarding/index.html b/packaging/windows/onboarding/index.html index 5eba2552d72..7779286cbcc 100644 --- a/packaging/windows/onboarding/index.html +++ b/packaging/windows/onboarding/index.html @@ -144,6 +144,11 @@

Connect the model you trust.

Credentials are requested only after the selected endpoint passes validation and are never written to Windows Installer logs.

+
+
+
+
+

Connection details

+

NVIDIA hosted inference

+
+ Protected by Windows +
+
+ + + +
+ +
@@ -198,25 +251,26 @@

Begin simple. Add integrations when you need them.

>Open the agent when setup finishesLaunch the selected agent surface immediately. +
@@ -237,6 +291,14 @@

Ready to launch NemoClaw.

Inference
NVIDIA hosted inference
+
+
Model
+
nvidia/nemotron-3-super-120b-a12b
+
+
+
Credential
+
Windows Credential Manager
+
Isolation
OpenShell + Microsoft MXC ProcessContainer
diff --git a/packaging/windows/onboarding/styles.css b/packaging/windows/onboarding/styles.css index a79a047f2ef..7ed0a78ab05 100644 --- a/packaging/windows/onboarding/styles.css +++ b/packaging/windows/onboarding/styles.css @@ -21,7 +21,8 @@ body { background: linear-gradient(145deg, #f7f7f7, #ececec); } button, -input { +input, +select { font: inherit; } button { @@ -288,6 +289,101 @@ h1 { box-shadow: 0 0 0 2px rgba(118, 185, 0, 0.14); } +.qualification-note, +.configuration-card { + max-width: 860px; + margin-bottom: 18px; +} +.qualification-note { + padding: 14px 16px; + border: 1px solid #c9ddaa; + border-radius: 9px; + background: #f4f9ec; + color: #385800; + line-height: 1.45; +} +.qualification-note strong { + display: block; + margin-bottom: 3px; +} +.configuration-card { + margin-top: 18px; + padding: 22px; + border: 1px solid var(--line); + border-radius: 11px; + background: linear-gradient(145deg, #fff, #fafbf8); +} +.configuration-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-bottom: 18px; +} +.configuration-heading .eyebrow { + margin-bottom: 4px; +} +.configuration-heading h2 { + margin: 0; + font-size: 20px; + letter-spacing: -0.02em; +} +.secure-chip { + padding: 6px 10px; + border: 1px solid #cbdab5; + border-radius: 20px; + background: #f3f8eb; + color: var(--green-dark); + font-size: 11px; + font-weight: 700; +} +.field-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} +.field { + display: flex; + min-width: 0; + flex-direction: column; + gap: 7px; + color: #343434; + font-size: 13px; + font-weight: 650; +} +.field.span-two { + grid-column: 1 / -1; +} +.field input { + width: 100%; + height: 43px; + padding: 0 12px; + border: 1px solid #bcbfba; + border-radius: 7px; + background: #fff; + color: var(--ink); + outline: none; +} +.field input:focus { + border-color: var(--green); + box-shadow: 0 0 0 3px rgba(118, 185, 0, 0.14); +} +.field input:disabled { + background: #f1f1f1; + color: #777; +} +.field small { + color: var(--muted); + font-size: 11px; + font-weight: 400; + line-height: 1.4; +} +.field-error { + margin: 14px 0 0; + color: #8a2222; + font-size: 13px; +} + .toggle-list { max-width: 820px; border: 1px solid var(--line); @@ -402,7 +498,8 @@ h1 { min-height: calc(100vh - 28px); } .agent-grid, - .choice-grid { + .choice-grid, + .field-grid { grid-template-columns: 1fr; } } diff --git a/packaging/windows/runtime/nemocua/run_with_harness.py b/packaging/windows/runtime/nemocua/run_with_harness.py index bb1e94bd61d..59db2350cba 100644 --- a/packaging/windows/runtime/nemocua/run_with_harness.py +++ b/packaging/windows/runtime/nemocua/run_with_harness.py @@ -38,12 +38,18 @@ class HarnessError(RuntimeError): """Raised when the browser, model, or action receipt violates the contract.""" -def request_json(url: str, *, method: str = "GET", payload: Any = None) -> dict[str, Any]: +def request_json( + url: str, + *, + token: str, + method: str = "GET", + payload: Any = None, +) -> dict[str, Any]: body = None if payload is None else json.dumps(payload).encode("utf-8") request = Request( url, data=body, - headers={"content-type": "application/json"}, + headers={"authorization": f"Bearer {token}", "content-type": "application/json"}, method=method, ) try: @@ -69,7 +75,12 @@ def validated_bridge_url(value: str) -> str: return value -def model_action(bridge: str, task: str, observation: dict[str, Any]) -> dict[str, Any]: +def model_action( + bridge: str, + token: str, + task: str, + observation: dict[str, Any], +) -> dict[str, Any]: prompt = { "task": task, "observation": { @@ -82,6 +93,7 @@ def model_action(bridge: str, task: str, observation: dict[str, Any]) -> dict[st } response = request_json( f"{bridge}/v1/chat/completions", + token=token, method="POST", payload={ "model": "nemocua-native-preview", @@ -111,19 +123,26 @@ def verify_postcondition(name: str, observation: dict[str, Any]) -> None: raise HarnessError("NemoCUA did not complete the real browser task") -def qualify(bridge_url: str, result_path: Path) -> int: +def run(bridge_url: str, bridge_token: str, result_path: Path) -> int: bridge = validated_bridge_url(bridge_url) + if not bridge_token or len(bridge_token) > 256 or any(ch.isspace() for ch in bridge_token): + raise HarnessError("NemoCUA bridge token is invalid") turns: list[dict[str, Any]] = [] for index, (task, postcondition, token) in enumerate(TURN_TASKS, start=1): - observation = request_json(f"{bridge}/observe") + observation = request_json(f"{bridge}/observe", token=bridge_token) screenshot_hash = observation.get("screenshotSha256") if not isinstance(screenshot_hash, str) or len(screenshot_hash) != 64: raise HarnessError("NemoCUA observation lacks screenshot evidence") - action = model_action(bridge, task, observation) - action_receipt = request_json(f"{bridge}/act", method="POST", payload=action) + action = model_action(bridge, bridge_token, task, observation) + action_receipt = request_json( + f"{bridge}/act", + token=bridge_token, + method="POST", + payload=action, + ) if action_receipt.get("applied") is not True: raise HarnessError("NemoCUA browser action was not applied") - after = request_json(f"{bridge}/observe") + after = request_json(f"{bridge}/observe", token=bridge_token) verify_postcondition(postcondition, after) print(f"NEMOCUA> TURN {index} PASS {token}", flush=True) turns.append( @@ -156,7 +175,9 @@ def parser() -> argparse.ArgumentParser: result = argparse.ArgumentParser(prog="run_with_harness.py") result.add_argument("--version", action="store_true") result.add_argument("--qualification", action="store_true") + result.add_argument("--configured", action="store_true") result.add_argument("--bridge-url") + result.add_argument("--bridge-token") result.add_argument("--result-path", type=Path) return result @@ -166,9 +187,11 @@ def main() -> int: if args.version: print(VERSION) return 0 - if not args.qualification or not args.bridge_url or args.result_path is None: - parser().error("--qualification, --bridge-url, and --result-path are required") - return qualify(args.bridge_url, args.result_path) + if args.qualification == args.configured: + parser().error("select exactly one of --qualification or --configured") + if not args.bridge_url or not args.bridge_token or args.result_path is None: + parser().error("--bridge-url, --bridge-token, and --result-path are required") + return run(args.bridge_url, args.bridge_token, args.result_path) if __name__ == "__main__": diff --git a/packaging/windows/runtime/run-installed-native-console-agent.mts b/packaging/windows/runtime/run-installed-native-console-agent.mts new file mode 100644 index 00000000000..0b710437738 --- /dev/null +++ b/packaging/windows/runtime/run-installed-native-console-agent.mts @@ -0,0 +1,551 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import fs from "node:fs"; +import { createServer } from "node:http"; +import path from "node:path"; + +import { + allowlistedWindowsEnvironment, + argumentValue, + freePort, + jsonContainsExactValue, + quoteYamlPath, + removeDirectory, + requiredDirectory, + requiredFile, + run, + sanitizedDiagnostic, + stopChild, + waitForFileText, + waitForPort, +} from "./run-installed-native-turn.mts"; + +const AGENT_ADAPTERS = { + pi: { displayName: "Pi", runtimeDirectory: "pi" }, + hermes: { displayName: "Hermes Agent", runtimeDirectory: "hermes" }, + "langchain-deepagents-code": { + displayName: "Deep Agents Code", + runtimeDirectory: "deepagents", + }, +}; + +function fail(message) { + throw new Error(`NemoClaw native terminal launch failed: ${message}`); +} + +function readConfiguration(agentId) { + const localAppData = requiredDirectory( + process.env.LOCALAPPDATA ?? "", + "Windows local application-data directory", + ); + const stateRoot = path.join(localAppData, "NVIDIA", "NemoClaw", "agents", agentId); + const configPath = requiredFile( + path.join(stateRoot, "native-windows.json"), + `${AGENT_ADAPTERS[agentId].displayName} configuration`, + ); + const config = JSON.parse(fs.readFileSync(configPath, "utf8")); + if ( + config?.schemaVersion !== 1 || + config?.classification !== "nemoclaw-native-windows-agent-configuration" || + config?.agent !== agentId || + !["nvidia", "openrouter", "compatible", "local"].includes(config?.inference) || + typeof config?.endpoint !== "string" || + typeof config?.model !== "string" || + typeof config?.credentialStored !== "boolean" + ) + fail("the graphical onboarding configuration is incomplete"); + return { config, stateRoot }; +} + +async function readWindowsCredential(launcher, provider, required) { + if (!required) return ""; + const result = await new Promise((resolve, reject) => { + const child = spawn(launcher, ["--credential-read", provider], { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + const chunks = []; + let stderr = ""; + let size = 0; + child.stdout.on("data", (chunk) => { + size += chunk.length; + if (size <= 2048) chunks.push(chunk); + }); + child.stderr.on("data", (chunk) => { + stderr = `${stderr}${chunk.toString("utf8")}`.slice(-2048); + }); + child.once("error", reject); + child.once("close", (code) => resolve({ code: code ?? 1, secret: Buffer.concat(chunks) })); + }); + if (result.code !== 0 || !result.secret.length || result.secret.length > 2048) + fail("Windows Credential Manager does not contain the selected provider credential"); + return result.secret.toString("utf8"); +} + +async function readRequest(request) { + const chunks = []; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > 16 * 1024 * 1024) fail("the agent request exceeded the broker limit"); + chunks.push(chunk); + } + return Buffer.concat(chunks); +} + +async function startInferenceBroker(configuration, credential, brokerToken) { + const endpoint = new URL(`${configuration.endpoint.replace(/\/$/u, "")}/`); + const server = createServer(async (request, response) => { + try { + if (!request.url?.startsWith("/v1/")) { + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: { message: "not found" } })); + return; + } + if (request.headers.authorization !== `Bearer ${brokerToken}`) { + response.writeHead(401, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: { message: "unauthorized" } })); + return; + } + const upstreamPath = request.url.slice("/v1/".length); + const upstreamUrl = new URL(upstreamPath, endpoint); + const body = + request.method === "GET" || request.method === "HEAD" + ? undefined + : await readRequest(request); + const headers = { accept: request.headers.accept ?? "application/json" }; + if (request.headers["content-type"]) + headers["content-type"] = request.headers["content-type"]; + if (credential) headers.authorization = `Bearer ${credential}`; + if (configuration.inference === "openrouter") { + headers["http-referer"] = "https://www.nvidia.com/nemoclaw/"; + headers["x-openrouter-title"] = "NVIDIA NemoClaw"; + } + const upstream = await fetch(upstreamUrl, { + method: request.method, + headers, + body, + redirect: "error", + signal: AbortSignal.timeout(180_000), + }); + const responseBody = Buffer.from(await upstream.arrayBuffer()); + if (responseBody.length > 32 * 1024 * 1024) + fail("the provider response exceeded the broker limit"); + const responseHeaders = { + "cache-control": "no-store", + "content-type": upstream.headers.get("content-type") ?? "application/json", + }; + response.writeHead(upstream.status, responseHeaders); + response.end(responseBody); + } catch (error) { + response.writeHead(502, { "content-type": "application/json" }); + response.end( + JSON.stringify({ + error: { + message: error instanceof Error ? error.message : "inference provider request failed", + }, + }), + ); + } + }); + const port = await freePort(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", resolve); + }); + return { server, port }; +} + +function interactiveWorkloadSource() { + return String.raw`import { spawn } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const required = (name) => { + const value = process.env[name]; + if (!value) throw new Error(name + " is required"); + return value; +}; +const agent = required("NEMOCLAW_AGENT_ID"); +const home = required("NEMOCLAW_AGENT_HOME"); +const model = required("NEMOCLAW_AGENT_MODEL"); +const brokerToken = required("NEMOCLAW_AGENT_BROKER_TOKEN"); +const exitReceipt = required("NEMOCLAW_AGENT_EXIT_RECEIPT"); +const proxyPort = required("NEMOCLAW_AGENT_PROXY_PORT"); +const node = required("NEMOCLAW_AGENT_NODE"); +const runtime = required("NEMOCLAW_AGENT_RUNTIME"); +const python = process.env.NEMOCLAW_AGENT_PYTHON; +const sitePackages = process.env.NEMOCLAW_AGENT_SITE_PACKAGES; +const baseUrl = "http://127.0.0.1:" + proxyPort + "/v1"; +mkdirSync(home, { recursive: true }); +let executable; +let args; +let extraEnvironment = {}; + +if (agent === "pi") { + const configDirectory = join(home, ".pi", "agent"); + mkdirSync(configDirectory, { recursive: true }); + writeFileSync(join(configDirectory, "models.json"), JSON.stringify({ + defaultModel: model, + providers: { openshell: { + api: "openai-completions", + apiKey: brokerToken, + baseUrl, + models: [{ id: model, name: model, reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 131072, maxTokens: 4096 }], + } }, + }, null, 2) + "\n", "utf8"); + writeFileSync(join(configDirectory, "settings.json"), JSON.stringify({ + defaultProvider: "openshell", + defaultModel: model, + enableInstallTelemetry: false, + enableAnalytics: false, + }, null, 2) + "\n", "utf8"); + executable = node; + args = [join(runtime, "node_modules", "@earendil-works", "pi-coding-agent", "dist", "cli.js"), "--no-approve", "--provider", "openshell", "--model", model]; + extraEnvironment = { PI_CODING_AGENT_DIR: configDirectory }; +} else if (agent === "hermes") { + if (!python || !sitePackages) throw new Error("Hermes Python runtime is incomplete"); + const hermesHome = join(home, ".hermes"); + mkdirSync(hermesHome, { recursive: true }); + writeFileSync(join(hermesHome, "config.yaml"), [ + "model:", + " default: " + JSON.stringify(model), + " provider: custom", + " base_url: " + JSON.stringify(baseUrl), + " api_key: " + JSON.stringify(brokerToken), + " context_length: 131072", + "memory:", + " memory_enabled: true", + " user_profile_enabled: true", + "updates:", + " pre_update_backup: false", + " refresh_cua_driver: false", + "", + ].join("\n"), "utf8"); + const runner = join(home, "run-hermes.py"); + writeFileSync(runner, [ + "import os", + "import sys", + "sys.path.insert(0, os.environ['NEMOCLAW_AGENT_SITE_PACKAGES'])", + "from hermes_cli.main import main", + "main()", + "", + ].join("\n"), "utf8"); + executable = python; + args = [runner, "--provider", "custom", "--model", model]; + extraEnvironment = { HERMES_HOME: hermesHome }; +} else if (agent === "langchain-deepagents-code") { + if (!python || !sitePackages) throw new Error("Deep Agents Code Python runtime is incomplete"); + const configDirectory = join(home, ".deepagents"); + mkdirSync(join(configDirectory, ".state"), { recursive: true }); + mkdirSync(join(configDirectory, "skills"), { recursive: true }); + writeFileSync(join(configDirectory, "config.toml"), [ + "# Generated by NemoClaw. This file contains no provider secrets.", + "[models]", + "default = " + JSON.stringify("openai:" + model), + "", + "[models.providers.openai]", + "models = [" + JSON.stringify(model) + "]", + "api_key_env = \"DEEPAGENTS_CODE_OPENAI_API_KEY\"", + "base_url = " + JSON.stringify(baseUrl), + "enabled = true", + "", + "[models.providers.openai.params]", + "use_responses_api = false", + "", + "[update]", + "check = false", + "auto_update = false", + "", + ].join("\n"), "utf8"); + const runner = join(home, "run-deep-agents.py"); + writeFileSync(runner, [ + "import os", + "import sys", + "sys.path.insert(0, os.environ['NEMOCLAW_AGENT_SITE_PACKAGES'])", + "from deepagents_code import cli_main", + "cli_main()", + "", + ].join("\n"), "utf8"); + executable = python; + args = [runner, "--sandbox", "none"]; + extraEnvironment = { DEEPAGENTS_CODE_OPENAI_API_KEY: brokerToken }; +} else { + throw new Error("unsupported native terminal agent " + agent); +} + +const child = spawn(executable, args, { + cwd: home, + env: { + ...process.env, + ...extraEnvironment, + HOME: home, + LOCALAPPDATA: home, + NODE_DISABLE_COMPILE_CACHE: "1", + PYTHONDONTWRITEBYTECODE: "1", + PYTHONNOUSERSITE: "1", + PYTHONUTF8: "1", + USERPROFILE: home, + }, + stdio: "inherit", + windowsHide: false, +}); +child.once("error", (error) => { throw error; }); +const exitCode = await new Promise((resolve) => child.once("close", (code) => resolve(code ?? 1))); +writeFileSync(exitReceipt, JSON.stringify({ schemaVersion: 1, agent, exitCode }) + "\n", "utf8"); +process.exitCode = exitCode; +`; +} + +async function main() { + if (process.platform !== "win32" || process.arch !== "arm64") + fail("native Windows ARM64 is required"); + if (!process.argv.includes("--configured")) fail("graphical onboarding is required"); + const agentId = argumentValue("--agent") ?? ""; + if (!Object.hasOwn(AGENT_ADAPTERS, agentId)) fail(`unsupported terminal agent: ${agentId}`); + const adapter = AGENT_ADAPTERS[agentId]; + process.title = `NemoClaw · ${adapter.displayName} · Native ARM64`; + console.log(`NVIDIA NemoClaw · ${adapter.displayName}`); + console.log("Native Windows ARM64 · OpenShell + Microsoft MXC · no WSL · no Docker\n"); + + const installRoot = requiredDirectory( + process.env.NEMOCLAW_NATIVE_INSTALL_ROOT ?? "", + "NemoClaw installation root", + ); + const { config, stateRoot } = readConfiguration(agentId); + const binRoot = requiredDirectory(path.join(installRoot, "bin"), "NemoClaw bin directory"); + const launcher = requiredFile(path.join(binRoot, "NemoClaw.exe"), "NemoClaw launcher"); + const installedNode = requiredFile(path.join(binRoot, "node.exe"), "Node.js runtime"); + const openshell = requiredFile(path.join(binRoot, "openshell.exe"), "OpenShell CLI"); + const gatewayExecutable = requiredFile( + path.join(binRoot, "openshell-gateway.exe"), + "OpenShell gateway", + ); + const installedRuntime = requiredDirectory( + path.join(installRoot, adapter.runtimeDirectory), + `${adapter.displayName} runtime`, + ); + const installedPython = + agentId === "pi" ? null : requiredDirectory(path.join(installRoot, "python"), "Python runtime"); + const gatewayConfig = requiredFile( + path.join(installRoot, "config", "mxc-gateway.toml"), + "MXC gateway configuration", + ); + requiredFile(path.join(installRoot, "mxc", "wxc-exec.exe"), "MXC executor"); + const credential = await readWindowsCredential( + launcher, + config.inference, + config.credentialStored, + ); + const brokerToken = randomBytes(32).toString("base64url"); + const broker = await startInferenceBroker(config, credential, brokerToken); + + const systemDrive = process.env.SystemDrive; + if (!systemDrive || !/^[A-Za-z]:$/u.test(systemDrive)) fail("SystemDrive is invalid"); + const systemRoot = requiredDirectory(process.env.SystemRoot ?? "", "Windows system root"); + const runId = randomBytes(5).toString("hex"); + const runRoot = path.join(`${systemDrive}\\`, `NemoClaw-${agentId}-${runId}`); + const runtimeRoot = path.join(`${systemDrive}\\`, `NemoClawRuntime-${agentId}-${runId}`); + fs.mkdirSync(runRoot); + fs.mkdirSync(runtimeRoot); + const node = path.join(runtimeRoot, "node.exe"); + fs.copyFileSync(installedNode, node); + const runtime = path.join(runtimeRoot, adapter.runtimeDirectory); + fs.cpSync(installedRuntime, runtime, { recursive: true }); + const pythonRoot = path.join(runtimeRoot, "python"); + if (installedPython !== null) fs.cpSync(installedPython, pythonRoot, { recursive: true }); + const workload = path.join(stateRoot, "run-native-agent.mjs"); + const exitReceipt = path.join(stateRoot, `native-session-${runId}.json`); + fs.writeFileSync(workload, interactiveWorkloadSource(), "utf8"); + const policyPath = path.join(runRoot, "policy.yaml"); + fs.writeFileSync( + policyPath, + [ + "version: 1", + "", + "filesystem_policy:", + " include_workdir: false", + " read_only:", + ` - ${quoteYamlPath(runtimeRoot)}`, + " read_write:", + ` - ${quoteYamlPath(stateRoot)}`, + "", + ].join("\n"), + "utf8", + ); + const configRoot = path.join(runRoot, "config"); + const gatewayState = path.join(runRoot, "state"); + const temp = path.join(stateRoot, "temp"); + for (const directory of [configRoot, gatewayState, temp]) + fs.mkdirSync(directory, { recursive: true }); + const gatewayPort = await freePort(); + const sandboxName = `nc-${agentId}-${runId}`; + const gatewayName = `nemoclaw-${agentId}-${runId}`; + const gatewayLogPath = path.join(runRoot, "openshell-gateway.log"); + const gatewayErrorPath = path.join(runRoot, "openshell-gateway.err.log"); + const gatewayLog = fs.openSync(gatewayLogPath, "w"); + const gatewayError = fs.openSync(gatewayErrorPath, "w"); + const gatewayEnvironment = allowlistedWindowsEnvironment({ + OPENSHELL_DRIVERS: "mxc", + OPENSHELL_GATEWAY_CONFIG: gatewayConfig, + XDG_CONFIG_HOME: configRoot, + XDG_STATE_HOME: gatewayState, + }); + const gateway = spawn( + gatewayExecutable, + [ + "--port", + String(gatewayPort), + "--disable-tls", + "--db-url", + "sqlite::memory:", + "--log-level", + "info", + ], + { env: gatewayEnvironment, stdio: ["ignore", gatewayLog, gatewayError], windowsHide: true }, + ); + let cliEnvironment = gatewayEnvironment; + let create = null; + let passed = false; + try { + console.log("Starting the native OpenShell MXC boundary…"); + await waitForPort(gatewayPort, gateway); + cliEnvironment = allowlistedWindowsEnvironment({ + ...gatewayEnvironment, + OPENSHELL_GATEWAY: undefined, + }); + await run( + openshell, + ["gateway", "add", `http://127.0.0.1:${gatewayPort}`, "--local", "--name", gatewayName], + cliEnvironment, + "Registering the native gateway", + ); + await run( + openshell, + ["gateway", "select", gatewayName], + cliEnvironment, + "Selecting the native gateway", + ); + const environment = { + HOME: stateRoot, + LOCALAPPDATA: stateRoot, + NEMOCLAW_AGENT_HOME: stateRoot, + NEMOCLAW_AGENT_ID: agentId, + NEMOCLAW_AGENT_BROKER_TOKEN: brokerToken, + NEMOCLAW_AGENT_EXIT_RECEIPT: exitReceipt, + NEMOCLAW_AGENT_MODEL: config.model, + NEMOCLAW_AGENT_NODE: node, + NEMOCLAW_AGENT_PROXY_PORT: String(broker.port), + NEMOCLAW_AGENT_PYTHON: installedPython === null ? "" : path.join(pythonRoot, "python.exe"), + NEMOCLAW_AGENT_RUNTIME: runtime, + NEMOCLAW_AGENT_SITE_PACKAGES: + installedPython === null ? "" : path.join(runtime, "site-packages"), + NODE_DISABLE_COMPILE_CACHE: "1", + NUMBER_OF_PROCESSORS: process.env.NUMBER_OF_PROCESSORS ?? "1", + OS: "Windows_NT", + PATH: `${path.join(systemRoot, "System32")};${systemRoot}`, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + PROCESSOR_ARCHITECTURE: "ARM64", + SYSTEMDRIVE: systemDrive, + SYSTEMROOT: systemRoot, + TEMP: temp, + TMP: temp, + USERPROFILE: stateRoot, + WINDIR: systemRoot, + }; + const createArgs = [ + "sandbox", + "create", + "--name", + sandboxName, + "--policy", + policyPath, + "--driver-config-json", + JSON.stringify({ mxc: { command: [node, workload], cwd: stateRoot, host_loopback: true } }), + "--tty", + ]; + for (const [name, value] of Object.entries(environment)) + createArgs.push("--env", `${name}=${value}`); + console.log(`Opening the authentic ${adapter.displayName} terminal inside native MXC…\n`); + create = spawn(openshell, createArgs, { + env: cliEnvironment, + stdio: "inherit", + windowsHide: false, + }); + const createFailure = new Promise((_, reject) => { + create.once("error", reject); + create.once("close", (code) => { + if (!fs.existsSync(exitReceipt)) + reject(new Error(`OpenShell request exited ${code ?? 1} before the agent finished`)); + }); + }); + await Promise.race([ + waitForFileText(exitReceipt, '"exitCode":', 24 * 60 * 60_000), + createFailure, + ]); + const exitCode = JSON.parse(fs.readFileSync(exitReceipt, "utf8")).exitCode; + if (!(await stopChild(create))) fail("the OpenShell sandbox request watcher did not stop"); + if (exitCode !== 0) fail(`${adapter.displayName} exited with status ${exitCode}`); + await run( + openshell, + ["sandbox", "delete", sandboxName], + cliEnvironment, + "Deleting the native agent sandbox", + ); + const sandboxList = await run( + openshell, + ["sandbox", "list", "-o", "json"], + cliEnvironment, + "Verifying native sandbox cleanup", + ); + if (jsonContainsExactValue(JSON.parse(sandboxList.stdout.trim()), sandboxName)) + fail("the native agent sandbox remained registered"); + passed = true; + console.log(`\n${adapter.displayName} closed. NemoClaw removed the temporary sandbox.`); + } finally { + if (create !== null) await stopChild(create); + if (!passed) { + try { + await run( + openshell, + ["sandbox", "delete", sandboxName], + cliEnvironment, + "Failure cleanup native sandbox", + 30_000, + ); + } catch {} + } + await stopChild(gateway); + await new Promise((resolve) => broker.server.close(() => resolve())); + fs.closeSync(gatewayLog); + fs.closeSync(gatewayError); + if (!passed) { + const diagnostic = sanitizedDiagnostic( + [gatewayLogPath, gatewayErrorPath] + .filter((file) => fs.statSync(file, { throwIfNoEntry: false })?.isFile()) + .map((file) => fs.readFileSync(file, "utf8")) + .join("\n"), + [ + [installRoot, ""], + [runtimeRoot, ""], + [runRoot, ""], + [stateRoot, ""], + ], + ); + if (diagnostic) + fs.writeFileSync(path.join(stateRoot, `failure-${runId}.log`), diagnostic, "utf8"); + } + for (const directory of [runRoot, runtimeRoot]) await removeDirectory(directory); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : "NemoClaw native terminal launch failed."); + console.error("Press Enter to close."); + process.stdin.resume(); + process.stdin.once("data", () => process.stdin.pause()); + process.exitCode = 1; +}); diff --git a/packaging/windows/runtime/run-installed-native-nemocua.mts b/packaging/windows/runtime/run-installed-native-nemocua.mts index c9c2922bd48..a8d54594300 100644 --- a/packaging/windows/runtime/run-installed-native-nemocua.mts +++ b/packaging/windows/runtime/run-installed-native-nemocua.mts @@ -33,7 +33,91 @@ const EXPECTED_TOKENS = [ const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); function fail(message) { - throw new Error(`Native Windows NemoCUA qualification failed: ${message}`); + throw new Error(`NemoClaw native Windows NemoCUA failed: ${message}`); +} + +function readNativeConfiguration() { + const localAppData = requiredDirectory( + process.env.LOCALAPPDATA ?? "", + "Windows local application-data directory", + ); + const stateRoot = path.join(localAppData, "NVIDIA", "NemoClaw", "agents", "nemocua"); + const configPath = requiredFile( + path.join(stateRoot, "native-windows.json"), + "NemoCUA graphical configuration", + ); + const config = JSON.parse(fs.readFileSync(configPath, "utf8")); + if ( + config?.schemaVersion !== 1 || + config?.classification !== "nemoclaw-native-windows-agent-configuration" || + config?.agent !== "nemocua" || + !["nvidia", "openrouter", "compatible", "local"].includes(config?.inference) || + typeof config?.endpoint !== "string" || + typeof config?.model !== "string" || + typeof config?.credentialStored !== "boolean" + ) + fail("NemoCUA graphical configuration is incomplete"); + return { config, stateRoot }; +} + +async function readWindowsCredential(launcher, provider, required) { + if (!required) return ""; + const result = await new Promise((resolve, reject) => { + const child = spawn(launcher, ["--credential-read", provider], { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + const chunks = []; + let size = 0; + child.stdout.on("data", (chunk) => { + size += chunk.length; + if (size <= 2048) chunks.push(chunk); + }); + child.once("error", reject); + child.once("close", (code) => resolve({ code: code ?? 1, secret: Buffer.concat(chunks) })); + }); + if (result.code !== 0 || !result.secret.length || result.secret.length > 2048) + fail("Windows Credential Manager does not contain the selected provider credential"); + return result.secret.toString("utf8"); +} + +async function forwardConfiguredModel(body, configuration, credential) { + const endpoint = new URL(`${configuration.endpoint.replace(/\/$/u, "")}/`); + const upstreamUrl = new URL("chat/completions", endpoint); + const messages = Array.isArray(body?.messages) ? body.messages : []; + const upstreamBody = { + ...body, + model: configuration.model, + messages: [ + { + role: "system", + content: + 'You control a bounded browser verification task. Return only one JSON object with kind "focus", "type", or "click" and the selector from the observation. Do not use Markdown.', + }, + ...messages, + ], + stream: false, + }; + const headers = { "content-type": "application/json" }; + if (credential) headers.authorization = `Bearer ${credential}`; + if (configuration.inference === "openrouter") { + headers["http-referer"] = "https://www.nvidia.com/nemoclaw/"; + headers["x-openrouter-title"] = "NVIDIA NemoClaw"; + } + const upstream = await fetch(upstreamUrl, { + method: "POST", + headers, + body: JSON.stringify(upstreamBody), + redirect: "error", + signal: AbortSignal.timeout(180_000), + }); + const responseBody = Buffer.from(await upstream.arrayBuffer()); + if (responseBody.length > 4 * 1024 * 1024) fail("the provider response exceeded the limit"); + return { + status: upstream.status, + contentType: upstream.headers.get("content-type") ?? "application/json", + body: responseBody, + }; } function resolveEdge() { @@ -114,7 +198,14 @@ function taskScript() { });`; } -async function startBrowserBridge(openClawRoot, evidenceRoot) { +async function startBrowserBridge( + openClawRoot, + evidenceRoot, + qualification, + configuration, + credential, + bridgeToken, +) { const playwrightRoot = requiredDirectory( path.join(openClawRoot, "node_modules", "openclaw", "node_modules", "playwright-core"), "installed Playwright browser driver", @@ -156,6 +247,11 @@ async function startBrowserBridge(openClawRoot, evidenceRoot) { response.end(taskScript()); return; } + if (request.headers.authorization !== `Bearer ${bridgeToken}`) { + response.writeHead(401, { "content-type": "application/json" }); + response.end(JSON.stringify({ message: "unauthorized" })); + return; + } if (request.method === "GET" && url.pathname === "/observe") { const screenshot = await page.screenshot({ fullPage: false }); observationIndex += 1; @@ -189,6 +285,12 @@ async function startBrowserBridge(openClawRoot, evidenceRoot) { } if (request.method === "POST" && url.pathname === "/v1/chat/completions") { const body = JSON.parse(await readRequestBody(request)); + if (!qualification) { + const upstream = await forwardConfiguredModel(body, configuration, credential); + response.writeHead(upstream.status, { "content-type": upstream.contentType }); + response.end(upstream.body); + return; + } const content = body?.messages?.at(-1)?.content; const prompt = typeof content === "string" ? content : JSON.stringify(content ?? ""); const action = prompt.includes("focus its input") @@ -259,13 +361,16 @@ async function startBrowserBridge(openClawRoot, evidenceRoot) { async function main() { if (process.platform !== "win32" || process.arch !== "arm64") fail("native Windows ARM64 is required"); - if (!process.argv.includes("--qualification")) - fail("the experimental NemoCUA preview requires completed graphical configuration"); + const qualification = process.argv.includes("--qualification"); + const configured = process.argv.includes("--configured"); + if (qualification === configured) + fail("select exactly one of qualification or configured execution"); const installRoot = requiredDirectory( process.env.NEMOCLAW_NATIVE_INSTALL_ROOT ?? "", "NemoClaw installation root", ); const binRoot = requiredDirectory(path.join(installRoot, "bin"), "NemoClaw bin directory"); + const launcher = requiredFile(path.join(binRoot, "NemoClaw.exe"), "NemoClaw launcher"); const openshell = requiredFile(path.join(binRoot, "openshell.exe"), "OpenShell CLI"); const gatewayExecutable = requiredFile( path.join(binRoot, "openshell-gateway.exe"), @@ -285,6 +390,14 @@ async function main() { "MXC gateway configuration", ); requiredFile(path.join(installRoot, "mxc", "wxc-exec.exe"), "MXC executor"); + const configuredIdentity = configured ? readNativeConfiguration() : null; + const credential = configuredIdentity + ? await readWindowsCredential( + launcher, + configuredIdentity.config.inference, + configuredIdentity.config.credentialStored, + ) + : ""; const systemDrive = process.env.SystemDrive; if (!systemDrive || !/^[A-Za-z]:$/u.test(systemDrive)) fail("SystemDrive is invalid"); @@ -294,12 +407,12 @@ async function main() { const shareRoot = path.join(`${systemDrive}\\`, `NemoClawNativeCuaShare-${runId}`); const runtimeRoot = path.join(`${systemDrive}\\`, `NemoClawNativeCuaRuntime-${runId}`); for (const directory of [runRoot, shareRoot, runtimeRoot]) { - if (fs.existsSync(directory)) fail("qualification root already exists"); + if (fs.existsSync(directory)) fail("runtime root already exists"); fs.mkdirSync(directory); } const evidenceRoot = path.resolve( argumentValue("--artifact-directory") ?? - path.join(process.env.LOCALAPPDATA ?? runRoot, "NVIDIA", "NemoClaw", "evidence", "nemocua"), + path.join(configuredIdentity?.stateRoot ?? process.env.LOCALAPPDATA ?? runRoot, "evidence"), ); fs.mkdirSync(evidenceRoot, { recursive: true }); const pythonRoot = path.join(runtimeRoot, "python"); @@ -333,7 +446,15 @@ async function main() { const temp = path.join(shareRoot, "temp"); for (const directory of [configRoot, stateRoot, temp]) fs.mkdirSync(directory, { recursive: true }); - const bridge = await startBrowserBridge(installedOpenClawRoot, evidenceRoot); + const bridgeToken = randomBytes(32).toString("base64url"); + const bridge = await startBrowserBridge( + installedOpenClawRoot, + evidenceRoot, + qualification, + configuredIdentity?.config ?? null, + credential, + bridgeToken, + ); const openShellPort = await freePort(); const sandboxName = `nc-nemocua-${runId}`; const gatewayName = `nemoclaw-nemocua-${runId}`; @@ -416,9 +537,11 @@ async function main() { command: [ python, harness, - "--qualification", + qualification ? "--qualification" : "--configured", "--bridge-url", `http://127.0.0.1:${bridge.port}`, + "--bridge-token", + bridgeToken, "--result-path", resultPath, ], @@ -500,7 +623,9 @@ async function main() { browser: "Microsoft Edge", browserVersion: bridge.browserVersion, interface: "NemoCUA visible browser task", - deterministicLocalModel: true, + deterministicLocalModel: qualification, + inferenceProvider: configuredIdentity?.config.inference ?? "qualification", + model: configuredIdentity?.config.model ?? "nemocua-native-preview", visiblePostcondition: finalState, createWatcherStopped: true, sandboxDeleted: true, @@ -562,8 +687,11 @@ async function main() { } main().catch((error) => { - console.error( - error instanceof Error ? error.message : "Native Windows NemoCUA qualification failed.", - ); + console.error(error instanceof Error ? error.message : "NemoClaw native Windows NemoCUA failed."); + if (process.argv.includes("--configured")) { + console.error("Press Enter to close."); + process.stdin.resume(); + process.stdin.once("data", () => process.stdin.pause()); + } process.exitCode = 1; }); diff --git a/packaging/windows/runtime/run-installed-native-pi.mts b/packaging/windows/runtime/run-installed-native-pi.mts index b96735d3002..3fbe363b369 100644 --- a/packaging/windows/runtime/run-installed-native-pi.mts +++ b/packaging/windows/runtime/run-installed-native-pi.mts @@ -462,9 +462,6 @@ writeFileSync(join(configDirectory, "config.toml"), [ "check = false", "auto_update = false", "", - "[warnings]", - "suppress = [\"tavily\"]", - "", ].join("\n"), "utf8"); const runner = join(home, "run-deep-agents.py"); writeFileSync(runner, [ diff --git a/packaging/windows/runtime/run-installed-native-web-ui.mts b/packaging/windows/runtime/run-installed-native-web-ui.mts index 1f844be01e0..0ce3ae2ccf4 100644 --- a/packaging/windows/runtime/run-installed-native-web-ui.mts +++ b/packaging/windows/runtime/run-installed-native-web-ui.mts @@ -34,7 +34,257 @@ const AGENT_CHOICE_PROOF = ["openclaw", "hermes", "langchain-deepagents-code", " const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); function fail(message) { - throw new Error(`Native Windows OpenClaw UI qualification failed: ${message}`); + throw new Error(`NemoClaw native Windows launch failed: ${message}`); +} + +const PROVIDER_CONFIGURATION = { + nvidia: { + endpoint: "https://integrate.api.nvidia.com/v1", + credentialRequired: true, + credentialPrefix: "nvapi-", + }, + openrouter: { + endpoint: "https://openrouter.ai/api/v1", + credentialRequired: true, + credentialPrefix: "sk-or-", + }, + compatible: { endpoint: null, credentialRequired: false, credentialPrefix: null }, + local: { endpoint: null, credentialRequired: false, credentialPrefix: null }, +}; + +function normalizeOnboardingConfiguration(submitted, qualification) { + const agents = new Set(["openclaw", "hermes", "langchain-deepagents-code", "pi", "nemocua"]); + if (!agents.has(submitted?.agent)) throw new Error("Select a valid agent runtime."); + if (qualification) { + if (submitted?.inference !== "qualification") + throw new Error("Qualification must use its deterministic local inference endpoint."); + return { + agent: submitted.agent, + inference: "qualification", + endpoint: "http://127.0.0.1/qualification", + model: "native-preview", + credential: "", + options: submitted.options ?? {}, + }; + } + if (!Object.hasOwn(PROVIDER_CONFIGURATION, submitted?.inference)) + throw new Error("Select a valid inference provider."); + const provider = PROVIDER_CONFIGURATION[submitted.inference]; + const options = submitted?.options; + if (options === null || typeof options !== "object" || Array.isArray(options)) + throw new Error("Onboarding options are invalid."); + const submittedEndpoint = typeof options.endpoint === "string" ? options.endpoint.trim() : ""; + const endpoint = provider.endpoint ?? submittedEndpoint; + let endpointUrl; + try { + endpointUrl = new URL(endpoint); + } catch { + throw new Error("Enter a complete inference endpoint URL."); + } + const endpointIsAllowed = + submitted.inference === "local" + ? endpointUrl.protocol === "http:" || endpointUrl.protocol === "https:" + : endpointUrl.protocol === "https:"; + if (!endpointIsAllowed) throw new Error("The selected inference endpoint protocol is unsafe."); + if ( + submitted.inference === "local" && + !["127.0.0.1", "localhost", "[::1]"].includes(endpointUrl.hostname) + ) + throw new Error("Local inference must use a loopback endpoint."); + const model = typeof options.model === "string" ? options.model.trim() : ""; + if (!model || model.length > 256 || /[\u0000-\u001f\u007f]/u.test(model)) + throw new Error("Enter a valid model ID."); + const credential = typeof options.credential === "string" ? options.credential.trim() : ""; + if (credential.length > 2048 || /[\u0000\r\n]/u.test(credential)) + throw new Error("The provider credential is invalid."); + if (provider.credentialRequired && !credential) + throw new Error("The selected provider requires an API key."); + if (provider.credentialPrefix && !credential.startsWith(provider.credentialPrefix)) + throw new Error(`The ${submitted.inference} API key has an unexpected format.`); + return { + agent: submitted.agent, + inference: submitted.inference, + endpoint: endpointUrl.toString().replace(/\/$/u, ""), + model, + credential, + options, + }; +} + +async function updateWindowsCredential(launcher, provider, credential) { + const operation = credential ? "--credential-write" : "--credential-delete"; + const result = await new Promise((resolve, reject) => { + const child = spawn(launcher, [operation, provider], { + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout = `${stdout}${chunk.toString("utf8")}`.slice(-4096); + }); + child.stderr.on("data", (chunk) => { + stderr = `${stderr}${chunk.toString("utf8")}`.slice(-4096); + }); + child.once("error", reject); + child.once("close", (code) => resolve({ code: code ?? 1, stdout, stderr })); + child.stdin.end(credential, "utf8"); + }); + if (result.code !== 0 || result.stdout) + throw new Error( + credential + ? "Windows Credential Manager could not protect this API key." + : "Windows Credential Manager could not clear the previous API key.", + ); +} + +function writeNativeAgentConfiguration(configuration) { + const localAppData = requiredDirectory( + process.env.LOCALAPPDATA ?? "", + "Windows local application-data directory", + ); + const stateRoot = path.join(localAppData, "NVIDIA", "NemoClaw", "agents", configuration.agent); + fs.mkdirSync(stateRoot, { recursive: true, mode: 0o700 }); + const configPath = path.join(stateRoot, "native-windows.json"); + const temporaryPath = `${configPath}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`; + const persisted = { + schemaVersion: 1, + classification: "nemoclaw-native-windows-agent-configuration", + agent: configuration.agent, + inference: configuration.inference, + endpoint: configuration.endpoint, + model: configuration.model, + credentialStored: Boolean(configuration.credential), + options: Object.fromEntries( + Object.entries(configuration.options).filter( + ([name]) => !["credential", "endpoint", "model"].includes(name), + ), + ), + }; + fs.writeFileSync(temporaryPath, `${JSON.stringify(persisted, null, 2)}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + fs.renameSync(temporaryPath, configPath); + return configPath; +} + +function readNativeAgentConfiguration(agent) { + const localAppData = requiredDirectory( + process.env.LOCALAPPDATA ?? "", + "Windows local application-data directory", + ); + const stateRoot = path.join(localAppData, "NVIDIA", "NemoClaw", "agents", agent); + const configPath = requiredFile( + path.join(stateRoot, "native-windows.json"), + `${agentNamesForLaunch[agent]} configuration`, + ); + const config = JSON.parse(fs.readFileSync(configPath, "utf8")); + if ( + config?.schemaVersion !== 1 || + config?.classification !== "nemoclaw-native-windows-agent-configuration" || + config?.agent !== agent || + !["nvidia", "openrouter", "compatible", "local"].includes(config?.inference) || + typeof config?.endpoint !== "string" || + typeof config?.model !== "string" || + typeof config?.credentialStored !== "boolean" + ) + fail(`${agentNamesForLaunch[agent]} graphical configuration is incomplete`); + return { config, stateRoot }; +} + +async function readWindowsCredential(launcher, provider, required) { + if (!required) return ""; + const result = await new Promise((resolve, reject) => { + const child = spawn(launcher, ["--credential-read", provider], { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + const chunks = []; + let size = 0; + child.stdout.on("data", (chunk) => { + size += chunk.length; + if (size <= 2048) chunks.push(chunk); + }); + child.once("error", reject); + child.once("close", (code) => resolve({ code: code ?? 1, secret: Buffer.concat(chunks) })); + }); + if (result.code !== 0 || !result.secret.length || result.secret.length > 2048) + fail("Windows Credential Manager does not contain the selected provider credential"); + return result.secret.toString("utf8"); +} + +async function readBrokerRequest(request) { + const chunks = []; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > 16 * 1024 * 1024) fail("the agent request exceeded the broker limit"); + chunks.push(chunk); + } + return Buffer.concat(chunks); +} + +async function startHostInferenceBroker(configuration, credential, brokerToken) { + const endpoint = new URL(`${configuration.endpoint.replace(/\/$/u, "")}/`); + const server = createServer(async (request, response) => { + try { + if (!request.url?.startsWith("/v1/")) { + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: { message: "not found" } })); + return; + } + if (request.headers.authorization !== `Bearer ${brokerToken}`) { + response.writeHead(401, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: { message: "unauthorized" } })); + return; + } + const upstreamUrl = new URL(request.url.slice("/v1/".length), endpoint); + const body = + request.method === "GET" || request.method === "HEAD" + ? undefined + : await readBrokerRequest(request); + const headers = { accept: request.headers.accept ?? "application/json" }; + if (request.headers["content-type"]) + headers["content-type"] = request.headers["content-type"]; + if (credential) headers.authorization = `Bearer ${credential}`; + if (configuration.inference === "openrouter") { + headers["http-referer"] = "https://www.nvidia.com/nemoclaw/"; + headers["x-openrouter-title"] = "NVIDIA NemoClaw"; + } + const upstream = await fetch(upstreamUrl, { + method: request.method, + headers, + body, + redirect: "error", + signal: AbortSignal.timeout(180_000), + }); + const responseBody = Buffer.from(await upstream.arrayBuffer()); + if (responseBody.length > 32 * 1024 * 1024) + fail("the provider response exceeded the broker limit"); + response.writeHead(upstream.status, { + "cache-control": "no-store", + "content-type": upstream.headers.get("content-type") ?? "application/json", + }); + response.end(responseBody); + } catch (error) { + response.writeHead(502, { "content-type": "application/json" }); + response.end( + JSON.stringify({ + error: { + message: error instanceof Error ? error.message : "inference provider request failed", + }, + }), + ); + } + }); + const port = await freePort(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", resolve); + }); + return { server, port }; } function gatewaySource() { @@ -50,7 +300,10 @@ const required = (name) => { }; const launcher = required("NEMOCLAW_MXC_OPENCLAW_ENTRY"); const home = required("NEMOCLAW_MXC_HOME"); -const mockPort = Number(required("NEMOCLAW_MXC_MOCK_PORT")); +const modelPort = Number(required("NEMOCLAW_MXC_MODEL_PORT")); +const modelId = required("NEMOCLAW_MXC_MODEL_ID"); +const modelToken = required("NEMOCLAW_MXC_MODEL_TOKEN"); +const qualification = required("NEMOCLAW_MXC_QUALIFICATION") === "1"; const uiPort = Number(required("NEMOCLAW_MXC_UI_PORT")); const readBody = async (request) => { const chunks = []; @@ -67,7 +320,7 @@ const responseFor = (body) => { const turn = text.match(/NATIVE_WINDOWS_TURN_([123])_OK/u)?.[1]; return turn ? "NATIVE_WINDOWS_TURN_" + turn + "_OK" : "NEMOCLAW_NATIVE_PREVIEW_OK"; }; -const mock = createServer(async (request, response) => { +const mock = qualification ? createServer(async (request, response) => { if (request.method === "GET" && request.url === "/v1/models") { response.writeHead(200, { "content-type": "application/json" }); response.end(JSON.stringify({ object: "list", data: [{ id: "native-preview", object: "model" }] })); @@ -79,7 +332,7 @@ const mock = createServer(async (request, response) => { return; } const body = JSON.parse(await readBody(request)); - if (body?.model !== "native-preview" || !Array.isArray(body?.messages)) { + if (request.headers.authorization !== "Bearer " + modelToken || body?.model !== modelId || !Array.isArray(body?.messages)) { response.writeHead(400, { "content-type": "application/json" }); response.end(JSON.stringify({ error: { message: "unexpected request" } })); return; @@ -105,10 +358,10 @@ const mock = createServer(async (request, response) => { model: "native-preview", choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], })); -}); -await new Promise((resolve, reject) => { +}) : null; +if (mock !== null) await new Promise((resolve, reject) => { mock.once("error", reject); - mock.listen(mockPort, "127.0.0.1", resolve); + mock.listen(modelPort, "127.0.0.1", resolve); }); const configDirectory = join(home, ".openclaw"); mkdirSync(configDirectory, { recursive: true }); @@ -120,14 +373,14 @@ writeFileSync(join(configDirectory, "openclaw.json"), JSON.stringify({ auth: { mode: "none" }, controlUi: { allowedOrigins: ["http://127.0.0.1:" + uiPort, "http://localhost:" + uiPort] }, }, - models: { mode: "merge", providers: { nemoclawNativePreview: { - baseUrl: "http://127.0.0.1:" + mockPort + "/v1", - apiKey: "unused", + models: { mode: "merge", providers: { nemoclawNative: { + baseUrl: "http://127.0.0.1:" + modelPort + "/v1", + apiKey: modelToken, api: "openai-completions", timeoutSeconds: 180, - models: [{ id: "native-preview", name: "NemoClaw Native Preview", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 131072, maxTokens: 4096 }], + models: [{ id: modelId, name: modelId, reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 131072, maxTokens: 4096 }], } } }, - agents: { defaults: { model: { primary: "nemoclawNativePreview/native-preview" }, timeoutSeconds: 180, skipBootstrap: true, thinkingDefault: "off" }, list: [{ id: "main", default: true }] }, + agents: { defaults: { model: { primary: "nemoclawNative/" + modelId }, timeoutSeconds: 180, skipBootstrap: true, thinkingDefault: "off" }, list: [{ id: "main", default: true }] }, }), "utf8"); Object.assign(process.env, { HOME: home, @@ -155,7 +408,13 @@ function resolveEdge() { fail("Microsoft Edge is required for the visible Control UI proof"); } -async function startOnboardingServer(installRoot, openClawUrl, evidenceRoot) { +async function startOnboardingServer( + installRoot, + openClawUrl, + evidenceRoot, + qualification, + launcher, +) { const onboardingRoot = requiredDirectory( path.join(installRoot, "onboarding"), "NemoClaw graphical onboarder", @@ -173,6 +432,7 @@ async function startOnboardingServer(installRoot, openClawUrl, evidenceRoot) { ["/assets/nemocua.png", ["assets/nemocua.png", "image/png"]], ]); let selection = null; + let runtimeConfiguration = null; const server = createServer(async (request, response) => { try { const pathname = new URL(request.url ?? "/", "http://127.0.0.1").pathname; @@ -185,21 +445,35 @@ async function startOnboardingServer(installRoot, openClawUrl, evidenceRoot) { chunks.push(chunk); } const submitted = JSON.parse(Buffer.concat(chunks).toString("utf8")); - const agents = new Set([ - "openclaw", - "hermes", - "langchain-deepagents-code", - "pi", - "nemocua", - ]); - const inference = new Set(["nvidia", "openrouter", "compatible", "local"]); - if (!agents.has(submitted?.agent) || !inference.has(submitted?.inference)) - throw new Error("onboarding selection is invalid"); + const normalized = normalizeOnboardingConfiguration(submitted, qualification); + if (!qualification) { + await updateWindowsCredential(launcher, normalized.inference, normalized.credential); + } + const configPath = qualification ? null : writeNativeAgentConfiguration(normalized); selection = { schemaVersion: 1, - agent: submitted.agent, - inference: submitted.inference, - options: submitted.options ?? {}, + agent: normalized.agent, + inference: normalized.inference, + endpoint: qualification ? "deterministic-loopback" : normalized.endpoint, + model: normalized.model, + credentialStorage: qualification + ? "none" + : normalized.credential + ? "Windows Credential Manager" + : "not required", + options: Object.fromEntries( + Object.entries(normalized.options).filter( + ([name]) => !["credential", "endpoint", "model"].includes(name), + ), + ), + }; + runtimeConfiguration = { + agent: normalized.agent, + inference: normalized.inference, + endpoint: normalized.endpoint, + model: normalized.model, + credentialStored: !qualification && Boolean(normalized.credential), + configPath, }; fs.writeFileSync( path.join(evidenceRoot, "onboarding-selection.json"), @@ -210,7 +484,7 @@ async function startOnboardingServer(installRoot, openClawUrl, evidenceRoot) { response.end( JSON.stringify({ redirect: - submitted.agent === "openclaw" + qualification && submitted.agent === "openclaw" ? `${openClawUrl}/chat` : `/launching.html?agent=${encodeURIComponent(submitted.agent)}`, }), @@ -265,6 +539,7 @@ async function startOnboardingServer(installRoot, openClawUrl, evidenceRoot) { server, url: `http://127.0.0.1:${port}`, selection: () => selection, + runtimeConfiguration: () => runtimeConfiguration, }; } @@ -307,7 +582,10 @@ async function driveBrowser( viewport: { width: 1200, height: 630 }, }); const page = await context.newPage(); - await page.goto(onboardingUrl, { + const onboardingPageUrl = new URL(onboardingUrl); + onboardingPageUrl.searchParams.set("agent", targetAgent); + if (qualification) onboardingPageUrl.searchParams.set("qualification", "1"); + await page.goto(onboardingPageUrl.toString(), { waitUntil: "domcontentloaded", timeout: 90_000, }); @@ -320,7 +598,9 @@ async function driveBrowser( path: path.join(evidenceRoot, "onboarding-agent.png"), }); console.log(`WEB UI> READY ${onboardingUrl}`); - await new Promise((resolve) => browser.once("disconnected", resolve)); + await page.waitForURL(`${onboardingUrl}/launching.html?agent=*`, { + timeout: 30 * 60_000, + }); return { browserVersion, demonstratedAgentChoices: [], @@ -420,6 +700,90 @@ async function driveBrowser( } } +async function runInitialOnboarding( + installRoot, + installedOpenClawRoot, + initialAgent, + evidenceRoot, +) { + const launcher = requiredFile(path.join(installRoot, "bin", "NemoClaw.exe"), "NemoClaw launcher"); + const onboarding = await startOnboardingServer(installRoot, "", evidenceRoot, false, launcher); + try { + console.log(`WEB UI> Launching graphical onboarding for ${agentNamesForLaunch[initialAgent]}`); + await driveBrowser( + installedOpenClawRoot, + onboarding.url, + "", + evidenceRoot, + false, + initialAgent, + ); + } finally { + await new Promise((resolve) => onboarding.server.close(() => resolve())); + } + const selection = onboarding.selection(); + const runtimeConfiguration = onboarding.runtimeConfiguration(); + if ( + !Object.hasOwn(agentNamesForLaunch, selection?.agent) || + runtimeConfiguration?.agent !== selection.agent || + typeof runtimeConfiguration.configPath !== "string" + ) + fail("graphical onboarding did not publish a complete agent configuration"); + if (selection.options.launch !== "on") { + console.log(`WEB UI> Saved ${agentNamesForLaunch[selection.agent]} configuration for later`); + return; + } + const arguments_ = ["--configured", "--agent", selection.agent]; + arguments_.unshift("--console"); + const child = spawn(launcher, arguments_, { + cwd: installRoot, + detached: true, + stdio: "ignore", + windowsHide: false, + }); + child.once("error", (error) => { + console.error( + `WEB UI> ${agentNamesForLaunch[selection.agent]} launch failed: ${error.message}`, + ); + }); + child.unref(); + console.log(`WEB UI> Opened the authentic ${agentNamesForLaunch[selection.agent]} surface`); +} + +async function driveConfiguredOpenClaw(openClawRoot, openClawUrl) { + const playwrightRoot = requiredDirectory( + path.join(openClawRoot, "node_modules", "openclaw", "node_modules", "playwright-core"), + "installed Playwright browser driver", + ); + const require = createRequire(import.meta.url); + const { chromium } = require(playwrightRoot); + const browser = await chromium.launch({ + executablePath: resolveEdge(), + headless: false, + args: [ + "--no-first-run", + "--no-default-browser-check", + "--window-position=20,10", + "--window-size=1240,700", + ], + }); + try { + const context = await browser.newContext({ viewport: { width: 1200, height: 630 } }); + const page = await context.newPage(); + await page.goto(`${openClawUrl}/chat`, { waitUntil: "domcontentloaded", timeout: 90_000 }); + const composer = page.locator(".agent-chat__composer-combobox > textarea").first(); + await composer.waitFor({ state: "visible", timeout: 90_000 }); + await page.evaluate(() => { + document.title = "NemoClaw Native Windows · OpenClaw Control UI"; + }); + console.log("WEB UI> OpenClaw Control UI is ready inside native MXC"); + await new Promise((resolve) => browser.once("disconnected", resolve)); + } catch (error) { + if (browser.isConnected()) await browser.close(); + throw error; + } +} + async function runSelectedNonOpenClaw( installRoot, installedNode, @@ -428,7 +792,14 @@ async function runSelectedNonOpenClaw( qualification, evidenceRoot, ) { - const onboarding = await startOnboardingServer(installRoot, "", evidenceRoot); + const launcher = requiredFile(path.join(installRoot, "bin", "NemoClaw.exe"), "NemoClaw launcher"); + const onboarding = await startOnboardingServer( + installRoot, + "", + evidenceRoot, + qualification, + launcher, + ); let browserProof; try { console.log(`WEB UI> Launching graphical onboarding for ${agentNamesForLaunch[targetAgent]}`); @@ -446,8 +817,7 @@ async function runSelectedNonOpenClaw( const onboardingSelection = onboarding.selection(); if (onboardingSelection?.agent !== targetAgent) fail(`graphical onboarding did not select ${targetAgent}`); - if (!qualification) - fail(`${agentNamesForLaunch[targetAgent]} requires completed provider configuration`); + if (!qualification) fail("the non-OpenClaw runtime bypassed its configured adapter"); const runtimeEvidence = path.join(evidenceRoot, "runtime"); fs.mkdirSync(runtimeEvidence, { recursive: true }); @@ -535,6 +905,7 @@ async function main() { if (process.platform !== "win32" || process.arch !== "arm64") fail("native Windows ARM64 is required"); const qualification = process.argv.includes("--qualification"); + const configured = process.argv.includes("--configured"); const targetAgent = argumentValue("--agent") ?? "openclaw"; if (!Object.hasOwn(agentNamesForLaunch, targetAgent)) fail(`unknown agent ${targetAgent}`); const installRoot = requiredDirectory( @@ -573,6 +944,15 @@ async function main() { ), ); fs.mkdirSync(selectedEvidenceRoot, { recursive: true }); + if (!qualification && !configured) { + await runInitialOnboarding( + installRoot, + installedOpenClawRoot, + targetAgent, + selectedEvidenceRoot, + ); + return; + } if (targetAgent !== "openclaw") { await runSelectedNonOpenClaw( installRoot, @@ -585,6 +965,21 @@ async function main() { return; } + const launcherPath = requiredFile(path.join(binRoot, "NemoClaw.exe"), "NemoClaw launcher"); + const configuredIdentity = configured ? readNativeAgentConfiguration("openclaw") : null; + const modelId = configuredIdentity?.config.model ?? "native-preview"; + const modelToken = randomBytes(32).toString("base64url"); + const credential = configuredIdentity + ? await readWindowsCredential( + launcherPath, + configuredIdentity.config.inference, + configuredIdentity.config.credentialStored, + ) + : ""; + const inferenceBroker = configuredIdentity + ? await startHostInferenceBroker(configuredIdentity.config, credential, modelToken) + : null; + const systemDrive = process.env.SystemDrive; if (!systemDrive || !/^[A-Za-z]:$/u.test(systemDrive)) fail("SystemDrive is invalid"); const systemRoot = requiredDirectory(process.env.SystemRoot ?? "", "Windows system root"); @@ -622,19 +1017,25 @@ async function main() { ` - ${quoteYamlPath(runtimeRoot)}`, " read_write:", ` - ${quoteYamlPath(shareRoot)}`, + ...(configuredIdentity === null + ? [] + : [` - ${quoteYamlPath(configuredIdentity.stateRoot)}`]), "", ].join("\n"), "utf8", ); const configRoot = path.join(runRoot, "config"); const stateRoot = path.join(runRoot, "state"); - const home = path.join(shareRoot, "home"); + const home = + configuredIdentity === null + ? path.join(shareRoot, "home") + : path.join(configuredIdentity.stateRoot, "runtime"); const temp = path.join(shareRoot, "temp"); for (const directory of [configRoot, stateRoot, home, temp]) fs.mkdirSync(directory, { recursive: true }); const openShellPort = await freePort(); const uiPort = await freePort(); - const mockPort = await freePort(); + const modelPort = inferenceBroker?.port ?? (await freePort()); const sandboxName = `nc-ui-${runId}`; const gatewayName = `nemoclaw-ui-${runId}`; const gatewayLogPath = path.join(runRoot, "openshell-gateway.log"); @@ -693,8 +1094,11 @@ async function main() { const sandboxEnvironment = { LOCALAPPDATA: home, NEMOCLAW_MXC_HOME: home, - NEMOCLAW_MXC_MOCK_PORT: String(mockPort), + NEMOCLAW_MXC_MODEL_ID: modelId, + NEMOCLAW_MXC_MODEL_PORT: String(modelPort), + NEMOCLAW_MXC_MODEL_TOKEN: modelToken, NEMOCLAW_MXC_OPENCLAW_ENTRY: openClawEntry, + NEMOCLAW_MXC_QUALIFICATION: qualification ? "1" : "0", NEMOCLAW_MXC_UI_PORT: String(uiPort), NODE_DISABLE_COMPILE_CACHE: "1", NUMBER_OF_PROCESSORS: process.env.NUMBER_OF_PROCESSORS ?? "1", @@ -739,23 +1143,41 @@ async function main() { console.log("WEB UI> Waiting for the real OpenClaw Control UI"); await waitForPort(uiPort, create, "OpenClaw Control UI", 180_000); const uiUrl = `http://127.0.0.1:${uiPort}`; - onboarding = await startOnboardingServer(installRoot, uiUrl, evidenceRoot); - console.log(`WEB UI> Launching the NemoClaw graphical onboarder at ${onboarding.url}`); - const browserProof = await driveBrowser( - openClawRoot, - onboarding.url, - uiUrl, - evidenceRoot, - qualification, - targetAgent, - ); - const onboardingSelection = onboarding.selection(); - await new Promise((resolve, reject) => { - onboarding.server.close((error) => (error ? reject(error) : resolve())); - }); - onboarding = null; - if (qualification && onboardingSelection?.agent !== "openclaw") - fail("graphical onboarding did not select OpenClaw"); + let browserProof; + let onboardingSelection = null; + if (qualification) { + onboarding = await startOnboardingServer( + installRoot, + uiUrl, + evidenceRoot, + true, + launcherPath, + ); + console.log(`WEB UI> Launching the NemoClaw graphical onboarder at ${onboarding.url}`); + browserProof = await driveBrowser( + openClawRoot, + onboarding.url, + uiUrl, + evidenceRoot, + true, + targetAgent, + ); + onboardingSelection = onboarding.selection(); + await new Promise((resolve, reject) => { + onboarding.server.close((error) => (error ? reject(error) : resolve())); + }); + onboarding = null; + if (onboardingSelection?.agent !== "openclaw") + fail("graphical onboarding did not select OpenClaw"); + } else { + browserProof = { + browserVersion: "Microsoft Edge", + demonstratedAgentChoices: [], + disabledAgentChoices: [], + turns: [], + }; + await driveConfiguredOpenClaw(openClawRoot, uiUrl); + } await run( openshell, ["sandbox", "delete", sandboxName], @@ -827,6 +1249,9 @@ async function main() { } catch {} } await stopChild(gateway); + if (inferenceBroker !== null) { + await new Promise((resolve) => inferenceBroker.server.close(() => resolve())); + } if (!logsClosed) { fs.closeSync(gatewayLog); fs.closeSync(gatewayError); @@ -858,8 +1283,11 @@ async function main() { } main().catch((error) => { - console.error( - error instanceof Error ? error.message : "Native Windows OpenClaw UI qualification failed.", - ); + console.error(error instanceof Error ? error.message : "NemoClaw native Windows launch failed."); + if (process.argv.includes("--configured")) { + console.error("Press Enter to close."); + process.stdin.resume(); + process.stdin.once("data", () => process.stdin.pause()); + } process.exitCode = 1; }); diff --git a/scripts/checks/build-windows-native-package.ps1 b/scripts/checks/build-windows-native-package.ps1 index 41ba9923d38..f44839daf59 100644 --- a/scripts/checks/build-windows-native-package.ps1 +++ b/scripts/checks/build-windows-native-package.ps1 @@ -232,6 +232,7 @@ foreach ($requiredPayload in @( 'config\mxc-gateway.toml', 'qualification\run-installed-native-turn.mts', 'qualification\run-installed-native-web-ui.mts', + 'qualification\run-installed-native-console-agent.mts', 'qualification\run-installed-native-pi.mts', 'qualification\run-installed-native-nemocua.mts', 'agent-support.json', diff --git a/scripts/checks/create-windows-native-proof-video.ps1 b/scripts/checks/create-windows-native-proof-video.ps1 index 0b02f38947e..560de478c9c 100644 --- a/scripts/checks/create-windows-native-proof-video.ps1 +++ b/scripts/checks/create-windows-native-proof-video.ps1 @@ -344,6 +344,8 @@ if ($null -ne $qualification -and (-not $qualification.repairRestoredDigest -or $qualification.nativeTurn.sandboxDeleted -ne $true -or $qualification.nativeTurn.sandboxRegistryAbsent -ne $true -or $qualification.nativeTurn.qualificationRootsRemoved -ne $true -or + $qualification.credentialManager.exactRoundTrip -ne $true -or + $qualification.credentialManager.removedAfterProbe -ne $true -or $qualification.webUi.verdict -cne 'pass' -or [int]$qualification.webUi.turnCount -ne 3 -or $qualification.webUi.onboardingSelection.agent -cne 'openclaw' -or @@ -656,10 +658,15 @@ public static class NemoClawConsoleVideoEncoder $agentVideos = [ordered]@{} foreach ($agent in $script:AgentVideoSegments) { $segment = $agentSegmentFrames[$agent] - if ($null -eq $segment.start -or $null -eq $segment.end -or $segment.end -lt $segment.start) { - $captureFailures.Add("The recording is missing a complete $agent agent segment.") + if ($null -eq $segment.start) { + $captureFailures.Add("The recording did not start a $agent agent segment.") continue } + $segmentComplete = $null -ne $segment.end -and $segment.end -ge $segment.start + if (-not $segmentComplete) { + $captureFailures.Add("The recording captured a failed or incomplete $agent agent segment.") + $segment.end = $framePaths.Count - 1 + } $segmentStart = [Math]::Max(0, [int]$segment.start - (2 * $script:CaptureFramesPerSecond)) $segmentEnd = [Math]::Min($framePaths.Count - 1, [int]$segment.end + (2 * $script:CaptureFramesPerSecond)) $segmentFrames = [string[]]$framePaths[$segmentStart..$segmentEnd] @@ -692,6 +699,7 @@ public static class NemoClawConsoleVideoEncoder firstCombinedFrame = $segmentStart lastCombinedFrame = $segmentEnd frameCount = $segmentFrames.Count + completed = $segmentComplete expectedDurationMilliseconds = $segmentFrames.Count * $script:FrameDurationMilliseconds sha256 = (Get-FileHash -LiteralPath $agentVideoPath -Algorithm SHA256).Hash.ToLowerInvariant() bytes = (Get-Item -LiteralPath $agentVideoPath).Length @@ -716,6 +724,11 @@ public static class NemoClawConsoleVideoEncoder $recordedQualification.nativeTurn.qualificationRootsRemoved -ne $true)) { $captureFailures.Add('The recorded qualification receipt does not prove the installed NemoClaw turn.') } + if ($null -ne $recordedQualification -and ( + $recordedQualification.credentialManager.exactRoundTrip -ne $true -or + $recordedQualification.credentialManager.removedAfterProbe -ne $true)) { + $captureFailures.Add('The recorded qualification receipt does not prove the Windows Credential Manager boundary.') + } if ($null -ne $recordedQualification -and ($recordedQualification.webUi.verdict -cne 'pass' -or [int]$recordedQualification.webUi.turnCount -ne 3 -or $recordedQualification.webUi.onboardingSelection.agent -cne 'openclaw' -or diff --git a/scripts/checks/prepare-windows-native-package-payload.ps1 b/scripts/checks/prepare-windows-native-package-payload.ps1 index 573d7a239d8..710af8e5db1 100644 --- a/scripts/checks/prepare-windows-native-package-payload.ps1 +++ b/scripts/checks/prepare-windows-native-package-payload.ps1 @@ -367,6 +367,7 @@ debug = false [IO.Directory]::CreateDirectory($qualificationRoot) | Out-Null Copy-Item -LiteralPath (Join-Path $candidate 'packaging\windows\runtime\run-installed-native-turn.mts') -Destination $qualificationRoot Copy-Item -LiteralPath (Join-Path $candidate 'packaging\windows\runtime\run-installed-native-web-ui.mts') -Destination $qualificationRoot + Copy-Item -LiteralPath (Join-Path $candidate 'packaging\windows\runtime\run-installed-native-console-agent.mts') -Destination $qualificationRoot Copy-Item -LiteralPath (Join-Path $candidate 'packaging\windows\runtime\run-installed-native-pi.mts') -Destination $qualificationRoot Copy-Item -LiteralPath (Join-Path $candidate 'packaging\windows\runtime\run-installed-native-nemocua.mts') -Destination $qualificationRoot Copy-Item -LiteralPath (Join-Path $candidate 'packaging\windows\runtime\nemocua') -Destination (Join-Path $output 'nemocua') -Recurse @@ -400,6 +401,7 @@ debug = false 'config\mxc-gateway.toml', 'qualification\run-installed-native-turn.mts', 'qualification\run-installed-native-web-ui.mts', + 'qualification\run-installed-native-console-agent.mts', 'qualification\run-installed-native-pi.mts', 'qualification\run-installed-native-nemocua.mts', 'agent-support.json' @@ -417,7 +419,16 @@ debug = false launcher = [pscustomobject]@{ rustVersion = $script:RustVersion sha256 = (Get-FileHash -LiteralPath (Join-Path $output 'bin\NemoClaw.exe') -Algorithm SHA256).Hash.ToLowerInvariant() + credentialBackend = 'Windows Credential Manager generic credentials' + configurationRoot = '%LOCALAPPDATA%\NVIDIA\NemoClaw\agents' } + agentAdapters = @( + [pscustomobject]@{ agent = 'openclaw'; interface = 'OpenClaw Control UI'; status = 'candidate' }, + [pscustomobject]@{ agent = 'hermes'; interface = 'Hermes native terminal'; status = 'candidate' }, + [pscustomobject]@{ agent = 'langchain-deepagents-code'; interface = 'Deep Agents Code terminal'; status = 'candidate' }, + [pscustomobject]@{ agent = 'pi'; interface = 'Pi native terminal'; status = 'experimental-candidate' }, + [pscustomobject]@{ agent = 'nemocua'; interface = 'NemoCUA visible browser'; status = 'experimental-candidate' } + ) openClaw = [pscustomobject]@{ version = '2026.7.1' } pi = [pscustomobject]@{ version = '0.84.1' diff --git a/scripts/checks/run-windows-native-package-qualification.ps1 b/scripts/checks/run-windows-native-package-qualification.ps1 index c744a5a1a20..e22bd71de1d 100644 --- a/scripts/checks/run-windows-native-package-qualification.ps1 +++ b/scripts/checks/run-windows-native-package-qualification.ps1 @@ -354,6 +354,83 @@ function Invoke-PythonScriptVersionProbe { } } +function Invoke-NativeCredentialManagerRoundTrip { + param([Parameter(Mandatory)][string]$LauncherPath) + + $secret = "NemoClawNativeCredential-$([guid]::NewGuid().ToString('N'))" + $invokeHelper = { + param( + [Parameter(Mandatory)][string[]]$Arguments, + [AllowEmptyString()][string]$StandardInput = '' + ) + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $LauncherPath + $startInfo.Arguments = ($Arguments | ForEach-Object { + ConvertTo-NativeArgument -Value $_ + }) -join ' ' + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardInput = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + try { + if (-not $process.Start()) { + Fail-PackageQualification 'The native Windows credential helper could not start.' + } + if (-not [string]::IsNullOrEmpty($StandardInput)) { + $process.StandardInput.Write($StandardInput) + } + $process.StandardInput.Close() + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit(30000)) { + $process.Kill() + $process.WaitForExit() + Fail-PackageQualification 'The native Windows credential helper exceeded its timeout.' + } + $process.WaitForExit() + return [pscustomobject]@{ + exitCode = $process.ExitCode + stdout = $stdoutTask.GetAwaiter().GetResult() + stderr = $stderrTask.GetAwaiter().GetResult() + } + } finally { + $process.Dispose() + } + } + + $write = & $invokeHelper @('--credential-write', 'local') $secret + if ($write.exitCode -ne 0 -or -not [string]::IsNullOrEmpty($write.stdout) -or + -not [string]::IsNullOrEmpty($write.stderr)) { + Fail-PackageQualification 'The native Windows credential helper did not store a test credential.' + } + try { + $read = & $invokeHelper @('--credential-read', 'local') + if ($read.exitCode -ne 0 -or $read.stdout -cne $secret -or + -not [string]::IsNullOrEmpty($read.stderr)) { + Fail-PackageQualification 'Windows Credential Manager did not return the exact test credential.' + } + } finally { + $delete = & $invokeHelper @('--credential-delete', 'local') + if ($delete.exitCode -ne 0) { + Fail-PackageQualification 'The native Windows credential helper did not delete its test credential.' + } + } + $absent = & $invokeHelper @('--credential-read', 'local') + if ($absent.exitCode -eq 0 -or -not [string]::IsNullOrEmpty($absent.stdout)) { + Fail-PackageQualification 'The native Windows credential helper left its test credential behind.' + } + Write-Host '[PASS] Native launcher stored, read, and removed a secret through Windows Credential Manager' + return [pscustomobject]@{ + backend = 'Windows Credential Manager generic credential' + provider = 'local' + exactRoundTrip = $true + removedAfterProbe = $true + } +} + function Get-ArpEntries { param([Parameter(Mandatory)][string]$DisplayName) @@ -594,6 +671,7 @@ foreach ($requiredPayload in @( 'config\mxc-gateway.toml', 'qualification\run-installed-native-turn.mts', 'qualification\run-installed-native-web-ui.mts', + 'qualification\run-installed-native-console-agent.mts', 'qualification\run-installed-native-pi.mts', 'qualification\run-installed-native-nemocua.mts', 'agent-support.json', @@ -680,6 +758,7 @@ try { Invoke-PythonDistributionVersionProbe -PythonPath $pythonPath -SitePackages $deepAgentsSitePackages -Distribution 'deepagents-code' -ExpectedVersion '0.1.55' -EntryRelativePath 'deepagents_code\main.py' -Label 'Installed Deep Agents Code runtime' Invoke-PythonScriptVersionProbe -PythonPath $pythonPath -ScriptPath $nemoCuaEntryPath -ExpectedVersion '0.1.0-windows-experimental' -Label 'Installed NemoCUA runtime' ) + $credentialManagerEvidence = Invoke-NativeCredentialManagerRoundTrip -LauncherPath $nemoclawUiLauncherPath $nativeTurnArtifacts = Join-Path $artifactRoot 'native-turn' Write-Host "PS> Installed NemoClaw native MXC agent turn :: nemoclaw debug --native-windows-turn" & $nemoclawLauncherPath debug --native-windows-turn --artifact-directory $nativeTurnArtifacts @@ -734,7 +813,7 @@ try { $webUiReceipt.browser -cne 'Microsoft Edge' -or $webUiReceipt.deterministicLocalModel -ne $true -or $webUiReceipt.onboardingSelection.agent -cne 'openclaw' -or - $webUiReceipt.onboardingSelection.inference -cne 'nvidia' -or + $webUiReceipt.onboardingSelection.inference -cne 'qualification' -or @($webUiReceipt.demonstratedAgentChoices).Count -ne $expectedAgentChoices.Count -or @($webUiReceipt.disabledAgentChoices).Count -ne $expectedDisabledAgentChoices.Count -or [int]$webUiReceipt.turnCount -ne 3 -or @@ -1030,6 +1109,7 @@ try { } nativeExecutions = $nativeEvidence applicationExecutions = $applicationEvidence + credentialManager = $credentialManagerEvidence nativeTurn = $nativeTurnReceipt webUi = $webUiReceipt pi = $piReceipt From 0aac82d5564e58a55aca99c5f37fe4da7e801176 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 3 Sep 2026 10:29:40 -0700 Subject: [PATCH 091/144] docs(windows): cover every executable signing gate --- packaging/windows/SIGNING.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packaging/windows/SIGNING.md b/packaging/windows/SIGNING.md index a0f5ce47abf..edaaa98fabb 100644 --- a/packaging/windows/SIGNING.md +++ b/packaging/windows/SIGNING.md @@ -10,6 +10,9 @@ an approved Authenticode identity: 1. Sign and verify `openshell.exe`, `openshell-gateway.exe`, `NemoClaw.exe`, and the native ARM64 bootstrapper application before MSI or Burn binding. + Verify the approved publisher signatures on the pinned `node.exe`, + `python.exe`, `wxc-exec.exe`, and `wxc-host-prep.exe` inputs, and apply an + NVIDIA signature too if release policy requires it. 2. Build the ARM64 MSI from those signed payloads, then sign and verify the MSI. 3. Build the Burn bundle with the signed MSI embedded. 4. Use the pinned WiX tool to detach the Burn engine, sign and verify the From c81dc8c5a9370ba794bf27c189b989ec9b0de853 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 3 Sep 2026 10:30:52 -0700 Subject: [PATCH 092/144] test(windows): bind agent receipts to installed payloads --- .../runtime/run-installed-native-nemocua.mts | 7 +++++ .../runtime/run-installed-native-pi.mts | 27 ++++++++++++++++++- .../runtime/run-installed-native-web-ui.mts | 10 ++++++- ...n-windows-native-package-qualification.ps1 | 13 +++++++++ 4 files changed, 55 insertions(+), 2 deletions(-) diff --git a/packaging/windows/runtime/run-installed-native-nemocua.mts b/packaging/windows/runtime/run-installed-native-nemocua.mts index a8d54594300..b74e41df89b 100644 --- a/packaging/windows/runtime/run-installed-native-nemocua.mts +++ b/packaging/windows/runtime/run-installed-native-nemocua.mts @@ -623,6 +623,13 @@ async function main() { browser: "Microsoft Edge", browserVersion: bridge.browserVersion, interface: "NemoCUA visible browser task", + runtimeEntrypointSha256: createHash("sha256") + .update(fs.readFileSync(path.join(installedNemoCuaRoot, "run_with_harness.py"))) + .digest("hex"), + pythonSha256: createHash("sha256") + .update(fs.readFileSync(path.join(installedPythonRoot, "python.exe"))) + .digest("hex"), + openShellSha256: createHash("sha256").update(fs.readFileSync(openshell)).digest("hex"), deterministicLocalModel: qualification, inferenceProvider: configuredIdentity?.config.inference ?? "qualification", model: configuredIdentity?.config.model ?? "nemocua-native-preview", diff --git a/packaging/windows/runtime/run-installed-native-pi.mts b/packaging/windows/runtime/run-installed-native-pi.mts index 3fbe363b369..6a5e15ad0fe 100644 --- a/packaging/windows/runtime/run-installed-native-pi.mts +++ b/packaging/windows/runtime/run-installed-native-pi.mts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { spawn } from "node:child_process"; -import { randomBytes } from "node:crypto"; +import { createHash, randomBytes } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; @@ -44,6 +44,10 @@ function fail(message) { throw new Error(`Native Windows terminal-agent qualification failed: ${message}`); } +function sha256(file) { + return createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +} + function piWorkloadSource() { return String.raw`import { spawn } from "node:child_process"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; @@ -570,6 +574,21 @@ async function main() { isHermes || isDeepAgents ? requiredDirectory(path.join(installRoot, "python"), "Python runtime") : null; + const installedAgentEntrypoint = requiredFile( + isHermes + ? path.join(installedAgentRoot, "site-packages", "hermes_cli", "main.py") + : isDeepAgents + ? path.join(installedAgentRoot, "site-packages", "deepagents_code", "main.py") + : path.join( + installedAgentRoot, + "node_modules", + "@earendil-works", + "pi-coding-agent", + "dist", + "cli.js", + ), + `${agentLabel} installed entrypoint`, + ); const gatewayConfig = requiredFile( path.join(installRoot, "config", "mxc-gateway.toml"), "MXC gateway configuration", @@ -834,6 +853,12 @@ async function main() { architecture: "arm64", backend: "process_container", interface: `${agentLabel} terminal one-shot mode`, + runtimeEntrypointSha256: sha256(installedAgentEntrypoint), + runtimeHostSha256: sha256( + installedPythonRoot === null + ? installedNode + : requiredFile(path.join(installedPythonRoot, "python.exe"), "installed Python runtime"), + ), deterministicLocalModel: true, createWatcherStopped: true, sandboxDeleted: true, diff --git a/packaging/windows/runtime/run-installed-native-web-ui.mts b/packaging/windows/runtime/run-installed-native-web-ui.mts index 0ce3ae2ccf4..f8c200073fe 100644 --- a/packaging/windows/runtime/run-installed-native-web-ui.mts +++ b/packaging/windows/runtime/run-installed-native-web-ui.mts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { spawn } from "node:child_process"; -import { randomBytes } from "node:crypto"; +import { createHash, randomBytes } from "node:crypto"; import fs from "node:fs"; import { createServer } from "node:http"; import { createRequire } from "node:module"; @@ -37,6 +37,10 @@ function fail(message) { throw new Error(`NemoClaw native Windows launch failed: ${message}`); } +function sha256(file) { + return createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +} + const PROVIDER_CONFIGURATION = { nvidia: { endpoint: "https://integrate.api.nvidia.com/v1", @@ -1209,6 +1213,10 @@ async function main() { backend: "process_container", browser: "Microsoft Edge", browserVersion: browserProof.browserVersion, + openClawEntrypointSha256: sha256(installedOpenClawEntry), + nodeSha256: sha256(installedNode), + openShellSha256: sha256(openshell), + openShellGatewaySha256: sha256(gatewayExecutable), deterministicLocalModel: qualification, onboardingSelection, demonstratedAgentChoices: browserProof.demonstratedAgentChoices, diff --git a/scripts/checks/run-windows-native-package-qualification.ps1 b/scripts/checks/run-windows-native-package-qualification.ps1 index e22bd71de1d..e96a4b501b5 100644 --- a/scripts/checks/run-windows-native-package-qualification.ps1 +++ b/scripts/checks/run-windows-native-package-qualification.ps1 @@ -811,6 +811,10 @@ try { if ($webUiReceipt.verdict -cne 'pass' -or $webUiReceipt.backend -cne 'process_container' -or $webUiReceipt.browser -cne 'Microsoft Edge' -or + $webUiReceipt.openClawEntrypointSha256 -cne $payloadHashes['openclaw\node_modules\openclaw\openclaw.mjs'] -or + $webUiReceipt.nodeSha256 -cne $payloadHashes['bin\node.exe'] -or + $webUiReceipt.openShellSha256 -cne $payloadHashes['bin\openshell.exe'] -or + $webUiReceipt.openShellGatewaySha256 -cne $payloadHashes['bin\openshell-gateway.exe'] -or $webUiReceipt.deterministicLocalModel -ne $true -or $webUiReceipt.onboardingSelection.agent -cne 'openclaw' -or $webUiReceipt.onboardingSelection.inference -cne 'qualification' -or @@ -870,6 +874,8 @@ try { $piReceipt.piVersion -cne '0.84.1' -or $piReceipt.backend -cne 'process_container' -or $piReceipt.interface -cne 'Pi terminal one-shot mode' -or + $piReceipt.runtimeEntrypointSha256 -cne $payloadHashes['pi\node_modules\@earendil-works\pi-coding-agent\dist\cli.js'] -or + $piReceipt.runtimeHostSha256 -cne $payloadHashes['bin\node.exe'] -or [int]$piReceipt.turnCount -ne 3 -or @($piReceipt.turns).Count -ne 3 -or $piReceipt.createWatcherStopped -ne $true -or @@ -902,6 +908,8 @@ try { $hermesReceipt.hermesVersion -cne '0.19.0' -or $hermesReceipt.backend -cne 'process_container' -or $hermesReceipt.interface -cne 'Hermes terminal one-shot mode' -or + $hermesReceipt.runtimeEntrypointSha256 -cne $payloadHashes['hermes\site-packages\hermes_cli\main.py'] -or + $hermesReceipt.runtimeHostSha256 -cne $payloadHashes['python\python.exe'] -or [int]$hermesReceipt.turnCount -ne 3 -or @($hermesReceipt.turns).Count -ne 3 -or $hermesReceipt.createWatcherStopped -ne $true -or @@ -934,6 +942,8 @@ try { $deepAgentsReceipt.deepAgentsCodeVersion -cne '0.1.55' -or $deepAgentsReceipt.backend -cne 'process_container' -or $deepAgentsReceipt.interface -cne 'Deep Agents Code terminal one-shot mode' -or + $deepAgentsReceipt.runtimeEntrypointSha256 -cne $payloadHashes['deepagents\site-packages\deepagents_code\main.py'] -or + $deepAgentsReceipt.runtimeHostSha256 -cne $payloadHashes['python\python.exe'] -or [int]$deepAgentsReceipt.turnCount -ne 3 -or @($deepAgentsReceipt.turns).Count -ne 3 -or $deepAgentsReceipt.createWatcherStopped -ne $true -or @@ -967,6 +977,9 @@ try { $nemoCuaReceipt.backend -cne 'process_container' -or $nemoCuaReceipt.interface -cne 'NemoCUA visible browser task' -or $nemoCuaReceipt.browser -cne 'Microsoft Edge' -or + $nemoCuaReceipt.runtimeEntrypointSha256 -cne $payloadHashes['nemocua\run_with_harness.py'] -or + $nemoCuaReceipt.pythonSha256 -cne $payloadHashes['python\python.exe'] -or + $nemoCuaReceipt.openShellSha256 -cne $payloadHashes['bin\openshell.exe'] -or [int]$nemoCuaReceipt.turnCount -ne 3 -or @($nemoCuaReceipt.turns).Count -ne 3 -or $nemoCuaReceipt.visiblePostcondition.inputValue -cne 'NEMOCUA_NATIVE_WINDOWS' -or From 590888dc25dbb7f1eb378888ee90b38efcc6a563 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 3 Sep 2026 10:33:54 -0700 Subject: [PATCH 093/144] fix(windows): authenticate graphical onboarding session --- packaging/windows/onboarding/app.ts | 8 +++++--- .../runtime/run-installed-native-web-ui.mts | 16 +++++++++++----- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/packaging/windows/onboarding/app.ts b/packaging/windows/onboarding/app.ts index 7bb9bca3345..23524e02974 100644 --- a/packaging/windows/onboarding/app.ts +++ b/packaging/windows/onboarding/app.ts @@ -52,8 +52,10 @@ const providerDefaults = { }, }; -const qualification = new URLSearchParams(window.location.search).get("qualification") === "1"; -const requestedAgent = new URLSearchParams(window.location.search).get("agent"); +const query = new URLSearchParams(window.location.search); +const qualification = query.get("qualification") === "1"; +const requestedAgent = query.get("agent"); +const sessionToken = query.get("session") ?? ""; const state = { step: 1, agent: Object.hasOwn(agentNames, requestedAgent) ? requestedAgent : "openclaw", @@ -200,7 +202,7 @@ form.addEventListener("submit", async (event) => { try { const response = await fetch("/api/configure", { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", "x-nemoclaw-session": sessionToken }, body: JSON.stringify({ ...state, options }), }); const result = await response.json(); diff --git a/packaging/windows/runtime/run-installed-native-web-ui.mts b/packaging/windows/runtime/run-installed-native-web-ui.mts index f8c200073fe..c91c98549a0 100644 --- a/packaging/windows/runtime/run-installed-native-web-ui.mts +++ b/packaging/windows/runtime/run-installed-native-web-ui.mts @@ -437,10 +437,13 @@ async function startOnboardingServer( ]); let selection = null; let runtimeConfiguration = null; + const sessionToken = randomBytes(32).toString("base64url"); const server = createServer(async (request, response) => { try { const pathname = new URL(request.url ?? "/", "http://127.0.0.1").pathname; if (request.method === "POST" && pathname === "/api/configure") { + if (request.headers["x-nemoclaw-session"] !== sessionToken) + throw new Error("The onboarding session token is invalid."); const chunks = []; let size = 0; for await (const chunk of request) { @@ -539,9 +542,11 @@ async function startOnboardingServer( server.once("error", reject); server.listen(port, "127.0.0.1", resolve); }); + const origin = `http://127.0.0.1:${port}`; return { server, - url: `http://127.0.0.1:${port}`, + origin, + url: `${origin}?session=${sessionToken}`, selection: () => selection, runtimeConfiguration: () => runtimeConfiguration, }; @@ -587,6 +592,7 @@ async function driveBrowser( }); const page = await context.newPage(); const onboardingPageUrl = new URL(onboardingUrl); + const onboardingOrigin = onboardingPageUrl.origin; onboardingPageUrl.searchParams.set("agent", targetAgent); if (qualification) onboardingPageUrl.searchParams.set("qualification", "1"); await page.goto(onboardingPageUrl.toString(), { @@ -601,8 +607,8 @@ async function driveBrowser( await page.screenshot({ path: path.join(evidenceRoot, "onboarding-agent.png"), }); - console.log(`WEB UI> READY ${onboardingUrl}`); - await page.waitForURL(`${onboardingUrl}/launching.html?agent=*`, { + console.log(`WEB UI> READY ${onboardingOrigin}`); + await page.waitForURL(`${onboardingOrigin}/launching.html?agent=*`, { timeout: 30 * 60_000, }); return { @@ -650,7 +656,7 @@ async function driveBrowser( await sleep(2500); await page.locator("#launch").click(); if (targetAgent !== "openclaw") { - await page.waitForURL(`${onboardingUrl}/launching.html?agent=${targetAgent}`, { + await page.waitForURL(`${onboardingOrigin}/launching.html?agent=${targetAgent}`, { timeout: 30_000, }); await page.screenshot({ @@ -1157,7 +1163,7 @@ async function main() { true, launcherPath, ); - console.log(`WEB UI> Launching the NemoClaw graphical onboarder at ${onboarding.url}`); + console.log(`WEB UI> Launching the NemoClaw graphical onboarder at ${onboarding.origin}`); browserProof = await driveBrowser( openClawRoot, onboarding.url, From 40d0f9ec60f47590105de3959c515ceb13621815 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 3 Sep 2026 10:39:32 -0700 Subject: [PATCH 094/144] fix(windows): keep complete onboarding window visible --- packaging/windows/onboarding/styles.css | 32 +++++++++++++------ .../runtime/run-installed-native-nemocua.mts | 4 +-- .../runtime/run-installed-native-web-ui.mts | 8 ++--- 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/packaging/windows/onboarding/styles.css b/packaging/windows/onboarding/styles.css index 7ed0a78ab05..e2841762b8a 100644 --- a/packaging/windows/onboarding/styles.css +++ b/packaging/windows/onboarding/styles.css @@ -30,8 +30,11 @@ button { } .shell { + display: flex; width: min(1120px, calc(100vw - 64px)); - min-height: calc(100vh - 64px); + height: calc(100vh - 64px); + min-height: 620px; + flex-direction: column; margin: 32px auto; overflow: hidden; background: var(--surface); @@ -40,6 +43,13 @@ button { box-shadow: 0 22px 60px rgba(0, 0, 0, 0.09); } +#onboarding-form { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; +} + .masthead { display: flex; align-items: center; @@ -107,6 +117,8 @@ button { } .panel.active { display: block; + flex: 1; + overflow-y: auto; } .eyebrow { margin: 0 0 8px; @@ -251,14 +263,14 @@ h1 { .choice-grid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; - max-width: 860px; + max-width: none; } .choice { position: relative; display: flex; - min-height: 112px; + min-height: 106px; flex-direction: column; gap: 8px; padding: 20px; @@ -276,9 +288,8 @@ h1 { line-height: 1.4; } .choice em { - position: absolute; - top: 18px; - right: 18px; + align-self: flex-start; + margin-top: auto; color: var(--green-dark); font-size: 11px; font-style: normal; @@ -291,7 +302,7 @@ h1 { .qualification-note, .configuration-card { - max-width: 860px; + max-width: none; margin-bottom: 18px; } .qualification-note { @@ -494,8 +505,8 @@ h1 { } .shell { width: calc(100vw - 28px); + height: calc(100vh - 28px); margin: 14px; - min-height: calc(100vh - 28px); } .agent-grid, .choice-grid, @@ -508,4 +519,7 @@ h1 { .agent-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .choice-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } diff --git a/packaging/windows/runtime/run-installed-native-nemocua.mts b/packaging/windows/runtime/run-installed-native-nemocua.mts index b74e41df89b..4e68c70447e 100644 --- a/packaging/windows/runtime/run-installed-native-nemocua.mts +++ b/packaging/windows/runtime/run-installed-native-nemocua.mts @@ -219,10 +219,10 @@ async function startBrowserBridge( "--no-first-run", "--no-default-browser-check", "--window-position=20,10", - "--window-size=1240,700", + "--window-size=1280,900", ], }); - const context = await browser.newContext({ viewport: { width: 1200, height: 630 } }); + const context = await browser.newContext({ viewport: { width: 1240, height: 820 } }); const page = await context.newPage(); let observationIndex = 0; let port = 0; diff --git a/packaging/windows/runtime/run-installed-native-web-ui.mts b/packaging/windows/runtime/run-installed-native-web-ui.mts index c91c98549a0..0efa37d5f59 100644 --- a/packaging/windows/runtime/run-installed-native-web-ui.mts +++ b/packaging/windows/runtime/run-installed-native-web-ui.mts @@ -581,14 +581,14 @@ async function driveBrowser( "--no-first-run", "--no-default-browser-check", "--window-position=20,10", - "--window-size=1240,700", + "--window-size=1280,900", ], }); let browserVersion = "unknown"; try { browserVersion = browser.version(); const context = await browser.newContext({ - viewport: { width: 1200, height: 630 }, + viewport: { width: 1240, height: 820 }, }); const page = await context.newPage(); const onboardingPageUrl = new URL(onboardingUrl); @@ -774,11 +774,11 @@ async function driveConfiguredOpenClaw(openClawRoot, openClawUrl) { "--no-first-run", "--no-default-browser-check", "--window-position=20,10", - "--window-size=1240,700", + "--window-size=1280,900", ], }); try { - const context = await browser.newContext({ viewport: { width: 1200, height: 630 } }); + const context = await browser.newContext({ viewport: { width: 1240, height: 820 } }); const page = await context.newPage(); await page.goto(`${openClawUrl}/chat`, { waitUntil: "domcontentloaded", timeout: 90_000 }); const composer = page.locator(".agent-chat__composer-combobox > textarea").first(); From 8697371d2236b6364562e34eb6053ebea37277db Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 3 Sep 2026 10:40:57 -0700 Subject: [PATCH 095/144] fix(windows): frame proof windows at full aspect --- .../windows/runtime/run-installed-native-nemocua.mts | 4 ++-- packaging/windows/runtime/run-installed-native-web-ui.mts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packaging/windows/runtime/run-installed-native-nemocua.mts b/packaging/windows/runtime/run-installed-native-nemocua.mts index 4e68c70447e..7181e796bed 100644 --- a/packaging/windows/runtime/run-installed-native-nemocua.mts +++ b/packaging/windows/runtime/run-installed-native-nemocua.mts @@ -219,10 +219,10 @@ async function startBrowserBridge( "--no-first-run", "--no-default-browser-check", "--window-position=20,10", - "--window-size=1280,900", + "--window-size=1440,810", ], }); - const context = await browser.newContext({ viewport: { width: 1240, height: 820 } }); + const context = await browser.newContext({ viewport: { width: 1400, height: 730 } }); const page = await context.newPage(); let observationIndex = 0; let port = 0; diff --git a/packaging/windows/runtime/run-installed-native-web-ui.mts b/packaging/windows/runtime/run-installed-native-web-ui.mts index 0efa37d5f59..e494df42d59 100644 --- a/packaging/windows/runtime/run-installed-native-web-ui.mts +++ b/packaging/windows/runtime/run-installed-native-web-ui.mts @@ -581,14 +581,14 @@ async function driveBrowser( "--no-first-run", "--no-default-browser-check", "--window-position=20,10", - "--window-size=1280,900", + "--window-size=1440,810", ], }); let browserVersion = "unknown"; try { browserVersion = browser.version(); const context = await browser.newContext({ - viewport: { width: 1240, height: 820 }, + viewport: { width: 1400, height: 730 }, }); const page = await context.newPage(); const onboardingPageUrl = new URL(onboardingUrl); @@ -774,11 +774,11 @@ async function driveConfiguredOpenClaw(openClawRoot, openClawUrl) { "--no-first-run", "--no-default-browser-check", "--window-position=20,10", - "--window-size=1280,900", + "--window-size=1440,810", ], }); try { - const context = await browser.newContext({ viewport: { width: 1240, height: 820 } }); + const context = await browser.newContext({ viewport: { width: 1400, height: 730 } }); const page = await context.newPage(); await page.goto(`${openClawUrl}/chat`, { waitUntil: "domcontentloaded", timeout: 90_000 }); const composer = page.locator(".agent-chat__composer-combobox > textarea").first(); From d42121530bcca104f245bf3c08eaf2e0acf6a17a Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 3 Sep 2026 10:43:47 -0700 Subject: [PATCH 096/144] docs(windows): describe credential handling accurately --- packaging/windows/onboarding/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packaging/windows/onboarding/index.html b/packaging/windows/onboarding/index.html index 7779286cbcc..98dbda97db8 100644 --- a/packaging/windows/onboarding/index.html +++ b/packaging/windows/onboarding/index.html @@ -141,8 +141,8 @@

Start with the experience that fits your work.

Choose inference

Connect the model you trust.

- Credentials are requested only after the selected endpoint passes validation and are - never written to Windows Installer logs. + NemoClaw validates the connection details before launch. Credentials are protected by + your Windows account and never written to Windows Installer logs.