diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a42bcdf23c..5f019c0aa5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai - Gateway/performance: keep raw channel-config schema parsing from discovering bundled plugin runtime metadata, and add `pnpm gateway:watch --benchmark-no-force` for profiling startup without the default port cleanup. - Plugins/onboarding: let Manual setup install optional official plugins, including ClawHub-backed diagnostics with npm fallback, and expose the external Codex plugin as a selectable provider setup choice. Thanks @vincentkoc. - Plugins/update: treat official externalized bundled npm migrations and ClawHub-to-npm fallbacks as trusted source-linked installs, so prerelease-only official plugin packages can migrate from bundled builds without being rejected as unsafe prerelease resolutions. Thanks @vincentkoc. +- Plugins/update: move ClawHub-preferred externalized plugin installs back to ClawHub after an earlier npm fallback once the ClawHub package becomes available. Thanks @vincentkoc. - Plugins/update: clean stale bundled load paths for already-externalized pinned npm and ClawHub plugin installs, so release-channel sync does not leave removed bundled paths ahead of the installed external package. Thanks @vincentkoc. - Plugins/CLI: include package dependency install state in `openclaw plugins list --json` so scripts can spot missing plugin dependencies without runtime-loading plugins. - Google Meet: preserve `realtime.introMessage: ""` so realtime Chrome joins can stay silent instead of restoring the default spoken intro. Thanks @vincentkoc. diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 46ff7e97354..904696aa2fd 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -2330,6 +2330,63 @@ describe("syncPluginsForUpdateChannel", () => { ); }); + it("moves ClawHub-preferred externalized plugin fallbacks back to ClawHub", async () => { + resolveBundledPluginSourcesMock.mockReturnValue(new Map()); + installPluginFromClawHubMock.mockResolvedValue( + createSuccessfulClawHubUpdateResult({ + pluginId: "legacy-chat", + targetDir: "/tmp/openclaw-plugins/legacy-chat", + version: "2026.5.1-beta.2", + clawhubPackage: "legacy-chat", + }), + ); + + const result = await syncPluginsForUpdateChannel({ + channel: "stable", + externalizedBundledPluginBridges: [ + { + bundledPluginId: "legacy-chat", + preferredSource: "clawhub", + clawhubSpec: "clawhub:legacy-chat@2026.5.1-beta.2", + npmSpec: "@openclaw/legacy-chat", + channelIds: ["legacy-chat"], + }, + ], + config: { + channels: { + "legacy-chat": { + enabled: true, + }, + }, + plugins: { + installs: { + "legacy-chat": { + source: "npm", + spec: "@openclaw/legacy-chat", + installPath: "/tmp/openclaw-plugins/legacy-chat", + }, + }, + }, + }, + }); + + expect(installPluginFromClawHubMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "clawhub:legacy-chat@2026.5.1-beta.2", + mode: "update", + expectedPluginId: "legacy-chat", + }), + ); + expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled(); + expect(result.changed).toBe(true); + expect(result.summary.switchedToClawHub).toEqual(["legacy-chat"]); + expect(result.config.plugins?.installs?.["legacy-chat"]).toMatchObject({ + source: "clawhub", + spec: "clawhub:legacy-chat@2026.5.1-beta.2", + installPath: "/tmp/openclaw-plugins/legacy-chat", + }); + }); + it("fails closed without npm fallback when ClawHub returns integrity drift", async () => { resolveBundledPluginSourcesMock.mockReturnValue(new Map()); installPluginFromClawHubMock.mockResolvedValue({ diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 787de6d10be..2d09e41551a 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -492,6 +492,36 @@ function isTrustedSourceLinkedOfficialBridgeNpmInstall(params: { return Boolean(officialPackageName && requestedPackageName === officialPackageName); } +function isBridgeNpmInstall(params: { + bridge: ExternalizedBundledPluginBridge; + record: PluginInstallRecord; +}): boolean { + const npmSpec = getExternalizedBundledPluginNpmSpec(params.bridge); + if (!npmSpec || params.record.source !== "npm") { + return false; + } + const bridgePackageName = resolveNpmSpecPackageName(npmSpec); + const recordPackageName = + params.record.resolvedName ?? + resolveNpmSpecPackageName(params.record.spec) ?? + resolveNpmSpecPackageName(params.record.resolvedSpec); + return Boolean(bridgePackageName && recordPackageName === bridgePackageName); +} + +function isBridgeClawHubInstall(params: { + bridge: ExternalizedBundledPluginBridge; + record: PluginInstallRecord; +}): boolean { + if (params.record.source !== "clawhub") { + return false; + } + const clawhubSpec = getExternalizedBundledPluginClawHubSpec(params.bridge); + const bridgeClawHubPackage = clawhubSpec ? parseClawHubPluginSpec(clawhubSpec)?.name : undefined; + const recordClawHubPackage = + params.record.clawhubPackage ?? parseClawHubPluginSpec(params.record.spec ?? "")?.name; + return Boolean(bridgeClawHubPackage && recordClawHubPackage === bridgeClawHubPackage); +} + function resolveNpmUpdateSpecs(params: { record: PluginInstallRecord; specOverride?: string; @@ -576,28 +606,20 @@ function isBridgeAlreadyInstalledFromPreferredSource(params: { bridge: ExternalizedBundledPluginBridge; record: PluginInstallRecord; }): boolean { - const npmSpec = getExternalizedBundledPluginNpmSpec(params.bridge); - if (npmSpec && params.record.source === "npm") { - const bridgePackageName = resolveNpmSpecPackageName(npmSpec); - const recordPackageName = - params.record.resolvedName ?? - resolveNpmSpecPackageName(params.record.spec) ?? - resolveNpmSpecPackageName(params.record.resolvedSpec); - if (bridgePackageName && recordPackageName === bridgePackageName) { - return true; - } - } - const clawhubSpec = getExternalizedBundledPluginClawHubSpec(params.bridge); - const bridgeClawHubPackage = clawhubSpec ? parseClawHubPluginSpec(clawhubSpec)?.name : undefined; - const recordClawHubPackage = - params.record.source === "clawhub" - ? (params.record.clawhubPackage ?? parseClawHubPluginSpec(params.record.spec ?? "")?.name) - : undefined; - return Boolean( - bridgeClawHubPackage && - params.record.source === "clawhub" && - recordClawHubPackage === bridgeClawHubPackage, - ); + const preferredSource = getExternalizedBundledPluginPreferredSource(params.bridge); + return preferredSource === "clawhub" + ? isBridgeClawHubInstall(params) + : isBridgeNpmInstall(params); +} + +function isBridgeInstalledFromFallbackSource(params: { + bridge: ExternalizedBundledPluginBridge; + record: PluginInstallRecord; +}): boolean { + const preferredSource = getExternalizedBundledPluginPreferredSource(params.bridge); + return preferredSource === "clawhub" + ? isBridgeNpmInstall(params) + : isBridgeClawHubInstall(params); } function replacePluginIdInList( @@ -1448,6 +1470,10 @@ export async function syncPluginsForUpdateChannel(params: { bridge, record: existing.record, env, + }) && + !isBridgeInstalledFromFallbackSource({ + bridge, + record: existing.record, }) ) { continue;