fix(onboard): normalize channel setup metadata (#66706)

thanks @darkamenosa
This commit is contained in:
darkamenosa
2026-04-15 01:11:52 +07:00
committed by GitHub
parent a848ddaa7e
commit 58a9905976
10 changed files with 521 additions and 75 deletions

View File

@@ -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

View File

@@ -0,0 +1,49 @@
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import type { ChannelMeta } from "./types.public.js";
function stripRequiredChannelMeta(meta?: Partial<ChannelMeta> | null) {
const {
id: _ignoredId,
label: _ignoredLabel,
selectionLabel: _ignoredSelectionLabel,
docsPath: _ignoredDocsPath,
blurb: _ignoredBlurb,
...rest
} = meta ?? {};
return rest;
}
export function normalizeChannelMeta<TId extends string>(params: {
id: TId;
meta?: Partial<ChannelMeta> | null;
existing?: Partial<ChannelMeta> | 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 };
}

View File

@@ -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",
}),
}),
]);
});
});

View File

@@ -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<string, ChannelMeta>();
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),
}),
);
}
}

View File

@@ -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()]);

View File

@@ -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,

View File

@@ -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>): 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',
]);
});
});

View File

@@ -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<ChannelMeta> | 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<ChannelMeta> | 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),
}),
};
}

View File

@@ -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({

View File

@@ -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({