mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-25 17:02:46 +00:00
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:
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 () => {});
|
||||
|
||||
Reference in New Issue
Block a user