diff --git a/.github/workflows/e2e-scenarios.yaml b/.github/workflows/e2e-scenarios.yaml index 771544c979..5f1dfc8b37 100644 --- a/.github/workflows/e2e-scenarios.yaml +++ b/.github/workflows/e2e-scenarios.yaml @@ -149,31 +149,177 @@ jobs: "WSL_CHECKOUT_DIR=$wslCheckoutPath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append "WSL_WORKDIR=$wslWorkdir" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + - name: Ensure Ubuntu WSL exists + if: contains(inputs.scenarios || github.event.inputs.scenarios, 'wsl-repo-cloud-openclaw') + shell: powershell + run: | + wsl --list --verbose 2>&1 | Out-Default + $null = wsl -d $env:WSL_DISTRO -- echo ok 2>&1 + if ($LASTEXITCODE -ne 0) { + $maxAttempts = 3 + $installed = $false + for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { + Write-Host "Ubuntu not found - installing via wsl --install (attempt $attempt/$maxAttempts)" + wsl --install -d $env:WSL_DISTRO --no-launch --web-download + $installExitCode = $LASTEXITCODE + if ($installExitCode -eq 0) { + wsl -d $env:WSL_DISTRO -- bash -c 'echo distro initialised' + $launchExitCode = $LASTEXITCODE + if ($launchExitCode -eq 0) { + $installed = $true + break + } + Write-Warning "distro first-launch failed with exit code $launchExitCode" + } else { + Write-Warning "wsl --install failed with exit code $installExitCode" + } + + $null = wsl -d $env:WSL_DISTRO -- echo ok 2>&1 + if ($LASTEXITCODE -eq 0) { + Write-Host 'Ubuntu became available after the install command returned non-zero' + $installed = $true + break + } + + if ($attempt -lt $maxAttempts) { + Write-Host 'Cleaning up any partial WSL registration before retrying' + $null = wsl --unregister $env:WSL_DISTRO 2>&1 + $delaySeconds = [Math]::Min(60, 20 * $attempt) + Write-Host "Retrying WSL install in $delaySeconds seconds..." + Start-Sleep -Seconds $delaySeconds + } + } + + if (-not $installed) { + throw ("failed to install and initialize $env:WSL_DISTRO after $maxAttempts attempts") + } + } else { + Write-Host 'Ubuntu already available' + } + wsl --set-default $env:WSL_DISTRO + if ($LASTEXITCODE -ne 0) { + throw ('wsl --set-default failed with exit code ' + $LASTEXITCODE) + } + + - name: Verify WSL + if: contains(inputs.scenarios || github.event.inputs.scenarios, 'wsl-repo-cloud-openclaw') + shell: powershell + run: | + wsl -d $env:WSL_DISTRO -- bash -lc "uname -a" + wsl -d $env:WSL_DISTRO -- bash -lc "cat /etc/os-release" + + - name: Install Ubuntu dependencies + if: contains(inputs.scenarios || github.event.inputs.scenarios, 'wsl-repo-cloud-openclaw') + shell: powershell + run: | + $script = @' + set -euo pipefail + export DEBIAN_FRONTEND=noninteractive + printf '%s\n' \ + 'Acquire::ForceIPv4 "true";' \ + 'Acquire::Retries "5";' \ + >/etc/apt/apt.conf.d/99github-actions-network + apt-get update + apt-get install -y bash ca-certificates curl git jq lsb-release make python3 python3-pip rsync tar unzip xz-utils + '@ + $tmp = "$env:RUNNER_TEMP\wsl-step.sh" + [IO.File]::WriteAllText($tmp, ($script -replace "`r",""), (New-Object System.Text.UTF8Encoding $false)) + $wslTmp = wsl -d $env:WSL_DISTRO -- wslpath -u ($tmp -replace '\\','/') + wsl -d $env:WSL_DISTRO -- bash -l $wslTmp + + - name: Install Node.js 22 in WSL + if: contains(inputs.scenarios || github.event.inputs.scenarios, 'wsl-repo-cloud-openclaw') + shell: powershell + run: | + $script = @' + set -euo pipefail + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - + apt-get install -y nodejs + node --version + npm --version + '@ + $tmp = "$env:RUNNER_TEMP\wsl-step.sh" + [IO.File]::WriteAllText($tmp, ($script -replace "`r",""), (New-Object System.Text.UTF8Encoding $false)) + $wslTmp = wsl -d $env:WSL_DISTRO -- wslpath -u ($tmp -replace '\\','/') + wsl -d $env:WSL_DISTRO -- bash -l $wslTmp + + - name: Copy checkout into WSL ext4 workspace + if: contains(inputs.scenarios || github.event.inputs.scenarios, 'wsl-repo-cloud-openclaw') + shell: powershell + run: | + $checkout = $env:WSL_CHECKOUT_DIR + $workdir = $env:WSL_WORKDIR + $workdirParent = $workdir.Substring(0, $workdir.LastIndexOf('/')) + $script = @" + set -euo pipefail + echo 'Syncing checkout from $checkout to $workdir' + if [ ! -d '$checkout/.git' ]; then + echo 'Expected a Git checkout at $checkout' >&2 + exit 1 + fi + rm -rf '$workdir' + mkdir -p '$workdirParent' + rsync -a --no-owner --no-group --delete \ + --exclude '/node_modules/' \ + --exclude '/nemoclaw/node_modules/' \ + --exclude '/nemoclaw-blueprint/.venv/' \ + '$checkout'/ '$workdir'/ + git config --global --add safe.directory '$workdir' + git -C '$workdir' reset --hard HEAD + git -C '$workdir' clean -ffdx + git -C '$workdir' status --short + echo 'WSL ext4 workspace ready at $workdir' + "@ + $tmp = "$env:RUNNER_TEMP\wsl-step.sh" + [IO.File]::WriteAllText($tmp, ($script -replace "`r",""), (New-Object System.Text.UTF8Encoding $false)) + $wslTmp = wsl -d $env:WSL_DISTRO -- wslpath -u ($tmp -replace '\\','/') + wsl -d $env:WSL_DISTRO -- bash -l $wslTmp + - name: Run typed scenarios in WSL if: contains(inputs.scenarios || github.event.inputs.scenarios, 'wsl-repo-cloud-openclaw') - shell: bash + shell: powershell env: NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} SCENARIOS: ${{ inputs.scenarios || github.event.inputs.scenarios }} run: | - set -euo pipefail - if [[ ! "${SCENARIOS}" =~ ^[A-Za-z0-9._-]+(,[A-Za-z0-9._-]+)*$ ]]; then - echo "::error::Invalid scenario input: ${SCENARIOS}" >&2 + if ($env:SCENARIOS -notmatch '^[A-Za-z0-9._-]+(,[A-Za-z0-9._-]+)*$') { + Write-Error "Invalid scenario input: $env:SCENARIOS" exit 1 + } + $workdir = $env:WSL_WORKDIR + $checkout = $env:WSL_CHECKOUT_DIR + $scenarios = $env:SCENARIOS + $script = @" + set -euo pipefail + workdir='$workdir' + checkout_dir='$checkout' + scenarios='$scenarios' + mkdir -p "`$workdir" + cd "`$workdir" + export E2E_CONTEXT_DIR="`$workdir" + npm ci --ignore-scripts + set +e + npx tsx test/e2e-scenario/scenarios/run.ts --scenarios "`$scenarios" --dry-run + status=`$? + if [ -d "`$workdir/.e2e" ]; then + rm -rf "`$checkout_dir/.e2e" + cp -a "`$workdir/.e2e" "`$checkout_dir/.e2e" + fi + if [ -d "`$workdir/test/e2e/logs" ]; then + mkdir -p "`$checkout_dir/test/e2e" + rm -rf "`$checkout_dir/test/e2e/logs" + cp -a "`$workdir/test/e2e/logs" "`$checkout_dir/test/e2e/logs" fi - wsl -d "${WSL_DISTRO}" -- env \ - NVIDIA_API_KEY="${NVIDIA_API_KEY}" \ - SCENARIOS="${SCENARIOS}" \ - WSL_CHECKOUT_DIR="${WSL_CHECKOUT_DIR}" \ - WSL_WORKDIR="${WSL_WORKDIR}" \ - bash -lc ' - set -euo pipefail - cd "${WSL_CHECKOUT_DIR}" - mkdir -p "${WSL_WORKDIR}" - export E2E_CONTEXT_DIR="${WSL_WORKDIR}" - npm ci --ignore-scripts - npx tsx test/e2e-scenario/scenarios/run.ts --scenarios "${SCENARIOS}" --dry-run - ' + exit "`$status" + "@ + $tmp = "$env:RUNNER_TEMP\wsl-step.sh" + [IO.File]::WriteAllText($tmp, ($script -replace "`r",""), (New-Object System.Text.UTF8Encoding $false)) + $wslTmp = wsl -d $env:WSL_DISTRO -- wslpath -u ($tmp -replace '\\','/') + $apiKeyArg = "NVIDIA_API_KEY=$env:NVIDIA_API_KEY" + wsl -d $env:WSL_DISTRO -- env $apiKeyArg bash -l $wslTmp + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } - name: Append plan summary if: always() diff --git a/scripts/bootstrap-windows.ps1 b/scripts/bootstrap-windows.ps1 index de964361d7..ff19419b5d 100644 --- a/scripts/bootstrap-windows.ps1 +++ b/scripts/bootstrap-windows.ps1 @@ -84,6 +84,11 @@ function ConvertTo-ProcessArgument { return '"' + ($Value -replace '"', '\"') + '"' } +function ConvertTo-PowerShellLiteral { + param([Parameter(Mandatory)] [string]$Value) + return "'" + ($Value -replace "'", "''") + "'" +} + function Get-ScriptInvocationArguments { param([switch]$ResumeRun) @@ -228,6 +233,21 @@ function Minimize-DockerDesktopWindow { } } +function Test-DockerDesktopRunning { + try { + return $null -ne ( + Get-Process -ErrorAction SilentlyContinue | + Where-Object { + $_.ProcessName -eq 'Docker Desktop' -or + $_.MainWindowTitle -like '*Docker Desktop*' + } | + Select-Object -First 1 + ) + } catch { + return $false + } +} + function Resolve-WslExe { $candidates = @( (Join-Path -Path $env:SystemRoot -ChildPath 'System32\wsl.exe'), @@ -247,25 +267,6 @@ function Resolve-WslExe { throw 'wsl.exe was not found. WSL installation requires Windows 10 version 2004/build 19041 or later, or Windows 11.' } -function Resolve-UbuntuLauncher { - $names = @('ubuntu.exe') - $sanitizedName = $DistroName -replace '[^A-Za-z0-9]', '' - if ($sanitizedName) { - $names += "$sanitizedName.exe" - } - foreach ($name in ($names | Select-Object -Unique)) { - $command = Get-Command $name -ErrorAction SilentlyContinue - if ($command) { - return $command.Source - } - $alias = Join-Path -Path $env:LOCALAPPDATA -ChildPath "Microsoft\WindowsApps\$name" - if (Test-Path -LiteralPath $alias) { - return $alias - } - } - return $null -} - function Resolve-WingetExe { $cmd = Get-Command 'winget.exe' -ErrorAction SilentlyContinue if ($cmd) { @@ -278,6 +279,81 @@ function Resolve-WingetExe { return $null } +function Set-JsonProperty { + param( + [Parameter(Mandatory)] $Object, + [Parameter(Mandatory)] [string]$PropertyName, + [AllowNull()] $Value + ) + + $property = $Object.PSObject.Properties[$PropertyName] + if ($null -ne $property) { + $property.Value = $Value + } else { + Add-Member -InputObject $Object -MemberType NoteProperty -Name $PropertyName -Value $Value + } +} + +function Get-DockerDesktopSettingsPath { + $settingsDir = Join-Path -Path $env:APPDATA -ChildPath 'Docker' + $settingsStorePath = Join-Path -Path $settingsDir -ChildPath 'settings-store.json' + $legacySettingsPath = Join-Path -Path $settingsDir -ChildPath 'settings.json' + + if (Test-Path -LiteralPath $settingsStorePath) { + return $settingsStorePath + } + if (Test-Path -LiteralPath $legacySettingsPath) { + return $legacySettingsPath + } + return $settingsStorePath +} + +function Enable-DockerDesktopWslIntegration { + param([Parameter(Mandatory)] [string]$Name) + + if (-not $InstallDockerDesktop) { + return + } + if (-not $env:APPDATA) { + Write-Status -Level WARN 'APPDATA is not set; cannot update Docker Desktop WSL integration settings.' + return + } + + $settingsPath = Get-DockerDesktopSettingsPath + $settingsDir = Split-Path -Parent $settingsPath + New-Item -ItemType Directory -Path $settingsDir -Force | Out-Null + + if (Test-Path -LiteralPath $settingsPath) { + $backupPath = "$settingsPath.bak.$(Get-Date -Format yyyyMMddHHmmss)" + Copy-Item -LiteralPath $settingsPath -Destination $backupPath -Force + try { + $settings = Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json + } catch { + Write-Status -Level WARN "Could not parse Docker Desktop settings at $settingsPath; leaving settings unchanged." + return + } + } else { + $settings = [pscustomobject]@{} + } + + Set-JsonProperty -Object $settings -PropertyName 'wslEngineEnabled' -Value $true + Set-JsonProperty -Object $settings -PropertyName 'enableIntegrationWithDefaultWslDistro' -Value $false + + $integratedDistros = @() + $integratedDistrosProperty = $settings.PSObject.Properties['integratedWslDistros'] + if ($null -ne $integratedDistrosProperty -and $null -ne $integratedDistrosProperty.Value) { + $integratedDistros = @($integratedDistrosProperty.Value) + } + if ($integratedDistros -notcontains $Name) { + $integratedDistros += $Name + } + Set-JsonProperty -Object $settings -PropertyName 'integratedWslDistros' -Value ([string[]]($integratedDistros | Where-Object { $_ } | Select-Object -Unique)) + + $json = $settings | ConvertTo-Json -Depth 100 + [System.IO.File]::WriteAllText($settingsPath, $json + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false)) + Write-Status "Enabled Docker Desktop WSL integration settings for '$Name'." +} + function Get-WindowsFeatureState { param([Parameter(Mandatory)] [string]$Name) $feature = Get-WindowsOptionalFeature -Online -FeatureName $Name -ErrorAction SilentlyContinue @@ -421,7 +497,12 @@ function Start-DockerDesktop { return } - Write-Status 'Launching Docker Desktop...' + $wasRunning = Test-DockerDesktopRunning + if ($wasRunning) { + Write-Status 'Docker Desktop is already running.' + } else { + Write-Status 'Launching Docker Desktop...' + } Start-Process -FilePath $script:DockerDesktopExe | Out-Null if (-not (Test-Path -LiteralPath $script:DockerCli)) { @@ -430,8 +511,13 @@ function Start-DockerDesktop { } Wait-DockerDesktopEngine -TimeoutSeconds 120 | Out-Null - Write-Status 'Restarting Docker Desktop so WSL integration picks up the default distro...' - Restart-DockerDesktop + if ($wasRunning) { + Write-Status 'Restarting Docker Desktop so WSL integration picks up the configured distro...' + Restart-DockerDesktop + } else { + Minimize-DockerDesktopWindow + Set-InstallerWindowForeground + } } function Restart-DockerDesktop { @@ -659,6 +745,142 @@ function Get-WslInstallCommandText { return "wsl --install -d $Name" } +function Wait-WslDistroRegistration { + param( + [Parameter(Mandatory)] [string]$Name, + [int]$TimeoutSeconds = 300 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + if ((Get-WslDistros) -contains $Name) { + return $true + } + Start-Sleep -Seconds 5 + } + + return $false +} + +function Get-WslDistroRegistryProperties { + param([Parameter(Mandatory)] [string]$Name) + + $lxssPath = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss' + if (-not (Test-Path -LiteralPath $lxssPath)) { + return $null + } + + foreach ($key in (Get-ChildItem -Path $lxssPath -ErrorAction SilentlyContinue)) { + $properties = Get-ItemProperty -LiteralPath $key.PSPath -ErrorAction SilentlyContinue + if (-not $properties) { + continue + } + $distributionName = $properties.PSObject.Properties['DistributionName'] + if ($null -ne $distributionName -and $distributionName.Value -eq $Name) { + return $properties + } + } + + return $null +} + +function Get-WslDistroDefaultUid { + param([Parameter(Mandatory)] [string]$Name) + + $properties = Get-WslDistroRegistryProperties -Name $Name + if (-not $properties) { + return $null + } + + $defaultUid = $properties.PSObject.Properties['DefaultUid'] + if ($null -eq $defaultUid -or $null -eq $defaultUid.Value) { + return $null + } + + return [int]$defaultUid.Value +} + +function Wait-WslDefaultUserReady { + param( + [Parameter(Mandatory)] [string]$Name, + [int]$TimeoutSeconds = 600 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + $uid = Get-WslDistroDefaultUid -Name $Name + if ($null -ne $uid -and $uid -gt 0) { + return $uid + } + Start-Sleep -Seconds 2 + } + + return $null +} + +function Start-WslInstallInPowerShellWindow { + param([Parameter(Mandatory)] [string]$Name) + + $wsl = Resolve-WslExe + $installCommand = '& {0} --install -d {1}' -f (ConvertTo-PowerShellLiteral -Value $wsl), (ConvertTo-PowerShellLiteral -Value $Name) + $installArguments = @( + '-NoLogo', + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-Command', + $installCommand + ) + $installArgumentLine = ($installArguments | ForEach-Object { ConvertTo-ProcessArgument -Value $_ }) -join ' ' + + Start-Process -FilePath 'powershell.exe' -ArgumentList $installArgumentLine | Out-Null +} + +function Stop-WslDistroForDockerIntegration { + param( + [Parameter(Mandatory)] [string]$Name, + [string]$Reason = 'so Docker Desktop integration is applied on next launch' + ) + + $wsl = Resolve-WslExe + Write-Status "Terminating WSL distro '$Name' $Reason..." + $terminateExitCode = Invoke-NativeCommand -FilePath $wsl -ArgumentList @('--terminate', $Name) -SuppressOutput + if ($terminateExitCode -ne 0) { + Write-Status -Level WARN "wsl --terminate $Name exited with code $terminateExitCode." + } +} + +function Assert-WslDistroStarts { + param([Parameter(Mandatory)] [string]$Name) + + $wsl = Resolve-WslExe + Write-Status "Verifying WSL distro '$Name' starts..." + $startExitCode = Invoke-NativeCommand -FilePath $wsl -ArgumentList @('-d', $Name, '--', 'echo', 'WSL_OK') -SuppressOutput + if ($startExitCode -ne 0) { + throw "WSL distro '$Name' is registered but could not start. Run 'wsl -d $Name' from PowerShell, resolve the startup error, then rerun this script." + } + Write-Status "Verified WSL distro '$Name' starts." +} + +function Ensure-WslDockerCliConfigDirectory { + param([Parameter(Mandatory)] [string]$Name) + + if (-not $InstallDockerDesktop) { + return + } + + $wsl = Resolve-WslExe + Write-Status "Preparing Docker CLI config directory in '$Name'..." + $mkdirExitCode = Invoke-NativeCommand -FilePath $wsl -ArgumentList @('-d', $Name, '--', 'sh', '-lc', 'mkdir -p "$HOME/.docker"') -SuppressOutput + if ($mkdirExitCode -ne 0) { + Write-Status -Level WARN "Could not prepare ~/.docker in '$Name'; Docker Desktop may need to retry WSL integration." + } else { + Write-Status "Prepared Docker CLI config directory in '$Name'." + } + + Stop-WslDistroForDockerIntegration -Name $Name -Reason 'after preparing Docker CLI config directory' +} + function Write-WslUbuntuRequiredNotice { param([Parameter(Mandatory)] [string]$Name) @@ -676,29 +898,37 @@ function Write-WslUbuntuRequiredNotice { } function Ensure-UbuntuWsl { - $wsl = Resolve-WslExe $script:InstallDistroAtHandoff = $false $distros = Get-WslDistros if ($distros -notcontains $DistroName) { - Write-Status "$DistroName is not registered yet. It will be installed during the final Ubuntu handoff." - $script:InstallDistroAtHandoff = $true - return - } + Write-Host '' + Write-Host "$DistroName is not registered yet. Installing it in a separate PowerShell window..." -ForegroundColor Cyan + Write-Host 'Create the Unix user in that window. This script will continue automatically after setup completes.' -ForegroundColor Cyan + Write-Host '' + Start-WslInstallInPowerShellWindow -Name $DistroName - Write-Status "WSL distro already registered: $DistroName" + if (-not (Wait-WslDistroRegistration -Name $DistroName)) { + Write-WslUbuntuRequiredNotice -Name $DistroName + throw "WSL distro '$DistroName' is still not registered after install." + } - Ensure-WslDistroVersion2 -Name $DistroName + Write-Status "WSL distro registered: $DistroName" + $defaultUid = Wait-WslDefaultUserReady -Name $DistroName + if ($null -eq $defaultUid) { + throw "Timed out waiting for $DistroName first-run user creation." + } - $setDefaultExitCode = Invoke-NativeCommand -FilePath $wsl -ArgumentList @('--set-default', $DistroName) - if ($setDefaultExitCode -ne 0) { - throw "wsl --set-default failed with exit code $setDefaultExitCode" + Write-Status "$DistroName first-run user is registered (UID $defaultUid)." + Stop-WslDistroForDockerIntegration -Name $DistroName -Reason 'after first-run setup so Docker Desktop sees a settled user profile' + } else { + Write-Status "WSL distro already registered: $DistroName" } - $verify = Invoke-NativeCommandOutput -FilePath $wsl -ArgumentList @('-d', $DistroName, '--', 'echo', 'WSL_OK') -MergeError - if ($verify.ExitCode -ne 0 -or $verify.Output -notmatch 'WSL_OK') { - throw "Could not start WSL distro '$DistroName'. Output: $($verify.Output)" - } + Ensure-WslDistroVersion2 -Name $DistroName + Assert-WslDistroStarts -Name $DistroName + Ensure-WslDockerCliConfigDirectory -Name $DistroName + Write-Status "$DistroName is ready." } @@ -800,6 +1030,24 @@ function Get-NemoClawInstallerCommand { return $installerCommand } +function Open-WslInPowerShellWindow { + param([Parameter(Mandatory)] [string]$Name) + + $wsl = Resolve-WslExe + $launchCommand = '& {0} -d {1}' -f (ConvertTo-PowerShellLiteral -Value $wsl), (ConvertTo-PowerShellLiteral -Value $Name) + $launchArguments = @( + '-NoLogo', + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-Command', + $launchCommand + ) + $launchArgumentLine = ($launchArguments | ForEach-Object { ConvertTo-ProcessArgument -Value $_ }) -join ' ' + + Start-Process -FilePath 'powershell.exe' -ArgumentList $launchArgumentLine | Out-Null +} + function Open-UbuntuForInstaller { $wsl = Resolve-WslExe try { @@ -807,22 +1055,7 @@ function Open-UbuntuForInstaller { Start-Process -FilePath $wsl -ArgumentList @('--install', '-d', $DistroName) | Out-Null return } - - $ubuntuLauncher = Resolve-UbuntuLauncher - $windowsTerminal = Get-Command 'wt.exe' -ErrorAction SilentlyContinue - if ($windowsTerminal) { - if ($ubuntuLauncher) { - Start-Process -FilePath $windowsTerminal.Source -ArgumentList @($ubuntuLauncher) | Out-Null - } else { - Start-Process -FilePath $windowsTerminal.Source -ArgumentList @('wsl.exe', '-d', $DistroName) | Out-Null - } - return - } - if ($ubuntuLauncher) { - Start-Process -FilePath $ubuntuLauncher | Out-Null - return - } - Start-Process -FilePath $wsl -ArgumentList @('-d', $DistroName) | Out-Null + Open-WslInPowerShellWindow -Name $DistroName } catch { Write-Status -Level WARN "Could not open $DistroName automatically: $($_.Exception.Message)" if ($script:InstallDistroAtHandoff) { @@ -834,6 +1067,7 @@ function Open-UbuntuForInstaller { function Write-InstallerHandoff { $installerCommand = Get-NemoClawInstallerCommand + Write-Host '' Write-Host 'Windows preparation is complete.' -ForegroundColor Green Write-Host '' @@ -859,9 +1093,10 @@ function Invoke-Main { Enable-WslFeatures Ensure-UbuntuWsl Install-DockerDesktop + Enable-DockerDesktopWslIntegration -Name $DistroName Start-DockerDesktop if ($script:InstallDistroAtHandoff) { - Write-Status "Skipping Docker-in-WSL verification until $DistroName is installed and first-run setup completes." + Write-Status "Skipping Docker-in-WSL verification until $DistroName first-run setup completes." } else { Ensure-DockerWslIntegration } diff --git a/src/lib/onboard/bridge-dns-preflight.test.ts b/src/lib/onboard/bridge-dns-preflight.test.ts index 40c8128079..78c6a0a4f5 100644 --- a/src/lib/onboard/bridge-dns-preflight.test.ts +++ b/src/lib/onboard/bridge-dns-preflight.test.ts @@ -202,4 +202,26 @@ describe("printDockerBridgeContainerStartFailure", () => { expect(rerunLine).toContain("nemohermes onboard"); expect(rerunLine).not.toMatch(/\bnemoclaw onboard\b/); }); + + it("prints the Docker Desktop WSL integration hint for WSL daemon access failures", () => { + const messages: string[] = []; + const errSpy = vi.spyOn(console, "error").mockImplementation((arg?: unknown) => { + messages.push(String(arg ?? "")); + }); + printDockerBridgeContainerStartFailure( + { + ok: false, + reason: "docker_daemon_unreachable", + details: "Cannot connect to the Docker daemon", + timedOut: false, + exitCode: null, + signal: null, + }, + { isWsl: true }, + ); + errSpy.mockRestore(); + const blob = messages.join("\n"); + expect(blob).toContain("Docker Desktop > Settings > Resources > WSL integration"); + expect(blob).toContain("enable integration for this distro"); + }); }); diff --git a/src/lib/onboard/bridge-dns-preflight.ts b/src/lib/onboard/bridge-dns-preflight.ts index 1f2f6845c3..96ba1fe827 100644 --- a/src/lib/onboard/bridge-dns-preflight.ts +++ b/src/lib/onboard/bridge-dns-preflight.ts @@ -77,6 +77,7 @@ function printDaemonJsonDnsPatch(opts: DaemonJsonDnsPatchOpts): void { } import { BUSYBOX_PROBE_IMAGE, + DOCKER_DESKTOP_WSL_INTEGRATION_HINT, type DockerBridgeContainerStartProbeResult, getDockerBridgeGatewayIp, type HostAssessment, @@ -89,6 +90,7 @@ type Host = HostAssessment; export function printDockerBridgeContainerStartFailure( result: DockerBridgeContainerStartProbeResult, + host?: Pick, ): void { console.error(" ✗ Docker could not start a bridge-network test container."); if (result.details) { @@ -113,6 +115,9 @@ export function printDockerBridgeContainerStartFailure( console.error(" Restart Docker and check for stuck container/network operations before retrying."); } else if (result.reason === "docker_daemon_unreachable") { console.error(" The Docker CLI cannot reach the Docker daemon (dockerd is down or wedged)."); + if (host?.isWsl) { + console.error(` ${DOCKER_DESKTOP_WSL_INTEGRATION_HINT}`); + } console.error( " Restart the Docker daemon (`sudo systemctl restart docker`, or restart Docker Desktop/Colima)", ); @@ -157,7 +162,7 @@ export function assertDockerBridgeAndContainerDnsHealthy(host: Host): void { bridgeStart.reason === "killed" || bridgeStart.reason === "docker_daemon_unreachable" ) { - printDockerBridgeContainerStartFailure(bridgeStart); + printDockerBridgeContainerStartFailure(bridgeStart, host); process.exit(1); } else { console.warn( @@ -204,25 +209,31 @@ export function assertDockerBridgeAndContainerDnsHealthy(host: Host): void { } if (dns.reason === "veth_unsupported") { - printDockerBridgeContainerStartFailure({ - ok: false, - reason: "veth_unsupported", - details: dns.details, - timedOut: dns.timedOut, - exitCode: dns.exitCode, - signal: dns.signal, - }); + printDockerBridgeContainerStartFailure( + { + ok: false, + reason: "veth_unsupported", + details: dns.details, + timedOut: dns.timedOut, + exitCode: dns.exitCode, + signal: dns.signal, + }, + host, + ); process.exit(1); } if (dns.reason === "docker_daemon_unreachable") { - printDockerBridgeContainerStartFailure({ - ok: false, - reason: "docker_daemon_unreachable", - details: dns.details, - timedOut: dns.timedOut, - exitCode: dns.exitCode, - signal: dns.signal, - }); + printDockerBridgeContainerStartFailure( + { + ok: false, + reason: "docker_daemon_unreachable", + details: dns.details, + timedOut: dns.timedOut, + exitCode: dns.exitCode, + signal: dns.signal, + }, + host, + ); process.exit(1); } if (dns.reason === "timeout" || dns.reason === "killed") { diff --git a/src/lib/onboard/gateway-sandbox-reachability.test.ts b/src/lib/onboard/gateway-sandbox-reachability.test.ts index 7043e01ce1..dfbc77f247 100644 --- a/src/lib/onboard/gateway-sandbox-reachability.test.ts +++ b/src/lib/onboard/gateway-sandbox-reachability.test.ts @@ -392,6 +392,20 @@ describe("formatSandboxBridgeUnreachableMessage", () => { expect(msg).not.toContain("continuing"); }); + it("emits the Docker Desktop WSL integration hint for WSL daemon access failures", () => { + const msg = formatSandboxBridgeUnreachableMessage( + { + ok: false, + reason: "docker_daemon_unreachable", + detail: "Cannot connect to the Docker daemon", + }, + 8787, + { isWsl: true }, + ); + expect(msg).toContain("Docker Desktop > Settings > Resources > WSL integration"); + expect(msg).toContain("enable integration for this distro"); + }); + it("uses cliDisplayName() and cliName() in fatal messages instead of hardcoded NemoClaw branding (#3630 CodeRabbit)", () => { const savedAgent = process.env.NEMOCLAW_AGENT; const savedInvoked = process.env.NEMOCLAW_INVOKED_AS; diff --git a/src/lib/onboard/gateway-sandbox-reachability.ts b/src/lib/onboard/gateway-sandbox-reachability.ts index b780d78c0b..ebc5c0349e 100644 --- a/src/lib/onboard/gateway-sandbox-reachability.ts +++ b/src/lib/onboard/gateway-sandbox-reachability.ts @@ -10,10 +10,16 @@ * diagnosis; a plain helper container on the bridge is not equivalent. */ +import os from "node:os"; + import { dockerCapture, dockerRun } from "../adapters/docker/run"; import { GATEWAY_PORT } from "../core/ports"; import { cliDisplayName, cliName } from "./branding"; -import { ensureProbeImageCached, isDockerDaemonUnreachable } from "./preflight"; +import { + DOCKER_DESKTOP_WSL_INTEGRATION_HINT, + ensureProbeImageCached, + isDockerDaemonUnreachable, +} from "./preflight"; const DEFAULT_PROBE_IMAGE = "busybox@sha256:73aaf090f3d85aa34ee199857f03fa3a95c8ede2ffd4cc2cdb5b94e566b11662"; @@ -79,6 +85,14 @@ export interface SandboxBridgeReachabilityOptions { ensureImageCachedOverride?: import("./preflight").EnsureProbeImageCachedResult; } +export interface FormatSandboxBridgeUnreachableMessageOptions { + isWsl?: boolean; +} + +function isRunningInWsl(env: NodeJS.ProcessEnv = process.env, release = os.release()): boolean { + return Boolean(env.WSL_DISTRO_NAME || env.WSL_INTEROP || /microsoft/i.test(release)); +} + function parseDockerNetworkIpamConfig(raw: string): DockerBridgeNetworkInfo | undefined { const text = raw.trim(); if (!text || text === "") return undefined; @@ -379,8 +393,10 @@ export async function isSandboxBridgeGatewayReachable( export function formatSandboxBridgeUnreachableMessage( result: SandboxBridgeReachabilityResult, port: number = GATEWAY_PORT, + opts: FormatSandboxBridgeUnreachableMessageOptions = {}, ): string { if (result.ok) return ""; + const includeWslIntegrationHint = opts.isWsl ?? isRunningInWsl(); if (result.reason === "probe_unavailable") { return [ " ⚠ Could not verify sandbox bridge reachability.", @@ -410,6 +426,7 @@ export function formatSandboxBridgeUnreachableMessage( return [ " ✗ Docker daemon is not reachable for the sandbox bridge probe.", result.detail ? ` ${result.detail}` : undefined, + includeWslIntegrationHint ? ` ${DOCKER_DESKTOP_WSL_INTEGRATION_HINT}` : undefined, " Restart the Docker daemon (e.g. `sudo systemctl restart docker`, or restart Docker Desktop/Colima)", ` and re-run \`${cliName()} onboard\`.`, ].filter((line): line is string => Boolean(line)).join("\n"); @@ -471,5 +488,6 @@ export async function verifySandboxBridgeGatewayReachableOrExit( export const __test = { buildOpenShellDockerRoute, buildProbeArgs, + isRunningInWsl, parseDockerNetworkIpamConfig, }; diff --git a/src/lib/onboard/preflight.ts b/src/lib/onboard/preflight.ts index e0ecdd9815..186769be8c 100644 --- a/src/lib/onboard/preflight.ts +++ b/src/lib/onboard/preflight.ts @@ -137,6 +137,9 @@ export interface RemediationAction { blocking: boolean; } +export const DOCKER_DESKTOP_WSL_INTEGRATION_HINT = + "If you use Docker Desktop from WSL, open Docker Desktop > Settings > Resources > WSL integration and enable integration for this distro."; + export interface AssessHostOpts { platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; @@ -682,6 +685,14 @@ export function planHostRemediation(assessment: HostAssessment): RemediationActi assessment.platform === "linux" && assessment.dockerServiceActive === true; if (likelyGroupIssue) { + const commands = [ + "sudo usermod -aG docker $USER", + "newgrp docker # or log out and back in", + "nemoclaw onboard", + ]; + if (assessment.isWsl) { + commands.unshift(DOCKER_DESKTOP_WSL_INTEGRATION_HINT); + } actions.push({ id: "docker_group_permission", title: "Add user to docker group", @@ -693,25 +704,25 @@ export function planHostRemediation(assessment: HostAssessment): RemediationActi "On personal Linux development machines, adding your user to the docker group is the standard way to run Docker without sudo. " + "Docker group members can control the daemon with root-level impact, so grant this access only to trusted local accounts; on shared or managed systems, use your organization's approved Docker access path. " + "Background: https://docs.docker.com/engine/security/#docker-daemon-attack-surface.", - commands: [ - "sudo usermod -aG docker $USER", - "newgrp docker # or log out and back in", - "nemoclaw onboard", - ], + commands, blocking: true, }); } else { + const commands = + assessment.platform === "darwin" + ? ["Start Docker Desktop or Colima, then rerun `nemoclaw onboard`."] + : assessment.systemctlAvailable + ? ["sudo systemctl start docker", "nemoclaw onboard"] + : ["Start the Docker daemon, then rerun `nemoclaw onboard`."]; + if (assessment.isWsl) { + commands.unshift(DOCKER_DESKTOP_WSL_INTEGRATION_HINT); + } actions.push({ id: "start_docker", title: "Start Docker", kind: "manual", reason: "Docker is installed but NemoClaw could not talk to the Docker daemon.", - commands: - assessment.platform === "darwin" - ? ["Start Docker Desktop or Colima, then rerun `nemoclaw onboard`."] - : assessment.systemctlAvailable - ? ["sudo systemctl start docker", "nemoclaw onboard"] - : ["Start the Docker daemon, then rerun `nemoclaw onboard`."], + commands, blocking: true, }); } diff --git a/test/bootstrap-windows.test.ts b/test/bootstrap-windows.test.ts index 77f14d15a9..460cbcb137 100644 --- a/test/bootstrap-windows.test.ts +++ b/test/bootstrap-windows.test.ts @@ -41,6 +41,8 @@ function runPowerShellHarness(script: string) { encoding: "utf8", env: { ...process.env, + TEMP: process.env.TEMP ?? process.env.TMPDIR ?? os.tmpdir(), + TMP: process.env.TMP ?? process.env.TMPDIR ?? os.tmpdir(), NEMOCLAW_BOOTSTRAP_WINDOWS_SOURCE_ONLY: "1", SystemRoot: process.env.SystemRoot ?? "C:\\Windows", }, @@ -57,7 +59,65 @@ function runPowerShellHarness(script: string) { } describe("Windows bootstrap WSL distro preflight", () => { - itPowerShell("defers missing Ubuntu 24.04 install to a separate handoff window", () => { + itPowerShell("starts Docker Desktop without restart when it was not already running", () => { + const result = runPowerShellHarness(` +$ErrorActionPreference = 'Stop' +. ${JSON.stringify(BOOTSTRAP_WINDOWS)} + +$script:DockerDesktopExe = 'Docker Desktop.exe' +$script:DockerCli = 'docker.exe' +$script:events = @() + +function Test-Path { param([string]$LiteralPath) return $true } +function Test-DockerDesktopRunning { return $false } +function Wait-DockerDesktopEngine { param([int]$TimeoutSeconds) $script:events += 'wait-ready'; return $true } +function Restart-DockerDesktop { $script:events += 'restart' } +function Minimize-DockerDesktopWindow { $script:events += 'minimize' } +function Set-InstallerWindowForeground { $script:events += 'foreground' } +function Start-Process { param([string]$FilePath) $script:events += "start-$FilePath"; return [pscustomobject]@{} } +function Write-Status { param([string]$Message, [string]$Level = 'INFO') } + +Start-DockerDesktop + +$script:events | ConvertTo-Json -Compress +`); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "[]"); + expect(parsed).toEqual(["start-Docker Desktop.exe", "wait-ready", "minimize", "foreground"]); + }); + + itPowerShell("restarts Docker Desktop when it was already running before settings changed", () => { + const result = runPowerShellHarness(` +$ErrorActionPreference = 'Stop' +. ${JSON.stringify(BOOTSTRAP_WINDOWS)} + +$script:DockerDesktopExe = 'Docker Desktop.exe' +$script:DockerCli = 'docker.exe' +$script:events = @() + +function Test-Path { param([string]$LiteralPath) return $true } +function Test-DockerDesktopRunning { return $true } +function Wait-DockerDesktopEngine { param([int]$TimeoutSeconds) $script:events += 'wait-ready'; return $true } +function Restart-DockerDesktop { $script:events += 'restart' } +function Minimize-DockerDesktopWindow { $script:events += 'minimize' } +function Set-InstallerWindowForeground { $script:events += 'foreground' } +function Start-Process { param([string]$FilePath) $script:events += "start-$FilePath"; return [pscustomobject]@{} } +function Write-Status { param([string]$Message, [string]$Level = 'INFO') } + +Start-DockerDesktop + +$script:events | ConvertTo-Json -Compress +`); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "[]"); + expect(parsed).toEqual(["start-Docker Desktop.exe", "wait-ready", "restart"]); + }); + + itPowerShell("installs missing Ubuntu 24.04 through first-run setup before Docker integration", () => { const result = runPowerShellHarness(` $ErrorActionPreference = 'Stop' . ${JSON.stringify(BOOTSTRAP_WINDOWS)} @@ -68,20 +128,25 @@ $script:statusMessages = @() function Resolve-WslExe { return 'wsl.exe' } function Get-WslDistros { return @() } +function Wait-WslDistroRegistration { param([string]$Name) return $true } +function Wait-WslDefaultUserReady { param([string]$Name) return 1000 } +function Ensure-WslDistroVersion2 { param([string]$Name) } +function Stop-WslDistroForDockerIntegration { param([string]$Name, [string]$Reason) $script:nativeCalls += ,@('Stop-WslDistroForDockerIntegration', $Name) } +function Ensure-WslDockerCliConfigDirectory { param([string]$Name) $script:nativeCalls += ,@('Ensure-WslDockerCliConfigDirectory', $Name) } function Invoke-NativeCommand { param([string]$FilePath, [string[]]$ArgumentList = @(), [switch]$SuppressOutput) $script:nativeCalls += ,@($FilePath, ($ArgumentList -join ' ')) return 0 } function Start-Process { - param([string]$FilePath, [string[]]$ArgumentList = @()) - $script:startProcessCalls += ,@($FilePath, ($ArgumentList -join ' ')) + param([string]$FilePath, [object]$ArgumentList = @()) + $argsText = if ($ArgumentList -is [array]) { $ArgumentList -join ' ' } else { [string]$ArgumentList } + $script:startProcessCalls += ,@($FilePath, $argsText) return [pscustomobject]@{} } function Write-Status { param([string]$Message, [string]$Level = 'INFO') $script:statusMessages += $Message } Ensure-UbuntuWsl -Open-UbuntuForInstaller [pscustomobject]@{ nativeCalls = $script:nativeCalls @@ -93,13 +158,96 @@ Open-UbuntuForInstaller expect(result.status).toBe(0); expect(result.stderr).toBe(""); - const parsed = JSON.parse(result.stdout.trim()); - expect(parsed.installDistroAtHandoff).toBe(true); - expect(parsed.nativeCalls).toEqual([]); - expect(parsed.startProcessCalls).toContainEqual(["wsl.exe", "--install -d Ubuntu-24.04"]); + const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "{}"); + expect(parsed.installDistroAtHandoff).toBe(false); + expect(parsed.startProcessCalls).toHaveLength(1); + expect(parsed.startProcessCalls[0][0]).toBe("powershell.exe"); + expect(parsed.startProcessCalls[0][1]).toContain("--install -d 'Ubuntu-24.04'"); + expect(parsed.startProcessCalls[0][1]).not.toContain("--no-launch"); + expect(parsed.nativeCalls).not.toContainEqual(["wsl.exe", "--set-default Ubuntu-24.04"]); + expect(parsed.nativeCalls).toContainEqual(["wsl.exe", "-d Ubuntu-24.04 -- echo WSL_OK"]); + expect(parsed.nativeCalls).toContainEqual(["Stop-WslDistroForDockerIntegration", "Ubuntu-24.04"]); + expect(parsed.nativeCalls).toContainEqual(["Ensure-WslDockerCliConfigDirectory", "Ubuntu-24.04"]); expect(parsed.statusMessages).toContain( - "Ubuntu-24.04 is not registered yet. It will be installed during the final Ubuntu handoff.", + "WSL distro registered: Ubuntu-24.04", ); + expect(parsed.statusMessages).toContain("Ubuntu-24.04 first-run user is registered (UID 1000)."); + }); + + itPowerShell("verifies WSL startup even when Docker Desktop install is disabled", () => { + const result = runPowerShellHarness(` +$ErrorActionPreference = 'Stop' +. ${JSON.stringify(BOOTSTRAP_WINDOWS)} + +$InstallDockerDesktop = $false +$script:nativeCalls = @() +$script:statusMessages = @() + +function Resolve-WslExe { return 'wsl.exe' } +function Get-WslDistros { return @('Ubuntu-24.04') } +function Ensure-WslDistroVersion2 { param([string]$Name) } +function Invoke-NativeCommand { + param([string]$FilePath, [string[]]$ArgumentList = @(), [switch]$SuppressOutput) + $script:nativeCalls += ,@($FilePath, ($ArgumentList -join ' ')) + return 0 +} +function Write-Status { param([string]$Message, [string]$Level = 'INFO') $script:statusMessages += $Message } + +Ensure-UbuntuWsl + +[pscustomobject]@{ + nativeCalls = $script:nativeCalls + statusMessages = $script:statusMessages +} | ConvertTo-Json -Depth 5 -Compress +`); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "{}"); + expect(parsed.nativeCalls).toContainEqual(["wsl.exe", "-d Ubuntu-24.04 -- echo WSL_OK"]); + expect(parsed.statusMessages).toContain("Verified WSL distro 'Ubuntu-24.04' starts."); + expect(parsed.statusMessages).toContain("Ubuntu-24.04 is ready."); + }); + + itPowerShell("fails an already registered distro that cannot start", () => { + const result = runPowerShellHarness(` +$ErrorActionPreference = 'Stop' +. ${JSON.stringify(BOOTSTRAP_WINDOWS)} + +$InstallDockerDesktop = $false +$script:nativeCalls = @() +$script:statusMessages = @() +$script:outcome = 'success' + +function Resolve-WslExe { return 'wsl.exe' } +function Get-WslDistros { return @('Ubuntu-24.04') } +function Ensure-WslDistroVersion2 { param([string]$Name) } +function Invoke-NativeCommand { + param([string]$FilePath, [string[]]$ArgumentList = @(), [switch]$SuppressOutput) + $script:nativeCalls += ,@($FilePath, ($ArgumentList -join ' ')) + return 1 +} +function Write-Status { param([string]$Message, [string]$Level = 'INFO') $script:statusMessages += $Message } + +try { + Ensure-UbuntuWsl +} catch { + $script:outcome = $_.Exception.Message +} + +[pscustomobject]@{ + nativeCalls = $script:nativeCalls + statusMessages = $script:statusMessages + outcome = $script:outcome +} | ConvertTo-Json -Depth 5 -Compress +`); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "{}"); + expect(parsed.nativeCalls).toContainEqual(["wsl.exe", "-d Ubuntu-24.04 -- echo WSL_OK"]); + expect(parsed.outcome).toContain("WSL distro 'Ubuntu-24.04' is registered but could not start"); + expect(parsed.statusMessages).not.toContain("Ubuntu-24.04 is ready."); }); itPowerShell("prints the issue 3974 guidance when the deferred Ubuntu launch fails", () => { @@ -128,4 +276,125 @@ try { expect(result.stdout).toContain("Then re-run this installer."); expect(result.stdout).toContain("CAUGHT: launch failed"); }); + + itPowerShell("opens the final Ubuntu handoff as one plain PowerShell-hosted WSL launch", () => { + const result = runPowerShellHarness(` +$ErrorActionPreference = 'Stop' +. ${JSON.stringify(BOOTSTRAP_WINDOWS)} + +$script:nativeCalls = @() +$script:startProcessCalls = @() +$script:stopCalls = @() + +function Resolve-WslExe { return 'wsl.exe' } +function Stop-WslDistroForDockerIntegration { param([string]$Name, [string]$Reason) $script:stopCalls += $Name } +function Invoke-NativeCommand { + param([string]$FilePath, [string[]]$ArgumentList = @(), [switch]$SuppressOutput) + $script:nativeCalls += ,@($FilePath, ($ArgumentList -join ' ')) + return 0 +} +function Start-Process { + param([string]$FilePath, [object]$ArgumentList = @(), [switch]$Wait, [switch]$PassThru) + $argsText = if ($ArgumentList -is [array]) { $ArgumentList -join ' ' } else { [string]$ArgumentList } + $script:startProcessCalls += ,@($FilePath, $argsText) + return [pscustomobject]@{ ExitCode = 0 } +} + +Open-UbuntuForInstaller + +[pscustomobject]@{ + nativeCalls = $script:nativeCalls + stopCalls = $script:stopCalls + startProcessCalls = $script:startProcessCalls +} | ConvertTo-Json -Compress +`); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "{}"); + expect(parsed.nativeCalls).toEqual([]); + expect(parsed.stopCalls).toEqual([]); + expect(parsed.startProcessCalls).toHaveLength(1); + expect(parsed.startProcessCalls[0][0]).toBe("powershell.exe"); + expect(parsed.startProcessCalls[0][1]).toContain("-Command"); + expect(parsed.startProcessCalls[0][1]).toContain("& 'wsl.exe' -d 'Ubuntu-24.04'"); + for (const launch of parsed.startProcessCalls.map((call: string[]) => call[1])) { + expect(launch).not.toContain("-- "); + expect(launch).not.toContain("bash"); + expect(launch).not.toContain("curl"); + expect(launch).not.toContain("true"); + expect(launch).not.toContain("nemoclaw.sh"); + } + }); + + itPowerShell("repairs Docker Desktop WSL integration settings for the target distro", () => { + const result = runPowerShellHarness(` +$ErrorActionPreference = 'Stop' +. ${JSON.stringify(BOOTSTRAP_WINDOWS)} + +$settingsDir = Join-Path $env:TEMP ('docker-settings-test-' + [guid]::NewGuid().ToString('N')) +$env:APPDATA = $settingsDir +$dockerDir = Join-Path $settingsDir 'Docker' +New-Item -ItemType Directory -Path $dockerDir -Force | Out-Null +$settingsPath = Join-Path $dockerDir 'settings-store.json' +@{ + wslEngineEnabled = $false + enableIntegrationWithDefaultWslDistro = $false + integratedWslDistros = @('Debian') +} | ConvertTo-Json | Set-Content -Path $settingsPath -Encoding UTF8 + +Enable-DockerDesktopWslIntegration -Name 'Ubuntu-24.04' + +$settings = Get-Content -Path $settingsPath -Raw | ConvertFrom-Json +$result = [pscustomobject]@{ + wslEngineEnabled = $settings.wslEngineEnabled + enableIntegrationWithDefaultWslDistro = $settings.enableIntegrationWithDefaultWslDistro + integratedWslDistros = $settings.integratedWslDistros + backupCount = @(Get-ChildItem -Path $dockerDir -Filter 'settings-store.json.bak.*').Count +} +Remove-Item -Path $settingsDir -Recurse -Force +$result | ConvertTo-Json -Compress +`); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "{}"); + expect(parsed.wslEngineEnabled).toBe(true); + expect(parsed.enableIntegrationWithDefaultWslDistro).toBe(false); + expect(parsed.integratedWslDistros).toContain("Debian"); + expect(parsed.integratedWslDistros).toContain("Ubuntu-24.04"); + expect(parsed.backupCount).toBe(1); + }); + + itPowerShell("creates Docker Desktop WSL integration settings when the settings file is missing", () => { + const result = runPowerShellHarness(` +$ErrorActionPreference = 'Stop' +. ${JSON.stringify(BOOTSTRAP_WINDOWS)} + +$settingsDir = Join-Path $env:TEMP ('docker-settings-missing-test-' + [guid]::NewGuid().ToString('N')) +$env:APPDATA = $settingsDir +$dockerDir = Join-Path $settingsDir 'Docker' +$settingsPath = Join-Path $dockerDir 'settings-store.json' + +Enable-DockerDesktopWslIntegration -Name 'Ubuntu-24.04' + +$settings = Get-Content -Path $settingsPath -Raw | ConvertFrom-Json +[pscustomobject]@{ + settingsExists = Test-Path -Path $settingsPath + wslEngineEnabled = $settings.wslEngineEnabled + enableIntegrationWithDefaultWslDistro = $settings.enableIntegrationWithDefaultWslDistro + integratedWslDistros = $settings.integratedWslDistros + backupCount = @(Get-ChildItem -Path $dockerDir -Filter 'settings-store.json.bak.*' -ErrorAction SilentlyContinue).Count +} | ConvertTo-Json -Compress +`); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + const parsed = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "{}"); + expect(parsed.settingsExists).toBe(true); + expect(parsed.wslEngineEnabled).toBe(true); + expect(parsed.enableIntegrationWithDefaultWslDistro).toBe(false); + expect(parsed.integratedWslDistros).toContain("Ubuntu-24.04"); + expect(parsed.backupCount).toBe(0); + }); }); diff --git a/tools/e2e-scenarios/workflow-boundary.mts b/tools/e2e-scenarios/workflow-boundary.mts index 3eba39a9c3..26394d1b4c 100644 --- a/tools/e2e-scenarios/workflow-boundary.mts +++ b/tools/e2e-scenarios/workflow-boundary.mts @@ -94,10 +94,29 @@ export function validateE2eScenariosWorkflowBoundary( requireRunContains(errors, normalRun, "--scenarios"); requireRunContains(errors, normalRun, "--dry-run"); + const wslInstall = requireStep(errors, steps, "Ensure Ubuntu WSL exists"); + requireRunContains(errors, wslInstall, "wsl --install"); + requireRunContains(errors, wslInstall, "wsl --set-default"); + + const wslDeps = requireStep(errors, steps, "Install Ubuntu dependencies"); + requireRunContains(errors, wslDeps, "apt-get install"); + requireRunContains(errors, wslDeps, "rsync"); + + const wslNode = requireStep(errors, steps, "Install Node.js 22 in WSL"); + requireRunContains(errors, wslNode, "setup_22.x"); + requireRunContains(errors, wslNode, "npm --version"); + + const wslWorkspace = requireStep(errors, steps, "Copy checkout into WSL ext4 workspace"); + requireRunContains(errors, wslWorkspace, "rsync -a"); + requireRunContains(errors, wslWorkspace, "WSL ext4 workspace ready"); + const wslRun = requireStep(errors, steps, "Run typed scenarios in WSL"); requireRunContains(errors, wslRun, "npx tsx test/e2e-scenario/scenarios/run.ts"); requireRunContains(errors, wslRun, "--scenarios"); requireRunContains(errors, wslRun, "--dry-run"); + requireRunContains(errors, wslRun, "$env:WSL_WORKDIR"); + requireRunContains(errors, wslRun, "WriteAllText"); + requireRunContains(errors, wslRun, "bash -l $wslTmp"); const upload = requireStep(errors, steps, "Upload scenario artifacts"); const uploadWith = asRecord(upload?.with);