From 041c47419f5a821fd4adcd46dfc7d85a7eda340e Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Mon, 23 Mar 2026 18:08:17 +0200 Subject: [PATCH] fix(channels): preserve external catalog overrides (#52988) * fix(channels): preserve external catalog overrides * fix(channels): clarify catalog precedence * fix(channels): respect overridden install specs --- src/channels/plugins/catalog.ts | 19 ++- src/channels/plugins/plugins-core.test.ts | 159 ++++++++++++++++++ src/cli/plugin-install-plan.test.ts | 23 +++ src/cli/plugin-install-plan.ts | 21 ++- .../channel-setup/plugin-install.test.ts | 54 ++++++ 5 files changed, 260 insertions(+), 16 deletions(-) diff --git a/src/channels/plugins/catalog.ts b/src/channels/plugins/catalog.ts index 70a29d37311..08d3eda3ab1 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -52,6 +52,9 @@ const ORIGIN_PRIORITY: Record = { 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 }); } } diff --git a/src/channels/plugins/plugins-core.test.ts b/src/channels/plugins/plugins-core.test.ts index 8d8b0b40c9d..d06d03caf5b 100644 --- a/src/channels/plugins/plugins-core.test.ts +++ b/src/channels/plugins/plugins-core.test.ts @@ -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([]); diff --git a/src/cli/plugin-install-plan.test.ts b/src/cli/plugin-install-plan.test.ts index 9aca36493d0..7d0396dc199 100644 --- a/src/cli/plugin-install-plan.test.ts +++ b/src/cli/plugin-install-plan.test.ts @@ -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", diff --git a/src/cli/plugin-install-plan.ts b/src/cli/plugin-install-plan.ts index 6c2467c15b7..22dafa3f820 100644 --- a/src/cli/plugin-install-plan.ts +++ b/src/cli/plugin-install-plan.ts @@ -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: { diff --git a/src/commands/channel-setup/plugin-install.test.ts b/src/commands/channel-setup/plugin-install.test.ts index 88c70bc26ef..137609bc9d1 100644 --- a/src/commands/channel-setup/plugin-install.test.ts +++ b/src/commands/channel-setup/plugin-install.test.ts @@ -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 () => "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 () => {});