fix(plugins): supplement external catalog contracts

This commit is contained in:
Vincent Koc
2026-05-03 16:15:28 -07:00
parent 4419a9de2a
commit b5affa64b3
5 changed files with 165 additions and 3 deletions

View File

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

View File

@@ -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",

View File

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

View File

@@ -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<string, PluginManifestChannelConfig>;
catalogChannelConfigs?: Record<string, PluginManifestChannelConfig>;
}): Record<string, PluginManifestChannelConfig> | undefined {
if (!params.catalogChannelConfigs) {
return params.manifestChannelConfigs;
}
const merged: Record<string, PluginManifestChannelConfig> = 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,

View File

@@ -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<string, PluginManifestChannelConfig>;
};
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,
);
}