feat(plugins): prefer clawhub for bundled cutovers

This commit is contained in:
Vincent Koc
2026-05-01 18:12:42 -07:00
parent 30ea49268c
commit 0c6c1cac76
5 changed files with 400 additions and 25 deletions

View File

@@ -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:<name>` across channel auth paths. (#75813)

View File

@@ -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 } : {}),

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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<ReturnType<typeof installPluginFromNpmSpec>>
| Awaited<ReturnType<typeof installPluginFromClawHub>>;
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<ReturnType<typeof installPluginFromClawHub>>,
{ 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<ReturnType<typeof installPluginFromNpmSpec>>,
{ 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;
}