diff --git a/CHANGELOG.md b/CHANGELOG.md index a0139a85d7bf..a55beb3f96f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,8 @@ Docs: https://docs.openclaw.ai ### Fixes +- **Dev-channel updates:** finish package-to-git switches in a fresh CLI process even when source SHA and version metadata are unchanged, preventing stale hashed chunks from loading after the global package root changes. +- **Parallels release smoke:** preserve Windows installer reboot results across Parallels, wait for WSL MSI/default-version readiness, force explicit test-owned gateway stops, and reset Linux package, config, and cache state before install lanes, preventing false prerequisite, safety-gate, and stale-config failures. - **OpenAI Realtime Talk auth:** remove the non-public Codex OAuth realtime fallback and require an OpenAI Platform API key for Talk, Voice Call, and Discord realtime voice, preventing OAuth-only gateways from advertising a browser session that the live service rejects. Fixes #115021. - **Codex native controls:** stop misclassifying valid thinking/fast runtime controls as provider overrides so Codex routes keep their native controls, while provider-native objects and invalid values stay fail-closed. Thanks @VACInc. (#107588) - **State snapshot verification:** run SQLite snapshot verification in a separate process so worker-thread file closes no longer drop the Gateway's POSIX WAL locks, eliminating spurious WAL misses and I/O errors. Thanks @VACInc. (#114016) diff --git a/scripts/e2e/parallels-windows-prepare.sh b/scripts/e2e/parallels-windows-prepare.sh index 39addd825c7e..cb9399d17d2b 100755 --- a/scripts/e2e/parallels-windows-prepare.sh +++ b/scripts/e2e/parallels-windows-prepare.sh @@ -166,10 +166,31 @@ PY } run_windows_installer() { + local argv_base64 + argv_base64="$( + python3 -c 'import base64, json, subprocess, sys; print(base64.b64encode(json.dumps({"executable": sys.argv[1], "argumentLine": subprocess.list2cmdline(sys.argv[2:])}).encode()).decode())' "$@" + )" + local guest_script + guest_script=" + \$invocation = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${argv_base64}')) | ConvertFrom-Json + \$executable = [string]\$invocation.executable + \$process = Start-Process -FilePath \$executable -ArgumentList ([string]\$invocation.argumentLine) -NoNewWindow -Wait -PassThru + \$installerExitCode = \$process.ExitCode + switch (\$installerExitCode) { + 0 { exit 0 } + 1641 { exit 105 } + 3010 { exit 194 } + default { + Write-Error ('Windows installer failed with exit code ' + \$installerExitCode + ': ' + \$executable) + exit 1 + } + } + " local exit_code=0 - run_bounded 1800 "$@" || exit_code=$? - # Windows success-with-reboot codes cross the POSIX boundary modulo 256. - # Accept 1641/3010 only for explicit DISM/installer calls; preserve all other failures. + run_bounded 1800 prlctl exec "$VM_NAME" powershell.exe \ + -NoProfile -ExecutionPolicy Bypass -Command "$guest_script" || exit_code=$? + # Parallels collapses native Windows exit codes above 255 to 255. Normalize the two + # installer success-with-reboot codes in the guest so every other failure stays fatal. case "$exit_code" in 0) return 0 ;; 105) WINDOWS_REBOOT_STARTED=1; return 0 ;; @@ -408,12 +429,12 @@ ensure_wsl_features() { local changed=0 if [[ "$(feature_state Microsoft-Windows-Subsystem-Linux)" != "Enabled" ]]; then say "Enabling Microsoft-Windows-Subsystem-Linux" - run_windows_installer prlctl exec "$VM_NAME" dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart + run_windows_installer dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart changed=1 fi if [[ "$(feature_state VirtualMachinePlatform)" != "Enabled" ]]; then say "Enabling VirtualMachinePlatform" - run_windows_installer prlctl exec "$VM_NAME" dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart + run_windows_installer dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart changed=1 fi if [[ "$changed" == "1" ]]; then @@ -438,9 +459,28 @@ raise SystemExit("No matching signed WSL MSI asset found") ' "$arch" } +get_wsl_default_version() { + guest_user_ps "Get-ItemPropertyValue 'HKCU:/Software/Microsoft/Windows/CurrentVersion/Lxss' -Name DefaultVersion -ErrorAction SilentlyContinue" | + tr -d '\r' | + tail -n 1 +} + +ensure_wsl_default_version() { + local attempt + for attempt in $(seq 1 40); do + guest_user_cmd 'wsl.exe --set-default-version 2' >/dev/null 2>&1 || true + if [[ "$(get_wsl_default_version)" == "2" ]]; then + say "WSL default version ready" + return + fi + sleep 3 + done + die "WSL default version did not become 2 within 120 seconds" +} + ensure_wsl_package() { if guest_user_cmd 'wsl.exe --version' >/dev/null 2>&1; then - guest_user_cmd 'wsl.exe --set-default-version 2' >/dev/null + ensure_wsl_default_version return fi local guest_arch asset_arch url signature wsl_msi @@ -457,13 +497,12 @@ ensure_wsl_package() { run_bounded 720 prlctl exec "$VM_NAME" curl.exe -fL --connect-timeout 20 --max-time 600 "$url" -o "$wsl_msi" || die "WSL download transport exceeded 12 minutes" signature="$(guest_system_ps "\$signature = Get-AuthenticodeSignature '${wsl_msi}'; if (\$signature.Status -eq 'Valid' -and \$signature.SignerCertificate.Subject -match 'Microsoft Corporation') { 'Valid' } else { \$signature.Status.ToString() + ': ' + \$signature.SignerCertificate.Subject }" | tr -d '\r' | tail -n 1)" [[ "$signature" == "Valid" ]] || die "WSL MSI signature was not valid Microsoft code: $signature" - run_windows_installer prlctl exec "$VM_NAME" msiexec.exe /i 'C:\ProgramData\OpenClawPrerequisiteInstallers\WSL.msi' /qn /norestart '/L*v' 'C:\Windows\Temp\openclaw-wsl-install.log' + run_windows_installer msiexec.exe /i 'C:\ProgramData\OpenClawPrerequisiteInstallers\WSL.msi' /qn /norestart '/L*v' 'C:\Windows\Temp\openclaw-wsl-install.log' finish_installer_reboot - guest_user_cmd 'wsl.exe --version' >/dev/null || { - guest_system_ps "Get-Content 'C:/Windows/Temp/openclaw-wsl-install.log' -Tail 80" >&2 || true - die "WSL package install did not produce a working wsl.exe" - } - guest_user_cmd 'wsl.exe --set-default-version 2' >/dev/null + # Parallels can return from the MSI client before the Windows Installer service + # finishes publishing wsl.exe. Match the bounded readiness check used by Git and Node. + wait_for_check WSL 'wsl.exe --version' + ensure_wsl_default_version guest_system_ps "Remove-Item -LiteralPath '${wsl_msi}','C:/Windows/Temp/openclaw-wsl-install.log' -Force -ErrorAction SilentlyContinue" } @@ -573,7 +612,7 @@ ensure_git() { [[ -n "$installer" ]] || die "winget did not download the Git installer" installer="$(stage_installer "$installer" Git 'Johannes Schindelin|Open Source Developer|Git for Windows' "$WINGET_EXPECTED_HASH")" say "Installing Git" - run_windows_installer prlctl exec "$VM_NAME" "$installer" /VERYSILENT /NORESTART /SP- /ALLUSERS + run_windows_installer "$installer" /VERYSILENT /NORESTART /SP- /ALLUSERS finish_installer_reboot wait_for_check Git 'where git.exe' } @@ -588,7 +627,7 @@ ensure_node() { [[ -n "$installer" ]] || die "winget did not download the Node.js installer" installer="$(stage_installer "$installer" NodeJS 'OpenJS Foundation' "$WINGET_EXPECTED_HASH")" say "Installing Node.js LTS" - run_windows_installer prlctl exec "$VM_NAME" msiexec.exe /i "$installer" /qn /norestart + run_windows_installer msiexec.exe /i "$installer" /qn /norestart finish_installer_reboot wait_for_check Node.js 'where node.exe' } @@ -605,7 +644,7 @@ verify_baseline() { guest_user_cmd 'wsl.exe --status' || die "WSL status failed" guest_system_ps "if (-not (Get-CimInstance Win32_ComputerSystem).HypervisorPresent) { throw 'Windows hypervisor is not active; WSL 2 workloads cannot start' }" local wsl_default - wsl_default="$(guest_user_ps "Get-ItemPropertyValue 'HKCU:/Software/Microsoft/Windows/CurrentVersion/Lxss' -Name DefaultVersion -ErrorAction SilentlyContinue" | tr -d '\r' | tail -n 1)" + wsl_default="$(get_wsl_default_version)" [[ "$wsl_default" == "2" ]] || die "WSL default version is ${wsl_default:-unset}, expected 2" assert_clean_product_state assert_no_pending_reboot @@ -631,7 +670,7 @@ prepare() { ensure_node cleanup_installers restart_guest - guest_user_cmd 'wsl.exe --set-default-version 2' >/dev/null + ensure_wsl_default_version verify_baseline create_snapshot "$BASELINE_SNAPSHOT" "E2E-ready OpenClaw Windows baseline with WSL 2, Git, Node/npm, and no OpenClaw product state." say "Baseline ready: $BASELINE_SNAPSHOT" diff --git a/scripts/e2e/parallels/linux-smoke.ts b/scripts/e2e/parallels/linux-smoke.ts index ac94daf4a574..064ac1055167 100755 --- a/scripts/e2e/parallels/linux-smoke.ts +++ b/scripts/e2e/parallels/linux-smoke.ts @@ -333,6 +333,7 @@ class LinuxSmoke extends SmokeRunController { await this.phase("fresh.bootstrap-guest", BOOTSTRAP_TIMEOUT_SECONDS, () => this.bootstrapGuest(), ); + await this.phase("fresh.reset-state", 180, () => this.resetState()); await this.phase("fresh.preflight", 90, () => this.logGuestPreflight()); await this.phase("fresh.install-latest-bootstrap", 420, () => this.installLatestRelease()); await this.phase("fresh.install-main", 420, () => @@ -361,6 +362,7 @@ class LinuxSmoke extends SmokeRunController { await this.phase("upgrade.bootstrap-guest", BOOTSTRAP_TIMEOUT_SECONDS, () => this.bootstrapGuest(), ); + await this.phase("upgrade.reset-state", 180, () => this.resetState()); await this.phase("upgrade.preflight", 90, () => this.logGuestPreflight()); await this.phase("upgrade.install-latest", 420, () => this.installLatestRelease()); this.status.latestInstalledVersion = await this.extractLastVersion("upgrade.install-latest"); @@ -491,6 +493,16 @@ run_apt_with_lock_retry apt-get -o Acquire::Check-Date=false -o DPkg::Lock::Time run_apt_with_lock_retry apt-get -o DPkg::Lock::Timeout=30 install -y curl ca-certificates`); } + private resetState(): void { + this.guestBash(String.raw`set -euo pipefail +pkill -f '[o]penclaw.*gateway run' >/dev/null 2>&1 || true +pkill -f '[o]penclaw-gateway' >/dev/null 2>&1 || true +pkill -f '[o]penclaw.mjs gateway' >/dev/null 2>&1 || true +npm uninstall -g openclaw >/dev/null 2>&1 || true +rm -rf /root/.openclaw /root/.npm/_cacache +rm -f /tmp/openclaw-parallels-linux-gateway.log`); + } + private installLatestRelease(): void { this.downloadGuestFile(this.options.installUrl, "/tmp/openclaw-install.sh"); if (this.options.installVersion) { diff --git a/scripts/e2e/parallels/windows-smoke.ts b/scripts/e2e/parallels/windows-smoke.ts index 78b70daca933..6e29d6730a29 100755 --- a/scripts/e2e/parallels/windows-smoke.ts +++ b/scripts/e2e/parallels/windows-smoke.ts @@ -696,11 +696,12 @@ Invoke-OpenClaw update status --json`, } private gatewayAction(action: "restart" | "stop"): Promise { + const forceFlag = action === "stop" ? " --force" : ""; return this.guestPowerShellBackground( `gateway-${action}`, `$ErrorActionPreference = 'Continue' $PSNativeCommandUseErrorActionPreference = $false -Invoke-OpenClaw gateway ${action} +Invoke-OpenClaw gateway ${action}${forceFlag} if ($LASTEXITCODE -ne 0) { throw "gateway ${action} failed with exit code $LASTEXITCODE" }`, 420_000, ); diff --git a/src/cli/update-cli/update-command-post-core.test.ts b/src/cli/update-cli/update-command-post-core.test.ts index 139493f933ad..a216c080b613 100644 --- a/src/cli/update-cli/update-command-post-core.test.ts +++ b/src/cli/update-cli/update-command-post-core.test.ts @@ -3,7 +3,10 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { readPostCorePluginInstallRecordsFile } from "./update-command-post-core.js"; +import { + readPostCorePluginInstallRecordsFile, + shouldResumePostCoreUpdateInFreshProcess, +} from "./update-command-post-core.js"; const tempDirs: string[] = []; @@ -89,3 +92,45 @@ describe("readPostCorePluginInstallRecordsFile", () => { ); }); }); + +describe("shouldResumePostCoreUpdateInFreshProcess", () => { + const unchangedGitResult = { + status: "ok" as const, + mode: "git" as const, + root: "/tmp/openclaw", + before: { sha: "abc123", version: "1.2.3" }, + after: { sha: "abc123", version: "1.2.3" }, + steps: [], + durationMs: 1, + }; + + it("uses the fresh CLI after an install-kind switch with unchanged git metadata", () => { + expect( + shouldResumePostCoreUpdateInFreshProcess({ + result: unchangedGitResult, + downgradeRisk: false, + installKindChanged: true, + }), + ).toBe(true); + }); + + it("keeps a metadata-identical git update in process when the install kind is unchanged", () => { + expect( + shouldResumePostCoreUpdateInFreshProcess({ + result: unchangedGitResult, + downgradeRisk: false, + installKindChanged: false, + }), + ).toBe(false); + }); + + it("does not resume after a failed install-kind switch", () => { + expect( + shouldResumePostCoreUpdateInFreshProcess({ + result: { ...unchangedGitResult, status: "error" }, + downgradeRisk: false, + installKindChanged: true, + }), + ).toBe(false); + }); +}); diff --git a/src/cli/update-cli/update-command-post-core.ts b/src/cli/update-cli/update-command-post-core.ts index eb799ce17f59..7f65ba7217b9 100644 --- a/src/cli/update-cli/update-command-post-core.ts +++ b/src/cli/update-cli/update-command-post-core.ts @@ -636,8 +636,15 @@ export function didCoreUpdateChangeInstall(result: UpdateRunResult): boolean { export function shouldResumePostCoreUpdateInFreshProcess(params: { result: UpdateRunResult; downgradeRisk: boolean; + installKindChanged?: boolean; }): boolean { - return !params.downgradeRisk && didCoreUpdateChangeInstall(params.result); + // A package-to-git switch can land on the same version already cloned at its + // target SHA. The package root still changed, so old hashed chunks are unsafe. + return ( + params.result.status === "ok" && + !params.downgradeRisk && + (params.installKindChanged === true || didCoreUpdateChangeInstall(params.result)) + ); } export async function writeControlPlaneUpdateRestartSentinelBestEffort(params: { diff --git a/src/cli/update-cli/update-command-post-update.ts b/src/cli/update-cli/update-command-post-update.ts index 733a58d0e16c..946920efb0ca 100644 --- a/src/cli/update-cli/update-command-post-update.ts +++ b/src/cli/update-cli/update-command-post-update.ts @@ -81,6 +81,7 @@ function pickUpdateQuip(): string { export async function finishUpdate(params: { result: UpdateRunResult; root: string; + installKindChanged: boolean; configSnapshot: Awaited>; requestedChannel: UpdateChannel | null; storedChannel: UpdateChannel | null; @@ -161,6 +162,7 @@ export async function finishUpdate(params: { const shouldResumePostCoreInFreshProcess = shouldResumePostCoreUpdateInFreshProcess({ result: params.result, downgradeRisk: params.downgradeRisk, + installKindChanged: params.installKindChanged, }); let postUpdateConfigSnapshot: Awaited> | undefined; diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 15c4a81fe4e2..d4c1a4e16306 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -573,6 +573,7 @@ async function updateCommandInternal( await finishUpdate({ result, root, + installKindChanged: switchToGit || switchToPackage, configSnapshot, requestedChannel, storedChannel, diff --git a/test/scripts/parallels-smoke-model.test.ts b/test/scripts/parallels-smoke-model.test.ts index ba642184b160..6f9d83aaa38b 100644 --- a/test/scripts/parallels-smoke-model.test.ts +++ b/test/scripts/parallels-smoke-model.test.ts @@ -281,6 +281,13 @@ describe("Parallels smoke model selection", () => { expect(controller).toContain('prlctl stop "$VM_NAME" --acpi'); expect(controller).toContain("HypervisorPresent"); expect(controller).toContain("git --version && node --version && npm --version"); + expect(controller).toContain("wait_for_check WSL 'wsl.exe --version'"); + expect(controller).toContain("ensure_wsl_default_version"); + expect(controller).toContain("WSL default version did not become 2 within 120 seconds"); + expect(controller).toContain("1641 { exit 105 }"); + expect(controller).toContain("3010 { exit 194 }"); + expect(controller).toContain('run_bounded 1800 prlctl exec "$VM_NAME" powershell.exe'); + expect(controller).not.toContain('run_windows_installer prlctl exec "$VM_NAME"'); expect(controller).toContain( "if (Test-Path -LiteralPath '${GUEST_PROFILE_PS}/Downloads/OpenClawPrereqs')", ); @@ -289,6 +296,28 @@ describe("Parallels smoke model selection", () => { expect(controller).not.toContain("openclaw-windows-node"); }); + it("resets Linux product state before both install lanes", () => { + const linux = readFileSync(TS_PATHS.linux, "utf8"); + for (const lane of ["fresh", "upgrade"]) { + const restoreIndex = linux.indexOf(`this.phase("${lane}.restore-snapshot"`); + const resetIndex = linux.indexOf(`this.phase("${lane}.reset-state"`); + const installIndex = linux.indexOf( + `this.phase("${lane}.${lane === "fresh" ? "install-latest-bootstrap" : "install-latest"}"`, + ); + expect(restoreIndex).toBeGreaterThanOrEqual(0); + expect(resetIndex).toBeGreaterThan(restoreIndex); + expect(installIndex).toBeGreaterThan(resetIndex); + } + expect(linux).toContain("npm uninstall -g openclaw"); + expect(linux).toContain("rm -rf /root/.openclaw /root/.npm/_cacache"); + }); + + it("forces the explicit test-owned Windows gateway stop", () => { + const windows = readFileSync(TS_PATHS.windows, "utf8"); + expect(windows).toContain('const forceFlag = action === "stop" ? " --force" : "";'); + expect(windows).toContain("Invoke-OpenClaw gateway ${action}${forceFlag}"); + }); + it("preserves caller arguments when loaded as the Windows controller library", () => { const result = spawnSync( "bash",