fix(channels): preserve external catalog overrides (#52988)

* fix(channels): preserve external catalog overrides

* fix(channels): clarify catalog precedence

* fix(channels): respect overridden install specs
This commit is contained in:
Nimrod Gutman 2026-03-23 18:08:17 +02:00 committed by GitHub
parent 29ad211e76
commit 041c47419f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 260 additions and 16 deletions

View File

@ -52,6 +52,9 @@ const ORIGIN_PRIORITY: Record<PluginOrigin, number> = {
bundled: 3,
};
const EXTERNAL_CATALOG_PRIORITY = ORIGIN_PRIORITY.bundled + 1;
const FALLBACK_CATALOG_PRIORITY = EXTERNAL_CATALOG_PRIORITY + 1;
type ExternalCatalogEntry = {
name?: string;
version?: string;
@ -149,12 +152,10 @@ function resolveOfficialCatalogPaths(options: CatalogOptions): string[] {
path.join(packageRoot, OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH),
);
try {
if (process.execPath) {
const execDir = path.dirname(process.execPath);
candidates.push(path.join(execDir, OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH));
candidates.push(path.join(execDir, "channel-catalog.json"));
} catch {
// ignore
}
return candidates.filter((entry, index, all) => entry && all.indexOf(entry) === index);
@ -393,7 +394,7 @@ export function listChannelPluginCatalogEntries(
}
for (const entry of loadBundledMetadataCatalogEntries(options)) {
const priority = ORIGIN_PRIORITY.bundled ?? 99;
const priority = FALLBACK_CATALOG_PRIORITY;
const existing = resolved.get(entry.id);
if (!existing || priority < existing.priority) {
resolved.set(entry.id, { entry, priority });
@ -401,7 +402,7 @@ export function listChannelPluginCatalogEntries(
}
for (const entry of loadOfficialCatalogEntries(options)) {
const priority = ORIGIN_PRIORITY.bundled ?? 99;
const priority = FALLBACK_CATALOG_PRIORITY;
const existing = resolved.get(entry.id);
if (!existing || priority < existing.priority) {
resolved.set(entry.id, { entry, priority });
@ -412,8 +413,12 @@ export function listChannelPluginCatalogEntries(
.map((entry) => buildExternalCatalogEntry(entry))
.filter((entry): entry is ChannelPluginCatalogEntry => Boolean(entry));
for (const entry of externalEntries) {
if (!resolved.has(entry.id)) {
resolved.set(entry.id, { entry, priority: 99 });
// External catalogs are the supported override seam for shipped fallback
// metadata, but discovered plugins should still win when they are present.
const priority = EXTERNAL_CATALOG_PRIORITY;
const existing = resolved.get(entry.id);
if (!existing || priority < existing.priority) {
resolved.set(entry.id, { entry, priority });
}
}

View File

@ -365,6 +365,165 @@ describe("channel plugin catalog", () => {
expect(entry?.install.npmSpec).toBe("@openclaw/whatsapp");
expect(entry?.pluginId).toBeUndefined();
});
it("lets external catalogs override shipped fallback channel metadata", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-fallback-catalog-"));
const bundledDir = path.join(dir, "dist", "extensions", "whatsapp");
const officialCatalogPath = path.join(dir, "channel-catalog.json");
const externalCatalogPath = path.join(dir, "catalog.json");
fs.mkdirSync(bundledDir, { recursive: true });
fs.writeFileSync(
path.join(bundledDir, "package.json"),
JSON.stringify({
name: "@openclaw/whatsapp",
openclaw: {
channel: {
id: "whatsapp",
label: "WhatsApp Bundled",
selectionLabel: "WhatsApp Bundled",
docsPath: "/channels/whatsapp",
blurb: "bundled fallback",
},
install: {
npmSpec: "@openclaw/whatsapp",
},
},
}),
"utf8",
);
fs.writeFileSync(
officialCatalogPath,
JSON.stringify({
entries: [
{
name: "@openclaw/whatsapp",
openclaw: {
channel: {
id: "whatsapp",
label: "WhatsApp Official",
selectionLabel: "WhatsApp Official",
docsPath: "/channels/whatsapp",
blurb: "official fallback",
},
install: {
npmSpec: "@openclaw/whatsapp",
},
},
},
],
}),
"utf8",
);
fs.writeFileSync(
externalCatalogPath,
JSON.stringify({
entries: [
{
name: "@vendor/whatsapp-fork",
openclaw: {
channel: {
id: "whatsapp",
label: "WhatsApp Fork",
selectionLabel: "WhatsApp Fork",
docsPath: "/channels/whatsapp",
blurb: "external override",
},
install: {
npmSpec: "@vendor/whatsapp-fork",
},
},
},
],
}),
"utf8",
);
const entry = listChannelPluginCatalogEntries({
catalogPaths: [externalCatalogPath],
officialCatalogPaths: [officialCatalogPath],
env: {
...process.env,
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(dir, "dist", "extensions"),
},
}).find((item) => item.id === "whatsapp");
expect(entry?.install.npmSpec).toBe("@vendor/whatsapp-fork");
expect(entry?.meta.label).toBe("WhatsApp Fork");
expect(entry?.pluginId).toBeUndefined();
});
it("keeps discovered plugins ahead of external catalog overrides", () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-state-"));
const pluginDir = path.join(stateDir, "extensions", "demo-channel-plugin");
const catalogPath = path.join(stateDir, "catalog.json");
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "@vendor/demo-channel-plugin",
openclaw: {
extensions: ["./index.js"],
channel: {
id: "demo-channel",
label: "Demo Channel Runtime",
selectionLabel: "Demo Channel Runtime",
docsPath: "/channels/demo-channel",
blurb: "discovered plugin",
},
install: {
npmSpec: "@vendor/demo-channel-plugin",
},
},
}),
"utf8",
);
fs.writeFileSync(
path.join(pluginDir, "openclaw.plugin.json"),
JSON.stringify({
id: "@vendor/demo-channel-runtime",
configSchema: {},
}),
"utf8",
);
fs.writeFileSync(path.join(pluginDir, "index.js"), "module.exports = {}", "utf8");
fs.writeFileSync(
catalogPath,
JSON.stringify({
entries: [
{
name: "@vendor/demo-channel-catalog",
openclaw: {
channel: {
id: "demo-channel",
label: "Demo Channel Catalog",
selectionLabel: "Demo Channel Catalog",
docsPath: "/channels/demo-channel",
blurb: "external catalog",
},
install: {
npmSpec: "@vendor/demo-channel-catalog",
},
},
},
],
}),
"utf8",
);
const entry = listChannelPluginCatalogEntries({
catalogPaths: [catalogPath],
env: {
...process.env,
OPENCLAW_STATE_DIR: stateDir,
CLAWDBOT_STATE_DIR: undefined,
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
},
}).find((item) => item.id === "demo-channel");
expect(entry?.install.npmSpec).toBe("@vendor/demo-channel-plugin");
expect(entry?.meta.label).toBe("Demo Channel Runtime");
expect(entry?.pluginId).toBe("@vendor/demo-channel-runtime");
});
});
const emptyRegistry = createTestRegistry([]);

View File

@ -82,6 +82,29 @@ describe("plugin install plan helpers", () => {
expect(result).toBeNull();
});
it("rejects plugin-id bundled matches when the catalog npm spec was overridden", () => {
const findBundledSource = vi
.fn()
.mockImplementation(({ kind }: { kind: "pluginId" | "npmSpec"; value: string }) => {
if (kind === "pluginId") {
return {
pluginId: "whatsapp",
localPath: "/tmp/extensions/whatsapp",
npmSpec: "@openclaw/whatsapp",
};
}
return undefined;
});
const result = resolveBundledInstallPlanForCatalogEntry({
pluginId: "whatsapp",
npmSpec: "@vendor/whatsapp-fork",
findBundledSource,
});
expect(result).toBeNull();
});
it("uses npm-spec bundled fallback only for package-not-found", () => {
const findBundledSource = vi.fn().mockReturnValue({
pluginId: "voice-call",

View File

@ -23,14 +23,6 @@ export function resolveBundledInstallPlanForCatalogEntry(params: {
return null;
}
const bundledById = params.findBundledSource({
kind: "pluginId",
value: pluginId,
});
if (bundledById?.pluginId === pluginId) {
return { bundledSource: bundledById };
}
const bundledBySpec = params.findBundledSource({
kind: "npmSpec",
value: npmSpec,
@ -39,7 +31,18 @@ export function resolveBundledInstallPlanForCatalogEntry(params: {
return { bundledSource: bundledBySpec };
}
return null;
const bundledById = params.findBundledSource({
kind: "pluginId",
value: pluginId,
});
if (bundledById?.pluginId !== pluginId) {
return null;
}
if (bundledById.npmSpec && bundledById.npmSpec !== npmSpec) {
return null;
}
return { bundledSource: bundledById };
}
export function resolveBundledInstallPlanBeforeNpm(params: {

View File

@ -248,6 +248,60 @@ describe("ensureChannelSetupPluginInstalled", () => {
);
});
it("does not default to bundled local path when an external catalog overrides the npm spec", async () => {
const runtime = makeRuntime();
const select = vi.fn((async <T extends string>() => "skip" as T) as WizardPrompter["select"]);
const prompter = makePrompter({ select: select as unknown as WizardPrompter["select"] });
const cfg: OpenClawConfig = { update: { channel: "beta" } };
vi.mocked(fs.existsSync).mockReturnValue(false);
resolveBundledPluginSources.mockReturnValue(
new Map([
[
"whatsapp",
{
pluginId: "whatsapp",
localPath: "/opt/openclaw/extensions/whatsapp",
npmSpec: "@openclaw/whatsapp",
},
],
]),
);
await ensureChannelSetupPluginInstalled({
cfg,
entry: {
id: "whatsapp",
meta: {
id: "whatsapp",
label: "WhatsApp",
selectionLabel: "WhatsApp",
docsPath: "/channels/whatsapp",
blurb: "Test",
},
install: {
npmSpec: "@vendor/whatsapp-fork",
},
},
prompter,
runtime,
});
expect(select).toHaveBeenCalledWith(
expect.objectContaining({
initialValue: "npm",
options: [
expect.objectContaining({
value: "npm",
label: "Download from npm (@vendor/whatsapp-fork)",
}),
expect.objectContaining({
value: "skip",
}),
],
}),
);
});
it("falls back to local path after npm install failure", async () => {
const runtime = makeRuntime();
const note = vi.fn(async () => {});