From 27cc6b84ac367786e8df3caa5d263726725bd657 Mon Sep 17 00:00:00 2001 From: Linus Huang Date: Fri, 11 Sep 2026 15:09:18 -0700 Subject: [PATCH 1/2] feat: bundle Node.js runtime in gateway MSIX Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a35bc4fd-e612-4be2-8166-7c100e8cf3ef --- .github/copilot-instructions.md | 24 +- .github/workflows/gateway-msix.yml | 12 + CONTRIBUTING.md | 7 +- README.md | 67 +++--- scripts/Build-LocalMSIX.ps1 | 24 ++ scripts/Build-MSIX.ps1 | 88 +++++++- scripts/Test-SigningInputs.Tests.ps1 | 41 +++- scripts/Test-SigningInputs.ps1 | 68 +++++- src/OpenClaw.Launcher/ClawCtlConsole.cs | 13 +- src/OpenClaw.Launcher/HostDataPaths.cs | 67 ++++++ src/OpenClaw.Launcher/HostDiagnosticLog.cs | 41 +--- src/OpenClaw.Launcher/HostOptions.cs | 10 + src/OpenClaw.Launcher/NodeRuntimeInstaller.cs | 208 ++++++++++++++++++ src/OpenClaw.Launcher/NodeRuntimeResolver.cs | 61 ++--- .../OpenClaw.Launcher.csproj | 5 + src/OpenClaw.Launcher/Program.cs | 35 ++- .../ClawCtlConsoleTests.cs | 9 +- .../HostOptionsTests.cs | 19 ++ .../NodeRuntimeInstallerTests.cs | 101 +++++++++ .../NodeRuntimeResolverTests.cs | 8 +- tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 10 +- 21 files changed, 739 insertions(+), 179 deletions(-) create mode 100644 src/OpenClaw.Launcher/HostDataPaths.cs create mode 100644 src/OpenClaw.Launcher/NodeRuntimeInstaller.cs create mode 100644 tests/OpenClaw.Launcher.Tests/NodeRuntimeInstallerTests.cs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 304db33e..7f3863d7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -57,10 +57,12 @@ and ARM64 separately. app execution alias and declares the `OpenClaw.Gateway` MSIX identity. - The package contains an expanded, read-only OpenClaw application tree. `HostOptions` resolves `app\openclaw.mjs` directly from the package. -- `openclaw` resolves device-installed Node.js, confirms the packaged entry - point exists, and forwards every argument unchanged to `openclaw.mjs`. -- `clawctl setup` is a read-only readiness check for compatible Node.js and the - packaged entry point. Runtime launches do not hash or walk package files. +- `openclaw` resolves the Node.js executable extracted into package LocalState, + confirms the packaged entry point exists, and forwards every argument + unchanged to `openclaw.mjs`. +- `clawctl setup` idempotently extracts the architecture-specific bundled + Node.js archive into versioned package LocalState and verifies the packaged + entry point. Runtime launches do not hash or walk application files. - `GatewayLauncher` starts Node without a shell, uses `ArgumentList`, inherits the console streams, and sets `OPENCLAW_SUPERVISOR_MODE=external` plus `OPENCLAW_NO_AUTO_UPDATE=1`. The child process exit code is the launcher exit @@ -71,9 +73,10 @@ and ARM64 separately. - The GitHub workflow first builds and packs a pinned `openclaw/openclaw` revision on Linux. Windows matrix jobs use `Build-Payload.ps1` to produce x64/ARM64 expanded trees and build metadata, - then `Build-MSIX.ps1` to reject bundled Node.js, build the application - inventory, publish the NativeAOT host, validate package contents, and emit - MSIX metadata. + then downloads the matching official Node.js archive and uses + `Build-MSIX.ps1` to reject Node.js from the application payload, build the + application inventory, publish the NativeAOT host, validate package + contents, and emit MSIX metadata including the runtime hash. - Unsigned artifacts are the normal PR/push output. Test signing uses a temporary runner-local certificate. Official signing is gated to `main` and the immutable upstream commit in `release-policy.json`; signing inputs are @@ -105,9 +108,10 @@ and ARM64 separately. - Treat launcher arguments as OpenClaw-owned. Do not add host-only switches, consume `--`, rewrite arguments, or block upstream commands; tests explicitly protect transparent forwarding. -- Preserve direct execution from the immutable package and the caller's - working directory. Do not add runtime extraction, copying, hashing, or - inventory walks. +- Preserve direct execution of `app\openclaw.mjs` from the immutable package + and the caller's working directory. Node.js extraction belongs only to + `clawctl setup` and targets versioned package LocalState; do not copy the + OpenClaw application payload. - The build-time inventory is a release trust boundary. Keep safe unique paths, lengths, and SHA-256 values synchronized across composition and signing validation. diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 826434fd..97881282 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -227,6 +227,16 @@ jobs: name: openclaw-gateway-payload-${{ matrix.architecture }} path: ${{ runner.temp }}\openclaw-payload + - name: Download bundled Node.js runtime + shell: pwsh + run: | + $archiveName = 'node-v${{ env.NODE_VERSION }}-win-${{ matrix.architecture }}.zip' + $archivePath = Join-Path $env:RUNNER_TEMP $archiveName + $archiveUri = 'https://nodejs.org/dist/v${{ env.NODE_VERSION }}/' + $archiveName + Invoke-WebRequest -Uri $archiveUri -OutFile $archivePath -UseBasicParsing + "NODE_ARCHIVE_PATH=$archivePath" | + Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + - name: Restore NativeAOT and MSIX dependencies shell: pwsh run: | @@ -246,6 +256,8 @@ jobs: .\scripts\Build-MSIX.ps1 ` -PayloadDirectory '${{ runner.temp }}\openclaw-payload' ` + -NodeArchivePath $env:NODE_ARCHIVE_PATH ` + -NodeVersion '${{ env.NODE_VERSION }}' ` -Architecture '${{ matrix.architecture }}' ` -PackageVersion $packageVersion ` -SourceCommit '${{ github.sha }}' ` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ca675887..f201aa41 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -142,9 +142,10 @@ bypassable, and required CI checks remain authoritative. Packaging builds set it to `true` and supply a runtime identifier. - Treat launcher arguments as OpenClaw-owned. Do not add host-only switches, consume `--`, rewrite arguments, or block upstream commands. -- Preserve direct execution from the read-only MSIX package. `clawctl setup` - is a readiness check; do not add runtime extraction, copying, repair, or - launcher-managed package state under the user profile. +- Preserve direct execution of `app\openclaw.mjs` from the read-only MSIX + package. `clawctl setup` owns idempotent extraction of the bundled Node.js + archive into versioned package LocalState; do not copy the OpenClaw + application payload or use device-installed Node.js. - Keep x64 and ARM64 behavior synchronized across the workflow matrix, scripts, project runtime identifiers, manifest content, and signing validation. - Metadata files are part of the release trust chain. Coordinate changes across diff --git a/README.md b/README.md index cfbcdbc7..3dacfe14 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,8 @@ This repository builds a Windows MSIX package containing: - one .NET 10 NativeAOT launcher exposed through the `openclaw` and `clawctl` app execution aliases; - a pinned, verified build of - [`openclaw/openclaw`](https://github.com/openclaw/openclaw). - -Node.js is a device prerequisite and is never downloaded or included in the -MSIX. + [`openclaw/openclaw`](https://github.com/openclaw/openclaw); +- the official Node.js 24.16.0 archive matching the package architecture. The package is independent from the [OpenClaw Windows Node and Companion](https://github.com/openclaw/openclaw-windows-node) @@ -29,9 +27,10 @@ own package-management commands. Every argument, including an empty argument list, is forwarded unchanged to `node openclaw.mjs`, and the launcher returns the exact child exit code. -Before launching, the host discovers `node.exe` on `PATH` and verifies its -version and executable architecture. It never downloads, installs, or services -Node.js. +Before launching, the host resolves the bundled Node.js executable previously +prepared by `clawctl setup` and verifies its version and executable +architecture. Device-installed Node.js and `PATH` do not affect command +passthrough. The expanded OpenClaw application is installed read-only inside the MSIX. After resolving Node.js, the launcher confirms that packaged @@ -57,7 +56,7 @@ the read-only application directory the workspace. | Command | Behavior | |---|---| -| `clawctl setup` | Verify compatible Node.js is on `PATH` and confirm packaged `app\openclaw.mjs` exists. | +| `clawctl setup` | Extract the bundled Node.js runtime when needed and confirm packaged `app\openclaw.mjs` exists. | | `clawctl --version` | Print the packaged launcher version. | Bare `clawctl`, `clawctl -h`, and `clawctl --help` print help without changing @@ -65,23 +64,21 @@ state. Commands such as `doctor`, `gateway`, and `uninstall` belong to the OpenClaw CLI and must be invoked through `openclaw`. -`setup` requires a compatible device-installed Node.js runtime. Missing, -outdated, malformed, or architecture-incompatible runtimes produce an -actionable error rather than a later process-launch failure. - -`clawctl setup` is read-only. It performs no extraction, hashing, inventory -walk, or state mutation. +`setup` extracts the architecture-specific runtime archive from the immutable +MSIX into the package's writable LocalState: +`%LOCALAPPDATA%\Packages\\LocalState\OpenClaw\NodeJS\node-v24.16.0-win-`. +Extraction is idempotent, versioned, and serialized across concurrent setup +processes. The launcher validates the extracted `node.exe` before reporting +readiness. The launcher places Node.js in a Windows job configured with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. The launcher remains alive while Node.js runs; if the launcher exits or is terminated, Windows terminates Node.js and its child processes when the job handle closes. -Install the current Node.js LTS release, open a new terminal, optionally check -readiness, then use `openclaw`: +Prepare the bundled runtime once, then use `openclaw`: ```powershell -winget install --id OpenJS.NodeJS.LTS --exact --source winget clawctl setup openclaw ``` @@ -127,12 +124,14 @@ dotnet test .\OpenClaw.Gateway.MSIX.slnx ` ``` `scripts\Build-Payload.ps1` npm-installs an OpenClaw package into an expanded, -architecture-specific application tree. `scripts\Build-MSIX.ps1` copies that -tree into package content, rejects any Node.js executable or runtime archive, -creates a per-file inventory, and then creates an unsigned NativeAOT MSIX. +architecture-specific application tree. The workflow downloads the official +Node.js archive for the same architecture. `scripts\Build-MSIX.ps1` copies the +application tree and runtime archive into package content, rejects Node.js +inside the application payload, creates a per-file inventory, and then creates +an unsigned NativeAOT MSIX. `scripts\Build-LocalMSIX.ps1` can reuse a successful workflow payload or a -local payload directory. The Node.js used by the payload build jobs is build -infrastructure only and is not copied into the MSIX. +local payload directory; unless `-NodeArchivePath` is supplied, it downloads +the pinned runtime archive used by CI. Normal pull-request and push workflows publish unsigned packages for validation. Manual runs support three signing modes: @@ -156,6 +155,8 @@ key is stored in the repository. | Data | Default path | |---|---| | OpenClaw application files | Read-only MSIX package `app` directory | +| Bundled Node.js archive | Read-only MSIX package `runtime` directory | +| Extracted Node.js runtime | `%LOCALAPPDATA%\Packages\\LocalState\OpenClaw\NodeJS\node-v24.16.0-win-` | | OpenClaw configuration and user state | `%USERPROFILE%\.openclaw` | | Launcher diagnostics | `%LOCALAPPDATA%\Packages\\LocalState\OpenClawGatewayMSIX\Logs\openclaw.log` | @@ -169,18 +170,18 @@ removing the MSIX. ## Integrity and isolation boundary The payload build emits an expanded npm-installed application tree. -`Build-MSIX.ps1` rejects bundled Node.js, copies the tree into package content, +`Build-MSIX.ps1` rejects Node.js from that tree, copies it into package content, and records every application file's path, length, and SHA-256 in -`payload-files.json`. Package construction verifies that exact inventory -against the generated MSIX. Official signing authorization repeats the -inventory validation, including rejecting missing, changed, duplicate, unsafe, -or unlisted application entries, before requesting signing credentials. - -At runtime, Windows' MSIX package integrity and read-only enforcement is the -trust boundary. `openclaw` and `clawctl setup` only check that -`app\openclaw.mjs` exists; neither performs file hashing or an inventory walk. -This avoids redundant startup overhead while keeping package mutation under -Windows servicing control. +`payload-files.json`. It separately validates and hashes the pinned Node.js +archive. Package construction verifies both inputs against the generated MSIX. +Official signing authorization repeats the application inventory and Node.js +archive validation before requesting signing credentials. + +At runtime, Windows' MSIX package integrity and read-only enforcement remains +the trust boundary for the application and archive. `clawctl setup` extracts +the archive into versioned package LocalState; `openclaw` launches the packaged +`app\openclaw.mjs` directly with that extracted executable. Neither command +hashes or walks the expanded application inventory. The longer-term design is to run the Gateway payload in a dedicated isolated agent session rather than the interactive session where the human user is diff --git a/scripts/Build-LocalMSIX.ps1 b/scripts/Build-LocalMSIX.ps1 index 7dc0a76c..c0e77db8 100644 --- a/scripts/Build-LocalMSIX.ps1 +++ b/scripts/Build-LocalMSIX.ps1 @@ -7,6 +7,11 @@ param( [long]$PayloadRunId, + [ValidatePattern('^\d+\.\d+\.\d+$')] + [string]$NodeVersion = '24.16.0', + + [string]$NodeArchivePath, + [string]$PackageVersion, [string]$OutputDirectory @@ -55,6 +60,23 @@ if (Test-Path -LiteralPath $OutputDirectory) { } New-Item -Path $workDirectory -ItemType Directory -Force | Out-Null +if ($NodeArchivePath) { + $resolvedNodeArchivePath = ( + Resolve-Path -LiteralPath $NodeArchivePath + ).Path +} +else { + $nodeArchiveName = "node-v$NodeVersion-win-$Architecture.zip" + $resolvedNodeArchivePath = Join-Path $workDirectory $nodeArchiveName + $nodeArchiveUri = + "https://nodejs.org/dist/v$NodeVersion/$nodeArchiveName" + Write-Host "Downloading bundled Node.js runtime from $nodeArchiveUri." + Invoke-WebRequest ` + -Uri $nodeArchiveUri ` + -OutFile $resolvedNodeArchivePath ` + -UseBasicParsing +} + if ($PayloadDirectory) { $resolvedPayloadDirectory = (Resolve-Path -LiteralPath $PayloadDirectory).Path } @@ -135,6 +157,8 @@ try { Write-Host "Building unsigned MSIX version $PackageVersion." & .\scripts\Build-MSIX.ps1 ` -PayloadDirectory $resolvedPayloadDirectory ` + -NodeArchivePath $resolvedNodeArchivePath ` + -NodeVersion $NodeVersion ` -Architecture $Architecture ` -PackageVersion $PackageVersion ` -SourceCommit $sourceCommit ` diff --git a/scripts/Build-MSIX.ps1 b/scripts/Build-MSIX.ps1 index d690f864..955d9ffa 100644 --- a/scripts/Build-MSIX.ps1 +++ b/scripts/Build-MSIX.ps1 @@ -3,6 +3,13 @@ param( [Parameter(Mandatory)] [string]$PayloadDirectory, + [Parameter(Mandatory)] + [string]$NodeArchivePath, + + [Parameter(Mandatory)] + [ValidatePattern('^\d+\.\d+\.\d+$')] + [string]$NodeVersion, + [Parameter(Mandatory)] [ValidateSet('x64', 'arm64')] [string]$Architecture, @@ -129,6 +136,36 @@ function Assert-ApplicationHasNoReparsePoints { } } +function Assert-NodeArchive { + param( + [Parameter(Mandatory)] + [string]$Path, + + [Parameter(Mandatory)] + [string]$ExpectedRoot + ) + + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [IO.Compression.ZipFile]::OpenRead($Path) + try { + $nodeEntries = @( + $archive.Entries | + Where-Object { + $_.FullName -ieq "$ExpectedRoot/node.exe" + } + ) + if ($nodeEntries.Count -ne 1) { + throw ( + "The Node.js archive must contain exactly one " + + "'$ExpectedRoot/node.exe' entry." + ) + } + } + finally { + $archive.Dispose() + } +} + function Add-VswhereToPath { if (Get-Command vswhere.exe -CommandType Application -ErrorAction SilentlyContinue) { return @@ -154,6 +191,20 @@ Add-VswhereToPath $PayloadDirectory = (Resolve-Path -LiteralPath $PayloadDirectory).Path $payloadApplication = Join-Path $PayloadDirectory 'app' $payloadMetadata = Join-Path $PayloadDirectory 'payload-metadata.json' +$NodeArchivePath = (Resolve-Path -LiteralPath $NodeArchivePath).Path +$expectedNodeArchiveName = "node-v$NodeVersion-win-$Architecture.zip" +if ([IO.Path]::GetFileName($NodeArchivePath) -cne $expectedNodeArchiveName) { + throw ( + "NodeArchivePath must name the expected runtime archive: " + + $expectedNodeArchiveName + ) +} +$expectedNodeArchiveRoot = [IO.Path]::GetFileNameWithoutExtension( + $expectedNodeArchiveName +) +Assert-NodeArchive ` + -Path $NodeArchivePath ` + -ExpectedRoot $expectedNodeArchiveRoot if (-not (Test-Path -LiteralPath $payloadApplication -PathType Container)) { throw "Required MSIX input was not found: $payloadApplication" } @@ -184,6 +235,10 @@ Assert-ApplicationDoesNotBundleNode -Path $payloadApplication $contentRoot = Join-Path $repositoryRoot 'content' $openClawContent = Join-Path $contentRoot 'openclaw' $applicationTarget = Join-Path $openClawContent 'app' +$runtimeTargetDirectory = Join-Path $openClawContent 'runtime' +$nodeArchiveTarget = Join-Path ` + $runtimeTargetDirectory ` + $expectedNodeArchiveName New-Item -Path $openClawContent -ItemType Directory -Force | Out-Null if ( @@ -197,6 +252,15 @@ if ( -Recurse } +Remove-DirectoryIfPresent -Path $runtimeTargetDirectory +New-Item -Path $runtimeTargetDirectory -ItemType Directory -Force | Out-Null +Copy-Item ` + -LiteralPath $NodeArchivePath ` + -Destination $nodeArchiveTarget +$nodeArchiveHash = ( + Get-FileHash -LiteralPath $nodeArchiveTarget -Algorithm SHA256 +).Hash.ToLowerInvariant() + $payloadSymbols = @( # MSBuild's own AppxPackagePayload step strips .pdb files when it later # packages content\openclaw\app; remove them here too so the inventory @@ -328,6 +392,12 @@ try { ).Hash.ToLowerInvariant() } ) + $expectedPackageFiles.Add( + "runtime/$expectedNodeArchiveName", + [pscustomobject]@{ + Hash = $nodeArchiveHash + } + ) $packageEntries = [System.Collections.Generic.HashSet[string]]::new( [System.StringComparer]::OrdinalIgnoreCase ) @@ -417,17 +487,20 @@ try { if (-not $packageEntries.Contains('openclaw.exe')) { throw 'The MSIX does not contain the NativeAOT host executable.' } - $bundledNodeEntries = @( + $unexpectedNodeEntries = @( $packageEntries | Where-Object { - [IO.Path]::GetFileName($_) -ieq 'node.exe' -or - [IO.Path]::GetFileName($_) -match '^node-v\d' + ( + [IO.Path]::GetFileName($_) -ieq 'node.exe' -or + [IO.Path]::GetFileName($_) -match '^node-v\d' + ) -and + $_ -ine "runtime/$expectedNodeArchiveName" } ) - if ($bundledNodeEntries.Count -ne 0) { + if ($unexpectedNodeEntries.Count -ne 0) { throw ( - 'The MSIX must not bundle Node.js: ' + - (($bundledNodeEntries | Sort-Object) -join ', ') + 'The MSIX contains unexpected Node.js content: ' + + (($unexpectedNodeEntries | Sort-Object) -join ', ') ) } foreach ($managedHostArtifact in @( @@ -463,6 +536,9 @@ try { payloadResolvedCommit = $payloadInfo.resolvedCommit.ToLowerInvariant() payloadLayout = 'immutable-package' payloadFileCount = $payloadFiles.Count + nodeRuntimeVersion = $NodeVersion + nodeRuntimeArchive = $expectedNodeArchiveName + nodeRuntimeSha256 = $nodeArchiveHash architecture = $Architecture archive = $msixName sha256 = $msixHash diff --git a/scripts/Test-SigningInputs.Tests.ps1 b/scripts/Test-SigningInputs.Tests.ps1 index e31b3e01..59b61eeb 100644 --- a/scripts/Test-SigningInputs.Tests.ps1 +++ b/scripts/Test-SigningInputs.Tests.ps1 @@ -124,6 +124,38 @@ function New-TestArtifact { -LiteralPath (Join-Path $runtimeDirectory 'node.exe') ` -Value 'bundled-node' } + else { + $runtimeDirectory = Join-Path $staging 'runtime' + New-Item -Path $runtimeDirectory -ItemType Directory | Out-Null + } + $nodeRuntimeVersion = '24.16.0' + $nodeRuntimeArchive = + "node-v$nodeRuntimeVersion-win-$Architecture.zip" + $nodeRuntimePath = Join-Path $runtimeDirectory $nodeRuntimeArchive + $nodeRuntimeZip = [IO.Compression.ZipFile]::Open( + $nodeRuntimePath, + [IO.Compression.ZipArchiveMode]::Create) + try { + $nodeRuntimeRoot = [IO.Path]::GetFileNameWithoutExtension( + $nodeRuntimeArchive + ) + $nodeEntry = $nodeRuntimeZip.CreateEntry( + "$nodeRuntimeRoot/node.exe" + ) + $nodeWriter = [IO.StreamWriter]::new($nodeEntry.Open()) + try { + $nodeWriter.Write('bundled-node') + } + finally { + $nodeWriter.Dispose() + } + } + finally { + $nodeRuntimeZip.Dispose() + } + $nodeRuntimeHash = ( + Get-FileHash -LiteralPath $nodeRuntimePath -Algorithm SHA256 + ).Hash.ToLowerInvariant() $msixName = "OpenClawGateway-$Architecture.msix" $msixPath = Join-Path $directory $msixName @@ -143,6 +175,9 @@ function New-TestArtifact { payloadResolvedCommit = $PayloadCommit payloadLayout = 'immutable-package' payloadFileCount = $payloadFiles.Count + nodeRuntimeVersion = $nodeRuntimeVersion + nodeRuntimeArchive = $nodeRuntimeArchive + nodeRuntimeSha256 = $nodeRuntimeHash architecture = $Architecture archive = $msixName sha256 = $msixHash @@ -296,7 +331,7 @@ try { -IncludeBundledNode $true New-TestArtifact -Root $testRoot -Architecture arm64 Assert-Fails ` - -MessagePattern 'x64 MSIX bundles Node.js' ` + -MessagePattern 'x64 MSIX has unexpected Node.js content' ` -Action { Invoke-PolicyValidation -Root $testRoot } @@ -309,7 +344,7 @@ try { -IncludeApplicationBundledNode $true New-TestArtifact -Root $testRoot -Architecture arm64 Assert-Fails ` - -MessagePattern 'x64 MSIX bundles Node.js' ` + -MessagePattern 'x64 MSIX has unexpected Node.js content' ` -Action { Invoke-PolicyValidation -Root $testRoot } @@ -322,7 +357,7 @@ try { -IncludeApplicationNodeArchive $true New-TestArtifact -Root $testRoot -Architecture arm64 Assert-Fails ` - -MessagePattern 'x64 MSIX bundles Node.js' ` + -MessagePattern 'x64 MSIX has unexpected Node.js content' ` -Action { Invoke-PolicyValidation -Root $testRoot } diff --git a/scripts/Test-SigningInputs.ps1 b/scripts/Test-SigningInputs.ps1 index 9b9cd52a..9a81655e 100644 --- a/scripts/Test-SigningInputs.ps1 +++ b/scripts/Test-SigningInputs.ps1 @@ -108,6 +108,39 @@ function Get-PackageEntrySha256 { } } +function Assert-NodeArchiveEntry { + param( + [Parameter(Mandatory)] + [IO.Compression.ZipArchiveEntry]$Entry, + + [Parameter(Mandatory)] + [string]$ExpectedRoot + ) + + $stream = $Entry.Open() + $archive = [IO.Compression.ZipArchive]::new( + $stream, + [IO.Compression.ZipArchiveMode]::Read) + try { + $nodeEntries = @( + $archive.Entries | + Where-Object { + $_.FullName -ieq "$ExpectedRoot/node.exe" + } + ) + if ($nodeEntries.Count -ne 1) { + throw ( + "The Node.js archive must contain exactly one " + + "'$ExpectedRoot/node.exe' entry." + ) + } + } + finally { + $archive.Dispose() + $stream.Dispose() + } +} + $resolvedArtifactsDirectory = ( Resolve-Path -LiteralPath $ArtifactsDirectory ).Path @@ -166,6 +199,10 @@ foreach ($architecture in @('x64', 'arm64')) { $metadata.payloadLayout -ne 'immutable-package' -or $metadata.payloadFileCount -isnot [int64] -or $metadata.payloadFileCount -le 0 -or + $metadata.nodeRuntimeVersion -notmatch '^\d+\.\d+\.\d+$' -or + $metadata.nodeRuntimeArchive -ne + "node-v$($metadata.nodeRuntimeVersion)-win-$architecture.zip" -or + $metadata.nodeRuntimeSha256 -notmatch '^[0-9a-fA-F]{64}$' -or $metadata.architecture -ne $architecture -or $metadata.archive -ne $msix.Name -or $metadata.sha256 -notmatch '^[0-9a-fA-F]{64}$' -or @@ -193,16 +230,37 @@ foreach ($architecture in @('x64', 'arm64')) { try { # MSIX percent-encodes some names, so index decoded paths once. $entriesByPath = New-PackageEntryIndex -Archive $packageArchive - $bundledNodeEntries = @( + $expectedNodeArchivePath = + "runtime/$($metadata.nodeRuntimeArchive)" + $unexpectedNodeEntries = @( $entriesByPath.Keys | Where-Object { - [IO.Path]::GetFileName($_) -ieq 'node.exe' -or - [IO.Path]::GetFileName($_) -match '^node-v\d' + ( + [IO.Path]::GetFileName($_) -ieq 'node.exe' -or + [IO.Path]::GetFileName($_) -match '^node-v\d' + ) -and + $_ -ine $expectedNodeArchivePath } ) - if ($bundledNodeEntries.Count -ne 0) { - throw "The $architecture MSIX bundles Node.js." + if ($unexpectedNodeEntries.Count -ne 0) { + throw "The $architecture MSIX has unexpected Node.js content." } + $nodeArchiveEntry = Get-PackageEntry ` + -EntriesByPath $entriesByPath ` + -Path $expectedNodeArchivePath + if ( + (Get-PackageEntrySha256 -Entry $nodeArchiveEntry) -ine + $metadata.nodeRuntimeSha256 + ) { + throw "The embedded $architecture Node.js archive is invalid." + } + Assert-NodeArchiveEntry ` + -Entry $nodeArchiveEntry ` + -ExpectedRoot ( + [IO.Path]::GetFileNameWithoutExtension( + [string]$metadata.nodeRuntimeArchive + ) + ) [xml]$manifest = Read-ZipEntryText ` -EntriesByPath $entriesByPath ` diff --git a/src/OpenClaw.Launcher/ClawCtlConsole.cs b/src/OpenClaw.Launcher/ClawCtlConsole.cs index b4bd2957..866991f8 100644 --- a/src/OpenClaw.Launcher/ClawCtlConsole.cs +++ b/src/OpenClaw.Launcher/ClawCtlConsole.cs @@ -10,32 +10,23 @@ public static void WriteHelp(TextWriter output) output.WriteLine(); output.WriteLine("Commands:"); output.WriteLine( - " setup Verify Node.js and the packaged OpenClaw application."); + " setup Extract Node.js and verify the packaged OpenClaw application."); output.WriteLine(); output.WriteLine("Options:"); output.WriteLine(" -h, --help Show this help."); output.WriteLine(" --version Print the packaged launcher version."); output.WriteLine(); - WriteNodePrerequisite(output); - output.WriteLine(); output.WriteLine("Run `openclaw ` to invoke the OpenClaw CLI."); } public static void WriteUsage(TextWriter output) => output.WriteLine("Usage: clawctl [setup | --help | --version]"); - public static void WriteNodePrerequisite(TextWriter output) - { - output.WriteLine( - $"Prerequisite: install Node.js {NodeRuntimeResolver.SupportedVersions}."); - output.WriteLine($" {NodeRuntimeResolver.InstallCommand}"); - } - internal static void WriteNodeRuntimeSummary( TextWriter output, NodeRuntime runtime) => output.WriteLine( - $"Using Node.js {runtime.Version} from {runtime.ExecutablePath}"); + $"Bundled Node.js {runtime.Version} is ready at {runtime.ExecutablePath}"); public static void WriteReadinessSummary( TextWriter output, diff --git a/src/OpenClaw.Launcher/HostDataPaths.cs b/src/OpenClaw.Launcher/HostDataPaths.cs new file mode 100644 index 00000000..b4a085dd --- /dev/null +++ b/src/OpenClaw.Launcher/HostDataPaths.cs @@ -0,0 +1,67 @@ +using System.Runtime.InteropServices; + +namespace OpenClaw.Launcher; + +internal static class HostDataPaths +{ + private const int ErrorInsufficientBuffer = 122; + private const int AppModelErrorNoPackage = 15700; + + public static string GetProductLocalStateRoot() + { + string localAppData = Environment.GetFolderPath( + Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrWhiteSpace(localAppData)) + { + throw new InvalidOperationException( + "The local application data directory is unavailable."); + } + + string? packageFamilyName = TryGetPackageFamilyName(); + return packageFamilyName is null + ? Path.Combine(localAppData, "OpenClaw") + : Path.Combine( + localAppData, + "Packages", + packageFamilyName, + "LocalState", + "OpenClaw"); + } + + internal static string? TryGetPackageFamilyName() + { + if (!OperatingSystem.IsWindows()) + { + return null; + } + + uint length = 0; + int result = GetCurrentPackageFamilyName(ref length, null); + if (result == AppModelErrorNoPackage) + { + return null; + } + + if (result != ErrorInsufficientBuffer || length == 0) + { + throw new InvalidOperationException( + $"Unable to determine package identity (error {result})."); + } + + var value = new char[length]; + result = GetCurrentPackageFamilyName(ref length, value); + if (result != 0) + { + throw new InvalidOperationException( + $"Unable to determine package identity (error {result})."); + } + + return new string(value, 0, checked((int)length - 1)); + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern int GetCurrentPackageFamilyName( + ref uint packageFamilyNameLength, + [Out, MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0)] + char[]? packageFamilyName); +} diff --git a/src/OpenClaw.Launcher/HostDiagnosticLog.cs b/src/OpenClaw.Launcher/HostDiagnosticLog.cs index f6b794b5..103866e2 100644 --- a/src/OpenClaw.Launcher/HostDiagnosticLog.cs +++ b/src/OpenClaw.Launcher/HostDiagnosticLog.cs @@ -1,5 +1,4 @@ using System.Globalization; -using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; @@ -7,8 +6,6 @@ namespace OpenClaw.Launcher; internal sealed class HostDiagnosticLog : IDisposable { - private const int ErrorInsufficientBuffer = 122; - private const int AppModelErrorNoPackage = 15700; private readonly Lock _sync = new(); private readonly Mutex _writeMutex; private bool _disposed; @@ -42,7 +39,7 @@ public static HostDiagnosticLog Create() "The local application data directory is unavailable."); } - string? packageFamilyName = GetPackageFamilyName(); + string? packageFamilyName = HostDataPaths.TryGetPackageFamilyName(); string logRoot = packageFamilyName is null ? System.IO.Path.Combine(localAppData, "OpenClawGatewayMSIX") : System.IO.Path.Combine( @@ -119,40 +116,4 @@ public void Dispose() GC.SuppressFinalize(this); } - private static string? GetPackageFamilyName() - { - if (!OperatingSystem.IsWindows()) - { - return null; - } - - uint length = 0; - int result = GetCurrentPackageFamilyName(ref length, null); - if (result == AppModelErrorNoPackage) - { - return null; - } - - if (result != ErrorInsufficientBuffer || length == 0) - { - throw new InvalidOperationException( - $"Unable to determine package identity (error {result})."); - } - - var value = new char[length]; - result = GetCurrentPackageFamilyName(ref length, value); - if (result != 0) - { - throw new InvalidOperationException( - $"Unable to determine package identity (error {result})."); - } - - return new string(value, 0, checked((int)length - 1)); - } - - [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] - private static extern int GetCurrentPackageFamilyName( - ref uint packageFamilyNameLength, - [Out, MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0)] - char[]? packageFamilyName); } diff --git a/src/OpenClaw.Launcher/HostOptions.cs b/src/OpenClaw.Launcher/HostOptions.cs index 0432db7a..6dace6f0 100644 --- a/src/OpenClaw.Launcher/HostOptions.cs +++ b/src/OpenClaw.Launcher/HostOptions.cs @@ -2,6 +2,7 @@ namespace OpenClaw.Launcher; internal sealed record HostOptions( string? PackagedApplicationDirectory, + string? PackagedNodeArchivePath, IReadOnlyList OpenClawArguments) { public static HostOptions Parse(IReadOnlyList arguments) => @@ -23,9 +24,18 @@ internal static HostOptions Parse( "openclaw.mjs")) ? packagedApplicationDirectory : null; + string packagedNodeArchivePath = Path.Combine( + baseDirectory, + "runtime", + NodeRuntimeInstaller.GetArchiveFileName( + System.Runtime.InteropServices.RuntimeInformation + .ProcessArchitecture)); return new HostOptions( directApplicationDirectory, + File.Exists(packagedNodeArchivePath) + ? packagedNodeArchivePath + : null, [.. arguments]); } } diff --git a/src/OpenClaw.Launcher/NodeRuntimeInstaller.cs b/src/OpenClaw.Launcher/NodeRuntimeInstaller.cs new file mode 100644 index 00000000..06475f6a --- /dev/null +++ b/src/OpenClaw.Launcher/NodeRuntimeInstaller.cs @@ -0,0 +1,208 @@ +using System.IO.Compression; +using System.Runtime.InteropServices; + +namespace OpenClaw.Launcher; + +internal static class NodeRuntimeInstaller +{ + public const string Version = "24.16.0"; + + public static string GetArchiveFileName(Architecture architecture) => + $"node-v{Version}-win-{GetArchitectureName(architecture)}.zip"; + + public static string GetInstallDirectory(Architecture architecture) => + Path.Combine( + HostDataPaths.GetProductLocalStateRoot(), + "NodeJS", + $"node-v{Version}-win-{GetArchitectureName(architecture)}"); + + public static string GetExecutablePath(Architecture architecture) => + Path.Combine(GetInstallDirectory(architecture), "node.exe"); + + public static async Task EnsureInstalledAsync( + string archivePath, + CancellationToken cancellationToken) + { + Architecture architecture = RuntimeInformation.ProcessArchitecture; + string executablePath = EnsureInstalled( + archivePath, + GetInstallDirectory(architecture), + architecture); + return await NodeRuntimeResolver.ResolvePathAsync( + executablePath, + cancellationToken).ConfigureAwait(false); + } + + internal static string EnsureInstalled( + string archivePath, + string installDirectory, + Architecture architecture) + { + string executablePath = Path.Combine(installDirectory, "node.exe"); + if (File.Exists(executablePath)) + { + return executablePath; + } + + if (!File.Exists(archivePath)) + { + throw new FileNotFoundException( + "The packaged Node.js runtime archive was not found.", + archivePath); + } + + string mutexName = + $"Local\\OpenClawGatewayMSIX.NodeRuntime.{GetArchitectureName(architecture)}"; + using var mutex = new Mutex(initiallyOwned: false, mutexName); + bool ownsMutex = false; + try + { + try + { + ownsMutex = mutex.WaitOne(); + } + catch (AbandonedMutexException) + { + ownsMutex = true; + } + + if (File.Exists(executablePath)) + { + return executablePath; + } + + string? parentDirectory = Path.GetDirectoryName(installDirectory); + if (string.IsNullOrWhiteSpace(parentDirectory)) + { + throw new InvalidOperationException( + "The Node.js installation path has no parent directory."); + } + + Directory.CreateDirectory(parentDirectory); + string stagingDirectory = + $"{installDirectory}.{Guid.NewGuid():N}.extract"; + try + { + ExtractRuntime( + archivePath, + stagingDirectory, + architecture); + if (!File.Exists(Path.Combine(stagingDirectory, "node.exe"))) + { + throw new InvalidDataException( + "The packaged Node.js archive does not contain node.exe."); + } + + if (Directory.Exists(installDirectory)) + { + Directory.Delete(installDirectory, recursive: true); + } + Directory.Move(stagingDirectory, installDirectory); + } + finally + { + if (Directory.Exists(stagingDirectory)) + { + Directory.Delete(stagingDirectory, recursive: true); + } + } + + return executablePath; + } + finally + { + if (ownsMutex) + { + mutex.ReleaseMutex(); + } + } + } + + private static void ExtractRuntime( + string archivePath, + string stagingDirectory, + Architecture architecture) + { + string archiveRoot = + $"node-v{Version}-win-{GetArchitectureName(architecture)}"; + string archivePrefix = archiveRoot + "/"; + string fullStagingDirectory = + Path.GetFullPath(stagingDirectory) + Path.DirectorySeparatorChar; + Directory.CreateDirectory(stagingDirectory); + + using ZipArchive archive = ZipFile.OpenRead(archivePath); + foreach (ZipArchiveEntry entry in archive.Entries) + { + string archivePathName = entry.FullName.Replace('\\', '/'); + if (string.Equals( + archivePathName, + archiveRoot + "/", + StringComparison.Ordinal)) + { + continue; + } + + if (!archivePathName.StartsWith( + archivePrefix, + StringComparison.Ordinal)) + { + throw new InvalidDataException( + $"Unexpected Node.js archive entry: {entry.FullName}"); + } + + bool isDirectory = string.IsNullOrEmpty(entry.Name); + string relativePath = archivePathName[archivePrefix.Length..]; + if (isDirectory) + { + relativePath = relativePath.TrimEnd('/'); + } + string[] segments = relativePath.Split('/'); + if ( + string.IsNullOrWhiteSpace(relativePath) || + Path.IsPathRooted(relativePath) || + relativePath.Contains(':', StringComparison.Ordinal) || + segments.Contains(string.Empty, StringComparer.Ordinal) || + segments.Contains(".", StringComparer.Ordinal) || + segments.Contains("..", StringComparer.Ordinal)) + { + throw new InvalidDataException( + $"Unsafe Node.js archive entry: {entry.FullName}"); + } + + string destinationPath = Path.GetFullPath( + Path.Combine(stagingDirectory, relativePath)); + if (!destinationPath.StartsWith( + fullStagingDirectory, + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException( + $"Unsafe Node.js archive entry: {entry.FullName}"); + } + + if (isDirectory) + { + Directory.CreateDirectory(destinationPath); + continue; + } + + string? destinationDirectory = Path.GetDirectoryName(destinationPath); + if (string.IsNullOrWhiteSpace(destinationDirectory)) + { + throw new InvalidDataException( + $"Invalid Node.js archive entry: {entry.FullName}"); + } + + Directory.CreateDirectory(destinationDirectory); + entry.ExtractToFile(destinationPath); + } + } + + private static string GetArchitectureName(Architecture architecture) => + architecture switch + { + Architecture.X64 => "x64", + Architecture.Arm64 => "arm64", + _ => throw new PlatformNotSupportedException( + $"Node.js runtime packaging does not support {architecture}.") + }; +} diff --git a/src/OpenClaw.Launcher/NodeRuntimeResolver.cs b/src/OpenClaw.Launcher/NodeRuntimeResolver.cs index f9fbc589..5f246861 100644 --- a/src/OpenClaw.Launcher/NodeRuntimeResolver.cs +++ b/src/OpenClaw.Launcher/NodeRuntimeResolver.cs @@ -13,9 +13,6 @@ internal sealed record NodeRuntime( internal static partial class NodeRuntimeResolver { - public const string InstallCommand = - "winget install --id OpenJS.NodeJS.LTS --exact --source winget"; - private static readonly TimeSpan VersionQueryTimeout = TimeSpan.FromSeconds(10); private static readonly NodeVersionRange[] SupportedVersionRanges = [ @@ -32,8 +29,16 @@ range.ExclusiveMajor is int exclusiveMajor : $">={range.Minimum}")); public static Task ResolveAsync(CancellationToken cancellationToken) => + ResolvePathAsync( + NodeRuntimeInstaller.GetExecutablePath( + RuntimeInformation.ProcessArchitecture), + cancellationToken); + + public static Task ResolvePathAsync( + string executablePath, + CancellationToken cancellationToken) => ResolveAsync( - FindPathCandidates(), + File.Exists(executablePath) ? [executablePath] : [], QueryVersionAsync, ReadArchitecture, RuntimeInformation.ProcessArchitecture, @@ -49,7 +54,8 @@ internal static async Task ResolveAsync( if (candidates.Count == 0) { throw new InvalidOperationException( - CreateFailureMessage("Node.js was not found on PATH.")); + CreateFailureMessage( + "The bundled Node.js runtime has not been extracted.")); } var failures = new List(); @@ -118,49 +124,8 @@ internal static Version ParseVersion(string output) internal static string CreateFailureMessage(string detail) => $"{detail}{Environment.NewLine}" + - $"Install a supported Node.js runtime ({SupportedVersions}):{Environment.NewLine}" + - $" {InstallCommand}{Environment.NewLine}" + - "Then open a new terminal and retry."; - - private static List FindPathCandidates() - { - string? pathValue = Environment.GetEnvironmentVariable("PATH"); - if (string.IsNullOrWhiteSpace(pathValue)) - { - return []; - } - - var candidates = new List(); - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (string entry in pathValue.Split(Path.PathSeparator)) - { - string directory = entry.Trim().Trim('"'); - if (string.IsNullOrWhiteSpace(directory)) - { - continue; - } - - string candidate; - try - { - candidate = Path.GetFullPath(Path.Combine(directory, "node.exe")); - } - catch (Exception exception) when ( - exception is ArgumentException or - NotSupportedException or - PathTooLongException) - { - continue; - } - - if (File.Exists(candidate) && seen.Add(candidate)) - { - candidates.Add(candidate); - } - } - - return candidates; - } + $"Run `clawctl setup` to extract Node.js {NodeRuntimeInstaller.Version} " + + "from the installed OpenClaw package."; private static async Task QueryVersionAsync( string executablePath, diff --git a/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj b/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj index c5aa467e..47071d72 100644 --- a/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj +++ b/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj @@ -81,6 +81,9 @@ + + RunControlAsync( Action log, Action writeError, TextWriter output, - Func>? resolveNode = null) + Func>? setupNode = null) { ClawCtlCommandParseResult parsed = ClawCtlCommandParser.Parse(args); if (parsed.Error is not null) @@ -189,9 +189,19 @@ await output.WriteLineAsync( return 0; case ClawCtlCommand.Setup: { - NodeRuntime nodeRuntime = await ( - resolveNode ?? NodeRuntimeResolver.ResolveAsync)( + NodeRuntime nodeRuntime; + if (setupNode is not null) + { + nodeRuntime = await setupNode(CancellationToken.None) + .ConfigureAwait(false); + } + else + { + string archivePath = GetPackagedNodeArchivePath(options); + nodeRuntime = await NodeRuntimeInstaller.EnsureInstalledAsync( + archivePath, CancellationToken.None).ConfigureAwait(false); + } ClawCtlConsole.WriteNodeRuntimeSummary(output, nodeRuntime); string applicationDirectory = GetPackagedApplicationDirectory(options); @@ -231,4 +241,23 @@ private static string GetPackagedApplicationDirectory(HostOptions options) return applicationDirectory; } + + private static string GetPackagedNodeArchivePath(HostOptions options) + { + string? archivePath = options.PackagedNodeArchivePath; + string expectedPath = archivePath ?? Path.Combine( + AppContext.BaseDirectory, + "runtime", + NodeRuntimeInstaller.GetArchiveFileName( + System.Runtime.InteropServices.RuntimeInformation + .ProcessArchitecture)); + if (archivePath is null || !File.Exists(archivePath)) + { + throw new FileNotFoundException( + "The packaged Node.js runtime archive was not found.", + expectedPath); + } + + return archivePath; + } } diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs index 2076d9cc..948999e6 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs @@ -27,14 +27,7 @@ public void WriteHelpListsOnlyThePublicCommands() $"{Environment.NewLine} repair", help, StringComparison.OrdinalIgnoreCase); - Assert.Contains( - NodeRuntimeResolver.SupportedVersions, - help, - StringComparison.Ordinal); - Assert.Contains( - NodeRuntimeResolver.InstallCommand, - help, - StringComparison.Ordinal); + Assert.Contains("Extract Node.js", help, StringComparison.Ordinal); Assert.DoesNotContain("update-package", help, StringComparison.Ordinal); Assert.DoesNotContain("gateway-service", help, StringComparison.Ordinal); } diff --git a/tests/OpenClaw.Launcher.Tests/HostOptionsTests.cs b/tests/OpenClaw.Launcher.Tests/HostOptionsTests.cs index 41f8893d..48c9ccbe 100644 --- a/tests/OpenClaw.Launcher.Tests/HostOptionsTests.cs +++ b/tests/OpenClaw.Launcher.Tests/HostOptionsTests.cs @@ -31,6 +31,7 @@ public void ParseReportsMissingPackagedApplication() HostOptions options = HostOptions.Parse([], _testDirectory); Assert.Null(options.PackagedApplicationDirectory); + Assert.Null(options.PackagedNodeArchivePath); Assert.Empty(options.OpenClawArguments); } @@ -50,9 +51,27 @@ public void ParseResolvesPackagedApplicationWhenEntryPointExists() Assert.Equal( applicationDirectory, options.PackagedApplicationDirectory); + Assert.Null(options.PackagedNodeArchivePath); Assert.Equal(["gateway", "run"], options.OpenClawArguments); } + [Fact] + public void ParseResolvesArchitectureSpecificPackagedNodeArchive() + { + string runtimeDirectory = Path.Combine(_testDirectory, "runtime"); + Directory.CreateDirectory(runtimeDirectory); + string archivePath = Path.Combine( + runtimeDirectory, + NodeRuntimeInstaller.GetArchiveFileName( + System.Runtime.InteropServices.RuntimeInformation + .ProcessArchitecture)); + File.WriteAllText(archivePath, "fixture"); + + HostOptions options = HostOptions.Parse([], _testDirectory); + + Assert.Equal(archivePath, options.PackagedNodeArchivePath); + } + public void Dispose() { Directory.Delete(_testDirectory, recursive: true); diff --git a/tests/OpenClaw.Launcher.Tests/NodeRuntimeInstallerTests.cs b/tests/OpenClaw.Launcher.Tests/NodeRuntimeInstallerTests.cs new file mode 100644 index 00000000..989d9442 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/NodeRuntimeInstallerTests.cs @@ -0,0 +1,101 @@ +using System.IO.Compression; +using System.Runtime.InteropServices; + +namespace OpenClaw.Launcher.Tests; + +public sealed class NodeRuntimeInstallerTests : IDisposable +{ + private readonly string _testDirectory = TestDirectory.Create(); + + [Fact] + public void EnsureInstalledExtractsArchitectureRuntimeOnce() + { + Architecture architecture = Architecture.X64; + string archivePath = CreateRuntimeArchive(architecture); + string installDirectory = Path.Combine(_testDirectory, "installed"); + + string firstPath = NodeRuntimeInstaller.EnsureInstalled( + archivePath, + installDirectory, + architecture); + DateTime firstWriteTime = File.GetLastWriteTimeUtc(firstPath); + string secondPath = NodeRuntimeInstaller.EnsureInstalled( + archivePath, + installDirectory, + architecture); + + Assert.Equal(Path.Combine(installDirectory, "node.exe"), firstPath); + Assert.Equal(firstPath, secondPath); + Assert.Equal("fixture-node", File.ReadAllText(firstPath)); + Assert.Equal(firstWriteTime, File.GetLastWriteTimeUtc(secondPath)); + } + + [Fact] + public void EnsureInstalledRejectsEntriesOutsideExpectedRuntimeRoot() + { + string archivePath = Path.Combine(_testDirectory, "unsafe.zip"); + using (ZipArchive archive = ZipFile.Open( + archivePath, + ZipArchiveMode.Create)) + { + ZipArchiveEntry entry = archive.CreateEntry("unexpected/node.exe"); + using StreamWriter writer = new(entry.Open()); + writer.Write("fixture-node"); + } + + Assert.Throws(() => + NodeRuntimeInstaller.EnsureInstalled( + archivePath, + Path.Combine(_testDirectory, "unsafe-install"), + Architecture.X64)); + } + + [Fact] + public void EnsureInstalledReplacesIncompleteRuntimeDirectory() + { + Architecture architecture = Architecture.X64; + string archivePath = CreateRuntimeArchive(architecture); + string installDirectory = Path.Combine(_testDirectory, "incomplete"); + Directory.CreateDirectory(installDirectory); + File.WriteAllText( + Path.Combine(installDirectory, "partial.txt"), + "partial"); + + string executablePath = NodeRuntimeInstaller.EnsureInstalled( + archivePath, + installDirectory, + architecture); + + Assert.True(File.Exists(executablePath)); + Assert.False(File.Exists( + Path.Combine(installDirectory, "partial.txt"))); + } + + public void Dispose() + { + Directory.Delete(_testDirectory, recursive: true); + GC.SuppressFinalize(this); + } + + private string CreateRuntimeArchive(Architecture architecture) + { + string archivePath = Path.Combine( + _testDirectory, + NodeRuntimeInstaller.GetArchiveFileName(architecture)); + string rootName = Path.GetFileNameWithoutExtension(archivePath); + using ZipArchive archive = ZipFile.Open( + archivePath, + ZipArchiveMode.Create); + archive.CreateEntry($"{rootName}/node_modules/"); + ZipArchiveEntry nestedEntry = archive.CreateEntry( + $"{rootName}/node_modules/package.json"); + using (StreamWriter nestedWriter = new(nestedEntry.Open())) + { + nestedWriter.Write("{}"); + } + ZipArchiveEntry entry = archive.CreateEntry($"{rootName}/node.exe"); + using StreamWriter writer = new(entry.Open()); + writer.Write("fixture-node"); + return archivePath; + } +} diff --git a/tests/OpenClaw.Launcher.Tests/NodeRuntimeResolverTests.cs b/tests/OpenClaw.Launcher.Tests/NodeRuntimeResolverTests.cs index 3c77ea15..cf92dcda 100644 --- a/tests/OpenClaw.Launcher.Tests/NodeRuntimeResolverTests.cs +++ b/tests/OpenClaw.Launcher.Tests/NodeRuntimeResolverTests.cs @@ -19,7 +19,7 @@ public async Task ResolveAcceptsCompatibleRuntime() } [Fact] - public async Task ResolveReportsInstallCommandWhenRuntimeIsMissing() + public async Task ResolveReportsSetupCommandWhenRuntimeIsMissing() { InvalidOperationException exception = await Assert.ThrowsAsync< InvalidOperationException>( @@ -29,8 +29,8 @@ public async Task ResolveReportsInstallCommandWhenRuntimeIsMissing() Architecture.X64, Architecture.X64)); - Assert.Contains("not found on PATH", exception.Message, StringComparison.Ordinal); - Assert.Contains(NodeRuntimeResolver.InstallCommand, exception.Message, StringComparison.Ordinal); + Assert.Contains("has not been extracted", exception.Message, StringComparison.Ordinal); + Assert.Contains("clawctl setup", exception.Message, StringComparison.Ordinal); } [Fact] @@ -103,7 +103,7 @@ public async Task ResolveReportsVersionQueryFailure() Architecture.X64)); Assert.Contains("query failed", exception.Message, StringComparison.Ordinal); - Assert.Contains(NodeRuntimeResolver.InstallCommand, exception.Message, StringComparison.Ordinal); + Assert.Contains("clawctl setup", exception.Message, StringComparison.Ordinal); } [Theory] diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index 1ef0b16d..c18f3d7c 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -13,7 +13,7 @@ await File.WriteAllTextAsync( Path.Combine(applicationDirectory, "openclaw.mjs"), "console.log('fixture');"); string[] arguments = ["gateway", "run", "--port", "12345"]; - var options = new HostOptions(applicationDirectory, arguments); + var options = new HostOptions(applicationDirectory, null, arguments); var nodeRuntime = new NodeRuntime( Path.Combine(_testDirectory, "node.exe"), new Version(24, 15, 0), @@ -44,14 +44,14 @@ await File.WriteAllTextAsync( } [Fact] - public async Task SetupChecksNodeAndPackagedApplicationWithoutMutation() + public async Task SetupPreparesNodeAndChecksPackagedApplication() { string applicationDirectory = Path.Combine(_testDirectory, "app"); Directory.CreateDirectory(applicationDirectory); string entryPoint = Path.Combine(applicationDirectory, "openclaw.mjs"); await File.WriteAllTextAsync(entryPoint, "console.log('fixture');"); DateTime lastWriteTime = File.GetLastWriteTimeUtc(entryPoint); - var options = new HostOptions(applicationDirectory, []); + var options = new HostOptions(applicationDirectory, null, []); var nodeRuntime = new NodeRuntime( Path.Combine(_testDirectory, "node.exe"), new Version(24, 15, 0), @@ -86,7 +86,7 @@ public async Task SetupResolvesNodeBeforeReportingMissingApplication() await Assert.ThrowsAsync( () => Program.RunControlAsync( - new HostOptions(null, []), + new HostOptions(null, null, []), ["setup"], _ => { }, _ => { }, @@ -112,7 +112,7 @@ public async Task AgentResolvesNodeBeforeReportingMissingApplication() await Assert.ThrowsAsync( () => Program.RunAgentAsync( - new HostOptions(null, []), + new HostOptions(null, null, []), _ => { }, _ => { From 5208c1d4f3f308487a05990d159837c486c99dec Mon Sep 17 00:00:00 2001 From: Linus Huang Date: Fri, 11 Sep 2026 16:23:14 -0700 Subject: [PATCH 2/2] fix: follow upstream Node selection and repair bundled runtimes Carry the upstream toolchain version through payload metadata and MSIX composition. Validate bundled executables without launching staged images, repair invalid caches under a cross-session installation lock, reclaim interrupted staging, and expose bundled tools only to the child PATH. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 25 +- .github/workflows/gateway-msix.yml | 42 ++-- CONTRIBUTING.md | 4 + README.md | 44 ++-- scripts/Build-LocalMSIX.ps1 | 21 +- scripts/Build-MSIX.ps1 | 53 ++-- scripts/Build-Payload.ps1 | 23 +- scripts/Test-NodeRuntimeInputs.Tests.ps1 | 103 ++++++++ scripts/Test-SigningInputs.Tests.ps1 | 18 +- scripts/Test-SigningInputs.ps1 | 8 + src/OpenClaw.Launcher/GatewayLauncher.cs | 8 + src/OpenClaw.Launcher/HostOptions.cs | 13 +- src/OpenClaw.Launcher/NodeRuntimeInstaller.cs | 136 +++++++---- src/OpenClaw.Launcher/NodeRuntimeResolver.cs | 190 +++++---------- .../OpenClaw.Launcher.csproj | 5 +- src/OpenClaw.Launcher/Program.cs | 12 +- .../GatewayLauncherTests.cs | 18 ++ .../HostOptionsTests.cs | 26 +- .../NodeRuntimeInstallerTests.cs | 227 +++++++++++++++--- .../NodeRuntimeResolverTests.cs | 133 +++++----- 20 files changed, 691 insertions(+), 418 deletions(-) create mode 100644 scripts/Test-NodeRuntimeInputs.Tests.ps1 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7f3863d7..78291470 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -60,23 +60,23 @@ and ARM64 separately. - `openclaw` resolves the Node.js executable extracted into package LocalState, confirms the packaged entry point exists, and forwards every argument unchanged to `openclaw.mjs`. -- `clawctl setup` idempotently extracts the architecture-specific bundled - Node.js archive into versioned package LocalState and verifies the packaged - entry point. Runtime launches do not hash or walk application files. +- `clawctl setup` validates and reuses or repairs the architecture-specific + bundled Node.js runtime in versioned package LocalState and verifies the + packaged entry point. Runtime launches do not hash or walk application files. - `GatewayLauncher` starts Node without a shell, uses `ArgumentList`, inherits the console streams, and sets `OPENCLAW_SUPERVISOR_MODE=external` plus `OPENCLAW_NO_AUTO_UPDATE=1`. The child process exit code is the launcher exit - code. + code. Only the child environment prepends the bundled runtime to `PATH`. - Diagnostics are written to packaged LocalState (or `%LOCALAPPDATA%\OpenClawGatewayMSIX` outside an MSIX context) with a named mutex so concurrent processes append complete records. -- The GitHub workflow first builds and packs a pinned - `openclaw/openclaw` revision on Linux. Windows matrix jobs use - `Build-Payload.ps1` to produce x64/ARM64 expanded trees and build metadata, - then downloads the matching official Node.js archive and uses - `Build-MSIX.ps1` to reject Node.js from the application payload, build the - application inventory, publish the NativeAOT host, validate package - contents, and emit MSIX metadata including the runtime hash. +- The GitHub workflow first builds and packs a pinned `openclaw/openclaw` + revision on Linux using that revision's `setup-node-env` action. The resolved + Node.js version flows through `source.json` and `payload-metadata.json`; + Windows payload builds use the same version. `Build-MSIX.ps1` downloads its + matching official archive, rejects Node.js from the application payload, + builds the application inventory, publishes the NativeAOT host, validates + package contents, and emits MSIX metadata including the runtime hash. - Unsigned artifacts are the normal PR/push output. Test signing uses a temporary runner-local certificate. Official signing is gated to `main` and the immutable upstream commit in `release-policy.json`; signing inputs are @@ -118,6 +118,9 @@ and ARM64 separately. - Keep x64 and ARM64 behavior synchronized across the workflow matrix, scripts, project runtime identifiers, manifest content, payload metadata, and signing validation. +- Do not add a packaging-side Node.js version pin or support-range policy. + The selected upstream toolchain owns version selection; package composition + supplies `NodeRuntimeArchiveFileName`, and the host reads the archive name. - Metadata files are part of the release trust chain, not incidental build output. Changes to their fields must be coordinated across payload creation, MSIX creation, signing validation, workflow artifacts, and tests. diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 97881282..f089454a 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -26,8 +26,6 @@ permissions: contents: read env: - NODE_VERSION: 24.16.0 - PNPM_VERSION: 11.15.1 OPENCLAW_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.openclaw_ref || '0965053fe6b9341776df147a6934b7485c60b5ca' }} PACKAGING_ROOT: . @@ -69,6 +67,10 @@ jobs: run: > .\scripts\Test-SigningInputs.Tests.ps1 + - name: Test Node.js packaging inputs + shell: pwsh + run: .\scripts\Test-NodeRuntimeInputs.Tests.ps1 + - name: Test workflow package version shell: pwsh run: > @@ -85,32 +87,26 @@ jobs: outputs: source_sha: ${{ steps.source.outputs.sha }} package_version: ${{ steps.source.outputs.version }} + node_version: ${{ steps.source.outputs.node_version }} steps: - name: Check out OpenClaw source uses: actions/checkout@v7 with: repository: openclaw/openclaw ref: ${{ env.OPENCLAW_REF }} - path: openclaw-source persist-credentials: false fetch-depth: 1 - - name: Set up Node.js - uses: actions/setup-node@v6 + - name: Set up upstream Node.js and pnpm + uses: ./.github/actions/setup-node-env with: - node-version: ${{ env.NODE_VERSION }} - - - name: Enable pnpm - run: | - corepack enable - corepack prepare "pnpm@${PNPM_VERSION}" --activate + install-bun: "false" + install-deps: "false" - name: Install dependencies - working-directory: openclaw-source run: pnpm install --frozen-lockfile - name: Build OpenClaw - working-directory: openclaw-source env: OPENCLAW_CONTROL_UI_RELEASE_BUILD: "1" run: | @@ -119,7 +115,6 @@ jobs: - name: Pack npm package id: source - working-directory: openclaw-source run: | set -euo pipefail artifact_dir="${RUNNER_TEMP}/openclaw-package" @@ -133,15 +128,18 @@ jobs: source_sha="$(git rev-parse HEAD)" package_version="$(node -p "require('./package.json').version")" + node_version="$(node -p 'process.versions.node')" echo "sha=${source_sha}" >> "${GITHUB_OUTPUT}" echo "version=${package_version}" >> "${GITHUB_OUTPUT}" + echo "node_version=${node_version}" >> "${GITHUB_OUTPUT}" cat > "${artifact_dir}/source.json" <\LocalState\OpenClaw\NodeJS\node-v24.16.0-win-`. +`%LOCALAPPDATA%\Packages\\LocalState\OpenClaw\NodeJS\node-v-win-`. Extraction is idempotent, versioned, and serialized across concurrent setup -processes. The launcher validates the extracted `node.exe` before reporting -readiness. +processes, including different Windows sessions. Setup validates existing +runtimes before reuse, replaces invalid runtimes, and validates extraction +before publishing it. The launcher places Node.js in a Windows job configured with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. The launcher remains alive while Node.js @@ -83,9 +87,9 @@ clawctl setup openclaw ``` -The packaged OpenClaw revision accepts Node.js -`>=22.22.3 <23 || >=24.15.0 <25 || >=25.9.0`. The launcher keeps this -requirement in one shared validator used by `clawctl` and `openclaw`. +When an MSIX update changes the bundled Node.js version, run `clawctl setup` +again before launching OpenClaw. Previously extracted versions are left in +place so an update does not remove a running process's runtime. ## Selecting the OpenClaw revision @@ -101,6 +105,14 @@ For a one-time override, run **Build OpenClaw Gateway MSIX** manually and provide a tag, branch, or preferably a full 40-character commit SHA in `openclaw_ref`. +The source build uses that revision's `.github/actions/setup-node-env` action +to select Node.js and pnpm. Its resolved Node.js version is recorded in +`source.json`, reused for both Windows payload builds, and carried in +`payload-metadata.json`. Package composition downloads that exact version; +the launcher derives its runtime version and LocalState path from the bundled +archive name. There is no separate packaging-side Node.js version pin or +runtime-support policy. + The payload artifact records the requested ref and resolved upstream commit in `payload-metadata.json`. That build-only file is not embedded in the MSIX. `msix-metadata.json` records both the packaging repository commit and bundled @@ -124,14 +136,14 @@ dotnet test .\OpenClaw.Gateway.MSIX.slnx ` ``` `scripts\Build-Payload.ps1` npm-installs an OpenClaw package into an expanded, -architecture-specific application tree. The workflow downloads the official -Node.js archive for the same architecture. `scripts\Build-MSIX.ps1` copies the -application tree and runtime archive into package content, rejects Node.js +architecture-specific application tree. `scripts\Build-MSIX.ps1` downloads +the official Node.js archive matching the payload's recorded build version +and architecture, copies both inputs into package content, rejects Node.js inside the application payload, creates a per-file inventory, and then creates an unsigned NativeAOT MSIX. `scripts\Build-LocalMSIX.ps1` can reuse a successful workflow payload or a -local payload directory; unless `-NodeArchivePath` is supplied, it downloads -the pinned runtime archive used by CI. +local payload directory. `-NodeArchivePath` can supply an already-downloaded +archive, but its version and architecture must match the payload metadata. Normal pull-request and push workflows publish unsigned packages for validation. Manual runs support three signing modes: @@ -156,7 +168,7 @@ key is stored in the repository. |---|---| | OpenClaw application files | Read-only MSIX package `app` directory | | Bundled Node.js archive | Read-only MSIX package `runtime` directory | -| Extracted Node.js runtime | `%LOCALAPPDATA%\Packages\\LocalState\OpenClaw\NodeJS\node-v24.16.0-win-` | +| Extracted Node.js runtime | `%LOCALAPPDATA%\Packages\\LocalState\OpenClaw\NodeJS\node-v-win-` | | OpenClaw configuration and user state | `%USERPROFILE%\.openclaw` | | Launcher diagnostics | `%LOCALAPPDATA%\Packages\\LocalState\OpenClawGatewayMSIX\Logs\openclaw.log` | diff --git a/scripts/Build-LocalMSIX.ps1 b/scripts/Build-LocalMSIX.ps1 index c0e77db8..57d36643 100644 --- a/scripts/Build-LocalMSIX.ps1 +++ b/scripts/Build-LocalMSIX.ps1 @@ -7,9 +7,6 @@ param( [long]$PayloadRunId, - [ValidatePattern('^\d+\.\d+\.\d+$')] - [string]$NodeVersion = '24.16.0', - [string]$NodeArchivePath, [string]$PackageVersion, @@ -61,20 +58,7 @@ if (Test-Path -LiteralPath $OutputDirectory) { New-Item -Path $workDirectory -ItemType Directory -Force | Out-Null if ($NodeArchivePath) { - $resolvedNodeArchivePath = ( - Resolve-Path -LiteralPath $NodeArchivePath - ).Path -} -else { - $nodeArchiveName = "node-v$NodeVersion-win-$Architecture.zip" - $resolvedNodeArchivePath = Join-Path $workDirectory $nodeArchiveName - $nodeArchiveUri = - "https://nodejs.org/dist/v$NodeVersion/$nodeArchiveName" - Write-Host "Downloading bundled Node.js runtime from $nodeArchiveUri." - Invoke-WebRequest ` - -Uri $nodeArchiveUri ` - -OutFile $resolvedNodeArchivePath ` - -UseBasicParsing + $NodeArchivePath = (Resolve-Path -LiteralPath $NodeArchivePath).Path } if ($PayloadDirectory) { @@ -157,8 +141,7 @@ try { Write-Host "Building unsigned MSIX version $PackageVersion." & .\scripts\Build-MSIX.ps1 ` -PayloadDirectory $resolvedPayloadDirectory ` - -NodeArchivePath $resolvedNodeArchivePath ` - -NodeVersion $NodeVersion ` + -NodeArchivePath $NodeArchivePath ` -Architecture $Architecture ` -PackageVersion $PackageVersion ` -SourceCommit $sourceCommit ` diff --git a/scripts/Build-MSIX.ps1 b/scripts/Build-MSIX.ps1 index 955d9ffa..0159d7cd 100644 --- a/scripts/Build-MSIX.ps1 +++ b/scripts/Build-MSIX.ps1 @@ -3,13 +3,8 @@ param( [Parameter(Mandatory)] [string]$PayloadDirectory, - [Parameter(Mandatory)] [string]$NodeArchivePath, - [Parameter(Mandatory)] - [ValidatePattern('^\d+\.\d+\.\d+$')] - [string]$NodeVersion, - [Parameter(Mandatory)] [ValidateSet('x64', 'arm64')] [string]$Architecture, @@ -186,25 +181,10 @@ function Add-VswhereToPath { } Test-PackageVersion -Add-VswhereToPath $PayloadDirectory = (Resolve-Path -LiteralPath $PayloadDirectory).Path $payloadApplication = Join-Path $PayloadDirectory 'app' $payloadMetadata = Join-Path $PayloadDirectory 'payload-metadata.json' -$NodeArchivePath = (Resolve-Path -LiteralPath $NodeArchivePath).Path -$expectedNodeArchiveName = "node-v$NodeVersion-win-$Architecture.zip" -if ([IO.Path]::GetFileName($NodeArchivePath) -cne $expectedNodeArchiveName) { - throw ( - "NodeArchivePath must name the expected runtime archive: " + - $expectedNodeArchiveName - ) -} -$expectedNodeArchiveRoot = [IO.Path]::GetFileNameWithoutExtension( - $expectedNodeArchiveName -) -Assert-NodeArchive ` - -Path $NodeArchivePath ` - -ExpectedRoot $expectedNodeArchiveRoot if (-not (Test-Path -LiteralPath $payloadApplication -PathType Container)) { throw "Required MSIX input was not found: $payloadApplication" } @@ -217,6 +197,8 @@ if ( $payloadInfo.repository -ne 'https://github.com/openclaw/openclaw' -or $payloadInfo.architecture -ne $Architecture -or $payloadInfo.layout -ne 'expanded-directory' -or + $payloadInfo.nodeVersion -isnot [string] -or + $payloadInfo.nodeVersion -notmatch '^v?\d+\.\d+\.\d+$' -or $payloadInfo.requestedRef -isnot [string] -or [string]::IsNullOrWhiteSpace($payloadInfo.requestedRef) -or $payloadInfo.resolvedCommit -notmatch '^[0-9a-fA-F]{40}$' @@ -224,6 +206,18 @@ if ( throw 'Payload metadata is not valid for this MSIX package.' } +$nodeVersion = $payloadInfo.nodeVersion.TrimStart('v') +$expectedNodeArchiveName = "node-v$nodeVersion-win-$Architecture.zip" +if ($NodeArchivePath) { + $NodeArchivePath = (Resolve-Path -LiteralPath $NodeArchivePath).Path + if ([IO.Path]::GetFileName($NodeArchivePath) -cne $expectedNodeArchiveName) { + throw ( + 'NodeArchivePath must match the payload build runtime: ' + + $expectedNodeArchiveName + ) + } +} + if (-not (Test-Path ` -LiteralPath (Join-Path $payloadApplication 'openclaw.mjs') ` -PathType Leaf)) { @@ -252,11 +246,18 @@ if ( -Recurse } -Remove-DirectoryIfPresent -Path $runtimeTargetDirectory New-Item -Path $runtimeTargetDirectory -ItemType Directory -Force | Out-Null -Copy-Item ` - -LiteralPath $NodeArchivePath ` - -Destination $nodeArchiveTarget +if (-not $NodeArchivePath) { + $archiveUri = "https://nodejs.org/dist/v$nodeVersion/$expectedNodeArchiveName" + Write-Host "Downloading bundled Node.js runtime from $archiveUri." + Invoke-WebRequest -Uri $archiveUri -OutFile $nodeArchiveTarget +} +elseif ($NodeArchivePath -ne $nodeArchiveTarget) { + Copy-Item -LiteralPath $NodeArchivePath -Destination $nodeArchiveTarget -Force +} +Assert-NodeArchive ` + -Path $nodeArchiveTarget ` + -ExpectedRoot ([IO.Path]::GetFileNameWithoutExtension($expectedNodeArchiveName)) $nodeArchiveHash = ( Get-FileHash -LiteralPath $nodeArchiveTarget -Algorithm SHA256 ).Hash.ToLowerInvariant() @@ -327,6 +328,7 @@ New-Item ` Out-Null try { + Add-VswhereToPath $appxOutput = $msixBuildDirectory.TrimEnd('\') + '\' Write-Host "Building unsigned NativeAOT win-$Architecture MSIX with MSBuild." Invoke-CheckedCommand ` @@ -341,6 +343,7 @@ try { -p:PublishAot=true ` -p:SelfContained=true ` -p:IncludePackagingContent=true ` + "-p:NodeRuntimeArchiveFileName=$expectedNodeArchiveName" ` -p:GenerateAppxPackageOnBuild=true ` "-p:AssemblyVersion=$PackageVersion" ` "-p:FileVersion=$PackageVersion" ` @@ -536,7 +539,7 @@ try { payloadResolvedCommit = $payloadInfo.resolvedCommit.ToLowerInvariant() payloadLayout = 'immutable-package' payloadFileCount = $payloadFiles.Count - nodeRuntimeVersion = $NodeVersion + nodeRuntimeVersion = $nodeVersion nodeRuntimeArchive = $expectedNodeArchiveName nodeRuntimeSha256 = $nodeArchiveHash architecture = $Architecture diff --git a/scripts/Build-Payload.ps1 b/scripts/Build-Payload.ps1 index a823b9da..4ce97e38 100644 --- a/scripts/Build-Payload.ps1 +++ b/scripts/Build-Payload.ps1 @@ -23,6 +23,22 @@ if (-not (Test-Path $sourceMetadataPath -PathType Leaf)) { throw "Missing source metadata: $sourceMetadataPath" } +$sourceMetadata = Get-Content $sourceMetadataPath -Raw | ConvertFrom-Json +$nodeVersion = & node -p 'process.versions.node' +if ($LASTEXITCODE -ne 0 -or $nodeVersion -notmatch '^\d+\.\d+\.\d+$') { + throw 'Unable to determine the payload build Node.js version.' +} +if ($sourceMetadata.nodeVersion -cne $nodeVersion) { + throw ( + "Payload Node.js $nodeVersion does not match the source build " + + "version '$($sourceMetadata.nodeVersion)'." + ) +} +$npmVersion = & npm --version +if ($LASTEXITCODE -ne 0) { + throw 'Unable to determine the payload build npm version.' +} + $stagingDirectory = Join-Path $env:RUNNER_TEMP "openclaw-stage-$Architecture" Remove-Item $stagingDirectory -Recurse -Force -ErrorAction SilentlyContinue New-Item $stagingDirectory -ItemType Directory | Out-Null @@ -105,16 +121,15 @@ Copy-Item ` -Destination $applicationDirectory ` -Recurse -$sourceMetadata = Get-Content $sourceMetadataPath -Raw | ConvertFrom-Json [ordered]@{ repository = $sourceMetadata.repository requestedRef = $sourceMetadata.requestedRef resolvedCommit = $sourceMetadata.resolvedCommit packageVersion = $sourceMetadata.packageVersion architecture = $Architecture - layout = 'expanded-directory' - nodeVersion = (& node --version) - npmVersion = (& npm --version) + layout = 'expanded-directory' + nodeVersion = $nodeVersion + npmVersion = $npmVersion } | ConvertTo-Json | Set-Content (Join-Path $OutputDirectory 'payload-metadata.json') -Encoding utf8 $files = @(Get-ChildItem -LiteralPath $applicationDirectory -File -Recurse) diff --git a/scripts/Test-NodeRuntimeInputs.Tests.ps1 b/scripts/Test-NodeRuntimeInputs.Tests.ps1 new file mode 100644 index 00000000..751efe8d --- /dev/null +++ b/scripts/Test-NodeRuntimeInputs.Tests.ps1 @@ -0,0 +1,103 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$testRoot = Join-Path ([IO.Path]::GetTempPath()) ( + "openclaw-node-inputs-$([guid]::NewGuid().ToString('N'))" +) +$previousRunnerTemp = $env:RUNNER_TEMP +$previousNpmCache = $env:npm_config_cache + +function Assert-Fails { + param([scriptblock]$Action, [string]$MessagePattern) + + try { + & $Action + } + catch { + if ($_.Exception.Message -notmatch $MessagePattern) { + throw + } + return + } + throw "Expected failure matching '$MessagePattern'." +} + +try { + $env:RUNNER_TEMP = $testRoot + $env:npm_config_cache = Join-Path $testRoot 'npm-cache' + $source = Join-Path $testRoot 'source' + $package = Join-Path $testRoot 'package' + $payload = Join-Path $testRoot 'payload' + New-Item -ItemType Directory -Path "$source\dist", $package -Force | Out-Null + '{"name":"openclaw","version":"0.0.0","type":"module"}' | + Set-Content -LiteralPath "$source\package.json" + 'console.log("fixture");' | Set-Content -LiteralPath "$source\openclaw.mjs" + 'export {};' | Set-Content -LiteralPath "$source\dist\index.js" + & npm pack $source --ignore-scripts --offline --silent --pack-destination $package + if ($LASTEXITCODE -ne 0) { + throw 'Unable to pack the local Node.js input fixture.' + } + $nodeVersion = & node -p 'process.versions.node' + if ($LASTEXITCODE -ne 0) { + throw 'Unable to determine the fixture Node.js version.' + } + $sourceMetadata = @{ + repository = 'https://github.com/openclaw/openclaw' + requestedRef = '1' * 40 + resolvedCommit = '1' * 40 + packageVersion = '0.0.0' + nodeVersion = $nodeVersion + } + $sourceMetadata | ConvertTo-Json | Set-Content -LiteralPath "$package\source.json" + + & "$PSScriptRoot\Build-Payload.ps1" ` + -PackageDirectory $package -Architecture x64 -OutputDirectory $payload + $metadataPath = Join-Path $payload 'payload-metadata.json' + $metadata = Get-Content -LiteralPath $metadataPath -Raw | ConvertFrom-Json + if ($metadata.nodeVersion -cne $nodeVersion) { + throw 'The payload did not preserve the exact source build Node.js version.' + } + + $sourceMetadata.nodeVersion = '0.0.0' + $sourceMetadata | ConvertTo-Json | Set-Content -LiteralPath "$package\source.json" + Assert-Fails -MessagePattern 'does not match the source build' -Action { + & "$PSScriptRoot\Build-Payload.ps1" ` + -PackageDirectory $package -Architecture x64 -OutputDirectory $payload + } + + foreach ($architecture in @('x64', 'arm64')) { + $wrongArchive = Join-Path $testRoot "node-v0.0.0-win-$architecture.zip" + Set-Content -LiteralPath $wrongArchive -Value 'wrong runtime' + $metadata.architecture = $architecture + foreach ($version in @('v24.15.0', '26.1.0')) { + $metadata.nodeVersion = $version + $metadata | ConvertTo-Json | Set-Content -LiteralPath $metadataPath + $expectedName = "node-v$($version.TrimStart('v'))-win-$architecture.zip" + Assert-Fails -MessagePattern ([regex]::Escape($expectedName)) -Action { + & "$PSScriptRoot\Build-MSIX.ps1" ` + -PayloadDirectory $payload -NodeArchivePath $wrongArchive ` + -Architecture $architecture -PackageVersion '0.1.1.0' ` + -SourceCommit ('1' * 40) -OutputDirectory "$testRoot\msix" + } + } + } + $metadata.nodeVersion = '>=24' + $metadata | ConvertTo-Json | Set-Content -LiteralPath $metadataPath + Assert-Fails -MessagePattern 'Payload metadata is not valid' -Action { + & "$PSScriptRoot\Build-MSIX.ps1" ` + -PayloadDirectory $payload -Architecture arm64 ` + -PackageVersion '0.1.1.0' -SourceCommit ('1' * 40) ` + -OutputDirectory "$testRoot\msix" + } + + Write-Host 'Node.js source and packaging input tests passed.' +} +finally { + $env:RUNNER_TEMP = $previousRunnerTemp + $env:npm_config_cache = $previousNpmCache + if (Test-Path -LiteralPath $testRoot) { + Remove-Item -LiteralPath $testRoot -Recurse -Force + } +} diff --git a/scripts/Test-SigningInputs.Tests.ps1 b/scripts/Test-SigningInputs.Tests.ps1 index 59b61eeb..1a83dec0 100644 --- a/scripts/Test-SigningInputs.Tests.ps1 +++ b/scripts/Test-SigningInputs.Tests.ps1 @@ -26,6 +26,8 @@ function New-TestArtifact { [bool]$SourceTreeDirty = $false, + [string]$NodeRuntimeVersion = '24.16.0', + [bool]$IncludeBundledNode = $false, [bool]$IncludeApplicationBundledNode = $false, @@ -128,7 +130,6 @@ function New-TestArtifact { $runtimeDirectory = Join-Path $staging 'runtime' New-Item -Path $runtimeDirectory -ItemType Directory | Out-Null } - $nodeRuntimeVersion = '24.16.0' $nodeRuntimeArchive = "node-v$nodeRuntimeVersion-win-$Architecture.zip" $nodeRuntimePath = Join-Path $runtimeDirectory $nodeRuntimeArchive @@ -289,6 +290,21 @@ try { Reset-TestArtifacts Invoke-PolicyValidation -Root $testRoot + Remove-Item -LiteralPath $testRoot -Recurse -Force + New-Item -Path $testRoot -ItemType Directory | Out-Null + New-TestArtifact -Root $testRoot -Architecture x64 -NodeRuntimeVersion '26.1.0' + New-TestArtifact -Root $testRoot -Architecture arm64 -NodeRuntimeVersion '26.1.0' + Invoke-PolicyValidation -Root $testRoot + + Remove-Item -LiteralPath $testRoot -Recurse -Force + New-Item -Path $testRoot -ItemType Directory | Out-Null + New-TestArtifact -Root $testRoot -Architecture x64 + New-TestArtifact -Root $testRoot -Architecture arm64 -NodeRuntimeVersion '26.1.0' + Assert-Fails ` + -MessagePattern 'Node.js runtime versions do not match' ` + -Action { Invoke-PolicyValidation -Root $testRoot } + + Reset-TestArtifacts Assert-Fails ` -MessagePattern 'approved immutable OpenClaw commit' ` -Action { diff --git a/scripts/Test-SigningInputs.ps1 b/scripts/Test-SigningInputs.ps1 index 9a81655e..bc31d642 100644 --- a/scripts/Test-SigningInputs.ps1 +++ b/scripts/Test-SigningInputs.ps1 @@ -170,6 +170,7 @@ if ( $expectedPackagingCommit = $PackagingCommit.ToLowerInvariant() $expectedPackageVersion = $null +$expectedNodeRuntimeVersion = $null foreach ($architecture in @('x64', 'arm64')) { $directory = Join-Path $resolvedArtifactsDirectory $architecture $metadataPath = Join-Path $directory 'msix-metadata.json' @@ -219,6 +220,13 @@ foreach ($architecture in @('x64', 'arm64')) { throw 'The x64 and ARM64 package versions do not match.' } + if ($null -eq $expectedNodeRuntimeVersion) { + $expectedNodeRuntimeVersion = $metadata.nodeRuntimeVersion + } + elseif ($metadata.nodeRuntimeVersion -ne $expectedNodeRuntimeVersion) { + throw 'The x64 and ARM64 Node.js runtime versions do not match.' + } + $actualMsixHash = ( Get-FileHash -LiteralPath $msix.FullName -Algorithm SHA256 ).Hash.ToLowerInvariant() diff --git a/src/OpenClaw.Launcher/GatewayLauncher.cs b/src/OpenClaw.Launcher/GatewayLauncher.cs index ec27931c..531a98a3 100644 --- a/src/OpenClaw.Launcher/GatewayLauncher.cs +++ b/src/OpenClaw.Launcher/GatewayLauncher.cs @@ -69,6 +69,14 @@ public static ProcessStartInfo CreateStartInfo( startInfo.Environment["OPENCLAW_SUPERVISOR_MODE"] = "external"; startInfo.Environment["OPENCLAW_SERVICE_REPAIR_POLICY"] = "external"; startInfo.Environment["OPENCLAW_NO_AUTO_UPDATE"] = "1"; + string? nodeDirectory = Path.GetDirectoryName(nodePath); + if (!string.IsNullOrEmpty(nodeDirectory)) + { + startInfo.Environment.TryGetValue("PATH", out string? inheritedPath); + startInfo.Environment["PATH"] = string.IsNullOrEmpty(inheritedPath) + ? nodeDirectory + : $"{nodeDirectory}{Path.PathSeparator}{inheritedPath}"; + } startInfo.ArgumentList.Add(entryPoint); foreach (string argument in openClawArguments) diff --git a/src/OpenClaw.Launcher/HostOptions.cs b/src/OpenClaw.Launcher/HostOptions.cs index 6dace6f0..5c14526f 100644 --- a/src/OpenClaw.Launcher/HostOptions.cs +++ b/src/OpenClaw.Launcher/HostOptions.cs @@ -24,18 +24,13 @@ internal static HostOptions Parse( "openclaw.mjs")) ? packagedApplicationDirectory : null; - string packagedNodeArchivePath = Path.Combine( - baseDirectory, - "runtime", - NodeRuntimeInstaller.GetArchiveFileName( - System.Runtime.InteropServices.RuntimeInformation - .ProcessArchitecture)); + string? packagedNodeArchivePath = NodeRuntimeInstaller.FindArchivePath( + Path.Combine(baseDirectory, "runtime"), + System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture); return new HostOptions( directApplicationDirectory, - File.Exists(packagedNodeArchivePath) - ? packagedNodeArchivePath - : null, + packagedNodeArchivePath, [.. arguments]); } } diff --git a/src/OpenClaw.Launcher/NodeRuntimeInstaller.cs b/src/OpenClaw.Launcher/NodeRuntimeInstaller.cs index 06475f6a..92451d99 100644 --- a/src/OpenClaw.Launcher/NodeRuntimeInstaller.cs +++ b/src/OpenClaw.Launcher/NodeRuntimeInstaller.cs @@ -1,49 +1,77 @@ using System.IO.Compression; using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; namespace OpenClaw.Launcher; -internal static class NodeRuntimeInstaller +internal static partial class NodeRuntimeInstaller { - public const string Version = "24.16.0"; + public static string? FindArchivePath( + string runtimeDirectory, + Architecture architecture) + { + if (!Directory.Exists(runtimeDirectory)) + { + return null; + } - public static string GetArchiveFileName(Architecture architecture) => - $"node-v{Version}-win-{GetArchitectureName(architecture)}.zip"; + string[] archives = Directory.GetFiles( + runtimeDirectory, + $"node-v*-win-{GetArchitectureName(architecture)}.zip"); + return archives.Length switch + { + 0 => null, + 1 => archives[0], + _ => throw new InvalidDataException( + "The package contains multiple Node.js runtime archives.") + }; + } - public static string GetInstallDirectory(Architecture architecture) => + public static Version GetArchiveVersion( + string archivePath, + Architecture architecture) + { + Match match = ArchiveNameRegex().Match(Path.GetFileName(archivePath)); + if (!match.Success || + match.Groups["architecture"].Value != GetArchitectureName(architecture)) + { + throw new InvalidDataException( + $"Unexpected Node.js runtime archive: {Path.GetFileName(archivePath)}"); + } + + return NodeRuntimeResolver.ParseVersion(match.Groups["version"].Value); + } + + public static string GetInstallDirectory(string archivePath) => Path.Combine( HostDataPaths.GetProductLocalStateRoot(), "NodeJS", - $"node-v{Version}-win-{GetArchitectureName(architecture)}"); + Path.GetFileNameWithoutExtension(archivePath)); - public static string GetExecutablePath(Architecture architecture) => - Path.Combine(GetInstallDirectory(architecture), "node.exe"); - - public static async Task EnsureInstalledAsync( + public static NodeRuntime EnsureInstalled( string archivePath, - CancellationToken cancellationToken) + Action log) { Architecture architecture = RuntimeInformation.ProcessArchitecture; - string executablePath = EnsureInstalled( + Version version = GetArchiveVersion(archivePath, architecture); + return EnsureInstalled( archivePath, - GetInstallDirectory(architecture), - architecture); - return await NodeRuntimeResolver.ResolvePathAsync( - executablePath, - cancellationToken).ConfigureAwait(false); + GetInstallDirectory(archivePath), + path => NodeRuntimeResolver.ResolvePath( + path, + version), + log); } - internal static string EnsureInstalled( + internal static NodeRuntime EnsureInstalled( string archivePath, string installDirectory, - Architecture architecture) + Func resolveNode, + Action log) { string executablePath = Path.Combine(installDirectory, "node.exe"); - if (File.Exists(executablePath)) - { - return executablePath; - } - if (!File.Exists(archivePath)) { throw new FileNotFoundException( @@ -51,9 +79,11 @@ internal static string EnsureInstalled( archivePath); } - string mutexName = - $"Local\\OpenClawGatewayMSIX.NodeRuntime.{GetArchitectureName(architecture)}"; - using var mutex = new Mutex(initiallyOwned: false, mutexName); + // LocalState is shared across Windows sessions. Keep validation and + // publication on this thread because mutex ownership is thread-affine. + using var mutex = new Mutex( + initiallyOwned: false, + GetInstallMutexName(installDirectory)); bool ownsMutex = false; try { @@ -66,38 +96,38 @@ internal static string EnsureInstalled( ownsMutex = true; } - if (File.Exists(executablePath)) + string stagingDirectory = $"{installDirectory}.extract"; + if (Directory.Exists(stagingDirectory)) { - return executablePath; + Directory.Delete(stagingDirectory, recursive: true); } - string? parentDirectory = Path.GetDirectoryName(installDirectory); - if (string.IsNullOrWhiteSpace(parentDirectory)) + if (File.Exists(executablePath)) { - throw new InvalidOperationException( - "The Node.js installation path has no parent directory."); + try + { + return resolveNode(executablePath); + } + catch (InvalidOperationException exception) + { + log($"Reinstalling invalid bundled Node.js: {exception.Message}"); + } } - Directory.CreateDirectory(parentDirectory); - string stagingDirectory = - $"{installDirectory}.{Guid.NewGuid():N}.extract"; try { ExtractRuntime( archivePath, - stagingDirectory, - architecture); - if (!File.Exists(Path.Combine(stagingDirectory, "node.exe"))) - { - throw new InvalidDataException( - "The packaged Node.js archive does not contain node.exe."); - } + stagingDirectory); + NodeRuntime runtime = resolveNode( + Path.Combine(stagingDirectory, "node.exe")); if (Directory.Exists(installDirectory)) { Directory.Delete(installDirectory, recursive: true); } Directory.Move(stagingDirectory, installDirectory); + return runtime with { ExecutablePath = executablePath }; } finally { @@ -106,8 +136,6 @@ internal static string EnsureInstalled( Directory.Delete(stagingDirectory, recursive: true); } } - - return executablePath; } finally { @@ -118,13 +146,18 @@ internal static string EnsureInstalled( } } + internal static string GetInstallMutexName(string installDirectory) + { + string identity = Path.GetFullPath(installDirectory).ToUpperInvariant(); + string hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity))); + return $"Global\\OpenClawGatewayMSIX.NodeRuntime.{hash}"; + } + private static void ExtractRuntime( string archivePath, - string stagingDirectory, - Architecture architecture) + string stagingDirectory) { - string archiveRoot = - $"node-v{Version}-win-{GetArchitectureName(architecture)}"; + string archiveRoot = Path.GetFileNameWithoutExtension(archivePath); string archivePrefix = archiveRoot + "/"; string fullStagingDirectory = Path.GetFullPath(stagingDirectory) + Path.DirectorySeparatorChar; @@ -197,6 +230,11 @@ private static void ExtractRuntime( } } + [GeneratedRegex( + @"^node-v(?\d+\.\d+\.\d+)-win-(?x64|arm64)\.zip$", + RegexOptions.CultureInvariant)] + private static partial Regex ArchiveNameRegex(); + private static string GetArchitectureName(Architecture architecture) => architecture switch { diff --git a/src/OpenClaw.Launcher/NodeRuntimeResolver.cs b/src/OpenClaw.Launcher/NodeRuntimeResolver.cs index 5f246861..6f1e9992 100644 --- a/src/OpenClaw.Launcher/NodeRuntimeResolver.cs +++ b/src/OpenClaw.Launcher/NodeRuntimeResolver.cs @@ -13,101 +13,71 @@ internal sealed record NodeRuntime( internal static partial class NodeRuntimeResolver { - private static readonly TimeSpan VersionQueryTimeout = TimeSpan.FromSeconds(10); - private static readonly NodeVersionRange[] SupportedVersionRanges = - [ - new(new Version(22, 22, 3), 23), - new(new Version(24, 15, 0), 25), - new(new Version(25, 9, 0), null) - ]; - - public static string SupportedVersions { get; } = string.Join( - " || ", - SupportedVersionRanges.Select(range => - range.ExclusiveMajor is int exclusiveMajor - ? $">={range.Minimum} <{exclusiveMajor}" - : $">={range.Minimum}")); - - public static Task ResolveAsync(CancellationToken cancellationToken) => - ResolvePathAsync( - NodeRuntimeInstaller.GetExecutablePath( - RuntimeInformation.ProcessArchitecture), - cancellationToken); - - public static Task ResolvePathAsync( + public static NodeRuntime Resolve(string archivePath) => + ResolvePath( + Path.Combine(NodeRuntimeInstaller.GetInstallDirectory(archivePath), "node.exe"), + NodeRuntimeInstaller.GetArchiveVersion( + archivePath, + RuntimeInformation.ProcessArchitecture)); + + public static NodeRuntime ResolvePath( string executablePath, - CancellationToken cancellationToken) => - ResolveAsync( - File.Exists(executablePath) ? [executablePath] : [], - QueryVersionAsync, - ReadArchitecture, - RuntimeInformation.ProcessArchitecture, - cancellationToken); - - internal static async Task ResolveAsync( - IReadOnlyList candidates, - Func> queryVersion, - Func readArchitecture, - Architecture requiredArchitecture, - CancellationToken cancellationToken) + Version expectedVersion) { - if (candidates.Count == 0) + if (!File.Exists(executablePath)) { throw new InvalidOperationException( CreateFailureMessage( "The bundled Node.js runtime has not been extracted.")); } - var failures = new List(); - foreach (string candidate in candidates) + return Resolve( + executablePath, + expectedVersion, + ReadVersion, + ReadArchitecture, + RuntimeInformation.ProcessArchitecture); + } + + internal static NodeRuntime Resolve( + string executablePath, + Version expectedVersion, + Func readVersion, + Func readArchitecture, + Architecture requiredArchitecture) + { + try { - try + Version version = ParseVersion(readVersion(executablePath)); + if (version != expectedVersion) { - string output = await queryVersion(candidate, cancellationToken) - .ConfigureAwait(false); - Version version = ParseVersion(output); - if (!IsSupported(version)) - { - throw new InvalidDataException( - $"version {version} is unsupported; required {SupportedVersions}"); - } - - Architecture architecture = readArchitecture(candidate); - if (architecture != requiredArchitecture) - { - throw new InvalidDataException( - $"architecture {architecture} does not match {requiredArchitecture}"); - } - - return new NodeRuntime(candidate, version, architecture); + throw new InvalidDataException( + $"version {version} does not match bundled version {expectedVersion}"); } - catch (Exception exception) when ( - exception is IOException or - UnauthorizedAccessException or - BadImageFormatException or - InvalidDataException or - InvalidOperationException or - TimeoutException or - Win32Exception) + + Architecture architecture = readArchitecture(executablePath); + if (architecture != requiredArchitecture) { - failures.Add($"{candidate}: {exception.Message}"); + throw new InvalidDataException( + $"architecture {architecture} does not match {requiredArchitecture}"); } - } - throw new InvalidOperationException( - CreateFailureMessage( - "No compatible Node.js runtime was found. " + - string.Join(" ", failures))); + return new NodeRuntime(executablePath, version, architecture); + } + catch (Exception exception) when ( + exception is IOException or + UnauthorizedAccessException or + BadImageFormatException or + InvalidDataException or + InvalidOperationException or + Win32Exception) + { + throw new InvalidOperationException( + CreateFailureMessage($"{executablePath}: {exception.Message}"), + exception); + } } - internal static bool IsSupported(Version version) => - SupportedVersionRanges.Any(range => - version >= range.Minimum && - ( - range.ExclusiveMajor is null || - version.Major < range.ExclusiveMajor - )); - internal static Version ParseVersion(string output) { Match match = NodeVersionRegex().Match(output.Trim()); @@ -116,7 +86,7 @@ internal static Version ParseVersion(string output) !Version.TryParse(match.Groups["version"].Value, out Version? version)) { throw new InvalidDataException( - $"Node.js returned an invalid version: {output.Trim()}"); + $"Node.js has an invalid product version: {output.Trim()}"); } return version; @@ -124,57 +94,19 @@ internal static Version ParseVersion(string output) internal static string CreateFailureMessage(string detail) => $"{detail}{Environment.NewLine}" + - $"Run `clawctl setup` to extract Node.js {NodeRuntimeInstaller.Version} " + - "from the installed OpenClaw package."; + "Run `clawctl setup` to prepare the Node.js runtime bundled " + + "with the installed OpenClaw package."; - private static async Task QueryVersionAsync( - string executablePath, - CancellationToken cancellationToken) + private static string ReadVersion(string executablePath) { - var startInfo = new ProcessStartInfo - { - FileName = executablePath, - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true - }; - startInfo.ArgumentList.Add("--version"); - - using Process process = Process.Start(startInfo) ?? - throw new InvalidOperationException("Unable to start Node.js."); - using var timeout = CancellationTokenSource.CreateLinkedTokenSource( - cancellationToken); - timeout.CancelAfter(VersionQueryTimeout); - - try + // Executing a staged image can keep it locked after process exit, + // preventing Windows from publishing the extracted directory. + string? version = FileVersionInfo.GetVersionInfo(executablePath).ProductVersion; + if (string.IsNullOrWhiteSpace(version)) { - Task standardOutput = process.StandardOutput.ReadToEndAsync( - timeout.Token); - Task standardError = process.StandardError.ReadToEndAsync( - timeout.Token); - await process.WaitForExitAsync(timeout.Token).ConfigureAwait(false); - string output = await standardOutput.ConfigureAwait(false); - string error = await standardError.ConfigureAwait(false); - if (process.ExitCode != 0) - { - throw new InvalidOperationException( - $"Node.js version query exited with code {process.ExitCode}: " + - error.Trim()); - } - - return output; - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - if (!process.HasExited) - { - process.Kill(entireProcessTree: true); - await process.WaitForExitAsync(CancellationToken.None) - .ConfigureAwait(false); - } - throw new TimeoutException("Node.js version query timed out."); + throw new InvalidDataException("Node.js has no product version."); } + return version; } private static Architecture ReadArchitecture(string executablePath) @@ -196,8 +128,4 @@ private static Architecture ReadArchitecture(string executablePath) @"^v?(?\d+\.\d+\.\d+)(?:\+[0-9A-Za-z.-]+)?$", RegexOptions.CultureInvariant)] private static partial Regex NodeVersionRegex(); - - private sealed record NodeVersionRange( - Version Minimum, - int? ExclusiveMajor); } diff --git a/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj b/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj index 47071d72..ddbe9d9a 100644 --- a/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj +++ b/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj @@ -81,7 +81,8 @@ - @@ -93,7 +94,7 @@ Text="Missing expanded OpenClaw application content." /> - diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index 36024480..05454cf2 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -131,7 +131,8 @@ internal static async Task RunAgentAsync( await RunAgentAsync( options, log, - resolveNode ?? NodeRuntimeResolver.ResolveAsync, + resolveNode ?? (_ => Task.FromResult(NodeRuntimeResolver.Resolve( + GetPackagedNodeArchivePath(options)))), GatewayLauncher.RunAsync).ConfigureAwait(false); // launchOpenClaw is a test seam: tests substitute a fake in place of @@ -198,9 +199,9 @@ await output.WriteLineAsync( else { string archivePath = GetPackagedNodeArchivePath(options); - nodeRuntime = await NodeRuntimeInstaller.EnsureInstalledAsync( + nodeRuntime = NodeRuntimeInstaller.EnsureInstalled( archivePath, - CancellationToken.None).ConfigureAwait(false); + log); } ClawCtlConsole.WriteNodeRuntimeSummary(output, nodeRuntime); string applicationDirectory = @@ -247,10 +248,7 @@ private static string GetPackagedNodeArchivePath(HostOptions options) string? archivePath = options.PackagedNodeArchivePath; string expectedPath = archivePath ?? Path.Combine( AppContext.BaseDirectory, - "runtime", - NodeRuntimeInstaller.GetArchiveFileName( - System.Runtime.InteropServices.RuntimeInformation - .ProcessArchitecture)); + "runtime"); if (archivePath is null || !File.Exists(archivePath)) { throw new FileNotFoundException( diff --git a/tests/OpenClaw.Launcher.Tests/GatewayLauncherTests.cs b/tests/OpenClaw.Launcher.Tests/GatewayLauncherTests.cs index 34bd1dbf..6c3b5741 100644 --- a/tests/OpenClaw.Launcher.Tests/GatewayLauncherTests.cs +++ b/tests/OpenClaw.Launcher.Tests/GatewayLauncherTests.cs @@ -48,6 +48,24 @@ public void CreateStartInfoDefaultsToCurrentWorkingDirectory() Assert.NotEqual(_payloadDirectory, startInfo.WorkingDirectory); } + [Fact] + public void CreateStartInfoPrependsBundledRuntimeOnlyToChildPath() + { + string? inheritedPath = Environment.GetEnvironmentVariable("PATH"); + string nodeDirectory = Path.Combine(_payloadDirectory, "runtime"); + + var startInfo = GatewayLauncher.CreateStartInfo( + Path.Combine(nodeDirectory, "node.exe"), + _payloadDirectory, + []); + + string expectedPath = string.IsNullOrEmpty(inheritedPath) + ? nodeDirectory + : $"{nodeDirectory}{Path.PathSeparator}{inheritedPath}"; + Assert.Equal(expectedPath, startInfo.Environment["PATH"]); + Assert.Equal(inheritedPath, Environment.GetEnvironmentVariable("PATH")); + } + [Fact] public void CreateStartInfoPreservesExplicitArguments() { diff --git a/tests/OpenClaw.Launcher.Tests/HostOptionsTests.cs b/tests/OpenClaw.Launcher.Tests/HostOptionsTests.cs index 48c9ccbe..a165df85 100644 --- a/tests/OpenClaw.Launcher.Tests/HostOptionsTests.cs +++ b/tests/OpenClaw.Launcher.Tests/HostOptionsTests.cs @@ -55,16 +55,20 @@ public void ParseResolvesPackagedApplicationWhenEntryPointExists() Assert.Equal(["gateway", "run"], options.OpenClawArguments); } - [Fact] - public void ParseResolvesArchitectureSpecificPackagedNodeArchive() + [Theory] + [InlineData("24.16.0")] + [InlineData("26.1.0")] + public void ParseResolvesArchitectureSpecificPackagedNodeArchive(string version) { string runtimeDirectory = Path.Combine(_testDirectory, "runtime"); Directory.CreateDirectory(runtimeDirectory); + string architecture = System.Runtime.InteropServices.RuntimeInformation + .ProcessArchitecture == System.Runtime.InteropServices.Architecture.X64 + ? "x64" + : "arm64"; string archivePath = Path.Combine( runtimeDirectory, - NodeRuntimeInstaller.GetArchiveFileName( - System.Runtime.InteropServices.RuntimeInformation - .ProcessArchitecture)); + $"node-v{version}-win-{architecture}.zip"); File.WriteAllText(archivePath, "fixture"); HostOptions options = HostOptions.Parse([], _testDirectory); @@ -72,6 +76,18 @@ public void ParseResolvesArchitectureSpecificPackagedNodeArchive() Assert.Equal(archivePath, options.PackagedNodeArchivePath); } + [Fact] + public void ArchiveDiscoveryRejectsAmbiguousVersions() + { + File.WriteAllText(Path.Combine(_testDirectory, "node-v24.16.0-win-x64.zip"), "fixture"); + File.WriteAllText(Path.Combine(_testDirectory, "node-v26.1.0-win-x64.zip"), "fixture"); + + Assert.Throws(() => + NodeRuntimeInstaller.FindArchivePath( + _testDirectory, + System.Runtime.InteropServices.Architecture.X64)); + } + public void Dispose() { Directory.Delete(_testDirectory, recursive: true); diff --git a/tests/OpenClaw.Launcher.Tests/NodeRuntimeInstallerTests.cs b/tests/OpenClaw.Launcher.Tests/NodeRuntimeInstallerTests.cs index 989d9442..a397a11b 100644 --- a/tests/OpenClaw.Launcher.Tests/NodeRuntimeInstallerTests.cs +++ b/tests/OpenClaw.Launcher.Tests/NodeRuntimeInstallerTests.cs @@ -7,81 +7,240 @@ public sealed class NodeRuntimeInstallerTests : IDisposable { private readonly string _testDirectory = TestDirectory.Create(); - [Fact] - public void EnsureInstalledExtractsArchitectureRuntimeOnce() + [Theory] + [InlineData("24.16.0", Architecture.X64)] + [InlineData("26.1.0", Architecture.X64)] + [InlineData("26.1.0", Architecture.Arm64)] + public void EnsureInstalledExtractsSelectedRuntimeOnce( + string version, + Architecture architecture) { - Architecture architecture = Architecture.X64; - string archivePath = CreateRuntimeArchive(architecture); + string archivePath = CreateRuntimeArchive(version, architecture); string installDirectory = Path.Combine(_testDirectory, "installed"); - string firstPath = NodeRuntimeInstaller.EnsureInstalled( + NodeRuntime first = EnsureInstalled( archivePath, - installDirectory, - architecture); - DateTime firstWriteTime = File.GetLastWriteTimeUtc(firstPath); - string secondPath = NodeRuntimeInstaller.EnsureInstalled( + installDirectory); + DateTime firstWriteTime = File.GetLastWriteTimeUtc(first.ExecutablePath); + NodeRuntime second = EnsureInstalled( archivePath, - installDirectory, - architecture); + installDirectory); - Assert.Equal(Path.Combine(installDirectory, "node.exe"), firstPath); - Assert.Equal(firstPath, secondPath); - Assert.Equal("fixture-node", File.ReadAllText(firstPath)); - Assert.Equal(firstWriteTime, File.GetLastWriteTimeUtc(secondPath)); + Assert.Equal(Path.Combine(installDirectory, "node.exe"), first.ExecutablePath); + Assert.Equal(Version.Parse(version), first.Version); + Assert.Equal(architecture, first.Architecture); + Assert.Equal(first, second); + Assert.Equal("fixture-node", File.ReadAllText(first.ExecutablePath)); + Assert.Equal(firstWriteTime, File.GetLastWriteTimeUtc(second.ExecutablePath)); + Assert.Equal( + Version.Parse(version), + NodeRuntimeInstaller.GetArchiveVersion(archivePath, architecture)); + Assert.EndsWith( + Path.GetFileNameWithoutExtension(archivePath), + NodeRuntimeInstaller.GetInstallDirectory(archivePath), + StringComparison.Ordinal); } - [Fact] - public void EnsureInstalledRejectsEntriesOutsideExpectedRuntimeRoot() + [Theory] + [InlineData("unexpected/node.exe")] + [InlineData("{root}/../outside.txt")] + [InlineData("{root}/nested/../../outside.txt")] + public void EnsureInstalledRejectsUnsafeArchiveEntries(string entryName) { - string archivePath = Path.Combine(_testDirectory, "unsafe.zip"); + ArgumentNullException.ThrowIfNull(entryName); + string archivePath = CreateRuntimeArchive("26.1.0", Architecture.X64); using (ZipArchive archive = ZipFile.Open( archivePath, - ZipArchiveMode.Create)) + ZipArchiveMode.Update)) { - ZipArchiveEntry entry = archive.CreateEntry("unexpected/node.exe"); + ZipArchiveEntry entry = archive.CreateEntry(entryName.Replace( + "{root}", + Path.GetFileNameWithoutExtension(archivePath), + StringComparison.Ordinal)); using StreamWriter writer = new(entry.Open()); writer.Write("fixture-node"); } + string installDirectory = Path.Combine(_testDirectory, "unsafe-install"); Assert.Throws(() => - NodeRuntimeInstaller.EnsureInstalled( - archivePath, - Path.Combine(_testDirectory, "unsafe-install"), - Architecture.X64)); + EnsureInstalled(archivePath, installDirectory)); + Assert.False(Directory.Exists(installDirectory)); + Assert.False(Directory.Exists($"{installDirectory}.extract")); } [Fact] public void EnsureInstalledReplacesIncompleteRuntimeDirectory() { - Architecture architecture = Architecture.X64; - string archivePath = CreateRuntimeArchive(architecture); + string archivePath = CreateRuntimeArchive("26.1.0", Architecture.X64); string installDirectory = Path.Combine(_testDirectory, "incomplete"); Directory.CreateDirectory(installDirectory); File.WriteAllText( Path.Combine(installDirectory, "partial.txt"), "partial"); - string executablePath = NodeRuntimeInstaller.EnsureInstalled( - archivePath, - installDirectory, - architecture); + NodeRuntime runtime = EnsureInstalled(archivePath, installDirectory); - Assert.True(File.Exists(executablePath)); + Assert.True(File.Exists(runtime.ExecutablePath)); Assert.False(File.Exists( Path.Combine(installDirectory, "partial.txt"))); } + [Fact] + public void EnsureInstalledRepairsInvalidExecutableAndThenReusesIt() + { + string archivePath = CreateRuntimeArchive("26.1.0", Architecture.X64); + string installDirectory = Path.Combine(_testDirectory, "invalid"); + Directory.CreateDirectory(installDirectory); + File.WriteAllText(Path.Combine(installDirectory, "node.exe"), "invalid-node"); + var messages = new List(); + + NodeRuntime runtime = EnsureInstalled(archivePath, installDirectory, messages.Add); + DateTime writeTime = File.GetLastWriteTimeUtc(runtime.ExecutablePath); + NodeRuntime reused = EnsureInstalled(archivePath, installDirectory, messages.Add); + + Assert.Equal("fixture-node", File.ReadAllText(runtime.ExecutablePath)); + Assert.Equal(runtime, reused); + Assert.Equal(writeTime, File.GetLastWriteTimeUtc(reused.ExecutablePath)); + Assert.Contains("Reinstalling invalid bundled Node.js", Assert.Single(messages), + StringComparison.Ordinal); + } + + [Fact] + public void EnsureInstalledDoesNotPublishInvalidRuntimeAndCanRetry() + { + string archivePath = CreateRuntimeArchive( + "26.1.0", + Architecture.X64, + "invalid-node"); + string installDirectory = Path.Combine(_testDirectory, "retry"); + + Assert.Throws(() => + EnsureInstalled(archivePath, installDirectory)); + Assert.False(Directory.Exists(installDirectory)); + Assert.False(Directory.Exists($"{installDirectory}.extract")); + + File.Delete(archivePath); + CreateRuntimeArchive("26.1.0", Architecture.X64); + NodeRuntime runtime = EnsureInstalled(archivePath, installDirectory); + + Assert.Equal("fixture-node", File.ReadAllText(runtime.ExecutablePath)); + } + + [Theory] + [InlineData(Architecture.X64)] + [InlineData(Architecture.Arm64)] + public void EnsureInstalledClearsInterruptedExtraction(Architecture architecture) + { + string archivePath = CreateRuntimeArchive("26.1.0", architecture); + string installDirectory = Path.Combine(_testDirectory, "interrupted"); + string stagingDirectory = $"{installDirectory}.extract"; + Directory.CreateDirectory(stagingDirectory); + File.WriteAllText(Path.Combine(stagingDirectory, "node.exe"), "partial-node"); + + NodeRuntime runtime = EnsureInstalled(archivePath, installDirectory); + + Assert.Equal("fixture-node", File.ReadAllText(runtime.ExecutablePath)); + Assert.False(Directory.Exists(stagingDirectory)); + } + + [Fact] + public async Task ConcurrentSetupPublishesOnlyOnce() + { + string archivePath = CreateRuntimeArchive("26.1.0", Architecture.X64); + string installDirectory = Path.Combine(_testDirectory, "concurrent"); + int publications = 0; + using var start = new Barrier(3); + + NodeRuntime Install() + { + start.SignalAndWait(); + return NodeRuntimeInstaller.EnsureInstalled( + archivePath, + installDirectory, + path => + { + if (path == Path.Combine($"{installDirectory}.extract", "node.exe")) + { + Interlocked.Increment(ref publications); + } + return ResolveFixture(path, archivePath); + }, + _ => { }); + } + + Task first = Task.Run(Install); + Task second = Task.Run(Install); + start.SignalAndWait(); + NodeRuntime[] runtimes = await Task.WhenAll(first, second); + + Assert.Equal(1, publications); + Assert.Equal(runtimes[0], runtimes[1]); + Assert.False(Directory.Exists($"{installDirectory}.extract")); + } + + [Fact] + public void InstallationLockIsGlobalAndScopedToItsDirectory() + { + string first = Path.Combine(_testDirectory, "first"); + string second = Path.Combine(_testDirectory, "second"); + string name = NodeRuntimeInstaller.GetInstallMutexName(first); + + Assert.StartsWith("Global\\", name, StringComparison.Ordinal); + Assert.Equal(name, NodeRuntimeInstaller.GetInstallMutexName(first.ToUpperInvariant())); + Assert.NotEqual(name, NodeRuntimeInstaller.GetInstallMutexName(second)); + } + + [Theory] + [InlineData("node-v26-win-x64.zip")] + [InlineData("node-v26.1.0-rc.1-win-x64.zip")] + [InlineData("node-v26.1.0-win-arm64.zip")] + public void ArchiveVersionRejectsMalformedOrWrongArchitectureNames(string name) + { + Assert.Throws(() => + NodeRuntimeInstaller.GetArchiveVersion(name, Architecture.X64)); + } + public void Dispose() { Directory.Delete(_testDirectory, recursive: true); GC.SuppressFinalize(this); } - private string CreateRuntimeArchive(Architecture architecture) + private static NodeRuntime EnsureInstalled( + string archivePath, + string installDirectory, + Action? log = null) => + NodeRuntimeInstaller.EnsureInstalled( + archivePath, + installDirectory, + path => ResolveFixture(path, archivePath), + log ?? (_ => { })); + + private static NodeRuntime ResolveFixture(string path, string archivePath) + { + if (!File.Exists(path) || File.ReadAllText(path) != "fixture-node") + { + throw new InvalidOperationException("Invalid fixture runtime."); + } + + Architecture architecture = archivePath.EndsWith("-win-x64.zip", StringComparison.Ordinal) + ? Architecture.X64 + : Architecture.Arm64; + return new NodeRuntime( + path, + NodeRuntimeInstaller.GetArchiveVersion(archivePath, architecture), + architecture); + } + + private string CreateRuntimeArchive( + string version, + Architecture architecture, + string nodeContent = "fixture-node") { + string architectureName = architecture == Architecture.X64 ? "x64" : "arm64"; string archivePath = Path.Combine( _testDirectory, - NodeRuntimeInstaller.GetArchiveFileName(architecture)); + $"node-v{version}-win-{architectureName}.zip"); string rootName = Path.GetFileNameWithoutExtension(archivePath); using ZipArchive archive = ZipFile.Open( archivePath, @@ -95,7 +254,7 @@ private string CreateRuntimeArchive(Architecture architecture) } ZipArchiveEntry entry = archive.CreateEntry($"{rootName}/node.exe"); using StreamWriter writer = new(entry.Open()); - writer.Write("fixture-node"); + writer.Write(nodeContent); return archivePath; } } diff --git a/tests/OpenClaw.Launcher.Tests/NodeRuntimeResolverTests.cs b/tests/OpenClaw.Launcher.Tests/NodeRuntimeResolverTests.cs index cf92dcda..8ccbc2b0 100644 --- a/tests/OpenClaw.Launcher.Tests/NodeRuntimeResolverTests.cs +++ b/tests/OpenClaw.Launcher.Tests/NodeRuntimeResolverTests.cs @@ -4,86 +4,87 @@ namespace OpenClaw.Launcher.Tests; public sealed class NodeRuntimeResolverTests { - [Fact] - public async Task ResolveAcceptsCompatibleRuntime() + [Theory] + [InlineData("24.16.0", Architecture.X64)] + [InlineData("26.1.0", Architecture.X64)] + [InlineData("26.1.0", Architecture.Arm64)] + public void ResolveAcceptsSelectedRuntime(string version, Architecture architecture) { - NodeRuntime runtime = await ResolveAsync( - ["C:\\Node\\node.exe"], - _ => Task.FromResult("v24.16.0"), - Architecture.X64, - Architecture.X64); + NodeRuntime runtime = Resolve( + _ => $"v{version}", + architecture, + architecture, + version); Assert.Equal("C:\\Node\\node.exe", runtime.ExecutablePath); - Assert.Equal(new Version(24, 16, 0), runtime.Version); - Assert.Equal(Architecture.X64, runtime.Architecture); + Assert.Equal(Version.Parse(version), runtime.Version); + Assert.Equal(architecture, runtime.Architecture); } [Fact] - public async Task ResolveReportsSetupCommandWhenRuntimeIsMissing() + public void ResolveReportsSetupCommandWhenRuntimeIsMissing() { - InvalidOperationException exception = await Assert.ThrowsAsync< + InvalidOperationException exception = Assert.Throws< InvalidOperationException>( - () => ResolveAsync( - [], - _ => Task.FromResult("v24.16.0"), - Architecture.X64, - Architecture.X64)); + () => NodeRuntimeResolver.ResolvePath( + Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "node.exe"), + new Version(26, 1, 0))); Assert.Contains("has not been extracted", exception.Message, StringComparison.Ordinal); Assert.Contains("clawctl setup", exception.Message, StringComparison.Ordinal); } - [Fact] - public async Task ResolveRejectsOutdatedRuntime() + [Theory] + [InlineData("24.14.0")] + [InlineData("24.17.0")] + public void ResolveRejectsRuntimeDifferentFromArchive(string version) { - InvalidOperationException exception = await Assert.ThrowsAsync< + InvalidOperationException exception = Assert.Throws< InvalidOperationException>( - () => ResolveAsync( - ["C:\\Node\\node.exe"], - _ => Task.FromResult("v24.14.0"), + () => Resolve( + _ => $"v{version}", Architecture.X64, Architecture.X64)); - Assert.Contains("version 24.14.0 is unsupported", exception.Message, StringComparison.Ordinal); - Assert.Contains(NodeRuntimeResolver.SupportedVersions, exception.Message, StringComparison.Ordinal); + Assert.Contains( + $"version {version} does not match bundled version 24.16.0", + exception.Message, + StringComparison.Ordinal); } [Fact] - public async Task ResolveRejectsMalformedVersion() + public void ResolveRejectsMalformedVersion() { - InvalidOperationException exception = await Assert.ThrowsAsync< + InvalidOperationException exception = Assert.Throws< InvalidOperationException>( - () => ResolveAsync( - ["C:\\Node\\node.exe"], - _ => Task.FromResult("not-node"), + () => Resolve( + _ => "not-node", Architecture.X64, Architecture.X64)); - Assert.Contains("invalid version", exception.Message, StringComparison.Ordinal); + Assert.Contains("invalid product version", exception.Message, StringComparison.Ordinal); } [Fact] - public async Task ResolveRejectsPrereleaseVersion() + public void ResolveRejectsPrereleaseVersion() { - InvalidOperationException exception = await Assert.ThrowsAsync< + InvalidOperationException exception = Assert.Throws< InvalidOperationException>( - () => ResolveAsync( - ["C:\\Node\\node.exe"], - _ => Task.FromResult("v24.15.0-rc.1"), + () => Resolve( + _ => "v24.15.0-rc.1", Architecture.X64, Architecture.X64)); - Assert.Contains("invalid version", exception.Message, StringComparison.Ordinal); + Assert.Contains("invalid product version", exception.Message, StringComparison.Ordinal); } [Fact] - public async Task ResolveRejectsIncompatibleArchitecture() + public void ResolveRejectsIncompatibleArchitecture() { - InvalidOperationException exception = await Assert.ThrowsAsync< + InvalidOperationException exception = Assert.Throws< InvalidOperationException>( - () => ResolveAsync( - ["C:\\Node\\node.exe"], - _ => Task.FromResult("v24.16.0"), + () => Resolve( + _ => "v24.16.0", Architecture.Arm64, Architecture.X64)); @@ -91,50 +92,28 @@ public async Task ResolveRejectsIncompatibleArchitecture() } [Fact] - public async Task ResolveReportsVersionQueryFailure() + public void ResolveReportsVersionReadFailure() { - InvalidOperationException exception = await Assert.ThrowsAsync< + InvalidOperationException exception = Assert.Throws< InvalidOperationException>( - () => ResolveAsync( - ["C:\\Node\\node.exe"], - _ => Task.FromException( - new InvalidOperationException("query failed")), + () => Resolve( + _ => throw new IOException("version read failed"), Architecture.X64, Architecture.X64)); - Assert.Contains("query failed", exception.Message, StringComparison.Ordinal); + Assert.Contains("version read failed", exception.Message, StringComparison.Ordinal); Assert.Contains("clawctl setup", exception.Message, StringComparison.Ordinal); } - [Theory] - [InlineData(22, 22, 2, false)] - [InlineData(22, 22, 3, true)] - [InlineData(23, 99, 0, false)] - [InlineData(24, 14, 99, false)] - [InlineData(24, 15, 0, true)] - [InlineData(25, 8, 99, false)] - [InlineData(25, 9, 0, true)] - [InlineData(26, 0, 0, true)] - public void SupportedVersionsMatchPackagedOpenClawRequirement( - int major, - int minor, - int build, - bool expected) - { - Assert.Equal( - expected, - NodeRuntimeResolver.IsSupported(new Version(major, minor, build))); - } - - private static Task ResolveAsync( - IReadOnlyList candidates, - Func> queryVersion, + private static NodeRuntime Resolve( + Func readVersion, Architecture candidateArchitecture, - Architecture requiredArchitecture) => - NodeRuntimeResolver.ResolveAsync( - candidates, - (path, _) => queryVersion(path), + Architecture requiredArchitecture, + string expectedVersion = "24.16.0") => + NodeRuntimeResolver.Resolve( + "C:\\Node\\node.exe", + Version.Parse(expectedVersion), + readVersion, _ => candidateArchitecture, - requiredArchitecture, - CancellationToken.None); + requiredArchitecture); }