diff --git a/src/channels/plugins/read-only.test.ts b/src/channels/plugins/read-only.test.ts index 051686e1f75..ad7404baa00 100644 --- a/src/channels/plugins/read-only.test.ts +++ b/src/channels/plugins/read-only.test.ts @@ -187,6 +187,35 @@ describe("listReadOnlyChannelPluginsForConfig", () => { expect(fs.existsSync(fullMarker)).toBe(false); }); + it("matches setup-only plugins by manifest-owned channel ids when plugin id differs", () => { + const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({ + pluginId: "external-chat-plugin", + channelId: "external-chat", + setupChannelId: "external-chat-plugin", + }); + const plugins = listReadOnlyChannelPluginsForConfig( + { + channels: { + "external-chat": { token: "configured" }, + }, + plugins: { + load: { paths: [pluginDir] }, + allow: ["external-chat-plugin"], + }, + } as never, + { + env: { ...process.env }, + includePersistedAuthState: false, + }, + ); + + const plugin = plugins.find((entry) => entry.id === "external-chat"); + expect(plugin?.meta.id).toBe("external-chat"); + expect(plugin?.meta.blurb).toBe("setup entry"); + expect(fs.existsSync(setupMarker)).toBe(true); + expect(fs.existsSync(fullMarker)).toBe(false); + }); + it("keeps configured external channels visible when no setup entry exists", () => { const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({ setupEntry: false, diff --git a/src/channels/plugins/read-only.ts b/src/channels/plugins/read-only.ts index fbcba9b285b..fcffad95211 100644 --- a/src/channels/plugins/read-only.ts +++ b/src/channels/plugins/read-only.ts @@ -67,6 +67,58 @@ function addChannelPlugins( } } +function cloneChannelPluginForChannelId(plugin: ChannelPlugin, channelId: string): ChannelPlugin { + if (plugin.id === channelId && plugin.meta.id === channelId) { + return plugin; + } + return { + ...plugin, + id: channelId, + meta: { + ...plugin.meta, + id: channelId, + }, + }; +} + +function addSetupChannelPlugins( + byId: Map, + setups: Iterable<{ + pluginId: string; + plugin: ChannelPlugin; + }>, + options: { + ownedMissingChannelIdsByPluginId: ReadonlyMap; + }, +): void { + for (const setup of setups) { + const ownedMissingChannelIds = options.ownedMissingChannelIdsByPluginId.get(setup.pluginId); + if (!ownedMissingChannelIds || ownedMissingChannelIds.length === 0) { + continue; + } + if (ownedMissingChannelIds.includes(setup.plugin.id)) { + addChannelPlugins(byId, [setup.plugin], { + onlyIds: new Set(ownedMissingChannelIds), + allowOverwrite: false, + }); + continue; + } + if (setup.plugin.id !== setup.pluginId) { + continue; + } + addChannelPlugins( + byId, + ownedMissingChannelIds.map((channelId) => + cloneChannelPluginForChannelId(setup.plugin, channelId), + ), + { + onlyIds: new Set(ownedMissingChannelIds), + allowOverwrite: false, + }, + ); + } +} + function resolveReadOnlyWorkspaceDir( cfg: OpenClawConfig, options: ReadOnlyChannelPluginOptions, @@ -189,6 +241,18 @@ export function resolveReadOnlyChannelPluginsForConfig( cache: options.cache, }); if (externalPluginIds.length > 0) { + const missingChannelIdSet = new Set(missingConfiguredChannelIds); + const ownedMissingChannelIdsByPluginId = new Map( + externalManifestRecords + .filter((record) => externalPluginIds.includes(record.id)) + .map( + (record) => + [ + record.id, + record.channels.filter((channelId) => missingChannelIdSet.has(channelId)), + ] as const, + ), + ); const registry = loadOpenClawPlugins({ config: cfg, activationSourceConfig: options.activationSourceConfig ?? cfg, @@ -201,14 +265,9 @@ export function resolveReadOnlyChannelPluginsForConfig( requireSetupEntryForSetupOnlyChannelPlugins: true, onlyPluginIds: externalPluginIds, }); - addChannelPlugins( - byId, - registry.channelSetups.map((setup) => setup.plugin), - { - onlyIds: new Set(missingConfiguredChannelIds), - allowOverwrite: false, - }, - ); + addSetupChannelPlugins(byId, registry.channelSetups, { + ownedMissingChannelIdsByPluginId, + }); } const plugins = [...byId.values()]; diff --git a/src/cli/command-secret-targets.import.test.ts b/src/cli/command-secret-targets.import.test.ts index 1f3d8f8bca5..f8dcbfc84a5 100644 --- a/src/cli/command-secret-targets.import.test.ts +++ b/src/cli/command-secret-targets.import.test.ts @@ -74,6 +74,24 @@ describe("command secret targets module import", () => { ], }, }, + { + id: "external-chat-plugin", + secrets: { + secretTargetRegistryEntries: [ + { + id: "channels.external-chat.token", + targetType: "channels.external-chat.token", + configFile: "openclaw.json", + pathPattern: "channels.external-chat.token", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + ], + }, + }, ]); vi.doMock("../secrets/target-registry.js", () => ({ @@ -86,9 +104,13 @@ describe("command secret targets module import", () => { const mod = await import("./command-secret-targets.js"); const targets = mod.getStatusCommandSecretTargetIds({ - channels: { telegram: { botToken: "123456:ABCDEF" } }, + channels: { + "external-chat": { token: "configured" }, + telegram: { botToken: "123456:ABCDEF" }, + }, }); + expect(targets.has("channels.external-chat.token")).toBe(true); expect(targets.has("channels.telegram.botToken")).toBe(true); expect(targets.has("channels.telegram.gatewayToken")).toBe(false); expect(targets.has("channels.telegram.gatewayTokenRef")).toBe(false); diff --git a/src/cli/command-secret-targets.ts b/src/cli/command-secret-targets.ts index b54027e43a8..2fd3a8e66fd 100644 --- a/src/cli/command-secret-targets.ts +++ b/src/cli/command-secret-targets.ts @@ -75,7 +75,6 @@ function getChannelSecretTargetIds(): string[] { } function isScopedChannelSecretTargetEntry(params: { - pluginId: string; entry: { id: string; configFile?: string; @@ -83,7 +82,11 @@ function isScopedChannelSecretTargetEntry(params: { refPathPattern?: string; }; }): boolean { - const allowedPrefix = `channels.${params.pluginId}.`; + const channelId = /^channels\.([^.]+)\./.exec(params.entry.id)?.[1]; + if (!channelId) { + return false; + } + const allowedPrefix = `channels.${channelId}.`; return ( params.entry.id.startsWith(allowedPrefix) && params.entry.configFile === "openclaw.json" && @@ -104,7 +107,7 @@ function getConfiguredChannelSecretTargetIds( includePersistedAuthState: false, })) { for (const entry of plugin.secrets?.secretTargetRegistryEntries ?? []) { - if (isScopedChannelSecretTargetEntry({ pluginId: plugin.id, entry })) { + if (isScopedChannelSecretTargetEntry({ entry })) { targetIds.add(entry.id); } }