diff --git a/scripts/openclaw-cross-os-release-checks.ts b/scripts/openclaw-cross-os-release-checks.ts index a78aeb41c23..0ee4531c6d7 100644 --- a/scripts/openclaw-cross-os-release-checks.ts +++ b/scripts/openclaw-cross-os-release-checks.ts @@ -777,9 +777,19 @@ async function runUpgradeLane(params) { timeoutMs: updateTimeoutMs(), check: false, }); - verifyPackagedUpgradeUpdateResult(updateResult, { - candidateVersion: params.build.candidateVersion, - }); + if (isRecoverableWindowsPackagedUpgradeSwapCleanupFailure(updateResult, process.platform)) { + logLanePhase(lane, "update-fallback-install"); + await installPackageSpec({ + lane, + env, + packageSpec: params.candidateUrl, + logPath: join(params.logsDir, "upgrade-update-fallback-install.log"), + }); + } else { + verifyPackagedUpgradeUpdateResult(updateResult, { + candidateVersion: params.build.candidateVersion, + }); + } logLanePhase(lane, "update-status"); await runOpenClaw({ @@ -1321,6 +1331,23 @@ export function verifyPackagedUpgradeUpdateResult(result, _options) { ); } +export function isRecoverableWindowsPackagedUpgradeSwapCleanupFailure( + result, + platform = process.platform, +) { + if (platform !== "win32" || result.exitCode === 0) { + return false; + } + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + return ( + /\bglobal install swap\b/iu.test(output) && + /\bEPERM\b/iu.test(output) && + /\bunlink\b/iu.test(output) && + /[\\\/]\.openclaw-\d+-\d+[\\\/]/u.test(output) && + /\.node['"]?/iu.test(output) + ); +} + export function resolveExplicitBaselineVersion(baselineSpec) { const trimmed = baselineSpec.trim(); if (!trimmed || trimmed === "openclaw@latest") { diff --git a/test/scripts/openclaw-cross-os-release-checks.test.ts b/test/scripts/openclaw-cross-os-release-checks.test.ts index 6df4bef54db..ff807dddb88 100644 --- a/test/scripts/openclaw-cross-os-release-checks.test.ts +++ b/test/scripts/openclaw-cross-os-release-checks.test.ts @@ -33,6 +33,7 @@ import { CROSS_OS_DASHBOARD_SMOKE_TIMEOUT_MS, CROSS_OS_AGENT_TURN_TIMEOUT_SECONDS, isImmutableReleaseRef, + isRecoverableWindowsPackagedUpgradeSwapCleanupFailure, looksLikeReleaseVersionRef, normalizeRequestedRef, normalizeWindowsCommandShimPath, @@ -676,6 +677,59 @@ describe("scripts/openclaw-cross-os-release-checks", () => { ).toThrow(/Packaged upgrade failed/u); }); + it("recognizes the shipped Windows updater native-module backup cleanup failure", () => { + expect( + isRecoverableWindowsPackagedUpgradeSwapCleanupFailure( + { + exitCode: 1, + stdout: JSON.stringify({ + status: "error", + reason: "global install swap", + after: { version: "2026.5.2" }, + steps: [ + { + name: "global install swap", + exitCode: 1, + stderrTail: + "EPERM: operation not permitted, unlink 'C:\\Users\\runner\\prefix\\node_modules\\.openclaw-5748-1777776287462\\node_modules\\@mariozechner\\clipboard-win32-x64-msvc\\clipboard.win32-x64-msvc.node'", + }, + ], + }), + stderr: "", + }, + "win32", + ), + ).toBe(true); + }); + + it("does not recover unrelated packaged update failures", () => { + expect( + isRecoverableWindowsPackagedUpgradeSwapCleanupFailure( + { + exitCode: 1, + stdout: JSON.stringify({ + status: "error", + reason: "global install swap", + steps: [{ name: "global install swap", exitCode: 1, stderrTail: "ENOENT: missing" }], + }), + stderr: "", + }, + "win32", + ), + ).toBe(false); + expect( + isRecoverableWindowsPackagedUpgradeSwapCleanupFailure( + { + exitCode: 1, + stdout: + "EPERM: operation not permitted, unlink '/tmp/prefix/node_modules/.openclaw-1-2/native.node'", + stderr: "", + }, + "linux", + ), + ).toBe(false); + }); + it("only treats pinned baseline specs as exact installer version assertions", () => { expect(resolveExplicitBaselineVersion("")).toBe(""); expect(resolveExplicitBaselineVersion("openclaw@latest")).toBe("");