diff --git a/CHANGELOG.md b/CHANGELOG.md index 053e3088aaa..7f25bcc358c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - Agents/OpenAI Responses: classify the exact `Unknown error (no error details in response)` transport failure as failover reason `unknown` so assistant/model fallback still runs for that no-details failure path. (#65254) Thanks @OpenCodeEngineer. - Models/probe: surface invalid-model probe failures as `format` instead of `unknown` in `models list --probe`, and lock the invalid-model fallback path in with regression coverage. (#50028) Thanks @xiwuqi. - Agents/failover: classify OpenAI-compatible `finish_reason: network_error` stream failures as timeout so model fallback retries continue instead of stopping with an unknown failover reason. (#61784) thanks @lawrence3699. +- Onboarding/channels: normalize channel setup metadata before discovery and validation so malformed or mixed-shape channel plugin metadata no longer breaks setup and onboarding channel lists. (#66706) Thanks @darkamenosa. ## 2026.4.14 diff --git a/src/channels/plugins/meta-normalization.ts b/src/channels/plugins/meta-normalization.ts new file mode 100644 index 00000000000..91c71eac931 --- /dev/null +++ b/src/channels/plugins/meta-normalization.ts @@ -0,0 +1,49 @@ +import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import type { ChannelMeta } from "./types.public.js"; + +function stripRequiredChannelMeta(meta?: Partial | null) { + const { + id: _ignoredId, + label: _ignoredLabel, + selectionLabel: _ignoredSelectionLabel, + docsPath: _ignoredDocsPath, + blurb: _ignoredBlurb, + ...rest + } = meta ?? {}; + return rest; +} + +export function normalizeChannelMeta(params: { + id: TId; + meta?: Partial | null; + existing?: Partial | null; +}): ChannelMeta & { id: TId } { + const next = params.meta ?? undefined; + const existing = params.existing ?? undefined; + const label = + normalizeOptionalString(next?.label) ?? + normalizeOptionalString(existing?.label) ?? + normalizeOptionalString(next?.selectionLabel) ?? + normalizeOptionalString(existing?.selectionLabel) ?? + params.id; + const selectionLabel = + normalizeOptionalString(next?.selectionLabel) ?? + normalizeOptionalString(existing?.selectionLabel) ?? + label; + const docsPath = + normalizeOptionalString(next?.docsPath) ?? + normalizeOptionalString(existing?.docsPath) ?? + `/channels/${params.id}`; + const blurb = + normalizeOptionalString(next?.blurb) ?? normalizeOptionalString(existing?.blurb) ?? ""; + + return { + ...stripRequiredChannelMeta(existing), + ...stripRequiredChannelMeta(next), + id: params.id, + label, + selectionLabel, + docsPath, + blurb, + } as ChannelMeta & { id: TId }; +} diff --git a/src/commands/channel-setup/discovery.test.ts b/src/commands/channel-setup/discovery.test.ts index f67744fe6d6..bc8d30610e4 100644 --- a/src/commands/channel-setup/discovery.test.ts +++ b/src/commands/channel-setup/discovery.test.ts @@ -116,4 +116,42 @@ describe("listManifestInstalledChannelIds", () => { expect(resolved.entries.map((entry) => entry.id)).toEqual(["telegram"]); }); + + it("preserves bundled channel display metadata when installed setup plugins omit it", () => { + listChatChannels.mockReturnValue([ + { + id: "telegram", + label: "Telegram", + selectionLabel: "Telegram", + docsPath: "/channels/telegram", + blurb: "bot token", + }, + ]); + + const resolved = resolveChannelSetupEntries({ + cfg: {} as never, + installedPlugins: [ + { + id: "telegram", + meta: { + id: "telegram", + }, + } as never, + ], + workspaceDir: "/tmp/workspace", + env: { OPENCLAW_HOME: "/tmp/home" } as NodeJS.ProcessEnv, + }); + + expect(resolved.entries).toEqual([ + expect.objectContaining({ + id: "telegram", + meta: expect.objectContaining({ + label: "Telegram", + selectionLabel: "Telegram", + blurb: "bot token", + docsPath: "/channels/telegram", + }), + }), + ]); + }); }); diff --git a/src/commands/channel-setup/discovery.ts b/src/commands/channel-setup/discovery.ts index c43c00be91f..13fec3c7301 100644 --- a/src/commands/channel-setup/discovery.ts +++ b/src/commands/channel-setup/discovery.ts @@ -2,6 +2,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/ag import { listChatChannels } from "../../channels/chat-meta.js"; import { type ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js"; import { isChannelVisibleInSetup } from "../../channels/plugins/exposure.js"; +import { normalizeChannelMeta } from "../../channels/plugins/meta-normalization.js"; import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; import type { ChannelMeta } from "../../channels/plugins/types.public.js"; import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js"; @@ -89,34 +90,77 @@ export function resolveChannelSetupEntries(params: { workspaceDir, env: params.env, }); - const installedCatalogEntries = installedCatalogEntriesSource.filter( - (entry) => - !installedPluginIds.has(entry.id) && - manifestInstalledIds.has(entry.id as ChannelChoice) && - shouldShowChannelInSetup(entry.meta), - ); - const installableCatalogEntries = installableCatalogEntriesSource.filter( - (entry) => - !installedPluginIds.has(entry.id) && - !manifestInstalledIds.has(entry.id as ChannelChoice) && - shouldShowChannelInSetup(entry.meta), - ); + const installedCatalogEntries = installedCatalogEntriesSource + .filter( + (entry) => + !installedPluginIds.has(entry.id) && + manifestInstalledIds.has(entry.id as ChannelChoice) && + shouldShowChannelInSetup(entry.meta), + ) + .map((entry) => ({ + ...entry, + meta: normalizeChannelMeta({ + id: entry.id as ChannelChoice, + meta: entry.meta, + }), + })); + const installableCatalogEntries = installableCatalogEntriesSource + .filter( + (entry) => + !installedPluginIds.has(entry.id) && + !manifestInstalledIds.has(entry.id as ChannelChoice) && + shouldShowChannelInSetup(entry.meta), + ) + .map((entry) => ({ + ...entry, + meta: normalizeChannelMeta({ + id: entry.id as ChannelChoice, + meta: entry.meta, + }), + })); const metaById = new Map(); for (const meta of listChatChannels()) { - metaById.set(meta.id, meta); + metaById.set( + meta.id, + normalizeChannelMeta({ + id: meta.id, + meta, + }), + ); } for (const plugin of params.installedPlugins) { - metaById.set(plugin.id, plugin.meta); + metaById.set( + plugin.id, + normalizeChannelMeta({ + id: plugin.id, + meta: plugin.meta, + existing: metaById.get(plugin.id), + }), + ); } for (const entry of installedCatalogEntries) { if (!metaById.has(entry.id)) { - metaById.set(entry.id, entry.meta); + metaById.set( + entry.id, + normalizeChannelMeta({ + id: entry.id as ChannelChoice, + meta: entry.meta, + existing: metaById.get(entry.id), + }), + ); } } for (const entry of installableCatalogEntries) { if (!metaById.has(entry.id)) { - metaById.set(entry.id, entry.meta); + metaById.set( + entry.id, + normalizeChannelMeta({ + id: entry.id as ChannelChoice, + meta: entry.meta, + existing: metaById.get(entry.id), + }), + ); } } diff --git a/src/commands/onboard-channels.e2e.test.ts b/src/commands/onboard-channels.e2e.test.ts index 2a04e59ffaa..7e36dc9f4a6 100644 --- a/src/commands/onboard-channels.e2e.test.ts +++ b/src/commands/onboard-channels.e2e.test.ts @@ -772,6 +772,93 @@ describe("setupChannels", () => { expect(multiselect).not.toHaveBeenCalled(); }); + it("does not render undefined primer lines for malformed external setup plugins", async () => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "external-chat", + source: "test", + plugin: { + ...createChannelTestPluginBase({ + id: "external-chat", + label: "External Chat", + docsPath: "/channels/external-chat", + }), + meta: { + id: "external-chat", + }, + }, + }, + ]), + ); + + const note = vi.fn(async (_message?: string, _title?: string) => {}); + const select = vi.fn(async () => "__done__"); + const { multiselect, text } = createUnexpectedPromptGuards(); + + const prompter = createPrompter({ + note, + select: select as unknown as WizardPrompter["select"], + multiselect, + text, + }); + + await runSetupChannels({} as OpenClawConfig, prompter); + + const primerMessage = + note.mock.calls.find(([, title]) => title === "How channels work")?.[0] ?? ""; + expect(primerMessage).toContain("external-chat:"); + expect(primerMessage).not.toContain("undefined: undefined"); + expect(multiselect).not.toHaveBeenCalled(); + }); + + it("keeps malformed external setup plugins selectable without undefined labels", async () => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "external-chat", + source: "test", + plugin: { + ...createChannelTestPluginBase({ + id: "external-chat", + label: "External Chat", + docsPath: "/channels/external-chat", + }), + meta: { + id: "external-chat", + }, + }, + }, + ]), + ); + + const note = vi.fn(async (_message?: string, _title?: string) => {}); + const { multiselect, text } = createUnexpectedPromptGuards(); + const select = vi.fn(async ({ message, options }: { message: string; options: unknown[] }) => { + if (message === "Select a channel") { + const external = (options as Array<{ value: string; label?: string; hint?: string }>).find( + (entry) => entry.value === "external-chat", + ); + expect(external?.label).toBe("external-chat"); + expect(external?.hint ?? "").not.toContain("undefined"); + return "__done__"; + } + return "__done__"; + }); + + const prompter = createPrompter({ + note, + select: select as unknown as WizardPrompter["select"], + multiselect, + text, + }); + + await runSetupChannels({} as OpenClawConfig, prompter); + + expect(select).toHaveBeenCalledWith(expect.objectContaining({ message: "Select a channel" })); + expect(multiselect).not.toHaveBeenCalled(); + }); + it("keeps configured external plugin channels visible when the active registry starts empty", async () => { setActivePluginRegistry(createEmptyPluginRegistry()); catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([createMSTeamsCatalogEntry()]); diff --git a/src/flows/channel-setup.ts b/src/flows/channel-setup.ts index f0e19a54585..0ca01cdc69b 100644 --- a/src/flows/channel-setup.ts +++ b/src/flows/channel-setup.ts @@ -1,5 +1,4 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { listChatChannels } from "../channels/chat-meta.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import { getChannelSetupPlugin, @@ -138,6 +137,12 @@ export async function setupChannels( } return Array.from(merged.values()); }; + const resolveVisibleChannelEntries = () => + resolveChannelSetupEntries({ + cfg: next, + installedPlugins: listVisibleInstalledPlugins(), + workspaceDir: resolveWorkspaceDir(), + }); const loadScopedChannelPlugin = async ( channel: ChannelChoice, pluginId?: string, @@ -191,13 +196,7 @@ export async function setupChannels( }; await preloadConfiguredExternalPlugins(); - const { - installedPlugins, - catalogEntries, - installedCatalogEntries, - statusByChannel, - statusLines, - } = await collectChannelStatus({ + const { statusByChannel, statusLines } = await collectChannelStatus({ cfg: next, options, accountOverrides, @@ -218,38 +217,11 @@ export async function setupChannels( return cfg; } - const corePrimer = listChatChannels() - .filter((meta) => shouldShowChannelInSetup(meta)) - .map((meta) => ({ - id: meta.id, - label: meta.label, - blurb: meta.blurb, - })); - const coreIds = new Set(corePrimer.map((entry) => entry.id)); - const primerChannels = [ - ...corePrimer, - ...installedPlugins - .filter((plugin) => !coreIds.has(plugin.id)) - .map((plugin) => ({ - id: plugin.id, - label: plugin.meta.label, - blurb: plugin.meta.blurb, - })), - ...installedCatalogEntries - .filter((entry) => !coreIds.has(entry.id as ChannelChoice)) - .map((entry) => ({ - id: entry.id as ChannelChoice, - label: entry.meta.label, - blurb: entry.meta.blurb, - })), - ...catalogEntries - .filter((entry) => !coreIds.has(entry.id as ChannelChoice)) - .map((entry) => ({ - id: entry.id as ChannelChoice, - label: entry.meta.label, - blurb: entry.meta.blurb, - })), - ]; + const primerChannels = resolveVisibleChannelEntries().entries.map((entry) => ({ + id: entry.id, + label: entry.meta.label, + blurb: entry.meta.blurb, + })); await noteChannelPrimer(prompter, primerChannels); const quickstartDefault = @@ -302,11 +274,7 @@ export async function setupChannels( }; const getChannelEntries = () => { - const resolved = resolveChannelSetupEntries({ - cfg: next, - installedPlugins: listVisibleInstalledPlugins(), - workspaceDir: resolveWorkspaceDir(), - }); + const resolved = resolveVisibleChannelEntries(); return { entries: resolved.entries, catalogById: resolved.installableCatalogById, diff --git a/src/plugins/channel-validation.test.ts b/src/plugins/channel-validation.test.ts new file mode 100644 index 00000000000..d238cdce54e --- /dev/null +++ b/src/plugins/channel-validation.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { getChatChannelMeta } from "../channels/chat-meta.js"; +import type { ChannelPlugin } from "../channels/plugins/types.public.js"; +import { normalizeRegisteredChannelPlugin } from "./channel-validation.js"; +import type { PluginDiagnostic } from "./types.js"; + +function collectDiagnostics() { + const diagnostics: PluginDiagnostic[] = []; + return { + diagnostics, + pushDiagnostic: (diag: PluginDiagnostic) => { + diagnostics.push(diag); + }, + }; +} + +function createChannelPlugin(overrides?: Partial): ChannelPlugin { + return { + id: "demo", + meta: { + id: "demo", + label: "Demo", + selectionLabel: "Demo", + docsPath: "/channels/demo", + blurb: "demo channel", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }), + }, + ...overrides, + }; +} + +describe("normalizeRegisteredChannelPlugin", () => { + it("fills bundled channel metadata from the canonical core baseline", () => { + const { diagnostics, pushDiagnostic } = collectDiagnostics(); + + const normalized = normalizeRegisteredChannelPlugin({ + pluginId: "demo-plugin", + source: "/tmp/demo/index.ts", + plugin: createChannelPlugin({ + id: "telegram", + meta: { + id: "telegram", + } as never, + }), + pushDiagnostic, + }); + + const telegram = getChatChannelMeta("telegram"); + expect(normalized?.meta).toMatchObject({ + label: telegram.label, + selectionLabel: telegram.selectionLabel, + docsPath: telegram.docsPath, + blurb: telegram.blurb, + }); + expect(diagnostics.map((diag) => diag.message)).toEqual([ + 'channel "telegram" registered incomplete metadata; filled missing label, selectionLabel, docsPath, blurb', + ]); + }); + + it("falls back to the channel id for external channels with incomplete metadata", () => { + const { diagnostics, pushDiagnostic } = collectDiagnostics(); + + const normalized = normalizeRegisteredChannelPlugin({ + pluginId: "demo-plugin", + source: "/tmp/demo/index.ts", + plugin: createChannelPlugin({ + id: "external-chat", + meta: { + id: "external-chat", + } as never, + }), + pushDiagnostic, + }); + + expect(normalized?.id).toBe("external-chat"); + expect(normalized?.meta).toMatchObject({ + id: "external-chat", + label: "external-chat", + selectionLabel: "external-chat", + docsPath: "/channels/external-chat", + blurb: "", + }); + expect(diagnostics.map((diag) => diag.message)).toEqual([ + 'channel "external-chat" registered incomplete metadata; filled missing label, selectionLabel, docsPath, blurb', + ]); + }); + + it("warns and repairs mismatched meta ids", () => { + const { diagnostics, pushDiagnostic } = collectDiagnostics(); + + const normalized = normalizeRegisteredChannelPlugin({ + pluginId: "demo-plugin", + source: "/tmp/demo/index.ts", + plugin: createChannelPlugin({ + id: "demo", + meta: { + id: "other-demo", + label: "Demo", + selectionLabel: "Demo", + docsPath: "/channels/demo", + blurb: "demo channel", + }, + }), + pushDiagnostic, + }); + + expect(normalized?.id).toBe("demo"); + expect(normalized?.meta.id).toBe("demo"); + expect(diagnostics.map((diag) => diag.message)).toEqual([ + 'channel "demo" meta.id mismatch ("other-demo"); using registered channel id', + ]); + }); +}); diff --git a/src/plugins/channel-validation.ts b/src/plugins/channel-validation.ts new file mode 100644 index 00000000000..ed6913ac324 --- /dev/null +++ b/src/plugins/channel-validation.ts @@ -0,0 +1,100 @@ +import { listChatChannels } from "../channels/chat-meta.js"; +import { normalizeChannelMeta } from "../channels/plugins/meta-normalization.js"; +import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; +import type { ChannelMeta } from "../channels/plugins/types.public.js"; +import { + normalizeOptionalString, + normalizeStringifiedOptionalString, +} from "../shared/string-coerce.js"; +import type { PluginDiagnostic } from "./manifest-types.js"; + +function pushChannelDiagnostic(params: { + level: PluginDiagnostic["level"]; + pluginId: string; + source: string; + message: string; + pushDiagnostic: (diag: PluginDiagnostic) => void; +}) { + params.pushDiagnostic({ + level: params.level, + pluginId: params.pluginId, + source: params.source, + message: params.message, + }); +} + +function resolveBundledChannelMeta(id: string): ChannelMeta | undefined { + return listChatChannels().find((meta) => meta.id === id); +} + +function collectMissingChannelMetaFields(meta?: Partial | null): string[] { + const missing: string[] = []; + if (!normalizeOptionalString(meta?.label)) { + missing.push("label"); + } + if (!normalizeOptionalString(meta?.selectionLabel)) { + missing.push("selectionLabel"); + } + if (!normalizeOptionalString(meta?.docsPath)) { + missing.push("docsPath"); + } + if (typeof meta?.blurb !== "string") { + missing.push("blurb"); + } + return missing; +} + +export function normalizeRegisteredChannelPlugin(params: { + pluginId: string; + source: string; + plugin: ChannelPlugin; + pushDiagnostic: (diag: PluginDiagnostic) => void; +}): ChannelPlugin | null { + const id = + normalizeOptionalString(params.plugin?.id) ?? + normalizeStringifiedOptionalString(params.plugin?.id) ?? + ""; + if (!id) { + pushChannelDiagnostic({ + level: "error", + pluginId: params.pluginId, + source: params.source, + message: "channel registration missing id", + pushDiagnostic: params.pushDiagnostic, + }); + return null; + } + + const rawMeta = params.plugin.meta as Partial | undefined; + const rawMetaId = normalizeOptionalString(rawMeta?.id); + if (rawMetaId && rawMetaId !== id) { + pushChannelDiagnostic({ + level: "warn", + pluginId: params.pluginId, + source: params.source, + message: `channel "${id}" meta.id mismatch ("${rawMetaId}"); using registered channel id`, + pushDiagnostic: params.pushDiagnostic, + }); + } + + const missingFields = collectMissingChannelMetaFields(rawMeta); + if (missingFields.length > 0) { + pushChannelDiagnostic({ + level: "warn", + pluginId: params.pluginId, + source: params.source, + message: `channel "${id}" registered incomplete metadata; filled missing ${missingFields.join(", ")}`, + pushDiagnostic: params.pushDiagnostic, + }); + } + + return { + ...params.plugin, + id, + meta: normalizeChannelMeta({ + id, + meta: rawMeta, + existing: resolveBundledChannelMeta(id), + }), + }; +} diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 1dd91fca48f..d9a1696ecb5 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -2396,6 +2396,52 @@ module.exports = { id: "throws-after-import", register() {} };`, expect(registry.diagnostics.some((d) => d.level === "error")).toBe(true); }); + it("repairs incomplete registered channel metadata before storing registry entries", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "channel-meta-repair", + filename: "channel-meta-repair.cjs", + body: `module.exports = { id: "channel-meta-repair", register(api) { + api.registerChannel({ + plugin: { + id: "telegram", + meta: { + id: "telegram" + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }) + }, + outbound: { deliveryMode: "direct" } + } + }); +} };`, + }); + + const registry = loadRegistryFromSinglePlugin({ + plugin, + pluginConfig: { + allow: ["channel-meta-repair"], + }, + }); + + const telegram = registry.channels.find((entry) => entry.plugin.id === "telegram")?.plugin; + expect(telegram?.meta).toMatchObject({ + id: "telegram", + label: "Telegram", + docsPath: "/channels/telegram", + }); + expect( + registry.diagnostics.some( + (diag) => + diag.level === "warn" && + diag.message === + 'channel "telegram" registered incomplete metadata; filled missing label, selectionLabel, docsPath, blurb', + ), + ).toBe(true); + }); + it("throws when strict plugin loading sees plugin errors", () => { useNoBundledPlugins(); const plugin = writePlugin({ diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index fa6f241efbd..ed555ced8d0 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -18,12 +18,10 @@ import { } from "../infra/node-commands.js"; import { normalizePluginGatewayMethodScope } from "../shared/gateway-method-policy.js"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; -import { - normalizeOptionalString, - normalizeStringifiedOptionalString, -} from "../shared/string-coerce.js"; +import { normalizeOptionalString } from "../shared/string-coerce.js"; import { resolveUserPath } from "../utils.js"; import { buildPluginApi } from "./api-builder.js"; +import { normalizeRegisteredChannelPlugin } from "./channel-validation.js"; import { registerPluginCommand, validatePluginCommandDefinition } from "./command-registration.js"; import { getRegisteredCompactionProvider, @@ -449,18 +447,16 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { typeof (registration as OpenClawPluginChannelRegistration).plugin === "object" ? (registration as OpenClawPluginChannelRegistration) : { plugin: registration as ChannelPlugin }; - const plugin = normalized.plugin; - const id = - normalizeOptionalString(plugin?.id) ?? normalizeStringifiedOptionalString(plugin?.id) ?? ""; - if (!id) { - pushDiagnostic({ - level: "error", - pluginId: record.id, - source: record.source, - message: "channel registration missing id", - }); + const plugin = normalizeRegisteredChannelPlugin({ + pluginId: record.id, + source: record.source, + plugin: normalized.plugin, + pushDiagnostic, + }); + if (!plugin) { return; } + const id = plugin.id; const existingRuntime = registry.channels.find((entry) => entry.plugin.id === id); if (mode !== "setup-only" && existingRuntime) { pushDiagnostic({