fix(onboarding): surface official WeCom channel install

This commit is contained in:
Vincent Koc 2026-04-23 00:28:51 -07:00
parent 1f91af17fd
commit ce4bb8f638
No known key found for this signature in database
11 changed files with 149 additions and 34 deletions

View file

@ -582,10 +582,10 @@ plugin on older hosts.
Exact npm version pinning already lives in `npmSpec`, for example
`"npmSpec": "@wecom/wecom-openclaw-plugin@1.2.3"`. Pair that with
`expectedIntegrity` when you want update flows to fail closed if the fetched
npm artifact no longer matches the pinned release. Interactive onboarding only
offers npm install choices from trusted catalog metadata when `npmSpec` is an
exact version and `expectedIntegrity` is present; otherwise it falls back to a
local source or skip.
npm artifact no longer matches the pinned release. Interactive onboarding
offers trusted registry npm specs, including bare package names and dist-tags.
When `expectedIntegrity` is present, install/update flows enforce it; when it
is omitted, the registry resolution is recorded without an integrity pin.
Channel plugins should provide `openclaw.setupEntry` when status, channel list,
or SecretRef scans need to identify configured accounts without loading the full

View file

@ -162,11 +162,11 @@ Interactive onboarding also uses `openclaw.install` for install-on-demand
surfaces. If your plugin exposes provider auth choices or channel setup/catalog
metadata before runtime loads, onboarding can show that choice, prompt for npm
vs local install, install or enable the plugin, then continue the selected
flow. Npm onboarding choices require trusted catalog metadata with an exact
`npmSpec` version and `expectedIntegrity`; unpinned package names and dist-tags
are not offered for automatic onboarding installs. Keep the "what to show"
metadata in `openclaw.plugin.json` and the "how to install it" metadata in
`package.json`.
flow. Npm onboarding choices require trusted catalog metadata with a registry
`npmSpec`; exact versions and `expectedIntegrity` are optional pins. If
`expectedIntegrity` is present, install/update flows enforce it. Keep the "what
to show" metadata in `openclaw.plugin.json` and the "how to install it"
metadata in `package.json`.
If `minHostVersion` is set, install and manifest-registry loading both enforce
it. Older hosts skip the plugin; invalid version strings are rejected.

View file

@ -0,0 +1,27 @@
{
"entries": [
{
"name": "@wecom/wecom-openclaw-plugin",
"description": "OpenClaw WeCom channel plugin by the Tencent WeCom team.",
"source": "external",
"kind": "channel",
"openclaw": {
"channel": {
"id": "wecom",
"label": "WeCom",
"selectionLabel": "WeCom (Enterprise WeChat)",
"detailLabel": "WeCom",
"docsPath": "/plugins/community#wecom",
"docsLabel": "wecom",
"blurb": "Enterprise WeChat bot and conversation channel.",
"aliases": ["qywx", "wework", "enterprise-wechat"],
"order": 45
},
"install": {
"npmSpec": "@wecom/wecom-openclaw-plugin",
"defaultChoice": "npm"
}
}
}
]
}

View file

@ -1,6 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import officialExternalChannelCatalog from "./lib/official-external-channel-catalog.json" with { type: "json" };
import { isRecord, trimString } from "./lib/record-shared.mjs";
import { writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs";
@ -13,9 +14,14 @@ function toCatalogInstall(value, packageName) {
return null;
}
const defaultChoice = trimString(install.defaultChoice);
const minHostVersion = trimString(install.minHostVersion);
const expectedIntegrity = trimString(install.expectedIntegrity);
return {
npmSpec,
...(defaultChoice === "npm" || defaultChoice === "local" ? { defaultChoice } : {}),
...(minHostVersion ? { minHostVersion } : {}),
...(expectedIntegrity ? { expectedIntegrity } : {}),
...(install.allowInvalidConfigRecovery === true ? { allowInvalidConfigRecovery: true } : {}),
};
}
@ -50,7 +56,9 @@ function buildCatalogEntry(packageJson) {
export function buildOfficialChannelCatalog(params = {}) {
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
const extensionsRoot = path.join(repoRoot, "extensions");
const entries = [];
const entries = Array.isArray(officialExternalChannelCatalog.entries)
? [...officialExternalChannelCatalog.entries]
: [];
if (!fs.existsSync(extensionsRoot)) {
return { entries };
}

View file

@ -1,5 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import officialExternalChannelCatalog from "../../../scripts/lib/official-external-channel-catalog.json" with { type: "json" };
import { MANIFEST_KEY } from "../../compat/legacy-names.js";
import { resolveOpenClawPackageRootSync } from "../../infra/openclaw-root.js";
import { listChannelCatalogEntries } from "../../plugins/channel-catalog-registry.js";
@ -162,7 +163,9 @@ function resolveOfficialCatalogPaths(options: CatalogOptions): string[] {
}
function loadOfficialCatalogEntries(options: CatalogOptions): ChannelPluginCatalogEntry[] {
return loadCatalogEntriesFromPaths(resolveOfficialCatalogPaths(options))
const builtInEntries = parseCatalogEntries(officialExternalChannelCatalog);
const fileEntries = loadCatalogEntriesFromPaths(resolveOfficialCatalogPaths(options));
return [...builtInEntries, ...fileEntries]
.map((entry) => buildExternalCatalogEntry(entry))
.filter((entry): entry is ChannelPluginCatalogEntry => Boolean(entry));
}

View file

@ -36,3 +36,9 @@ describeOfficialFallbackChannelCatalogContract({
externalNpmSpec: "@vendor/whatsapp-fork",
externalLabel: "WhatsApp Fork",
});
describeChannelCatalogEntryContract({
channelId: "wecom",
npmSpec: "@wecom/wecom-openclaw-plugin",
alias: "wework",
});

View file

@ -52,7 +52,7 @@ describe("ensureOnboardingPluginInstalled", () => {
withTimeout.mockImplementation(async <T>(promise: Promise<T>) => await promise);
});
it("passes pinned npm specs and expected integrity to npm installs with progress", async () => {
it("passes npm specs and optional expected integrity to npm installs with progress", async () => {
installPluginFromNpmSpec.mockImplementation(async (params) => {
params.logger?.info?.("Downloading demo-plugin…");
return {
@ -137,7 +137,7 @@ describe("ensureOnboardingPluginInstalled", () => {
);
});
it("does not offer npm installs without an exact version and integrity pin", async () => {
it("offers registry npm specs without requiring an exact version or integrity pin", async () => {
let captured:
| {
options: Array<{ value: "npm" | "local" | "skip"; label: string; hint?: string }>;
@ -163,8 +163,11 @@ describe("ensureOnboardingPluginInstalled", () => {
runtime: {} as never,
});
expect(captured?.options).toEqual([{ value: "skip", label: "Skip for now" }]);
expect(captured?.initialValue).toBe("skip");
expect(captured?.options).toEqual([
{ value: "npm", label: "Download from npm (@demo/plugin)" },
{ value: "skip", label: "Skip for now" },
]);
expect(captured?.initialValue).toBe("npm");
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
});

View file

@ -191,14 +191,13 @@ function resolveBundledLocalPath(params: {
);
}
function resolvePinnedNpmSpecForOnboarding(install: PluginPackageInstall): string | null {
function resolveNpmSpecForOnboarding(install: PluginPackageInstall): string | null {
const npmSpec = install.npmSpec?.trim();
const expectedIntegrity = install.expectedIntegrity?.trim();
if (!npmSpec || !expectedIntegrity) {
if (!npmSpec) {
return null;
}
const parsed = parseRegistryNpmSpec(npmSpec);
return parsed?.selectorKind === "exact-version" ? npmSpec : null;
return parsed ? npmSpec : null;
}
function resolveInstallDefaultChoice(params: {
@ -241,7 +240,7 @@ async function promptInstallChoice(params: {
defaultChoice: InstallChoice;
prompter: WizardPrompter;
}): Promise<InstallChoice> {
const npmSpec = resolvePinnedNpmSpecForOnboarding(params.entry.install);
const npmSpec = resolveNpmSpecForOnboarding(params.entry.install);
const safeLabel = sanitizeTerminalText(params.entry.label);
const safeNpmSpec = npmSpec ? sanitizeTerminalText(npmSpec) : null;
const safeLocalPath = params.localPath ? sanitizeTerminalText(params.localPath) : null;
@ -399,7 +398,7 @@ export async function ensureOnboardingPluginInstalled(params: {
workspaceDir,
allowLocal,
});
const npmSpec = resolvePinnedNpmSpecForOnboarding(entry.install);
const npmSpec = resolveNpmSpecForOnboarding(entry.install);
const defaultChoice = resolveInstallDefaultChoice({
cfg: next,
entry,

View file

@ -219,6 +219,60 @@ describe("provider install catalog", () => {
});
});
it("exposes trusted registry npm specs without requiring an exact version or integrity pin", () => {
discoverOpenClawPlugins.mockReturnValue({
candidates: [
{
idHint: "vllm",
origin: "config",
rootDir: "/Users/test/.openclaw/extensions/vllm",
source: "/Users/test/.openclaw/extensions/vllm/index.js",
packageName: "@openclaw/vllm",
packageDir: "/Users/test/.openclaw/extensions/vllm",
packageManifest: {
install: {
npmSpec: "@openclaw/vllm",
},
},
},
],
diagnostics: [],
});
loadPluginManifest.mockReturnValue({
ok: true,
manifestPath: "/Users/test/.openclaw/extensions/vllm/openclaw.plugin.json",
manifest: {
id: "vllm",
configSchema: {
type: "object",
},
},
});
resolveManifestProviderAuthChoices.mockReturnValue([
{
pluginId: "vllm",
providerId: "vllm",
methodId: "server",
choiceId: "vllm",
choiceLabel: "vLLM",
},
]);
expect(resolveProviderInstallCatalogEntry("vllm")).toEqual({
pluginId: "vllm",
providerId: "vllm",
methodId: "server",
choiceId: "vllm",
choiceLabel: "vLLM",
label: "vLLM",
origin: "config",
install: {
npmSpec: "@openclaw/vllm",
defaultChoice: "npm",
},
});
});
it("does not expose npm install specs from untrusted package metadata", () => {
discoverOpenClawPlugins.mockReturnValue({
candidates: [

View file

@ -53,7 +53,7 @@ function resolvePluginManifest(
return manifest.ok ? manifest : null;
}
function resolveTrustedPinnedNpmSpec(params: {
function resolveTrustedNpmSpec(params: {
origin: PluginOrigin;
install?: PluginPackageInstall;
}): string | undefined {
@ -61,12 +61,11 @@ function resolveTrustedPinnedNpmSpec(params: {
return undefined;
}
const npmSpec = params.install?.npmSpec?.trim();
const expectedIntegrity = params.install?.expectedIntegrity?.trim();
if (!npmSpec || !expectedIntegrity) {
if (!npmSpec) {
return undefined;
}
const parsed = parseRegistryNpmSpec(npmSpec);
return parsed?.selectorKind === "exact-version" ? npmSpec : undefined;
return parsed ? npmSpec : undefined;
}
function resolveInstallInfo(params: {
@ -75,7 +74,7 @@ function resolveInstallInfo(params: {
packageDir?: string;
workspaceDir?: string;
}): PluginPackageInstall | null {
const npmSpec = resolveTrustedPinnedNpmSpec({
const npmSpec = resolveTrustedNpmSpec({
origin: params.origin,
install: params.install,
});

View file

@ -68,8 +68,21 @@ describe("buildOfficialChannelCatalog", () => {
},
});
expect(buildOfficialChannelCatalog({ repoRoot })).toEqual({
entries: [
expect(buildOfficialChannelCatalog({ repoRoot }).entries).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "@wecom/wecom-openclaw-plugin",
openclaw: expect.objectContaining({
channel: expect.objectContaining({
id: "wecom",
label: "WeCom",
}),
install: {
npmSpec: "@wecom/wecom-openclaw-plugin",
defaultChoice: "npm",
},
}),
}),
{
name: "@openclaw/whatsapp",
version: "2026.3.23",
@ -89,8 +102,8 @@ describe("buildOfficialChannelCatalog", () => {
},
},
},
],
});
]),
);
});
it("writes the official catalog under dist", () => {
@ -118,8 +131,11 @@ describe("buildOfficialChannelCatalog", () => {
const outputPath = path.join(repoRoot, OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH);
expect(fs.existsSync(outputPath)).toBe(true);
expect(JSON.parse(fs.readFileSync(outputPath, "utf8"))).toEqual({
entries: [
expect(JSON.parse(fs.readFileSync(outputPath, "utf8")).entries).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "@wecom/wecom-openclaw-plugin",
}),
{
name: "@openclaw/whatsapp",
openclaw: {
@ -135,7 +151,7 @@ describe("buildOfficialChannelCatalog", () => {
},
},
},
],
});
]),
);
});
});