Skip to content

Commit bcbfbb8

Browse files
authored
Plugins: fail fast on channel and binding collisions (openclaw#45628)
* Plugins: reject duplicate channel ids * Bindings: reject duplicate adapter registration * Plugins: fail on export id mismatch
1 parent 27e863c commit bcbfbb8

File tree

5 files changed

+140
-11
lines changed

5 files changed

+140
-11
lines changed

src/infra/outbound/session-binding-service.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,4 +198,24 @@ describe("session binding service", () => {
198198
placements: [],
199199
});
200200
});
201+
202+
it("rejects duplicate adapter registration for the same channel account", () => {
203+
registerSessionBindingAdapter({
204+
channel: "discord",
205+
accountId: "default",
206+
bind: async (input) => createRecord(input),
207+
listBySession: () => [],
208+
resolveByConversation: () => null,
209+
});
210+
211+
expect(() =>
212+
registerSessionBindingAdapter({
213+
channel: "Discord",
214+
accountId: "DEFAULT",
215+
bind: async (input) => createRecord(input),
216+
listBySession: () => [],
217+
resolveByConversation: () => null,
218+
}),
219+
).toThrow("Session binding adapter already registered for discord:default");
220+
});
201221
});

src/infra/outbound/session-binding-service.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -148,15 +148,22 @@ function resolveAdapterCapabilities(
148148
const ADAPTERS_BY_CHANNEL_ACCOUNT = new Map<string, SessionBindingAdapter>();
149149

150150
export function registerSessionBindingAdapter(adapter: SessionBindingAdapter): void {
151-
const key = toAdapterKey({
152-
channel: adapter.channel,
153-
accountId: adapter.accountId,
154-
});
155-
ADAPTERS_BY_CHANNEL_ACCOUNT.set(key, {
151+
const normalizedAdapter = {
156152
...adapter,
157153
channel: adapter.channel.trim().toLowerCase(),
158154
accountId: normalizeAccountId(adapter.accountId),
155+
};
156+
const key = toAdapterKey({
157+
channel: normalizedAdapter.channel,
158+
accountId: normalizedAdapter.accountId,
159159
});
160+
const existing = ADAPTERS_BY_CHANNEL_ACCOUNT.get(key);
161+
if (existing && existing !== adapter) {
162+
throw new Error(
163+
`Session binding adapter already registered for ${normalizedAdapter.channel}:${normalizedAdapter.accountId}`,
164+
);
165+
}
166+
ADAPTERS_BY_CHANNEL_ACCOUNT.set(key, normalizedAdapter);
160167
}
161168

162169
export function unregisterSessionBindingAdapter(params: {

src/plugins/loader.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,37 @@ describe("loadOpenClawPlugins", () => {
825825
expect(registry.diagnostics.some((d) => d.level === "error")).toBe(true);
826826
});
827827

828+
it("fails when plugin export id mismatches manifest id", () => {
829+
useNoBundledPlugins();
830+
const plugin = writePlugin({
831+
id: "manifest-id",
832+
filename: "manifest-id.cjs",
833+
body: `module.exports = { id: "export-id", register() {} };`,
834+
});
835+
836+
const registry = loadRegistryFromSinglePlugin({
837+
plugin,
838+
pluginConfig: {
839+
allow: ["manifest-id"],
840+
},
841+
});
842+
843+
const loaded = registry.plugins.find((entry) => entry.id === "manifest-id");
844+
expect(loaded?.status).toBe("error");
845+
expect(loaded?.error).toBe(
846+
'plugin id mismatch (config uses "manifest-id", export uses "export-id")',
847+
);
848+
expect(
849+
registry.diagnostics.some(
850+
(entry) =>
851+
entry.level === "error" &&
852+
entry.pluginId === "manifest-id" &&
853+
entry.message ===
854+
'plugin id mismatch (config uses "manifest-id", export uses "export-id")',
855+
),
856+
).toBe(true);
857+
});
858+
828859
it("registers channel plugins", () => {
829860
useNoBundledPlugins();
830861
const plugin = writePlugin({
@@ -863,6 +894,69 @@ describe("loadOpenClawPlugins", () => {
863894
expect(channel).toBeDefined();
864895
});
865896

897+
it("rejects duplicate channel ids during plugin registration", () => {
898+
useNoBundledPlugins();
899+
const plugin = writePlugin({
900+
id: "channel-dup",
901+
filename: "channel-dup.cjs",
902+
body: `module.exports = { id: "channel-dup", register(api) {
903+
api.registerChannel({
904+
plugin: {
905+
id: "demo",
906+
meta: {
907+
id: "demo",
908+
label: "Demo Override",
909+
selectionLabel: "Demo Override",
910+
docsPath: "/channels/demo-override",
911+
blurb: "override"
912+
},
913+
capabilities: { chatTypes: ["direct"] },
914+
config: {
915+
listAccountIds: () => [],
916+
resolveAccount: () => ({ accountId: "default" })
917+
},
918+
outbound: { deliveryMode: "direct" }
919+
}
920+
});
921+
api.registerChannel({
922+
plugin: {
923+
id: "demo",
924+
meta: {
925+
id: "demo",
926+
label: "Demo Duplicate",
927+
selectionLabel: "Demo Duplicate",
928+
docsPath: "/channels/demo-duplicate",
929+
blurb: "duplicate"
930+
},
931+
capabilities: { chatTypes: ["direct"] },
932+
config: {
933+
listAccountIds: () => [],
934+
resolveAccount: () => ({ accountId: "default" })
935+
},
936+
outbound: { deliveryMode: "direct" }
937+
}
938+
});
939+
} };`,
940+
});
941+
942+
const registry = loadRegistryFromSinglePlugin({
943+
plugin,
944+
pluginConfig: {
945+
allow: ["channel-dup"],
946+
},
947+
});
948+
949+
expect(registry.channels.filter((entry) => entry.plugin.id === "demo")).toHaveLength(1);
950+
expect(
951+
registry.diagnostics.some(
952+
(entry) =>
953+
entry.level === "error" &&
954+
entry.pluginId === "channel-dup" &&
955+
entry.message === "channel already registered: demo (channel-dup)",
956+
),
957+
).toBe(true);
958+
});
959+
866960
it("registers http routes with auth and match options", () => {
867961
useNoBundledPlugins();
868962
const plugin = writePlugin({

src/plugins/loader.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -779,12 +779,10 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
779779
const register = resolved.register;
780780

781781
if (definition?.id && definition.id !== record.id) {
782-
registry.diagnostics.push({
783-
level: "warn",
784-
pluginId: record.id,
785-
source: record.source,
786-
message: `plugin id mismatch (config uses "${record.id}", export uses "${definition.id}")`,
787-
});
782+
pushPluginLoadError(
783+
`plugin id mismatch (config uses "${record.id}", export uses "${definition.id}")`,
784+
);
785+
continue;
788786
}
789787

790788
record.name = definition?.name ?? record.name;

src/plugins/registry.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,16 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
419419
});
420420
return;
421421
}
422+
const existing = registry.channels.find((entry) => entry.plugin.id === id);
423+
if (existing) {
424+
pushDiagnostic({
425+
level: "error",
426+
pluginId: record.id,
427+
source: record.source,
428+
message: `channel already registered: ${id} (${existing.pluginId})`,
429+
});
430+
return;
431+
}
422432
record.channelIds.push(id);
423433
registry.channels.push({
424434
pluginId: record.id,

0 commit comments

Comments
 (0)