From 5394efe71ff9a7c1f417b617c17fab5ba7442a0d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 24 Apr 2026 15:18:45 -0700 Subject: [PATCH] feat(channels): use manifest configs for read-only discovery --- CHANGELOG.md | 1 + docs/plugins/manifest.md | 4 +- src/channels/plugins/read-only.test.ts | 141 +++++++++++++++++++++ src/channels/plugins/read-only.ts | 167 ++++++++++++++++++++++--- 4 files changed, 295 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b7f011d65d..8572b975193 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai - Plugins/setup: include `setup.providers[].envVars` in generic provider auth/env lookups and warn non-bundled plugins that still rely on deprecated `providerAuthEnvVars` compatibility metadata. Thanks @vincentkoc. - Plugins/setup: surface manifest provider auth choices directly in provider setup flow before falling back to setup runtime or install-catalog choices. Thanks @vincentkoc. - Plugins/setup: warn when descriptor-only setup plugins still ship ignored setup runtime entries, keeping `setup.requiresRuntime: false` semantics explicit without breaking existing metadata. Thanks @vincentkoc. +- Plugins/channels: use manifest `channelConfigs` for read-only external channel discovery when no setup entry is available or setup descriptors declare runtime unnecessary. Thanks @vincentkoc. - TUI/dependencies: remove direct `cli-highlight` usage from the OpenClaw TUI code-block renderer, keeping themed code coloring without the extra root dependency. Thanks @vincentkoc. - Diagnostics/OTEL: export run, model-call, and tool-execution diagnostic lifecycle events as OTEL spans without retaining live span state. Thanks @vincentkoc. - Providers/Anthropic Vertex: move the Vertex SDK runtime behind the bundled provider plugin so core no longer owns that provider-specific dependency. Thanks @vincentkoc. diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 2cdf604ec61..22eab879a70 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -498,7 +498,9 @@ Each provider entry can include: ## channelConfigs reference Use `channelConfigs` when a channel plugin needs cheap config metadata before -runtime loads. +runtime loads. Read-only channel setup/status discovery can use this metadata +directly for configured external channels when no setup entry is available, or +when `setup.requiresRuntime: false` declares setup runtime unnecessary. ```json { diff --git a/src/channels/plugins/read-only.test.ts b/src/channels/plugins/read-only.test.ts index 9a75c7aaa98..35dec06a03a 100644 --- a/src/channels/plugins/read-only.test.ts +++ b/src/channels/plugins/read-only.test.ts @@ -17,6 +17,8 @@ function writeExternalSetupChannelPlugin( pluginId?: string; channelId?: string; manifestChannelIds?: string[]; + manifestChannelConfig?: boolean; + setupRequiresRuntime?: boolean; setupChannelId?: string; } = {}, ) { @@ -56,6 +58,36 @@ function writeExternalSetupChannelPlugin( channelEnvVars: { [channelId]: ["EXTERNAL_CHAT_TOKEN"], }, + ...(typeof options.setupRequiresRuntime === "boolean" + ? { setup: { requiresRuntime: options.setupRequiresRuntime } } + : {}), + ...(options.manifestChannelConfig + ? { + channelConfigs: Object.fromEntries( + manifestChannelIds.map((id) => [ + id, + { + schema: { + type: "object", + additionalProperties: false, + properties: { + token: { type: "string" }, + }, + }, + uiHints: { + token: { + label: "Token", + sensitive: true, + }, + }, + label: "External Chat Manifest", + description: "manifest config", + preferOver: ["legacy-external-chat"], + }, + ]), + ), + } + : {}), }, null, 2, @@ -456,6 +488,115 @@ describe("listReadOnlyChannelPluginsForConfig", () => { expect(fs.existsSync(fullMarker)).toBe(false); }); + it("uses manifest channel configs when no setup entry exists", () => { + const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({ + setupEntry: false, + pluginId: "external-chat-plugin", + channelId: "external-chat", + manifestChannelConfig: true, + }); + const plugins = listReadOnlyChannelPluginsForConfig( + { + channels: { + "external-chat": { token: "configured" }, + }, + plugins: { + load: { paths: [pluginDir] }, + allow: ["external-chat-plugin"], + }, + } as never, + { + env: { ...process.env }, + includePersistedAuthState: false, + }, + ); + + expect(plugins.find((entry) => entry.id === "external-chat")?.meta.blurb).toBe( + "manifest config", + ); + expect(fs.existsSync(setupMarker)).toBe(false); + expect(fs.existsSync(fullMarker)).toBe(false); + }); + + it("uses manifest channel configs before setup-only plugin loading", () => { + const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({ + pluginId: "external-chat-plugin", + channelId: "external-chat", + manifestChannelConfig: true, + setupRequiresRuntime: false, + }); + 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.label).toBe("External Chat Manifest"); + expect(plugin?.meta.blurb).toBe("manifest config"); + expect(plugin?.meta.preferOver).toEqual(["legacy-external-chat"]); + expect(plugin?.configSchema?.schema).toMatchObject({ + properties: { + token: { type: "string" }, + }, + }); + expect(plugin?.configSchema?.uiHints?.token).toMatchObject({ + label: "Token", + sensitive: true, + }); + expect( + plugin?.config.listAccountIds({ channels: { "external-chat": { token: "t" } } } as never), + ).toEqual(["default"]); + expect( + plugin?.config.resolveAccount({ + channels: { "external-chat": { token: "configured" } }, + } as never), + ).toMatchObject({ + accountId: "default", + config: { token: "configured" }, + }); + expect(fs.existsSync(setupMarker)).toBe(false); + expect(fs.existsSync(fullMarker)).toBe(false); + }); + + it("keeps setup-entry precedence when channel config descriptors are not runtime cutoffs", () => { + const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({ + pluginId: "external-chat-plugin", + channelId: "external-chat", + manifestChannelConfig: true, + }); + 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.blurb).toBe("setup entry"); + expect(fs.existsSync(setupMarker)).toBe(true); + expect(fs.existsSync(fullMarker)).toBe(false); + }); + it("uses external channel env vars as read-only configuration triggers", () => { const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({ pluginId: "external-chat-plugin", diff --git a/src/channels/plugins/read-only.ts b/src/channels/plugins/read-only.ts index 889f2b56c3c..dcc86e2c1bb 100644 --- a/src/channels/plugins/read-only.ts +++ b/src/channels/plugins/read-only.ts @@ -1,6 +1,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { + hasExplicitChannelConfig, listConfiguredChannelIdsForReadOnlyScope, resolveDiscoverableScopedChannelPluginIds, } from "../../plugins/channel-plugin-ids.js"; @@ -9,6 +10,7 @@ import { loadPluginManifestRegistry, type PluginManifestRecord, } from "../../plugins/manifest-registry.js"; +import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; import { getBundledChannelSetupPlugin } from "./bundled.js"; import { listChannelPlugins } from "./registry.js"; import type { ChannelPlugin } from "./types.plugin.js"; @@ -110,6 +112,99 @@ function restoreReboundChannelConfig(params: { }; } +function getChannelConfigRecord(cfg: OpenClawConfig, channelId: string): Record { + const channels = cfg.channels; + if (!channels || typeof channels !== "object" || Array.isArray(channels)) { + return {}; + } + const entry = (channels as Record)[channelId]; + return entry && typeof entry === "object" && !Array.isArray(entry) + ? (entry as Record) + : {}; +} + +function listManifestChannelAccountIds(cfg: OpenClawConfig, channelId: string): string[] { + const channelConfig = getChannelConfigRecord(cfg, channelId); + const accounts = channelConfig.accounts; + if (accounts && typeof accounts === "object" && !Array.isArray(accounts)) { + return Object.keys(accounts).toSorted((left, right) => left.localeCompare(right)); + } + return hasExplicitChannelConfig({ config: cfg, channelId }) ? [DEFAULT_ACCOUNT_ID] : []; +} + +function resolveManifestChannelAccountConfig(params: { + cfg: OpenClawConfig; + channelId: string; + accountId?: string | null; +}): Record { + const channelConfig = getChannelConfigRecord(params.cfg, params.channelId); + const resolvedAccountId = params.accountId?.trim() || DEFAULT_ACCOUNT_ID; + const accounts = channelConfig.accounts; + if (accounts && typeof accounts === "object" && !Array.isArray(accounts)) { + const accountConfig = (accounts as Record)[resolvedAccountId]; + if (accountConfig && typeof accountConfig === "object" && !Array.isArray(accountConfig)) { + return accountConfig as Record; + } + } + return channelConfig; +} + +function buildManifestChannelPlugin(params: { + record: PluginManifestRecord; + channelId: string; +}): ChannelPlugin | undefined { + const channelConfig = params.record.channelConfigs?.[params.channelId]; + if (!channelConfig) { + return undefined; + } + const label = channelConfig.label?.trim() || params.record.name || params.channelId; + const blurb = channelConfig.description?.trim() || params.record.description || ""; + return { + id: params.channelId, + meta: { + id: params.channelId, + label, + selectionLabel: label, + docsPath: `/channels/${params.channelId}`, + blurb, + ...(channelConfig.preferOver?.length ? { preferOver: channelConfig.preferOver } : {}), + }, + capabilities: { chatTypes: ["direct"] }, + configSchema: { + schema: channelConfig.schema, + ...(channelConfig.uiHints ? { uiHints: channelConfig.uiHints } : {}), + ...(channelConfig.runtime ? { runtime: channelConfig.runtime } : {}), + }, + config: { + listAccountIds: (cfg) => listManifestChannelAccountIds(cfg, params.channelId), + defaultAccountId: () => DEFAULT_ACCOUNT_ID, + resolveAccount: (cfg, accountId) => ({ + accountId: accountId?.trim() || DEFAULT_ACCOUNT_ID, + config: resolveManifestChannelAccountConfig({ + cfg, + channelId: params.channelId, + accountId, + }), + }), + isEnabled: (_account, cfg) => getChannelConfigRecord(cfg, params.channelId).enabled !== false, + isConfigured: (_account, cfg) => + hasExplicitChannelConfig({ + config: cfg, + channelId: params.channelId, + }), + hasConfiguredState: ({ cfg }) => + hasExplicitChannelConfig({ + config: cfg, + channelId: params.channelId, + }), + }, + }; +} + +function canUseManifestChannelPlugin(record: PluginManifestRecord): boolean { + return record.setup?.requiresRuntime === false || !record.setupSource; +} + function rebindChannelPluginConfig( config: ChannelPlugin["config"], sourceChannelId: string, @@ -283,6 +378,34 @@ function addSetupChannelPlugins( } } +function addManifestChannelPlugins( + byId: Map, + records: readonly PluginManifestRecord[], + options: { + pluginIds: ReadonlySet; + channelIds: readonly string[]; + }, +): void { + const channelIds = new Set(options.channelIds); + for (const record of records) { + if (!options.pluginIds.has(record.id)) { + continue; + } + if (!canUseManifestChannelPlugin(record)) { + continue; + } + for (const channelId of record.channels) { + if (!channelIds.has(channelId)) { + continue; + } + addChannelPlugins(byId, [buildManifestChannelPlugin({ record, channelId })], { + onlyIds: channelIds, + allowOverwrite: false, + }); + } + } +} + function resolveReadOnlyWorkspaceDir( cfg: OpenClawConfig, options: ReadOnlyChannelPluginOptions, @@ -389,8 +512,16 @@ export function resolveReadOnlyChannelPluginsForConfig( cache: options.cache, }); if (externalPluginIds.length > 0) { - const missingChannelIdSet = new Set(missingConfiguredChannelIds); const externalPluginIdSet = new Set(externalPluginIds); + addManifestChannelPlugins(byId, externalManifestRecords, { + pluginIds: externalPluginIdSet, + channelIds: missingConfiguredChannelIds, + }); + + const setupMissingChannelIds = missingConfiguredChannelIds.filter( + (channelId) => !byId.has(channelId), + ); + const missingChannelIdSet = new Set(setupMissingChannelIds); const ownedChannelIdsByPluginId = new Map( externalManifestRecords .filter((record) => externalPluginIdSet.has(record.id)) @@ -402,22 +533,24 @@ export function resolveReadOnlyChannelPluginsForConfig( [pluginId, channelIds.filter((channelId) => missingChannelIdSet.has(channelId))] as const, ), ); - const registry = loadOpenClawPlugins({ - config: cfg, - activationSourceConfig: options.activationSourceConfig ?? cfg, - env, - workspaceDir, - cache: false, - activate: false, - includeSetupOnlyChannelPlugins: true, - forceSetupOnlyChannelPlugins: true, - requireSetupEntryForSetupOnlyChannelPlugins: true, - onlyPluginIds: externalPluginIds, - }); - addSetupChannelPlugins(byId, registry.channelSetups, { - ownedChannelIdsByPluginId, - ownedMissingChannelIdsByPluginId, - }); + if (setupMissingChannelIds.length > 0) { + const registry = loadOpenClawPlugins({ + config: cfg, + activationSourceConfig: options.activationSourceConfig ?? cfg, + env, + workspaceDir, + cache: false, + activate: false, + includeSetupOnlyChannelPlugins: true, + forceSetupOnlyChannelPlugins: true, + requireSetupEntryForSetupOnlyChannelPlugins: true, + onlyPluginIds: externalPluginIds, + }); + addSetupChannelPlugins(byId, registry.channelSetups, { + ownedChannelIdsByPluginId, + ownedMissingChannelIdsByPluginId, + }); + } } const plugins = [...byId.values()];