From 1fdeee380e75aa8c2bb4fa8976a6f4973c1fd6c0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 20 May 2026 15:07:51 +0100 Subject: [PATCH] fix: preserve update compatibility host during release upgrades (cherry picked from commit 2823725134fc8039fae8b98075dc3c0aca65cd63) --- scripts/e2e/lib/upgrade-survivor/run.sh | 21 ++++++++- .../post-core-plugin-convergence.test.ts | 19 ++++++++ .../post-core-plugin-convergence.ts | 2 + src/cli/update-cli/update-command.ts | 6 +-- .../missing-configured-plugin-install.test.ts | 43 ++++++++++++------- .../missing-configured-plugin-install.ts | 3 +- src/infra/update-global.test.ts | 3 ++ src/infra/update-global.ts | 1 + src/plugins/clawhub.ts | 4 +- 9 files changed, 78 insertions(+), 24 deletions(-) diff --git a/scripts/e2e/lib/upgrade-survivor/run.sh b/scripts/e2e/lib/upgrade-survivor/run.sh index ad2fa903c9b..b20ef051675 100644 --- a/scripts/e2e/lib/upgrade-survivor/run.sh +++ b/scripts/e2e/lib/upgrade-survivor/run.sh @@ -1042,11 +1042,28 @@ resolve_candidate_version() { export OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT } +candidate_update_spec() { + if [ "$CANDIDATE_KIND" != "tarball" ]; then + printf '%s\n' "$CANDIDATE_SPEC" + return 0 + fi + case "$CANDIDATE_SPEC" in + file:*) + printf '%s\n' "$CANDIDATE_SPEC" + ;; + *) + printf 'file:%s\n' "$CANDIDATE_SPEC" + ;; + esac +} + update_candidate() { - echo "Updating baseline $baseline_spec to candidate $CANDIDATE_KIND:$CANDIDATE_SPEC ($candidate_version)" + local update_spec + update_spec="$(candidate_update_spec)" + echo "Updating baseline $baseline_spec to candidate $CANDIDATE_KIND:$update_spec ($candidate_version)" local update_start="" local update_end="" - local update_args=(update --tag "$CANDIDATE_SPEC" --yes --json) + local update_args=(update --tag "$update_spec" --yes --json) local update_env=( env -u OPENCLAW_GATEWAY_TOKEN diff --git a/src/cli/update-cli/post-core-plugin-convergence.test.ts b/src/cli/update-cli/post-core-plugin-convergence.test.ts index dccd9a5980c..9f1b52cfbc8 100644 --- a/src/cli/update-cli/post-core-plugin-convergence.test.ts +++ b/src/cli/update-cli/post-core-plugin-convergence.test.ts @@ -13,6 +13,7 @@ vi.mock("./plugin-payload-validation.js", () => ({ })); import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { VERSION } from "../../version.js"; import { convergenceWarningsToOutcomes, filterRecordsToActive, @@ -41,6 +42,22 @@ describe("runPostCorePluginConvergence", () => { cfg, env: { OPENCLAW_UPDATE_IN_PROGRESS: "1", + OPENCLAW_COMPATIBILITY_HOST_VERSION: VERSION, + OPENCLAW_UPDATE_POST_CORE_CONVERGENCE: "1", + }, + }); + }); + + it("uses the candidate runtime version over a stale inherited host version", async () => { + const cfg = { plugins: { entries: {} } } as unknown as OpenClawConfig; + await runPostCorePluginConvergence({ + cfg, + env: { OPENCLAW_COMPATIBILITY_HOST_VERSION: "2026.5.12" }, + }); + expect(mocks.repairMissingConfiguredPluginInstalls).toHaveBeenCalledWith({ + cfg, + env: { + OPENCLAW_COMPATIBILITY_HOST_VERSION: VERSION, OPENCLAW_UPDATE_POST_CORE_CONVERGENCE: "1", }, }); @@ -97,6 +114,7 @@ describe("runPostCorePluginConvergence", () => { expect(mocks.repairMissingConfiguredPluginInstalls).toHaveBeenCalledWith({ cfg, env: { + OPENCLAW_COMPATIBILITY_HOST_VERSION: VERSION, OPENCLAW_UPDATE_POST_CORE_CONVERGENCE: "1", }, baselineRecords: baseline, @@ -222,6 +240,7 @@ describe("runPostCorePluginConvergence", () => { expect(mocks.runPluginPayloadSmokeCheck).toHaveBeenCalledWith({ records, env: { + OPENCLAW_COMPATIBILITY_HOST_VERSION: VERSION, OPENCLAW_UPDATE_POST_CORE_CONVERGENCE: "1", }, }); diff --git a/src/cli/update-cli/post-core-plugin-convergence.ts b/src/cli/update-cli/post-core-plugin-convergence.ts index 47765dcb7be..278e3dd4896 100644 --- a/src/cli/update-cli/post-core-plugin-convergence.ts +++ b/src/cli/update-cli/post-core-plugin-convergence.ts @@ -7,6 +7,7 @@ import { resolveTrustedSourceLinkedOfficialClawHubSpec, resolveTrustedSourceLinkedOfficialNpmSpec, } from "../../plugins/update.js"; +import { VERSION } from "../../version.js"; import { runPluginPayloadSmokeCheck, type PluginPayloadSmokeFailure, @@ -62,6 +63,7 @@ export async function runPostCorePluginConvergence(params: { }): Promise { const env: NodeJS.ProcessEnv = { ...params.env, + OPENCLAW_COMPATIBILITY_HOST_VERSION: VERSION, [UPDATE_POST_CORE_CONVERGENCE_ENV]: "1", }; diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index f2505564bbb..377e362b6e1 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -95,6 +95,7 @@ import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { stylePromptMessage } from "../../terminal/prompt-style.js"; import { theme } from "../../terminal/theme.js"; import { resolveUserPath } from "../../utils.js"; +import { VERSION } from "../../version.js"; import { replaceCliName, resolveCliName } from "../cli-name.js"; import { formatCliCommand } from "../command-format.js"; import { installCompletion } from "../completion-runtime.js"; @@ -2886,10 +2887,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { return; } - const postCoreHostVersion = await readPackageVersion(root); - if (postCoreHostVersion) { - process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION = postCoreHostVersion; - } + process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION = (await readPackageVersion(root)) ?? VERSION; let postCoreConfigSnapshot = await readConfigFileSnapshot({ skipPluginValidation: true }); const preUpdateSourceConfig = await readPostCorePreUpdateSourceConfig({ diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts index 7ec962de41a..bc53e6b8eca 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -1209,9 +1209,8 @@ describe("repairMissingConfiguredPluginInstalls", () => { }); }); - it("prefers npm over ClawHub during post-core repair", async () => { + it("passes the post-core compatibility host version to ClawHub repair", async () => { const npmRoot = makeTempDir(); - const packageDir = path.join(npmRoot, "node_modules", "@openclaw", "whatsapp"); mocks.resolveDefaultPluginNpmDir.mockReturnValue(npmRoot); mocks.listChannelPluginCatalogEntries.mockReturnValue([ { @@ -1225,20 +1224,23 @@ describe("repairMissingConfiguredPluginInstalls", () => { }, ]); mocks.installPluginFromClawHub.mockResolvedValue({ - ok: false, - error: 'Plugin "@openclaw/whatsapp" requires plugin API >=2026.5.18.', - }); - mocks.installPluginFromNpmSpec.mockResolvedValue({ ok: true, pluginId: "whatsapp", - targetDir: packageDir, + targetDir: "/tmp/openclaw-plugins/whatsapp", version: "1.2.3", - npmResolution: { - name: "@openclaw/whatsapp", + clawhub: { + source: "clawhub", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "@openclaw/whatsapp", + clawhubFamily: "code-plugin", + clawhubChannel: "official", version: "1.2.3", - resolvedSpec: "@openclaw/whatsapp@1.2.3", - integrity: "sha512-whatsapp", + integrity: "sha256-whatsapp", resolvedAt: "2026-05-01T00:00:00.000Z", + clawpackSha256: "2".repeat(64), + clawpackSpecVersion: 1, + clawpackManifestSha256: "3".repeat(64), + clawpackSize: 1234, }, }); @@ -1256,18 +1258,27 @@ describe("repairMissingConfiguredPluginInstalls", () => { }, }, env: { + OPENCLAW_COMPATIBILITY_HOST_VERSION: "2026.5.19", OPENCLAW_UPDATE_POST_CORE_CONVERGENCE: "1", }, }); - expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled(); - expectRecordFields(mockCallArg(mocks.installPluginFromNpmSpec), { - spec: expectedNpmInstallSpec("@openclaw/whatsapp"), - npmDir: npmRoot, + expectRecordFields(mockCallArg(mocks.installPluginFromClawHub), { + spec: "clawhub:@openclaw/whatsapp", + env: { + OPENCLAW_COMPATIBILITY_HOST_VERSION: "2026.5.19", + OPENCLAW_UPDATE_POST_CORE_CONVERGENCE: "1", + }, mode: "install", }); + expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled(); expect(result.warnings).toEqual([]); - expect(result.records.whatsapp?.installPath).toBe(packageDir); + expectRecordFields(result.records.whatsapp, { + source: "clawhub", + spec: "clawhub:@openclaw/whatsapp", + installPath: "/tmp/openclaw-plugins/whatsapp", + clawhubPackage: "@openclaw/whatsapp", + }); }); it("repairs missing external payload during post-core convergence even with OPENCLAW_UPDATE_IN_PROGRESS=1", async () => { diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.ts b/src/commands/doctor/shared/missing-configured-plugin-install.ts index ee60f21bc49..785207ad57d 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ts @@ -815,6 +815,7 @@ async function installCandidate(params: { const clawhubResult = await installPluginFromClawHub({ spec: clawhubInstallSpec, extensionsDir, + env: params.env, expectedPluginId: candidate.pluginId, mode: params.mode === "update" || existingClawHubPackagePath ? "update" : "install", }); @@ -1135,7 +1136,7 @@ async function repairMissingPluginInstalls(params: { configChannel: normalizeUpdateChannel(params.cfg.update?.channel), currentVersion: VERSION, }); - const preferNpmInstalls = isLegacyPackageUpdateDoctorPass(env) || isPostCoreConvergencePass(env); + const preferNpmInstalls = isLegacyPackageUpdateDoctorPass(env); let nextRecords = records; for (const [pluginId, record] of Object.entries(records)) { diff --git a/src/infra/update-global.test.ts b/src/infra/update-global.test.ts index 79479a17d42..895ad1643e4 100644 --- a/src/infra/update-global.test.ts +++ b/src/infra/update-global.test.ts @@ -238,12 +238,15 @@ describe("update global helpers", () => { expect(isExplicitPackageInstallSpec("github:openclaw/openclaw#main")).toBe(true); expect(isExplicitPackageInstallSpec("https://example.com/openclaw-main.tgz")).toBe(true); expect(isExplicitPackageInstallSpec("file:/tmp/openclaw-main.tgz")).toBe(true); + expect(isExplicitPackageInstallSpec("/tmp/openclaw-main.tgz")).toBe(true); + expect(isExplicitPackageInstallSpec("openclaw-main.tgz")).toBe(true); expect(isExplicitPackageInstallSpec("beta")).toBe(false); expect(canResolveRegistryVersionForPackageTarget("latest")).toBe(true); expect(canResolveRegistryVersionForPackageTarget("2026.3.22")).toBe(true); expect(canResolveRegistryVersionForPackageTarget("main")).toBe(false); expect(canResolveRegistryVersionForPackageTarget("github:openclaw/openclaw#main")).toBe(false); + expect(canResolveRegistryVersionForPackageTarget("/tmp/openclaw-main.tgz")).toBe(false); }); it("detects install managers from resolved roots and on-disk presence", async () => { diff --git a/src/infra/update-global.ts b/src/infra/update-global.ts index e17fefe8aa3..a0f4fdafb38 100644 --- a/src/infra/update-global.ts +++ b/src/infra/update-global.ts @@ -79,6 +79,7 @@ export function isExplicitPackageInstallSpec(value: string): boolean { return false; } return ( + /\.(?:tgz|tar\.gz)$/iu.test(trimmed) || trimmed.includes("://") || trimmed.includes("#") || /^(?:file|github|git\+ssh|git\+https|git\+http|git\+file|npm):/i.test(trimmed) diff --git a/src/plugins/clawhub.ts b/src/plugins/clawhub.ts index 5b2c312cf2b..edf57c51b9b 100644 --- a/src/plugins/clawhub.ts +++ b/src/plugins/clawhub.ts @@ -33,6 +33,7 @@ import { import { formatErrorMessage } from "../infra/errors.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { resolveCompatibilityHostVersion } from "../version.js"; +import type { RuntimeVersionEnv } from "../version.js"; import type { ClawHubPluginInstallRecordFields } from "./clawhub-install-records.js"; import type { InstallSafetyOverrides } from "./install-security-scan.js"; import { installPluginFromArchive, type InstallPluginResult } from "./install.js"; @@ -1057,6 +1058,7 @@ export async function installPluginFromClawHub( timeoutMs?: number; dryRun?: boolean; expectedPluginId?: string; + env?: RuntimeVersionEnv; }, ): Promise< | ({ @@ -1101,7 +1103,7 @@ export async function installPluginFromClawHub( if (!versionState.ok) { return versionState; } - const runtimeVersion = resolveCompatibilityHostVersion(); + const runtimeVersion = resolveCompatibilityHostVersion(params.env); const validationFailure = validateClawHubPluginPackage({ detail, compatibility: versionState.compatibility,