From fd2d20a8ee1410571b05ef2e73d8c9f493550ce4 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 20 Apr 2026 19:04:32 -0400 Subject: [PATCH] fix: preserve external read-only channel metadata --- src/channels/plugins/read-only.test.ts | 204 ++++++++++++++++++ src/channels/plugins/read-only.ts | 142 ++++++++++-- src/cli/command-secret-targets.import.test.ts | 48 +++-- src/cli/command-secret-targets.ts | 36 ++-- src/plugins/loader.ts | 50 +++-- 5 files changed, 404 insertions(+), 76 deletions(-) create mode 100644 src/channels/plugins/read-only.test.ts diff --git a/src/channels/plugins/read-only.test.ts b/src/channels/plugins/read-only.test.ts new file mode 100644 index 00000000000..21d7336cd28 --- /dev/null +++ b/src/channels/plugins/read-only.test.ts @@ -0,0 +1,204 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { + cleanupPluginLoaderFixturesForTest, + EMPTY_PLUGIN_SCHEMA, + makeTempDir, + resetPluginLoaderTestStateForTest, + useNoBundledPlugins, +} from "../../plugins/loader.test-fixtures.js"; +import { listReadOnlyChannelPluginsForConfig } from "./read-only.js"; + +function writeExternalSetupChannelPlugin(options: { setupEntry?: boolean } = {}) { + useNoBundledPlugins(); + const pluginDir = makeTempDir(); + const fullMarker = path.join(pluginDir, "full-loaded.txt"); + const setupMarker = path.join(pluginDir, "setup-loaded.txt"); + const setupEntry = options.setupEntry !== false; + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: "@example/openclaw-external-chat", + version: "1.0.0", + openclaw: { + extensions: ["./index.cjs"], + ...(setupEntry ? { setupEntry: "./setup-entry.cjs" } : {}), + }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "external-chat", + configSchema: EMPTY_PLUGIN_SCHEMA, + channels: ["external-chat"], + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "index.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); +module.exports = { + id: "external-chat", + register(api) { + api.registerChannel({ + plugin: { + id: "external-chat", + meta: { + id: "external-chat", + label: "External Chat", + selectionLabel: "External Chat", + docsPath: "/channels/external-chat", + blurb: "full entry", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ accountId: "default", token: "configured" }), + }, + outbound: { deliveryMode: "direct" }, + secrets: { + secretTargetRegistryEntries: [ + { + id: "channels.external-chat.token", + targetType: "channel", + configFile: "openclaw.json", + pathPattern: "channels.external-chat.token", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + ], + }, + }, + }); + }, +};`, + "utf-8", + ); + if (setupEntry) { + fs.writeFileSync( + path.join(pluginDir, "setup-entry.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8"); +module.exports = { + plugin: { + id: "external-chat", + meta: { + id: "external-chat", + label: "External Chat", + selectionLabel: "External Chat", + docsPath: "/channels/external-chat", + blurb: "setup entry", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ accountId: "default", token: "configured" }), + }, + outbound: { deliveryMode: "direct" }, + secrets: { + secretTargetRegistryEntries: [ + { + id: "channels.external-chat.token", + targetType: "channel", + configFile: "openclaw.json", + pathPattern: "channels.external-chat.token", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + ], + }, + }, +};`, + "utf-8", + ); + } + + return { pluginDir, fullMarker, setupMarker }; +} + +afterEach(() => { + resetPluginLoaderTestStateForTest(); +}); + +afterAll(() => { + cleanupPluginLoaderFixturesForTest(); +}); + +describe("listReadOnlyChannelPluginsForConfig", () => { + it("loads configured external channel setup metadata without importing full runtime", () => { + const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin(); + const plugins = listReadOnlyChannelPluginsForConfig( + { + channels: { + "external-chat": { token: "configured" }, + }, + plugins: { + load: { paths: [pluginDir] }, + allow: ["external-chat"], + }, + } as never, + { + env: { ...process.env }, + includePersistedAuthState: false, + }, + ); + + const plugin = plugins.find((entry) => entry.id === "external-chat"); + expect(plugin?.meta.blurb).toBe("setup entry"); + expect( + plugin?.secrets?.secretTargetRegistryEntries?.some( + (entry) => entry.id === "channels.external-chat.token", + ), + ).toBe(true); + 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, + }); + const plugins = listReadOnlyChannelPluginsForConfig( + { + channels: { + "external-chat": { token: "configured" }, + }, + plugins: { + load: { paths: [pluginDir] }, + allow: ["external-chat"], + }, + } as never, + { + env: { ...process.env }, + includePersistedAuthState: false, + }, + ); + + const plugin = plugins.find((entry) => entry.id === "external-chat"); + expect(plugin?.meta.blurb).toBe("full entry"); + expect( + plugin?.secrets?.secretTargetRegistryEntries?.some( + (entry) => entry.id === "channels.external-chat.token", + ), + ).toBe(true); + expect(fs.existsSync(setupMarker)).toBe(false); + expect(fs.existsSync(fullMarker)).toBe(true); + }); +}); diff --git a/src/channels/plugins/read-only.ts b/src/channels/plugins/read-only.ts index 0997a8a38e0..9a2550a7bfb 100644 --- a/src/channels/plugins/read-only.ts +++ b/src/channels/plugins/read-only.ts @@ -1,27 +1,145 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { resolveDiscoverableScopedChannelPluginIds } from "../../plugins/channel-plugin-ids.js"; +import { loadOpenClawPlugins } from "../../plugins/loader.js"; +import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js"; import { listPotentialConfiguredChannelIds } from "../config-presence.js"; import { getBundledChannelSetupPlugin } from "./bundled.js"; import { listChannelPlugins } from "./registry.js"; import type { ChannelPlugin } from "./types.plugin.js"; -export function listReadOnlyChannelPluginsForConfig( - cfg: OpenClawConfig, - env: NodeJS.ProcessEnv = process.env, -): ChannelPlugin[] { - const byId = new Map(); +type ReadOnlyChannelPluginOptions = { + env?: NodeJS.ProcessEnv; + workspaceDir?: string; + activationSourceConfig?: OpenClawConfig; + includePersistedAuthState?: boolean; + cache?: boolean; +}; - for (const plugin of listChannelPlugins()) { - byId.set(plugin.id, plugin); +function resolveReadOnlyChannelPluginOptions( + envOrOptions?: NodeJS.ProcessEnv | ReadOnlyChannelPluginOptions, +): ReadOnlyChannelPluginOptions { + if (!envOrOptions) { + return {}; + } + if ( + "env" in envOrOptions || + "workspaceDir" in envOrOptions || + "activationSourceConfig" in envOrOptions || + "includePersistedAuthState" in envOrOptions || + "cache" in envOrOptions + ) { + return envOrOptions as ReadOnlyChannelPluginOptions; + } + return { env: envOrOptions as NodeJS.ProcessEnv }; +} + +function addChannelPlugins( + byId: Map, + plugins: Iterable, +): void { + for (const plugin of plugins) { + if (plugin) { + byId.set(plugin.id, plugin); + } + } +} + +function resolveExternalReadOnlyChannelPluginIds(params: { + cfg: OpenClawConfig; + activationSourceConfig?: OpenClawConfig; + channelIds: readonly string[]; + workspaceDir?: string; + env: NodeJS.ProcessEnv; + cache?: boolean; +}): string[] { + if (params.channelIds.length === 0) { + return []; + } + const candidatePluginIds = resolveDiscoverableScopedChannelPluginIds({ + config: params.cfg, + activationSourceConfig: params.activationSourceConfig, + channelIds: params.channelIds, + workspaceDir: params.workspaceDir, + env: params.env, + cache: params.cache, + }); + if (candidatePluginIds.length === 0) { + return []; } - for (const channelId of listPotentialConfiguredChannelIds(cfg, env)) { + const requestedChannelIds = new Set(params.channelIds); + const candidatePluginIdSet = new Set(candidatePluginIds); + return loadPluginManifestRegistry({ + config: params.cfg, + workspaceDir: params.workspaceDir, + env: params.env, + cache: params.cache, + }) + .plugins.filter( + (plugin) => + candidatePluginIdSet.has(plugin.id) && + plugin.origin !== "bundled" && + plugin.channels.some((channelId) => requestedChannelIds.has(channelId)), + ) + .map((plugin) => plugin.id) + .toSorted((left, right) => left.localeCompare(right)); +} + +export function listReadOnlyChannelPluginsForConfig( + cfg: OpenClawConfig, + env?: NodeJS.ProcessEnv, +): ChannelPlugin[]; +export function listReadOnlyChannelPluginsForConfig( + cfg: OpenClawConfig, + options?: ReadOnlyChannelPluginOptions, +): ChannelPlugin[]; +export function listReadOnlyChannelPluginsForConfig( + cfg: OpenClawConfig, + envOrOptions?: NodeJS.ProcessEnv | ReadOnlyChannelPluginOptions, +): ChannelPlugin[] { + const options = resolveReadOnlyChannelPluginOptions(envOrOptions); + const env = options.env ?? process.env; + const configuredChannelIds = listPotentialConfiguredChannelIds(cfg, env, { + includePersistedAuthState: options.includePersistedAuthState, + }); + const byId = new Map(); + + addChannelPlugins(byId, listChannelPlugins()); + + for (const channelId of configuredChannelIds) { if (byId.has(channelId)) { continue; } - const setupPlugin = getBundledChannelSetupPlugin(channelId); - if (setupPlugin) { - byId.set(setupPlugin.id, setupPlugin); - } + addChannelPlugins(byId, [getBundledChannelSetupPlugin(channelId)]); + } + + const missingConfiguredChannelIds = configuredChannelIds.filter( + (channelId) => !byId.has(channelId), + ); + const externalPluginIds = resolveExternalReadOnlyChannelPluginIds({ + cfg, + activationSourceConfig: options.activationSourceConfig ?? cfg, + channelIds: missingConfiguredChannelIds, + workspaceDir: options.workspaceDir, + env, + cache: options.cache, + }); + if (externalPluginIds.length > 0) { + const registry = loadOpenClawPlugins({ + config: cfg, + activationSourceConfig: options.activationSourceConfig ?? cfg, + env, + workspaceDir: options.workspaceDir, + cache: false, + activate: false, + includeSetupOnlyChannelPlugins: true, + forceSetupOnlyChannelPlugins: true, + onlyPluginIds: externalPluginIds, + }); + addChannelPlugins( + byId, + registry.channelSetups.map((setup) => setup.plugin), + ); } return [...byId.values()]; diff --git a/src/cli/command-secret-targets.import.test.ts b/src/cli/command-secret-targets.import.test.ts index 7d60da3a176..7f71795571e 100644 --- a/src/cli/command-secret-targets.import.test.ts +++ b/src/cli/command-secret-targets.import.test.ts @@ -32,32 +32,33 @@ describe("command secret targets module import", () => { const listSecretTargetRegistryEntries = vi.fn(() => { throw new Error("registry touched too early"); }); - const loadBundledChannelSecretContractApi = vi.fn((channelId: string) => - channelId === "telegram" - ? { - secretTargetRegistryEntries: [ - { - id: "channels.telegram.botToken", - targetType: "channels.telegram.botToken", - configFile: "openclaw.json", - pathPattern: "channels.telegram.botToken", - secretShape: "secret_input", - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - ], - } - : undefined, - ); + const listReadOnlyChannelPluginsForConfig = vi.fn(() => [ + { + id: "telegram", + secrets: { + secretTargetRegistryEntries: [ + { + id: "channels.telegram.botToken", + targetType: "channels.telegram.botToken", + configFile: "openclaw.json", + pathPattern: "channels.telegram.botToken", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + ], + }, + }, + ]); vi.doMock("../secrets/target-registry.js", () => ({ discoverConfigSecretTargetsByIds: vi.fn(() => []), listSecretTargetRegistryEntries, })); - vi.doMock("../secrets/channel-contract-api.js", () => ({ - loadBundledChannelSecretContractApi, + vi.doMock("../channels/plugins/read-only.js", () => ({ + listReadOnlyChannelPluginsForConfig, })); const mod = await import("./command-secret-targets.js"); @@ -67,7 +68,10 @@ describe("command secret targets module import", () => { expect(targets.has("channels.telegram.botToken")).toBe(true); expect(targets.has("agents.defaults.memorySearch.remote.apiKey")).toBe(true); - expect(loadBundledChannelSecretContractApi).toHaveBeenCalledWith("telegram"); + expect(listReadOnlyChannelPluginsForConfig).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ includePersistedAuthState: false }), + ); expect(listSecretTargetRegistryEntries).not.toHaveBeenCalled(); }); }); diff --git a/src/cli/command-secret-targets.ts b/src/cli/command-secret-targets.ts index a057ee00577..e14223b3512 100644 --- a/src/cli/command-secret-targets.ts +++ b/src/cli/command-secret-targets.ts @@ -1,7 +1,6 @@ -import { listPotentialConfiguredChannelIds } from "../channels/config-presence.js"; +import { listReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeOptionalAccountId } from "../routing/session-key.js"; -import { loadBundledChannelSecretContractApi } from "../secrets/channel-contract-api.js"; import { discoverConfigSecretTargetsByIds, listSecretTargetRegistryEntries, @@ -69,37 +68,28 @@ type CommandSecretTargets = { let cachedCommandSecretTargets: CommandSecretTargets | undefined; let cachedChannelSecretTargetIds: string[] | undefined; -const cachedBundledChannelSecretTargetIds = new Map(); function getChannelSecretTargetIds(): string[] { cachedChannelSecretTargetIds ??= idsByPrefix(["channels."]); return cachedChannelSecretTargetIds; } -function getBundledChannelSecretTargetIds(channelId: string): string[] { - const normalizedChannelId = channelId.trim(); - if (!normalizedChannelId) { - return []; - } - if (cachedBundledChannelSecretTargetIds.has(normalizedChannelId)) { - return cachedBundledChannelSecretTargetIds.get(normalizedChannelId) ?? []; - } - const targetIds = - loadBundledChannelSecretContractApi(normalizedChannelId) - ?.secretTargetRegistryEntries?.map((entry) => entry.id) - .filter((id) => id.startsWith(`channels.${normalizedChannelId}.`)) - .toSorted() ?? null; - cachedBundledChannelSecretTargetIds.set(normalizedChannelId, targetIds); - return targetIds ?? []; -} - function getConfiguredChannelSecretTargetIds( config: OpenClawConfig, env: NodeJS.ProcessEnv = process.env, ): string[] { - return listPotentialConfiguredChannelIds(config, env, { includePersistedAuthState: false }) - .toSorted() - .flatMap((channelId) => getBundledChannelSecretTargetIds(channelId)); + const targetIds = new Set(); + for (const plugin of listReadOnlyChannelPluginsForConfig(config, { + env, + includePersistedAuthState: false, + })) { + for (const entry of plugin.secrets?.secretTargetRegistryEntries ?? []) { + if (entry.id.startsWith(`channels.${plugin.id}.`)) { + targetIds.add(entry.id); + } + } + } + return [...targetIds].toSorted((left, right) => left.localeCompare(right)); } function buildCommandSecretTargets(): CommandSecretTargets { diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index f3683a1e8c6..75bc74931f4 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -134,6 +134,7 @@ export type PluginLoadOptions = { mode?: "full" | "validate"; onlyPluginIds?: string[]; includeSetupOnlyChannelPlugins?: boolean; + forceSetupOnlyChannelPlugins?: boolean; /** * Prefer `setupEntry` for configured channel plugins that explicitly opt in * via package metadata because their setup entry covers the pre-listen startup surface. @@ -505,6 +506,7 @@ function buildCacheKey(params: { env: NodeJS.ProcessEnv; onlyPluginIds?: string[]; includeSetupOnlyChannelPlugins?: boolean; + forceSetupOnlyChannelPlugins?: boolean; preferSetupRuntimeForChannelPlugins?: boolean; loadModules?: boolean; runtimeSubagentMode?: "default" | "explicit" | "gateway-bindable"; @@ -534,6 +536,8 @@ function buildCacheKey(params: { ); const scopeKey = serializePluginIdScope(params.onlyPluginIds); const setupOnlyKey = params.includeSetupOnlyChannelPlugins === true ? "setup-only" : "runtime"; + const setupOnlyModeKey = + params.forceSetupOnlyChannelPlugins === true ? "force-setup" : "normal-setup"; const startupChannelMode = params.preferSetupRuntimeForChannelPlugins === true ? "prefer-setup" : "full"; const moduleLoadMode = params.loadModules === false ? "manifest-only" : "load-modules"; @@ -544,7 +548,7 @@ function buildCacheKey(params: { installs, loadPaths, activationMetadataKey: params.activationMetadataKey ?? "", - })}::${scopeKey}::${setupOnlyKey}::${startupChannelMode}::${moduleLoadMode}::${runtimeSubagentMode}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}`; + })}::${scopeKey}::${setupOnlyKey}::${setupOnlyModeKey}::${startupChannelMode}::${moduleLoadMode}::${runtimeSubagentMode}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}`; } function matchesScopedPluginRequest(params: { @@ -619,6 +623,7 @@ function hasExplicitCompatibilityInputs(options: PluginLoadOptions): boolean { options.pluginSdkResolution !== undefined || options.coreGatewayHandlers !== undefined || options.includeSetupOnlyChannelPlugins === true || + options.forceSetupOnlyChannelPlugins === true || options.preferSetupRuntimeForChannelPlugins === true || options.loadModules === false ); @@ -634,6 +639,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) { }); const onlyPluginIds = normalizePluginIdScope(options.onlyPluginIds); const includeSetupOnlyChannelPlugins = options.includeSetupOnlyChannelPlugins === true; + const forceSetupOnlyChannelPlugins = options.forceSetupOnlyChannelPlugins === true; const preferSetupRuntimeForChannelPlugins = options.preferSetupRuntimeForChannelPlugins === true; const runtimeSubagentMode = resolveRuntimeSubagentMode(options.runtimeOptions); const coreGatewayMethodNames = Object.keys(options.coreGatewayHandlers ?? {}).toSorted(); @@ -648,6 +654,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) { env, onlyPluginIds, includeSetupOnlyChannelPlugins, + forceSetupOnlyChannelPlugins, preferSetupRuntimeForChannelPlugins, loadModules: options.loadModules, runtimeSubagentMode, @@ -663,6 +670,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) { autoEnabledReasons: options.autoEnabledReasons ?? {}, onlyPluginIds, includeSetupOnlyChannelPlugins, + forceSetupOnlyChannelPlugins, preferSetupRuntimeForChannelPlugins, shouldActivate: options.activate !== false, shouldLoadModules: options.loadModules !== false, @@ -1410,6 +1418,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi autoEnabledReasons, onlyPluginIds, includeSetupOnlyChannelPlugins, + forceSetupOnlyChannelPlugins, preferSetupRuntimeForChannelPlugins, shouldActivate, shouldLoadModules, @@ -1740,25 +1749,28 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi } } - const registrationMode = enableState.enabled - ? shouldLoadModules && - !validateOnly && - shouldLoadChannelPluginInSetupRuntime({ - manifestChannels: manifestRecord.channels, - setupSource: manifestRecord.setupSource, - startupDeferConfiguredChannelFullLoadUntilAfterListen: - manifestRecord.startupDeferConfiguredChannelFullLoadUntilAfterListen, - cfg, - env, - preferSetupRuntimeForChannelPlugins, - }) - ? "setup-runtime" - : "full" - : includeSetupOnlyChannelPlugins && + const canLoadScopedSetupOnlyChannelPlugin = + includeSetupOnlyChannelPlugins && + !validateOnly && + onlyPluginIdSet && + manifestRecord.channels.length > 0 && + (!enableState.enabled || forceSetupOnlyChannelPlugins); + const registrationMode = canLoadScopedSetupOnlyChannelPlugin + ? "setup-only" + : enableState.enabled + ? shouldLoadModules && !validateOnly && - onlyPluginIdSet && - manifestRecord.channels.length > 0 - ? "setup-only" + shouldLoadChannelPluginInSetupRuntime({ + manifestChannels: manifestRecord.channels, + setupSource: manifestRecord.setupSource, + startupDeferConfiguredChannelFullLoadUntilAfterListen: + manifestRecord.startupDeferConfiguredChannelFullLoadUntilAfterListen, + cfg, + env, + preferSetupRuntimeForChannelPlugins, + }) + ? "setup-runtime" + : "full" : null; if (!registrationMode) {