From 4f9a2014768f2bddf5e9ad1f675c1e2eb563dcf4 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 20 Apr 2026 20:35:11 -0400 Subject: [PATCH] fix: harden read-only channel discovery --- src/channels/plugins/read-only.test.ts | 50 +++++++++++++++++++++----- src/channels/plugins/read-only.ts | 21 +++++++++-- 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/src/channels/plugins/read-only.test.ts b/src/channels/plugins/read-only.test.ts index d281ada0340..051686e1f75 100644 --- a/src/channels/plugins/read-only.test.ts +++ b/src/channels/plugins/read-only.test.ts @@ -16,12 +16,16 @@ function writeExternalSetupChannelPlugin( pluginDir?: string; pluginId?: string; channelId?: string; + manifestChannelIds?: string[]; + setupChannelId?: string; } = {}, ) { useNoBundledPlugins(); const pluginDir = options.pluginDir ?? makeTempDir(); const pluginId = options.pluginId ?? "external-chat"; const channelId = options.channelId ?? "external-chat"; + const manifestChannelIds = options.manifestChannelIds ?? [channelId]; + const setupChannelId = options.setupChannelId ?? channelId; const fullMarker = path.join(pluginDir, "full-loaded.txt"); const setupMarker = path.join(pluginDir, "setup-loaded.txt"); const setupEntry = options.setupEntry !== false; @@ -48,7 +52,7 @@ function writeExternalSetupChannelPlugin( { id: pluginId, configSchema: EMPTY_PLUGIN_SCHEMA, - channels: [channelId], + channels: manifestChannelIds, channelEnvVars: { [channelId]: ["EXTERNAL_CHAT_TOKEN"], }, @@ -107,12 +111,12 @@ module.exports = { `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8"); module.exports = { plugin: { - id: ${JSON.stringify(channelId)}, + id: ${JSON.stringify(setupChannelId)}, meta: { - id: ${JSON.stringify(channelId)}, + id: ${JSON.stringify(setupChannelId)}, label: "External Chat", selectionLabel: "External Chat", - docsPath: ${JSON.stringify(`/channels/${channelId}`)}, + docsPath: ${JSON.stringify(`/channels/${setupChannelId}`)}, blurb: "setup entry", }, capabilities: { chatTypes: ["direct"] }, @@ -123,11 +127,11 @@ module.exports = { outbound: { deliveryMode: "direct" }, secrets: { secretTargetRegistryEntries: [ - { - id: ${JSON.stringify(`channels.${channelId}.token`)}, - targetType: "channel", - configFile: "openclaw.json", - pathPattern: ${JSON.stringify(`channels.${channelId}.token`)}, + { + id: ${JSON.stringify(`channels.${setupChannelId}.token`)}, + targetType: "channel", + configFile: "openclaw.json", + pathPattern: ${JSON.stringify(`channels.${setupChannelId}.token`)}, secretShape: "secret_input", expectedResolvedValue: "string", includeInPlan: true, @@ -272,4 +276,32 @@ describe("listReadOnlyChannelPluginsForConfig", () => { expect(fs.existsSync(setupMarker)).toBe(true); expect(fs.existsSync(fullMarker)).toBe(false); }); + + it("ignores external setup plugins that export an unrequested channel id", () => { + const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({ + pluginId: "external-chat-plugin", + channelId: "external-chat", + manifestChannelIds: ["external-chat", "spoofed-chat"], + setupChannelId: "spoofed-chat", + }); + const plugins = listReadOnlyChannelPluginsForConfig( + { + channels: { + "external-chat": { token: "configured" }, + }, + plugins: { + load: { paths: [pluginDir] }, + allow: ["external-chat-plugin"], + }, + } as never, + { + env: { ...process.env }, + }, + ); + + expect(plugins.some((entry) => entry.id === "spoofed-chat")).toBe(false); + expect(plugins.some((entry) => entry.id === "external-chat")).toBe(false); + expect(fs.existsSync(setupMarker)).toBe(true); + expect(fs.existsSync(fullMarker)).toBe(false); + }); }); diff --git a/src/channels/plugins/read-only.ts b/src/channels/plugins/read-only.ts index c92ab403f66..a53914023dc 100644 --- a/src/channels/plugins/read-only.ts +++ b/src/channels/plugins/read-only.ts @@ -42,11 +42,22 @@ function resolveReadOnlyChannelPluginOptions( function addChannelPlugins( byId: Map, plugins: Iterable, + options?: { + onlyIds?: ReadonlySet; + allowOverwrite?: boolean; + }, ): void { for (const plugin of plugins) { - if (plugin) { - byId.set(plugin.id, plugin); + if (!plugin) { + continue; } + if (options?.onlyIds && !options.onlyIds.has(plugin.id)) { + continue; + } + if (options?.allowOverwrite === false && byId.has(plugin.id)) { + continue; + } + byId.set(plugin.id, plugin); } } @@ -135,7 +146,7 @@ export function listReadOnlyChannelPluginsForConfig( workspaceDir, env, cache: options.cache, - includePersistedAuthState: options.includePersistedAuthState ?? false, + includePersistedAuthState: options.includePersistedAuthState, manifestRecords: externalManifestRecords, }), ), @@ -179,6 +190,10 @@ export function listReadOnlyChannelPluginsForConfig( addChannelPlugins( byId, registry.channelSetups.map((setup) => setup.plugin), + { + onlyIds: new Set(missingConfiguredChannelIds), + allowOverwrite: false, + }, ); }