diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d2763cf34b..36e3ff43d59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai - Plugins/source checkout: load bundled plugins from the `extensions/*` pnpm workspace tree in source checkouts, so plugin-local dependencies and edits are used directly while packaged installs keep using the built runtime tree. Thanks @vincentkoc. - Plugins/ClawHub: prefer versioned ClawPack artifacts when ClawHub publishes digest metadata, verifying the ClawPack response header and downloaded bytes before installing. Thanks @vincentkoc. - Plugins/ClawHub: persist ClawPack digest metadata on ClawHub plugin install and update records so registry refreshes and download verification can reuse stored artifact facts. Thanks @vincentkoc. +- Plugins/ClawHub: allow official bundled-plugin cutovers to prefer ClawHub installs with npm fallback only when the ClawHub package or version is absent. Thanks @vincentkoc. - Providers/OpenAI: add `extraBody`/`extra_body` passthrough for OpenAI-compatible TTS endpoints, so custom speech servers can receive fields such as `lang` in `/audio/speech` requests. Fixes #39900. Thanks @R3NK0R. - Dependencies: refresh workspace dependency pins, including TypeBox 1.1.37, AWS SDK 3.1041.0, Microsoft Teams 2.0.9, and Marked 18.0.3. Thanks @mariozechner, @aws, and @microsoft. - Discord/channels: add reusable message-channel access groups plus Discord channel-audience DM authorization, so allowlists can reference `accessGroup:` across channel auth paths. (#75813) diff --git a/src/cli/plugins-location-bridges.ts b/src/cli/plugins-location-bridges.ts index a46f0ab9cfc..971206d4ccc 100644 --- a/src/cli/plugins-location-bridges.ts +++ b/src/cli/plugins-location-bridges.ts @@ -21,6 +21,7 @@ function buildBridgeFromPersistedBundledRecord( return { bundledPluginId: record.pluginId, pluginId: record.pluginId, + preferredSource: "npm", npmSpec, ...(record.enabledByDefault ? { enabledByDefault: true } : {}), ...(manifest?.channels.length ? { channelIds: manifest.channels } : {}), diff --git a/src/plugins/externalized-bundled-plugins.ts b/src/plugins/externalized-bundled-plugins.ts index 6ed7ddcff57..ab2c70aee17 100644 --- a/src/plugins/externalized-bundled-plugins.ts +++ b/src/plugins/externalized-bundled-plugins.ts @@ -1,10 +1,18 @@ +export type ExternalizedBundledPluginPreferredSource = "npm" | "clawhub"; + export type ExternalizedBundledPluginBridge = { /** Plugin id used while the plugin was bundled in core. */ bundledPluginId: string; /** Plugin id declared by the external package. Defaults to bundledPluginId. */ pluginId?: string; - /** npm spec OpenClaw should install when migrating the bundled plugin out. */ - npmSpec: string; + /** Preferred external source when migrating the bundled plugin out. Defaults to npm. */ + preferredSource?: ExternalizedBundledPluginPreferredSource; + /** npm spec OpenClaw can install when migrating the bundled plugin out. */ + npmSpec?: string; + /** ClawHub spec OpenClaw can install when migrating the bundled plugin out. */ + clawhubSpec?: string; + /** Optional ClawHub base URL for non-default registries. */ + clawhubUrl?: string; /** Bundled directory name, when it differs from bundledPluginId. */ bundledDirName?: string; /** Previous bundled manifest default enablement from the persisted registry. */ @@ -21,6 +29,36 @@ function normalizePluginId(value: string | undefined): string { return value?.trim() ?? ""; } +function normalizeOptionalSpec(value: string | undefined): string { + return value?.trim() ?? ""; +} + +export function getExternalizedBundledPluginPreferredSource( + bridge: ExternalizedBundledPluginBridge, +): ExternalizedBundledPluginPreferredSource { + if (bridge.preferredSource === "clawhub") { + return "clawhub"; + } + if (bridge.preferredSource === "npm") { + return "npm"; + } + return normalizeOptionalSpec(bridge.clawhubSpec) && !normalizeOptionalSpec(bridge.npmSpec) + ? "clawhub" + : "npm"; +} + +export function getExternalizedBundledPluginNpmSpec( + bridge: ExternalizedBundledPluginBridge, +): string { + return normalizeOptionalSpec(bridge.npmSpec); +} + +export function getExternalizedBundledPluginClawHubSpec( + bridge: ExternalizedBundledPluginBridge, +): string { + return normalizeOptionalSpec(bridge.clawhubSpec); +} + export function getExternalizedBundledPluginTargetId( bridge: ExternalizedBundledPluginBridge, ): string { diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 9c22b1d114a..b4841713025 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -38,6 +38,11 @@ vi.mock("./marketplace.js", () => ({ })); vi.mock("./clawhub.js", () => ({ + CLAWHUB_INSTALL_ERROR_CODE: { + PACKAGE_NOT_FOUND: "package_not_found", + VERSION_NOT_FOUND: "version_not_found", + ARCHIVE_INTEGRITY_MISMATCH: "archive_integrity_mismatch", + }, installPluginFromClawHub: (...args: unknown[]) => installPluginFromClawHubMock(...args), })); @@ -73,6 +78,36 @@ function createSuccessfulNpmUpdateResult(params?: { }; } +function createSuccessfulClawHubUpdateResult(params?: { + pluginId?: string; + targetDir?: string; + version?: string; + clawhubPackage?: string; +}) { + return { + ok: true, + pluginId: params?.pluginId ?? "legacy-chat", + targetDir: params?.targetDir ?? "/tmp/openclaw-plugins/legacy-chat", + version: params?.version ?? "2026.5.1-beta.2", + extensions: ["index.ts"], + packageName: params?.clawhubPackage ?? "legacy-chat", + clawhub: { + source: "clawhub" as const, + clawhubUrl: "https://clawhub.ai", + clawhubPackage: params?.clawhubPackage ?? "legacy-chat", + clawhubFamily: "code-plugin" as const, + clawhubChannel: "official" as const, + version: params?.version ?? "2026.5.1-beta.2", + integrity: "sha256-clawpack", + resolvedAt: "2026-05-01T00:00:00.000Z", + clawpackSha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + clawpackSpecVersion: 1, + clawpackManifestSha256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + clawpackSize: 4096, + }, + }; +} + function createNpmInstallConfig(params: { pluginId: string; spec: string; @@ -1481,6 +1516,7 @@ describe("updateNpmInstalledPlugins", () => { describe("syncPluginsForUpdateChannel", () => { beforeEach(() => { installPluginFromNpmSpecMock.mockReset(); + installPluginFromClawHubMock.mockReset(); installPluginFromGitSpecMock.mockReset(); resolveBundledPluginSourcesMock.mockReset(); }); @@ -1662,6 +1698,193 @@ describe("syncPluginsForUpdateChannel", () => { }); }); + it("installs a ClawHub-preferred externalized bundled plugin", 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", + clawhubUrl: "https://clawhub.ai", + npmSpec: "@openclaw/legacy-chat", + channelIds: ["legacy-chat"], + }, + ], + config: { + channels: { + "legacy-chat": { + enabled: true, + }, + }, + plugins: { + load: { paths: [appBundledPluginRoot("legacy-chat")] }, + installs: { + "legacy-chat": { + source: "path", + sourcePath: appBundledPluginRoot("legacy-chat"), + installPath: appBundledPluginRoot("legacy-chat"), + }, + }, + }, + }, + }); + + expect(installPluginFromClawHubMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "clawhub:legacy-chat@2026.5.1-beta.2", + baseUrl: "https://clawhub.ai", + mode: "update", + expectedPluginId: "legacy-chat", + }), + ); + expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled(); + expect(result.changed).toBe(true); + expect(result.summary.switchedToClawHub).toEqual(["legacy-chat"]); + expect(result.summary.switchedToNpm).toEqual([]); + expect(result.summary.errors).toEqual([]); + expect(result.config.plugins?.load?.paths).toEqual([]); + 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", + version: "2026.5.1-beta.2", + integrity: "sha256-clawpack", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "legacy-chat", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + clawpackSha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + clawpackSpecVersion: 1, + clawpackManifestSha256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + clawpackSize: 4096, + }); + }); + + it("falls back from ClawHub to npm only when the ClawHub package is absent", async () => { + resolveBundledPluginSourcesMock.mockReturnValue(new Map()); + installPluginFromClawHubMock.mockResolvedValue({ + ok: false, + code: "package_not_found", + error: "Package not found on ClawHub.", + }); + installPluginFromNpmSpecMock.mockResolvedValue( + createSuccessfulNpmUpdateResult({ + pluginId: "legacy-chat", + targetDir: "/tmp/openclaw-plugins/legacy-chat", + version: "2.0.0", + }), + ); + + 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: { + load: { paths: [appBundledPluginRoot("legacy-chat")] }, + installs: { + "legacy-chat": { + source: "path", + sourcePath: appBundledPluginRoot("legacy-chat"), + installPath: appBundledPluginRoot("legacy-chat"), + }, + }, + }, + }, + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@openclaw/legacy-chat", + mode: "update", + expectedPluginId: "legacy-chat", + }), + ); + expect(result.changed).toBe(true); + expect(result.summary.switchedToClawHub).toEqual([]); + expect(result.summary.switchedToNpm).toEqual(["legacy-chat"]); + expect(result.summary.warnings).toEqual([ + "ClawHub clawhub:legacy-chat@2026.5.1-beta.2 unavailable for legacy-chat; falling back to npm @openclaw/legacy-chat.", + ]); + expect(result.summary.errors).toEqual([]); + expect(result.config.plugins?.installs?.["legacy-chat"]).toMatchObject({ + source: "npm", + spec: "@openclaw/legacy-chat", + installPath: "/tmp/openclaw-plugins/legacy-chat", + version: "2.0.0", + }); + }); + + it("fails closed without npm fallback when ClawHub returns integrity drift", async () => { + resolveBundledPluginSourcesMock.mockReturnValue(new Map()); + installPluginFromClawHubMock.mockResolvedValue({ + ok: false, + code: "archive_integrity_mismatch", + error: "ClawHub ClawPack integrity mismatch.", + }); + const config: OpenClawConfig = { + channels: { + "legacy-chat": { + enabled: true, + }, + }, + plugins: { + load: { paths: [appBundledPluginRoot("legacy-chat")] }, + installs: { + "legacy-chat": { + source: "path", + sourcePath: appBundledPluginRoot("legacy-chat"), + installPath: appBundledPluginRoot("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, + }); + + expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled(); + expect(result.changed).toBe(false); + expect(result.config).toBe(config); + expect(result.summary.errors).toEqual([ + "Failed to update legacy-chat: ClawHub ClawPack integrity mismatch. (ClawHub clawhub:legacy-chat@2026.5.1-beta.2).", + ]); + }); + it("externalizes bundled plugins that were enabled by default", async () => { resolveBundledPluginSourcesMock.mockReturnValue(new Map()); installPluginFromNpmSpecMock.mockResolvedValue( diff --git a/src/plugins/update.ts b/src/plugins/update.ts index ae92b4dce86..3d2bf90c2db 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -11,11 +11,14 @@ import { compareComparableSemver, parseComparableSemver } from "../infra/semver- import type { UpdateChannel } from "../infra/update-channels.js"; import { resolveUserPath } from "../utils.js"; import { resolveBundledPluginSources } from "./bundled-sources.js"; -import { installPluginFromClawHub } from "./clawhub.js"; +import { CLAWHUB_INSTALL_ERROR_CODE, installPluginFromClawHub } from "./clawhub.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; import { getExternalizedBundledPluginLegacyPathSuffix, + getExternalizedBundledPluginClawHubSpec, getExternalizedBundledPluginLookupIds, + getExternalizedBundledPluginNpmSpec, + getExternalizedBundledPluginPreferredSource, getExternalizedBundledPluginTargetId, type ExternalizedBundledPluginBridge, } from "./externalized-bundled-plugins.js"; @@ -62,6 +65,7 @@ export type PluginUpdateIntegrityDriftParams = { export type PluginChannelSyncSummary = { switchedToBundled: string[]; + switchedToClawHub: string[]; switchedToNpm: string[]; warnings: string[]; errors: string[]; @@ -383,6 +387,27 @@ function isExternalizedBundledPluginEnabled(params: { return false; } +function shouldFallbackClawHubBridgeToNpm(result: { ok: false; code?: string }): boolean { + return ( + result.code === CLAWHUB_INSTALL_ERROR_CODE.PACKAGE_NOT_FOUND || + result.code === CLAWHUB_INSTALL_ERROR_CODE.VERSION_NOT_FOUND + ); +} + +function isBridgeAlreadyInstalledFromPreferredSource(params: { + bridge: ExternalizedBundledPluginBridge; + record: PluginInstallRecord; +}): boolean { + const npmSpec = getExternalizedBundledPluginNpmSpec(params.bridge); + if (npmSpec && params.record.source === "npm" && params.record.spec === npmSpec) { + return true; + } + const clawhubSpec = getExternalizedBundledPluginClawHubSpec(params.bridge); + return Boolean( + clawhubSpec && params.record.source === "clawhub" && params.record.spec === clawhubSpec, + ); +} + function replacePluginIdInList( entries: string[] | undefined, fromId: string, @@ -1012,6 +1037,7 @@ export async function syncPluginsForUpdateChannel(params: { const logger = params.logger ?? {}; const summary: PluginChannelSyncSummary = { switchedToBundled: [], + switchedToClawHub: [], switchedToNpm: [], warnings: [], errors: [], @@ -1080,7 +1106,13 @@ export async function syncPluginsForUpdateChannel(params: { continue; } - if (existing?.record.source === "npm" && existing.record.spec === bridge.npmSpec) { + if ( + existing && + isBridgeAlreadyInstalledFromPreferredSource({ + bridge, + record: existing.record, + }) + ) { if (existing.pluginId !== targetPluginId) { next = migratePluginConfigId(next, existing.pluginId, targetPluginId); installs = next.plugins?.installs ?? {}; @@ -1101,19 +1133,67 @@ export async function syncPluginsForUpdateChannel(params: { continue; } - const result = await installPluginFromNpmSpec({ - spec: bridge.npmSpec, - mode: "update", - expectedPluginId: targetPluginId, - logger, - }); - if (!result.ok) { - const message = formatNpmInstallFailure({ - pluginId: targetPluginId, - spec: bridge.npmSpec, - phase: "update", - result, + const preferredSource = getExternalizedBundledPluginPreferredSource(bridge); + const npmSpec = getExternalizedBundledPluginNpmSpec(bridge); + const clawhubSpec = getExternalizedBundledPluginClawHubSpec(bridge); + let installSource = preferredSource; + let installSpec = preferredSource === "clawhub" ? clawhubSpec : npmSpec; + let result: + | Awaited> + | Awaited>; + + if (!installSpec) { + const message = `Failed to update ${targetPluginId}: missing ${preferredSource} install spec for externalized bundled plugin.`; + summary.errors.push(message); + logger.error?.(message); + continue; + } + + if (preferredSource === "clawhub") { + result = await installPluginFromClawHub({ + spec: clawhubSpec, + ...(bridge.clawhubUrl ? { baseUrl: bridge.clawhubUrl } : {}), + mode: "update", + expectedPluginId: targetPluginId, + logger, }); + if (!result.ok && npmSpec && shouldFallbackClawHubBridgeToNpm(result)) { + const warning = `ClawHub ${clawhubSpec} unavailable for ${targetPluginId}; falling back to npm ${npmSpec}.`; + summary.warnings.push(warning); + logger.warn?.(warning); + installSource = "npm"; + installSpec = npmSpec; + result = await installPluginFromNpmSpec({ + spec: npmSpec, + mode: "update", + expectedPluginId: targetPluginId, + logger, + }); + } + } else { + result = await installPluginFromNpmSpec({ + spec: npmSpec, + mode: "update", + expectedPluginId: targetPluginId, + logger, + }); + } + + if (!result.ok) { + const message = + installSource === "clawhub" + ? formatClawHubInstallFailure({ + pluginId: targetPluginId, + spec: installSpec, + phase: "update", + error: result.error, + }) + : formatNpmInstallFailure({ + pluginId: targetPluginId, + spec: installSpec, + phase: "update", + result, + }); summary.errors.push(message); logger.error?.(message); continue; @@ -1124,14 +1204,42 @@ export async function syncPluginsForUpdateChannel(params: { next = migratePluginConfigId(next, existing.pluginId, resolvedPluginId); } const nextVersion = result.version ?? (await readInstalledPackageVersion(result.targetDir)); - next = recordPluginInstall(next, { - pluginId: resolvedPluginId, - source: "npm", - spec: bridge.npmSpec, - installPath: result.targetDir, - version: nextVersion, - ...buildNpmResolutionInstallFields(result.npmResolution), - }); + if (installSource === "clawhub") { + const clawhubResult = result as Extract< + Awaited>, + { ok: true } + >; + next = recordPluginInstall(next, { + pluginId: resolvedPluginId, + source: "clawhub", + spec: installSpec, + installPath: result.targetDir, + version: nextVersion, + integrity: clawhubResult.clawhub.integrity, + resolvedAt: clawhubResult.clawhub.resolvedAt, + clawhubUrl: clawhubResult.clawhub.clawhubUrl, + clawhubPackage: clawhubResult.clawhub.clawhubPackage, + clawhubFamily: clawhubResult.clawhub.clawhubFamily, + clawhubChannel: clawhubResult.clawhub.clawhubChannel, + clawpackSha256: clawhubResult.clawhub.clawpackSha256, + clawpackSpecVersion: clawhubResult.clawhub.clawpackSpecVersion, + clawpackManifestSha256: clawhubResult.clawhub.clawpackManifestSha256, + clawpackSize: clawhubResult.clawhub.clawpackSize, + }); + } else { + const npmResult = result as Extract< + Awaited>, + { ok: true } + >; + next = recordPluginInstall(next, { + pluginId: resolvedPluginId, + source: "npm", + spec: installSpec, + installPath: result.targetDir, + version: nextVersion, + ...buildNpmResolutionInstallFields(npmResult.npmResolution), + }); + } installs = next.plugins?.installs ?? {}; if (existing?.record.sourcePath) { loadHelpers.removePath(existing.record.sourcePath); @@ -1140,7 +1248,11 @@ export async function syncPluginsForUpdateChannel(params: { loadHelpers.removePath(existing.record.installPath); } removeBridgeBundledLoadPaths({ bridge, loadPaths: loadHelpers, env }); - summary.switchedToNpm.push(resolvedPluginId); + if (installSource === "clawhub") { + summary.switchedToClawHub.push(resolvedPluginId); + } else { + summary.switchedToNpm.push(resolvedPluginId); + } changed = true; }