diff --git a/CHANGELOG.md b/CHANGELOG.md index 6732ebbae5d..f4b2d70bcd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Plugins/catalog: supplement lagging official external WeCom and Yuanbao npm manifests with channel config descriptors and declared tool contracts from the OpenClaw catalog, so trusted package sweeps no longer fail because external package metadata trails the host contract. Thanks @vincentkoc. - Plugins/install: let trusted official `@openclaw/*` catalog installs recover when npm `latest` points at a prerelease by falling back to the newest stable version, or by allowing prerelease-only launch packages with a warning instead of making beta/development plugin sweeps fail at install time. Thanks @vincentkoc. - Google Meet: use the local call-control microphone button instead of disabled remote participant mute buttons, and block realtime speech when the OpenClaw Meet microphone remains muted. - Google Meet: refresh realtime browser state during status and retry delayed speech after Meet finishes joining, so a just-opened in-call tab no longer leaves speech stuck behind stale `not-in-call` health. diff --git a/scripts/lib/official-external-channel-catalog.json b/scripts/lib/official-external-channel-catalog.json index fbd530304fc..ef34e291507 100644 --- a/scripts/lib/official-external-channel-catalog.json +++ b/scripts/lib/official-external-channel-catalog.json @@ -10,6 +10,9 @@ "id": "wecom-openclaw-plugin", "label": "WeCom" }, + "contracts": { + "tools": ["wecom_mcp"] + }, "channel": { "id": "wecom", "label": "WeCom", @@ -21,6 +24,16 @@ "aliases": ["qywx", "wework", "enterprise-wechat"], "order": 45 }, + "channelConfigs": { + "wecom": { + "label": "WeCom", + "description": "Enterprise WeChat conversation channel.", + "schema": { + "type": "object", + "additionalProperties": true + } + } + }, "install": { "npmSpec": "@wecom/wecom-openclaw-plugin@2026.4.23", "defaultChoice": "npm", @@ -38,6 +51,9 @@ "id": "openclaw-plugin-yuanbao", "label": "Yuanbao" }, + "contracts": { + "tools": ["query_group_info", "query_session_members", "yuanbao_remind"] + }, "channel": { "id": "yuanbao", "label": "Yuanbao", @@ -49,6 +65,16 @@ "aliases": ["yuanbao", "yb", "tencent-yuanbao", "元宝"], "order": 85 }, + "channelConfigs": { + "yuanbao": { + "label": "Yuanbao", + "description": "Tencent Yuanbao AI assistant channel.", + "schema": { + "type": "object", + "additionalProperties": true + } + } + }, "install": { "npmSpec": "openclaw-plugin-yuanbao@2.11.0", "defaultChoice": "npm", diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 40a13f4fc3b..9abcc19dec5 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -62,6 +62,7 @@ function createPluginCandidate(params: { origin: "bundled" | "global" | "workspace" | "config"; format?: "openclaw" | "bundle"; bundleFormat?: "codex" | "claude" | "cursor"; + packageName?: string; packageManifest?: OpenClawPackageManifest; packageDir?: string; bundledManifest?: PluginCandidate["bundledManifest"]; @@ -74,6 +75,7 @@ function createPluginCandidate(params: { origin: params.origin, format: params.format, bundleFormat: params.bundleFormat, + packageName: params.packageName, packageManifest: params.packageManifest, packageDir: params.packageDir, bundledManifest: params.bundledManifest, @@ -1098,6 +1100,39 @@ describe("loadPluginManifestRegistry", () => { ).toBe(false); }); + it("hydrates supplemental official external catalog contracts for lagging npm manifests", () => { + const dir = makeTempDir(); + writeManifest(dir, { + id: "wecom-openclaw-plugin", + channels: ["wecom"], + configSchema: { type: "object" }, + }); + + const registry = loadRegistry([ + createPluginCandidate({ + idHint: "wecom-openclaw-plugin", + rootDir: dir, + origin: "global", + packageName: "@wecom/wecom-openclaw-plugin", + }), + ]); + + expect(registry.plugins[0]?.contracts?.tools).toEqual(["wecom_mcp"]); + expect(registry.plugins[0]?.channelConfigs?.wecom).toEqual( + expect.objectContaining({ + label: "WeCom", + schema: expect.objectContaining({ + type: "object", + }), + }), + ); + expect( + registry.diagnostics.some((diagnostic) => + diagnostic.message.includes("without channelConfigs metadata"), + ), + ).toBe(false); + }); + it("drops prototype-polluting channel config keys from plugin manifests", () => { const dir = makeTempDir(); writeTextFile( diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index f77a2ed8434..d0a6676a66a 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -43,6 +43,10 @@ import { type PluginPackageInstall, } from "./manifest.js"; import { checkMinHostVersion } from "./min-host-version.js"; +import { + getOfficialExternalPluginCatalogEntryForPackage, + getOfficialExternalPluginCatalogManifest, +} from "./official-external-plugin-catalog.js"; import { isPathInside, safeRealpathSync } from "./path-safety.js"; import type { PluginKind } from "./plugin-kind.types.js"; import type { PluginOrigin } from "./plugin-origin.types.js"; @@ -258,6 +262,72 @@ function mergePackageChannelMetaIntoChannelConfigs(params: { return merged; } +function mergeContractLists( + left: readonly string[] | undefined, + right: readonly string[] | undefined, +): string[] | undefined { + const merged = [...(left ?? []), ...(right ?? [])] + .map((value) => value.trim()) + .filter((value, index, all) => value.length > 0 && all.indexOf(value) === index); + return merged.length > 0 ? merged : undefined; +} + +function mergeManifestContracts( + manifestContracts: PluginManifestContracts | undefined, + catalogContracts: PluginManifestContracts | undefined, +): PluginManifestContracts | undefined { + if (!catalogContracts) { + return manifestContracts; + } + const contracts: PluginManifestContracts = {}; + for (const key of [ + "embeddedExtensionFactories", + "agentToolResultMiddleware", + "externalAuthProviders", + "memoryEmbeddingProviders", + "speechProviders", + "realtimeTranscriptionProviders", + "realtimeVoiceProviders", + "mediaUnderstandingProviders", + "documentExtractors", + "imageGenerationProviders", + "videoGenerationProviders", + "musicGenerationProviders", + "webContentExtractors", + "webFetchProviders", + "webSearchProviders", + "migrationProviders", + "tools", + ] as const) { + const merged = mergeContractLists(manifestContracts?.[key], catalogContracts[key]); + if (merged) { + contracts[key] = merged; + } + } + return Object.keys(contracts).length > 0 ? contracts : undefined; +} + +function mergeCatalogChannelConfigs(params: { + manifestChannelConfigs?: Record; + catalogChannelConfigs?: Record; +}): Record | undefined { + if (!params.catalogChannelConfigs) { + return params.manifestChannelConfigs; + } + const merged: Record = Object.create(null); + for (const [key, value] of Object.entries(params.catalogChannelConfigs)) { + if (!isBlockedObjectKey(key)) { + merged[key] = value; + } + } + for (const [key, value] of Object.entries(params.manifestChannelConfigs ?? {})) { + if (!isBlockedObjectKey(key)) { + merged[key] = value; + } + } + return Object.keys(merged).length > 0 ? merged : undefined; +} + function buildRecord(params: { manifest: PluginManifest; candidate: PluginCandidate; @@ -274,8 +344,17 @@ function buildRecord(params: { packageManifest: params.candidate.packageManifest, }) : params.manifest.channelConfigs; + const officialCatalogManifest = + params.candidate.origin !== "bundled" + ? getOfficialExternalPluginCatalogManifest( + getOfficialExternalPluginCatalogEntryForPackage(params.candidate.packageName) ?? {}, + ) + : undefined; const channelConfigs = mergePackageChannelMetaIntoChannelConfigs({ - channelConfigs: manifestChannelConfigs, + channelConfigs: mergeCatalogChannelConfigs({ + manifestChannelConfigs, + catalogChannelConfigs: officialCatalogManifest?.channelConfigs, + }), packageChannel: params.candidate.packageManifest?.channel, }); const packageChannelCommands = normalizePackageChannelCommands( @@ -341,7 +420,10 @@ function buildRecord(params: { schemaCacheKey: params.schemaCacheKey, configSchema: params.configSchema, configUiHints: params.manifest.uiHints, - contracts: params.manifest.contracts, + contracts: mergeManifestContracts( + params.manifest.contracts, + officialCatalogManifest?.contracts, + ), mediaUnderstandingProviderMetadata: params.manifest.mediaUnderstandingProviderMetadata, imageGenerationProviderMetadata: params.manifest.imageGenerationProviderMetadata, videoGenerationProviderMetadata: params.manifest.videoGenerationProviderMetadata, diff --git a/src/plugins/official-external-plugin-catalog.ts b/src/plugins/official-external-plugin-catalog.ts index 00aca019ed3..34f73806436 100644 --- a/src/plugins/official-external-plugin-catalog.ts +++ b/src/plugins/official-external-plugin-catalog.ts @@ -4,7 +4,11 @@ import officialExternalProviderCatalog from "../../scripts/lib/official-external import { MANIFEST_KEY } from "../compat/legacy-names.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { isRecord } from "../utils.js"; -import type { PluginPackageInstall } from "./manifest.js"; +import type { + PluginManifestChannelConfig, + PluginManifestContracts, + PluginPackageInstall, +} from "./manifest.js"; type ManifestKey = typeof MANIFEST_KEY; @@ -60,6 +64,8 @@ export type OfficialExternalPluginCatalogManifest = { providers?: readonly OfficialExternalProviderCatalogProvider[]; webSearchProviders?: readonly OfficialExternalWebSearchProvider[]; install?: PluginPackageInstall; + contracts?: PluginManifestContracts; + channelConfigs?: Record; }; export type OfficialExternalPluginCatalogEntry = { @@ -198,3 +204,15 @@ export function getOfficialExternalPluginCatalogEntry( resolveOfficialExternalPluginLookupIds(entry).includes(normalized), ); } + +export function getOfficialExternalPluginCatalogEntryForPackage( + packageName: string | undefined, +): OfficialExternalPluginCatalogEntry | undefined { + const normalized = packageName?.trim(); + if (!normalized) { + return undefined; + } + return listOfficialExternalPluginCatalogEntries().find( + (entry) => normalizeOptionalString(entry.name) === normalized, + ); +}