diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 851251e..f8904f4 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` 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. - `clawctl` parses its own arguments with System.CommandLine (`ClawCtlCommandLine` builds the tree; `Program.RunControlAsync` invokes it). Help, usage, version, and completion are library behavior; parse errors exit @@ -70,16 +72,17 @@ and ARM64 separately. - `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 `Build-MSIX.ps1` to reject bundled Node.js, build the application - inventory, publish the NativeAOT host, validate package contents, and emit - MSIX metadata. +- 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 @@ -111,15 +114,19 @@ 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. - 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. - Official releases combine the x64 and ARM64 packages into one signed `.msixbundle` while retaining signed standalone packages for explicit architecture-specific deployment. Compose the bundle before signing; bundle diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 3b6e176..bdd30a9 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: . @@ -84,6 +82,10 @@ jobs: run: > .\scripts\Test-SigningInputs.Tests.ps1 + - name: Test Node.js packaging inputs + shell: pwsh + run: .\scripts\Test-NodeRuntimeInputs.Tests.ps1 + - name: Test signing workflow configuration shell: pwsh run: > @@ -110,32 +112,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: | @@ -144,7 +140,6 @@ jobs: - name: Pack npm package id: source - working-directory: openclaw-source run: | set -euo pipefail artifact_dir="${RUNNER_TEMP}/openclaw-package" @@ -158,15 +153,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-v-win-`. +Extraction is idempotent, versioned, and serialized across concurrent setup +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 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 ``` -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 @@ -123,6 +124,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 @@ -149,12 +158,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. `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. The Node.js used by the payload build jobs is build -infrastructure only and is not copied into the MSIX. +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: @@ -232,6 +243,8 @@ is recorded in `release-policy.json`. | 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-v-win-` | | OpenClaw configuration and user state | `%USERPROFILE%\.openclaw` | | Launcher diagnostics | `%LOCALAPPDATA%\Packages\\LocalState\OpenClawGatewayMSIX\Logs\openclaw.log` | @@ -245,18 +258,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 7dc0a76..57d3664 100644 --- a/scripts/Build-LocalMSIX.ps1 +++ b/scripts/Build-LocalMSIX.ps1 @@ -7,6 +7,8 @@ param( [long]$PayloadRunId, + [string]$NodeArchivePath, + [string]$PackageVersion, [string]$OutputDirectory @@ -55,6 +57,10 @@ if (Test-Path -LiteralPath $OutputDirectory) { } New-Item -Path $workDirectory -ItemType Directory -Force | Out-Null +if ($NodeArchivePath) { + $NodeArchivePath = (Resolve-Path -LiteralPath $NodeArchivePath).Path +} + if ($PayloadDirectory) { $resolvedPayloadDirectory = (Resolve-Path -LiteralPath $PayloadDirectory).Path } @@ -135,6 +141,7 @@ try { Write-Host "Building unsigned MSIX version $PackageVersion." & .\scripts\Build-MSIX.ps1 ` -PayloadDirectory $resolvedPayloadDirectory ` + -NodeArchivePath $NodeArchivePath ` -Architecture $Architecture ` -PackageVersion $PackageVersion ` -SourceCommit $sourceCommit ` diff --git a/scripts/Build-MSIX.ps1 b/scripts/Build-MSIX.ps1 index 6bab046..62da6fa 100644 --- a/scripts/Build-MSIX.ps1 +++ b/scripts/Build-MSIX.ps1 @@ -3,6 +3,8 @@ param( [Parameter(Mandatory)] [string]$PayloadDirectory, + [string]$NodeArchivePath, + [Parameter(Mandatory)] [ValidateSet('x64', 'arm64')] [string]$Architecture, @@ -129,6 +131,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 @@ -149,7 +181,6 @@ function Add-VswhereToPath { } Test-PackageVersion -Add-VswhereToPath $PayloadDirectory = (Resolve-Path -LiteralPath $PayloadDirectory).Path $payloadApplication = Join-Path $PayloadDirectory 'app' @@ -166,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 [string]::IsNullOrWhiteSpace([string]$payloadInfo.packageVersion) -or $payloadInfo.requestedRef -isnot [string] -or [string]::IsNullOrWhiteSpace($payloadInfo.requestedRef) -or @@ -174,6 +207,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)) { @@ -185,6 +230,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 ( @@ -198,6 +247,22 @@ if ( -Recurse } +New-Item -Path $runtimeTargetDirectory -ItemType Directory -Force | Out-Null +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() + $payloadSymbols = @( # MSBuild's own AppxPackagePayload step strips .pdb files when it later # packages content\openclaw\app; remove them here too so the inventory @@ -264,6 +329,7 @@ New-Item ` Out-Null try { + Add-VswhereToPath $appxOutput = $msixBuildDirectory.TrimEnd('\') + '\' Write-Host "Building unsigned NativeAOT win-$Architecture MSIX with MSBuild." Invoke-CheckedCommand ` @@ -278,6 +344,7 @@ try { -p:PublishAot=true ` -p:SelfContained=true ` -p:IncludePackagingContent=true ` + "-p:NodeRuntimeArchiveFileName=$expectedNodeArchiveName" ` -p:GenerateAppxPackageOnBuild=true ` "-p:AssemblyVersion=$PackageVersion" ` "-p:FileVersion=$PackageVersion" ` @@ -329,6 +396,12 @@ try { ).Hash.ToLowerInvariant() } ) + $expectedPackageFiles.Add( + "runtime/$expectedNodeArchiveName", + [pscustomobject]@{ + Hash = $nodeArchiveHash + } + ) $packageEntries = [System.Collections.Generic.HashSet[string]]::new( [System.StringComparer]::OrdinalIgnoreCase ) @@ -418,17 +491,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 @( @@ -465,6 +541,9 @@ try { payloadPackageVersion = [string]$payloadInfo.packageVersion payloadLayout = 'immutable-package' payloadFileCount = $payloadFiles.Count + nodeRuntimeVersion = $nodeVersion + nodeRuntimeArchive = $expectedNodeArchiveName + nodeRuntimeSha256 = $nodeArchiveHash architecture = $Architecture archive = $msixName sha256 = $msixHash diff --git a/scripts/Build-Payload.ps1 b/scripts/Build-Payload.ps1 index a823b9d..4ce97e3 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-NativeAotCli.Tests.ps1 b/scripts/Test-NativeAotCli.Tests.ps1 index e57bc7a..e7a8d21 100644 --- a/scripts/Test-NativeAotCli.Tests.ps1 +++ b/scripts/Test-NativeAotCli.Tests.ps1 @@ -28,7 +28,7 @@ injects is fixture-owned, including an explicit temporary diagnostic path and Node/launch delegates that cannot start a real process. - Scenarios that require a compatible device-installed Node.js runtime are + Scenarios that require a real bundled Node.js runtime are deliberately excluded. Those stay in the xUnit suite, where the runtime is injected, so this gate does not depend on the agent's installed Node version. The publish output lives under a temporary directory that this diff --git a/scripts/Test-NodeRuntimeInputs.Tests.ps1 b/scripts/Test-NodeRuntimeInputs.Tests.ps1 new file mode 100644 index 0000000..751efe8 --- /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 15d58e5..003c6bb 100644 --- a/scripts/Test-SigningInputs.Tests.ps1 +++ b/scripts/Test-SigningInputs.Tests.ps1 @@ -35,6 +35,8 @@ function New-TestArtifact { [bool]$SourceTreeDirty = $false, + [string]$NodeRuntimeVersion = '24.16.0', + [bool]$IncludeBundledNode = $false, [bool]$IncludeApplicationBundledNode = $false, @@ -133,6 +135,37 @@ 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 + } + $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 @@ -153,6 +186,9 @@ function New-TestArtifact { payloadPackageVersion = $PayloadPackageVersion payloadLayout = 'immutable-package' payloadFileCount = $payloadFiles.Count + nodeRuntimeVersion = $nodeRuntimeVersion + nodeRuntimeArchive = $nodeRuntimeArchive + nodeRuntimeSha256 = $nodeRuntimeHash architecture = $Architecture archive = $msixName sha256 = $msixHash @@ -336,6 +372,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 { @@ -391,7 +442,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 } @@ -404,7 +455,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 } @@ -417,7 +468,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 0a2359e..14c6171 100644 --- a/scripts/Test-SigningInputs.ps1 +++ b/scripts/Test-SigningInputs.ps1 @@ -111,6 +111,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 @@ -151,6 +184,7 @@ if ( $expectedPackagingCommit = $PackagingCommit.ToLowerInvariant() $expectedPackageVersion = $null +$expectedNodeRuntimeVersion = $null $expectedPackages = @{} foreach ($architecture in @('x64', 'arm64')) { $directory = Join-Path $resolvedArtifactsDirectory $architecture @@ -182,6 +216,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 @@ -199,6 +237,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() @@ -215,16 +260,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/ClawCtlCommandLine.cs b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs index de8b20a..1091199 100644 --- a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs +++ b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs @@ -22,24 +22,19 @@ internal static class ClawCtlCommandLine ResponseFileTokenReplacer = null }; - // Node guidance is part of help rather than only a launch-time failure so - // that a user can discover the prerequisite before running anything. + // Setup guidance is available without preparing the runtime. public static string RootDescription => - "Verify that this device can run the packaged OpenClaw application." + + "Prepare the bundled Node.js runtime and verify the packaged OpenClaw application." + Environment.NewLine + Environment.NewLine + - $"Prerequisite: install Node.js {NodeRuntimeResolver.SupportedVersions}." + - Environment.NewLine + - $" {NodeRuntimeResolver.InstallCommand}" + + "Run `clawctl setup` to extract or repair the bundled runtime." + Environment.NewLine + Environment.NewLine + "Run `openclaw ` to invoke the OpenClaw CLI."; public static string SetupDescription => - "Check for a compatible Node.js runtime and confirm the packaged " + - "OpenClaw application is present. Reads only; changes nothing." + - Environment.NewLine + - $"Requires Node.js {NodeRuntimeResolver.SupportedVersions}."; + "Extract or repair the bundled Node.js runtime in package LocalState " + + "and confirm the packaged OpenClaw application is present."; // runSetup stays a delegate so the command tree owns parsing and help while // Program keeps the readiness operation and its test seams. diff --git a/src/OpenClaw.Launcher/ClawCtlConsole.cs b/src/OpenClaw.Launcher/ClawCtlConsole.cs index 648c6e4..00f3ab3 100644 --- a/src/OpenClaw.Launcher/ClawCtlConsole.cs +++ b/src/OpenClaw.Launcher/ClawCtlConsole.cs @@ -6,7 +6,7 @@ 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/GatewayLauncher.cs b/src/OpenClaw.Launcher/GatewayLauncher.cs index ec27931..531a98a 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/HostDataPaths.cs b/src/OpenClaw.Launcher/HostDataPaths.cs new file mode 100644 index 0000000..b4a085d --- /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 f6b794b..103866e 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 0432db7..5c14526 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,13 @@ internal static HostOptions Parse( "openclaw.mjs")) ? packagedApplicationDirectory : null; + string? packagedNodeArchivePath = NodeRuntimeInstaller.FindArchivePath( + Path.Combine(baseDirectory, "runtime"), + System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture); return new HostOptions( directApplicationDirectory, + packagedNodeArchivePath, [.. arguments]); } } diff --git a/src/OpenClaw.Launcher/NodeRuntimeInstaller.cs b/src/OpenClaw.Launcher/NodeRuntimeInstaller.cs new file mode 100644 index 0000000..92451d9 --- /dev/null +++ b/src/OpenClaw.Launcher/NodeRuntimeInstaller.cs @@ -0,0 +1,246 @@ +using System.IO.Compression; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; + +namespace OpenClaw.Launcher; + +internal static partial class NodeRuntimeInstaller +{ + public static string? FindArchivePath( + string runtimeDirectory, + Architecture architecture) + { + if (!Directory.Exists(runtimeDirectory)) + { + return null; + } + + 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 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", + Path.GetFileNameWithoutExtension(archivePath)); + + public static NodeRuntime EnsureInstalled( + string archivePath, + Action log) + { + Architecture architecture = RuntimeInformation.ProcessArchitecture; + Version version = GetArchiveVersion(archivePath, architecture); + return EnsureInstalled( + archivePath, + GetInstallDirectory(archivePath), + path => NodeRuntimeResolver.ResolvePath( + path, + version), + log); + } + + internal static NodeRuntime EnsureInstalled( + string archivePath, + string installDirectory, + Func resolveNode, + Action log) + { + string executablePath = Path.Combine(installDirectory, "node.exe"); + if (!File.Exists(archivePath)) + { + throw new FileNotFoundException( + "The packaged Node.js runtime archive was not found.", + archivePath); + } + + // 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 + { + try + { + ownsMutex = mutex.WaitOne(); + } + catch (AbandonedMutexException) + { + ownsMutex = true; + } + + string stagingDirectory = $"{installDirectory}.extract"; + if (Directory.Exists(stagingDirectory)) + { + Directory.Delete(stagingDirectory, recursive: true); + } + + if (File.Exists(executablePath)) + { + try + { + return resolveNode(executablePath); + } + catch (InvalidOperationException exception) + { + log($"Reinstalling invalid bundled Node.js: {exception.Message}"); + } + } + + try + { + ExtractRuntime( + archivePath, + 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 + { + if (Directory.Exists(stagingDirectory)) + { + Directory.Delete(stagingDirectory, recursive: true); + } + } + } + finally + { + if (ownsMutex) + { + mutex.ReleaseMutex(); + } + } + } + + 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) + { + string archiveRoot = Path.GetFileNameWithoutExtension(archivePath); + 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); + } + } + + [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 + { + 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 f9fbc58..6f1e999 100644 --- a/src/OpenClaw.Launcher/NodeRuntimeResolver.cs +++ b/src/OpenClaw.Launcher/NodeRuntimeResolver.cs @@ -13,95 +13,71 @@ 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 = - [ - 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) => - ResolveAsync( - FindPathCandidates(), - QueryVersionAsync, - ReadArchitecture, - RuntimeInformation.ProcessArchitecture, - cancellationToken); - - internal static async Task ResolveAsync( - IReadOnlyList candidates, - Func> queryVersion, - Func readArchitecture, - Architecture requiredArchitecture, - CancellationToken cancellationToken) + 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, + Version expectedVersion) { - if (candidates.Count == 0) + if (!File.Exists(executablePath)) { 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(); - 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()); @@ -110,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; @@ -118,98 +94,19 @@ 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."; + "Run `clawctl setup` to prepare the Node.js runtime bundled " + + "with the installed OpenClaw package."; - private static List FindPathCandidates() + private static string ReadVersion(string executablePath) { - string? pathValue = Environment.GetEnvironmentVariable("PATH"); - if (string.IsNullOrWhiteSpace(pathValue)) + // 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)) { - 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; - } - - private static async Task QueryVersionAsync( - string executablePath, - CancellationToken cancellationToken) - { - 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 - { - 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) @@ -231,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 b37cf8e..e5c36c2 100644 --- a/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj +++ b/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj @@ -86,6 +86,10 @@ + + Task.FromResult(NodeRuntimeResolver.Resolve( + GetPackagedNodeArchivePath(options)))), startup.LaunchOpenClaw ?? GatewayLauncher.RunAsync) .ConfigureAwait(false); } @@ -141,7 +142,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 @@ -216,9 +218,17 @@ private static async Task RunSetupAsync( Func>? resolveNode, CancellationToken cancellationToken) { - NodeRuntime nodeRuntime = await ( - resolveNode ?? NodeRuntimeResolver.ResolveAsync)( - cancellationToken).ConfigureAwait(false); + NodeRuntime nodeRuntime; + if (resolveNode is not null) + { + nodeRuntime = await resolveNode(cancellationToken).ConfigureAwait(false); + } + else + { + nodeRuntime = NodeRuntimeInstaller.EnsureInstalled( + GetPackagedNodeArchivePath(options), + log); + } ClawCtlConsole.WriteNodeRuntimeSummary(output, nodeRuntime); string applicationDirectory = GetPackagedApplicationDirectory(options); log("Confirmed the packaged OpenClaw application is present."); @@ -251,4 +261,20 @@ 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"); + 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/ClawCtlCommandLineTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs index 8592ad6..c184a73 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs @@ -18,7 +18,7 @@ private static Task FailIfSetupRuns(CancellationToken _) => using var error = new StringWriter(); int exitCode = await Program.RunControlAsync( - new HostOptions(null, []), + new HostOptions(null, null, []), args, _ => { }, output, @@ -51,21 +51,15 @@ public async Task DiscoveryInputPrintsHelpAndSucceeds(params string[] args) } [Fact] - public async Task HelpDescribesReadinessAndTheNodePrerequisite() + public async Task HelpDescribesBundledRuntimePreparation() { (_, string output, _) = await RunAsync("--help").ConfigureAwait(true); string help = Normalize(output); Assert.Contains("setup", help, StringComparison.Ordinal); Assert.Contains("--version", help, StringComparison.Ordinal); - Assert.Contains( - Normalize(NodeRuntimeResolver.SupportedVersions), - help, - StringComparison.Ordinal); - Assert.Contains( - Normalize(NodeRuntimeResolver.InstallCommand), - help, - StringComparison.Ordinal); + Assert.Contains("bundled Node.js", help, StringComparison.Ordinal); + Assert.Contains("clawctl setup", help, StringComparison.Ordinal); Assert.Contains("openclaw ", help, StringComparison.Ordinal); } @@ -83,7 +77,7 @@ public void OnlyTheReadinessCommandIsExposed() } [Fact] - public async Task SetupHelpDescribesTheReadOnlyCheckWithoutRunningIt() + public async Task SetupHelpDescribesRuntimePreparationWithoutRunningIt() { (int exitCode, string output, string error) = await RunAsync("setup", "--help").ConfigureAwait(true); diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs index 2b4cbc4..8d4b7d8 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs @@ -20,7 +20,7 @@ private static Task FailIfSetupRuns(CancellationToken _) => using var output = new StringWriter(); int exitCode = await Program.RunControlAsync( - new HostOptions(null, []), + new HostOptions(null, null, []), args, _ => { }, output, diff --git a/tests/OpenClaw.Launcher.Tests/GatewayLauncherTests.cs b/tests/OpenClaw.Launcher.Tests/GatewayLauncherTests.cs index cc7c894..3be9a21 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 41f8893..a165df8 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,43 @@ public void ParseResolvesPackagedApplicationWhenEntryPointExists() Assert.Equal( applicationDirectory, options.PackagedApplicationDirectory); + Assert.Null(options.PackagedNodeArchivePath); Assert.Equal(["gateway", "run"], options.OpenClawArguments); } + [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, + $"node-v{version}-win-{architecture}.zip"); + File.WriteAllText(archivePath, "fixture"); + + HostOptions options = HostOptions.Parse([], _testDirectory); + + 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 new file mode 100644 index 0000000..a397a11 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/NodeRuntimeInstallerTests.cs @@ -0,0 +1,260 @@ +using System.IO.Compression; +using System.Runtime.InteropServices; + +namespace OpenClaw.Launcher.Tests; + +public sealed class NodeRuntimeInstallerTests : IDisposable +{ + private readonly string _testDirectory = TestDirectory.Create(); + + [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) + { + string archivePath = CreateRuntimeArchive(version, architecture); + string installDirectory = Path.Combine(_testDirectory, "installed"); + + NodeRuntime first = EnsureInstalled( + archivePath, + installDirectory); + DateTime firstWriteTime = File.GetLastWriteTimeUtc(first.ExecutablePath); + NodeRuntime second = EnsureInstalled( + archivePath, + installDirectory); + + 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); + } + + [Theory] + [InlineData("unexpected/node.exe")] + [InlineData("{root}/../outside.txt")] + [InlineData("{root}/nested/../../outside.txt")] + public void EnsureInstalledRejectsUnsafeArchiveEntries(string entryName) + { + ArgumentNullException.ThrowIfNull(entryName); + string archivePath = CreateRuntimeArchive("26.1.0", Architecture.X64); + using (ZipArchive archive = ZipFile.Open( + archivePath, + ZipArchiveMode.Update)) + { + 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(() => + EnsureInstalled(archivePath, installDirectory)); + Assert.False(Directory.Exists(installDirectory)); + Assert.False(Directory.Exists($"{installDirectory}.extract")); + } + + [Fact] + public void EnsureInstalledReplacesIncompleteRuntimeDirectory() + { + 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"); + + NodeRuntime runtime = EnsureInstalled(archivePath, installDirectory); + + 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 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, + $"node-v{version}-win-{architectureName}.zip"); + 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(nodeContent); + return archivePath; + } +} diff --git a/tests/OpenClaw.Launcher.Tests/NodeRuntimeResolverTests.cs b/tests/OpenClaw.Launcher.Tests/NodeRuntimeResolverTests.cs index 3c77ea1..8ccbc2b 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 ResolveReportsInstallCommandWhenRuntimeIsMissing() + 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("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] - 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(NodeRuntimeResolver.InstallCommand, 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))); + Assert.Contains("version read failed", exception.Message, StringComparison.Ordinal); + Assert.Contains("clawctl setup", exception.Message, StringComparison.Ordinal); } - 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); } diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index 59479b4..4d8adde 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"], _ => { }, TextWriter.Null, @@ -112,7 +112,7 @@ public async Task AgentResolvesNodeBeforeReportingMissingApplication() await Assert.ThrowsAsync( () => Program.RunAgentAsync( - new HostOptions(null, []), + new HostOptions(null, null, []), _ => { }, _ => {