fix(plugins): emit actionable install hint for externalized channel plugins (#77502)

Fixes #77483.\n\n- Suggest catalog-backed install commands for missing official external plugins in config validation.\n- Preserve stale/remove wording for non-catalog missing plugins.\n- Add regression coverage for plugins.entries and plugins.allow warnings.\n\nVerification:\n- pnpm exec oxfmt --check --threads=1 CHANGELOG.md src/config/validation.ts src/config/config.plugin-validation.test.ts\n- pnpm test src/config/config.plugin-validation.test.ts src/commands/doctor/shared/missing-configured-plugin-install.test.ts\n- pnpm crabbox:run -- --provider blacksmith-testbox ... pnpm check:changed\n- GitHub CI green on d1b1b10444
This commit is contained in:
hcl
2026-05-05 06:22:15 +08:00
committed by GitHub
parent 14aa98827a
commit b3e42bf327
3 changed files with 70 additions and 1 deletions

View File

@@ -253,6 +253,44 @@ describe("config plugin validation", () => {
}
});
it("reports catalog install hints for missing configured official external plugins", async () => {
const res = validateConfigObjectWithPlugins(
{
agents: { list: [{ id: "pi" }] },
plugins: {
entries: { brave: { enabled: true } },
allow: ["brave"],
},
},
{
env: suiteEnv(),
pluginMetadataSnapshot: {
manifestRegistry: {
plugins: [],
diagnostics: [],
},
},
},
);
expect(res.ok).toBe(true);
const message =
"plugin not installed: brave — install the official external plugin with: openclaw plugins install @openclaw/brave-plugin";
expect(res.warnings ?? []).toEqual(
expect.arrayContaining([
{ path: "plugins.entries.brave", message },
{ path: "plugins.allow", message },
]),
);
expect(
(res.warnings ?? []).some(
(warning) =>
(warning.path === "plugins.entries.brave" || warning.path === "plugins.allow") &&
warning.message.includes("remove it from plugins config"),
),
).toBe(false);
});
it.runIf(process.platform !== "win32")(
"reports configured blocked plugins without stale not-found wording",
async () => {
@@ -493,7 +531,7 @@ describe("config plugin validation", () => {
expect(res.warnings ?? []).toContainEqual({
path: "plugins.allow",
message:
"plugin not found: discord (stale config entry ignored; remove it from plugins config)",
"plugin not installed: discord — install the official external plugin with: openclaw plugins install @openclaw/discord",
});
});

View File

@@ -12,6 +12,10 @@ import {
import { loadInstalledPluginIndexInstallRecordsSync } from "../plugins/installed-plugin-index-record-reader.js";
import { resolveManifestCommandAliasOwnerInRegistry } from "../plugins/manifest-command-aliases.js";
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
import {
getOfficialExternalPluginCatalogEntry,
resolveOfficialExternalPluginInstall,
} from "../plugins/official-external-plugin-catalog.js";
import {
loadPluginMetadataSnapshot,
type PluginMetadataSnapshot,
@@ -93,6 +97,22 @@ function formatConfigPath(segments: readonly ConfigPathSegment[]): string {
return segments.join(".");
}
function formatMissingOfficialExternalPluginWarning(pluginId: string): string | null {
const catalogEntry = getOfficialExternalPluginCatalogEntry(pluginId);
if (!catalogEntry) {
return null;
}
const install = resolveOfficialExternalPluginInstall(catalogEntry);
const npmSpec = install?.npmSpec?.trim();
const clawhubSpec = install?.clawhubSpec?.trim();
const installSpec =
install?.defaultChoice === "clawhub" ? (clawhubSpec ?? npmSpec) : (npmSpec ?? clawhubSpec);
if (!installSpec) {
return null;
}
return `plugin not installed: ${pluginId} — install the official external plugin with: openclaw plugins install ${installSpec}`;
}
function asJsonSchemaLike(value: unknown): JsonSchemaLike | null {
return value && typeof value === "object" ? (value as JsonSchemaLike) : null;
}
@@ -1441,6 +1461,16 @@ function validateConfigObjectWithPluginsBase(
}
return;
}
if (opts?.warnOnly) {
const externalInstallWarning = formatMissingOfficialExternalPluginWarning(pluginId);
if (externalInstallWarning) {
warnings.push({
path,
message: externalInstallWarning,
});
return;
}
}
if (opts?.warnOnly) {
warnings.push({
path,