refactor: hide qa channels with exposure metadata

This commit is contained in:
Peter Steinberger
2026-04-05 08:22:27 +01:00
parent b58f9c5258
commit d7f75ee087
16 changed files with 230 additions and 39 deletions

View File

@@ -1454,7 +1454,10 @@ Useful `openclaw.channel` fields beyond the minimal example:
- `preferOver`: lower-priority plugin/channel ids this catalog entry should outrank
- `selectionDocsPrefix`, `selectionDocsOmitLabel`, `selectionExtras`: selection-surface copy controls
- `markdownCapable`: marks the channel as markdown-capable for outbound formatting decisions
- `showConfigured`: hide the channel from configured-channel listing surfaces when set to `false`
- `exposure.configured`: hide the channel from configured-channel listing surfaces when set to `false`
- `exposure.setup`: hide the channel from interactive setup/configure pickers when set to `false`
- `exposure.docs`: mark the channel as internal/private for docs navigation surfaces
- `showConfigured` / `showInSetup`: legacy aliases still accepted for compatibility; prefer `exposure`
- `quickstartAllowFrom`: opt the channel into the standard quickstart `allowFrom` flow
- `forceAccountBinding`: require explicit account binding even when only one account exists
- `preferSessionLookupForAnnounceTarget`: prefer session lookup when resolving announce targets

View File

@@ -101,7 +101,7 @@ surfaces before runtime loads.
| `selectionDocsOmitLabel` | `boolean` | Show the docs path directly instead of a labeled docs link in selection copy. |
| `selectionExtras` | `string[]` | Extra short strings appended in selection copy. |
| `markdownCapable` | `boolean` | Marks the channel as markdown-capable for outbound formatting decisions. |
| `showConfigured` | `boolean` | Controls whether configured-channel listing surfaces show this channel. |
| `exposure` | `object` | Channel visibility controls for setup, configured lists, and docs surfaces. |
| `quickstartAllowFrom` | `boolean` | Opt this channel into the standard quickstart `allowFrom` setup flow. |
| `forceAccountBinding` | `boolean` | Require explicit account binding even when only one account exists. |
| `preferSessionLookupForAnnounceTarget` | `boolean` | Prefer session lookup when resolving announce targets for this channel. |
@@ -125,12 +125,26 @@ Example:
"selectionDocsPrefix": "Guide:",
"selectionExtras": ["Markdown"],
"markdownCapable": true,
"exposure": {
"configured": true,
"setup": true,
"docs": true
},
"quickstartAllowFrom": true
}
}
}
```
`exposure` supports:
- `configured`: include the channel in configured/status-style listing surfaces
- `setup`: include the channel in interactive setup/configure pickers
- `docs`: mark the channel as public-facing in docs/navigation surfaces
`showConfigured` and `showInSetup` remain supported as legacy aliases. Prefer
`exposure`.
### `openclaw.install`
`openclaw.install` is package metadata, not manifest metadata.

View File

@@ -1,6 +1,7 @@
import { listChannelCatalogEntries } from "../plugins/channel-catalog-registry.js";
import type { PluginPackageChannel } from "../plugins/manifest.js";
import { CHAT_CHANNEL_ORDER, type ChatChannelId } from "./ids.js";
import { resolveChannelExposure } from "./plugins/exposure.js";
import type { ChannelMeta } from "./plugins/types.js";
export type ChatChannelMeta = ChannelMeta;
@@ -15,6 +16,7 @@ function toChatChannelMeta(params: {
if (!label) {
throw new Error(`Missing label for bundled chat channel "${params.id}"`);
}
const exposure = resolveChannelExposure(params.channel);
return {
id: params.id,
@@ -43,9 +45,7 @@ function toChatChannelMeta(params: {
...(params.channel.markdownCapable !== undefined
? { markdownCapable: params.channel.markdownCapable }
: {}),
...(params.channel.showConfigured !== undefined
? { showConfigured: params.channel.showConfigured }
: {}),
exposure,
...(params.channel.quickstartAllowFrom !== undefined
? { quickstartAllowFrom: params.channel.quickstartAllowFrom }
: {}),

View File

@@ -7,6 +7,7 @@ import type { OpenClawPackageManifest } from "../../plugins/manifest.js";
import type { PluginPackageChannel, PluginPackageInstall } from "../../plugins/manifest.js";
import type { PluginOrigin } from "../../plugins/types.js";
import { isRecord, resolveConfigDir, resolveUserPath } from "../../utils.js";
import { resolveChannelExposure } from "./exposure.js";
import type { ChannelMeta } from "./types.js";
export type ChannelUiMetaEntry = {
@@ -180,6 +181,7 @@ function toChannelMeta(params: {
const docsPath = params.channel.docsPath?.trim() || `/channels/${params.id}`;
const blurb = params.channel.blurb?.trim() || "";
const systemImage = params.channel.systemImage?.trim();
const exposure = resolveChannelExposure(params.channel);
return {
id: params.id,
@@ -203,9 +205,7 @@ function toChannelMeta(params: {
...(params.channel.markdownCapable !== undefined
? { markdownCapable: params.channel.markdownCapable }
: {}),
...(params.channel.showConfigured !== undefined
? { showConfigured: params.channel.showConfigured }
: {}),
exposure,
...(params.channel.quickstartAllowFrom !== undefined
? { quickstartAllowFrom: params.channel.quickstartAllowFrom }
: {}),

View File

@@ -0,0 +1,29 @@
import type { ChannelMeta } from "./types.js";
export function resolveChannelExposure(
meta: Pick<ChannelMeta, "exposure" | "showConfigured" | "showInSetup">,
) {
return {
configured: meta.exposure?.configured ?? meta.showConfigured ?? true,
setup: meta.exposure?.setup ?? meta.showInSetup ?? true,
docs: meta.exposure?.docs ?? true,
};
}
export function isChannelVisibleInConfiguredLists(
meta: Pick<ChannelMeta, "exposure" | "showConfigured" | "showInSetup">,
): boolean {
return resolveChannelExposure(meta).configured;
}
export function isChannelVisibleInSetup(
meta: Pick<ChannelMeta, "exposure" | "showConfigured" | "showInSetup">,
): boolean {
return resolveChannelExposure(meta).setup;
}
export function isChannelVisibleInDocs(
meta: Pick<ChannelMeta, "exposure" | "showConfigured" | "showInSetup">,
): boolean {
return resolveChannelExposure(meta).docs;
}

View File

@@ -14,6 +14,12 @@ import type { ChannelMessageCapability } from "./message-capabilities.js";
export type ChannelId = ChatChannelId | (string & {});
export type ChannelExposure = {
configured?: boolean;
setup?: boolean;
docs?: boolean;
};
export type ChannelOutboundTargetMode = "explicit" | "implicit" | "heartbeat";
/** Agent tool registered by a channel plugin. */
@@ -147,7 +153,9 @@ export type ChannelMeta = {
detailLabel?: string;
systemImage?: string;
markdownCapable?: boolean;
exposure?: ChannelExposure;
showConfigured?: boolean;
showInSetup?: boolean;
quickstartAllowFrom?: boolean;
forceAccountBinding?: boolean;
preferSessionLookupForAnnounceTarget?: boolean;

View File

@@ -1,3 +1,4 @@
import { isChannelVisibleInConfiguredLists } from "../channels/plugins/exposure.js";
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import {
getChannelPlugin,
@@ -104,7 +105,7 @@ function shouldShowProviderEntry(entry: ProviderAccountStatus, cfg: OpenClawConf
if (!plugin) {
return Boolean(entry.configured);
}
if (plugin.meta.showConfigured === false) {
if (!isChannelVisibleInConfiguredLists(plugin.meta)) {
const providerConfig = (cfg as Record<string, unknown>)[plugin.id];
return Boolean(entry.configured) || Boolean(providerConfig);
}

View File

@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginAutoEnableResult } from "../../config/plugin-auto-enable.js";
const loadPluginManifestRegistry = vi.hoisted(() => vi.fn());
const listChannelPluginCatalogEntries = vi.hoisted(() => vi.fn((): unknown[] => []));
const listChatChannels = vi.hoisted(() => vi.fn((): Array<Record<string, string>> => []));
const applyPluginAutoEnable = vi.hoisted(() =>
vi.fn<(args: { config: unknown; env?: NodeJS.ProcessEnv }) => PluginAutoEnableResult>(
({ config }) => ({
@@ -21,11 +23,24 @@ vi.mock("../../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable(args as { config: unknown; env?: NodeJS.ProcessEnv }),
}));
import { listManifestInstalledChannelIds } from "./discovery.js";
vi.mock("../../channels/plugins/catalog.js", () => ({
listChannelPluginCatalogEntries: (_args?: unknown) => listChannelPluginCatalogEntries(),
}));
vi.mock("../../channels/registry.js", () => ({
listChatChannels: () => listChatChannels(),
}));
import { listManifestInstalledChannelIds, resolveChannelSetupEntries } from "./discovery.js";
describe("listManifestInstalledChannelIds", () => {
beforeEach(() => {
loadPluginManifestRegistry.mockReset();
loadPluginManifestRegistry.mockReset().mockReturnValue({
plugins: [],
diagnostics: [],
});
listChannelPluginCatalogEntries.mockReset().mockReturnValue([]);
listChatChannels.mockReset().mockReturnValue([]);
applyPluginAutoEnable.mockReset().mockImplementation(({ config }) => ({
config: config as never,
changes: [] as string[],
@@ -68,4 +83,37 @@ describe("listManifestInstalledChannelIds", () => {
});
expect(installedIds).toEqual(new Set(["slack"]));
});
it("filters channels hidden from setup out of interactive entries", () => {
listChatChannels.mockReturnValue([
{
id: "telegram",
label: "Telegram",
selectionLabel: "Telegram",
docsPath: "/channels/telegram",
blurb: "bot token",
},
]);
const resolved = resolveChannelSetupEntries({
cfg: {} as never,
installedPlugins: [
{
id: "qa-channel",
meta: {
id: "qa-channel",
label: "QA Channel",
selectionLabel: "QA Channel",
docsPath: "/channels/qa-channel",
blurb: "synthetic",
exposure: { setup: false },
},
} as never,
],
workspaceDir: "/tmp/workspace",
env: { OPENCLAW_HOME: "/tmp/home" } as NodeJS.ProcessEnv,
});
expect(resolved.entries.map((entry) => entry.id)).toEqual(["telegram"]);
});
});

View File

@@ -3,6 +3,7 @@ import {
listChannelPluginCatalogEntries,
type ChannelPluginCatalogEntry,
} from "../../channels/plugins/catalog.js";
import { isChannelVisibleInSetup } from "../../channels/plugins/exposure.js";
import type { ChannelMeta, ChannelPlugin } from "../../channels/plugins/types.js";
import { listChatChannels } from "../../channels/registry.js";
import type { OpenClawConfig } from "../../config/config.js";
@@ -15,6 +16,12 @@ type ChannelCatalogEntry = {
meta: ChannelMeta;
};
export function shouldShowChannelInSetup(
meta: Pick<ChannelMeta, "exposure" | "showConfigured" | "showInSetup">,
): boolean {
return isChannelVisibleInSetup(meta);
}
export type ResolvedChannelSetupEntries = {
entries: ChannelCatalogEntry[];
installedCatalogEntries: ChannelPluginCatalogEntry[];
@@ -71,11 +78,15 @@ export function resolveChannelSetupEntries(params: {
const catalogEntries = listChannelPluginCatalogEntries({ workspaceDir });
const installedCatalogEntries = catalogEntries.filter(
(entry) =>
!installedPluginIds.has(entry.id) && manifestInstalledIds.has(entry.id as ChannelChoice),
!installedPluginIds.has(entry.id) &&
manifestInstalledIds.has(entry.id as ChannelChoice) &&
shouldShowChannelInSetup(entry.meta),
);
const installableCatalogEntries = catalogEntries.filter(
(entry) =>
!installedPluginIds.has(entry.id) && !manifestInstalledIds.has(entry.id as ChannelChoice),
!installedPluginIds.has(entry.id) &&
!manifestInstalledIds.has(entry.id as ChannelChoice) &&
shouldShowChannelInSetup(entry.meta),
);
const metaById = new Map<string, ChannelMeta>();
@@ -100,7 +111,7 @@ export function resolveChannelSetupEntries(params: {
entries: Array.from(metaById, ([id, meta]) => ({
id: id as ChannelChoice,
meta,
})),
})).filter((entry) => shouldShowChannelInSetup(entry.meta)),
installedCatalogEntries,
installableCatalogEntries,
installedCatalogById: new Map(

View File

@@ -1,4 +1,5 @@
import { loadAuthProfileStore } from "../../agents/auth-profiles.js";
import { isChannelVisibleInConfiguredLists } from "../../channels/plugins/exposure.js";
import { listChannelPlugins } from "../../channels/plugins/index.js";
import { buildChannelAccountSnapshot } from "../../channels/plugins/status.js";
import type { ChannelAccountSnapshot, ChannelPlugin } from "../../channels/plugins/types.js";
@@ -47,7 +48,7 @@ function formatLinked(value: boolean): string {
}
function shouldShowConfigured(channel: ChannelPlugin): boolean {
return channel.meta.showConfigured !== false;
return isChannelVisibleInConfiguredLists(channel.meta);
}
function formatAccountLine(params: {

View File

@@ -5,6 +5,7 @@ import { CONFIG_PATH } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import { note } from "../terminal/note.js";
import { shortenHomePath } from "../utils.js";
import { shouldShowChannelInSetup } from "./channel-setup/discovery.js";
import { confirm, select } from "./configure.shared.js";
import { guardCancel } from "./onboard-helpers.js";
@@ -17,6 +18,7 @@ export async function removeChannelConfigWizard(
const listConfiguredChannels = () =>
listChannelPlugins()
.map((plugin) => plugin.meta)
.filter((meta) => shouldShowChannelInSetup(meta))
.filter((meta) => next.channels?.[meta.id] !== undefined);
while (true) {

View File

@@ -818,6 +818,51 @@ describe("setupChannels", () => {
expect(multiselect).not.toHaveBeenCalled();
});
it("hides channels marked hidden from setup in the picker", async () => {
const qaChannelBase = createChannelTestPluginBase({
id: "qa-channel",
label: "QA Channel",
docsPath: "/channels/qa-channel",
});
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "qa-channel",
source: "test",
plugin: {
...qaChannelBase,
meta: {
...qaChannelBase.meta,
showInSetup: false,
},
},
},
]),
);
const select = vi.fn(async ({ message, options }: { message: string; options: unknown[] }) => {
if (message === "Select a channel") {
expect(
(options as Array<{ label?: string }>).some((option) =>
option.label?.includes("QA Channel"),
),
).toBe(false);
}
return "__done__";
});
const { multiselect, text } = createUnexpectedPromptGuards();
const prompter = createPrompter({
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("treats installed external plugin channels as installed without reinstall prompts", async () => {
setActivePluginRegistry(createEmptyPluginRegistry());
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([createMSTeamsCatalogEntry()]);

View File

@@ -9,6 +9,7 @@ import {
} from "../channels/registry.js";
import { formatCliCommand } from "../cli/command-format.js";
import { resolveChannelSetupEntries } from "../commands/channel-setup/discovery.js";
import { shouldShowChannelInSetup } from "../commands/channel-setup/discovery.js";
import { resolveChannelSetupWizardAdapterForPlugin } from "../commands/channel-setup/registry.js";
import type {
ChannelSetupWizardAdapter,
@@ -79,6 +80,9 @@ export async function collectChannelStatus(params: {
));
const statusEntries = await Promise.all(
installedPlugins.flatMap((plugin) => {
if (!shouldShowChannelInSetup(plugin.meta)) {
return [];
}
const adapter = resolveAdapter(plugin.id);
if (!adapter) {
return [];
@@ -92,6 +96,7 @@ export async function collectChannelStatus(params: {
);
const statusByChannel = new Map(statusEntries.map((entry) => [entry.channel, entry]));
const fallbackStatuses = listChatChannels()
.filter((meta) => shouldShowChannelInSetup(meta))
.filter((meta) => !statusByChannel.has(meta.id))
.map((meta) => {
const configured = isChannelConfigured(params.cfg, meta.id);
@@ -235,22 +240,31 @@ export function resolveChannelSelectionNoteLines(params: {
export function resolveChannelSetupSelectionContributions(params: {
entries: Array<{
id: ChannelChoice;
meta: { id: string; label: string; selectionLabel?: string };
meta: {
id: string;
label: string;
selectionLabel?: string;
exposure?: { setup?: boolean };
showConfigured?: boolean;
showInSetup?: boolean;
};
}>;
statusByChannel: Map<ChannelChoice, { selectionHint?: string }>;
resolveDisabledHint: (channel: ChannelChoice) => string | undefined;
}): ChannelSetupSelectionContribution[] {
return params.entries.map((entry) => {
const disabledHint = params.resolveDisabledHint(entry.id);
const hint =
[params.statusByChannel.get(entry.id)?.selectionHint, disabledHint]
.filter(Boolean)
.join(" · ") || undefined;
return buildChannelSetupSelectionContribution({
channel: entry.id,
label: entry.meta.selectionLabel ?? entry.meta.label,
hint,
source: listChatChannels().some((channel) => channel.id === entry.id) ? "core" : "plugin",
return params.entries
.filter((entry) => shouldShowChannelInSetup(entry.meta))
.map((entry) => {
const disabledHint = params.resolveDisabledHint(entry.id);
const hint =
[params.statusByChannel.get(entry.id)?.selectionHint, disabledHint]
.filter(Boolean)
.join(" · ") || undefined;
return buildChannelSetupSelectionContribution({
channel: entry.id,
label: entry.meta.selectionLabel ?? entry.meta.label,
hint,
source: listChatChannels().some((channel) => channel.id === entry.id) ? "core" : "plugin",
});
});
});
}

View File

@@ -8,7 +8,10 @@ import {
import type { ChannelSetupPlugin } from "../channels/plugins/setup-wizard-types.js";
import { listChatChannels } from "../channels/registry.js";
import { formatCliCommand } from "../cli/command-format.js";
import { resolveChannelSetupEntries } from "../commands/channel-setup/discovery.js";
import {
resolveChannelSetupEntries,
shouldShowChannelInSetup,
} from "../commands/channel-setup/discovery.js";
import {
ensureChannelSetupPluginInstalled,
loadChannelSetupPluginRegistrySnapshotForChannel,
@@ -98,10 +101,14 @@ export async function setupChannels(
const listVisibleInstalledPlugins = (): ChannelSetupPlugin[] => {
const merged = new Map<string, ChannelSetupPlugin>();
for (const plugin of listChannelSetupPlugins()) {
merged.set(plugin.id, plugin);
if (shouldShowChannelInSetup(plugin.meta)) {
merged.set(plugin.id, plugin);
}
}
for (const plugin of scopedPluginsById.values()) {
merged.set(plugin.id, plugin);
if (shouldShowChannelInSetup(plugin.meta)) {
merged.set(plugin.id, plugin);
}
}
return Array.from(merged.values());
};
@@ -181,11 +188,13 @@ export async function setupChannels(
return cfg;
}
const corePrimer = listChatChannels().map((meta) => ({
id: meta.id,
label: meta.label,
blurb: meta.blurb,
}));
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,

View File

@@ -1,5 +1,6 @@
import { CHAT_CHANNEL_ORDER, type ChatChannelId } from "../channels/ids.js";
import { emptyChannelConfigSchema } from "../channels/plugins/config-schema.js";
import { resolveChannelExposure } from "../channels/plugins/exposure.js";
import { buildAccountScopedDmSecurityPolicy } from "../channels/plugins/helpers.js";
import {
createScopedAccountReplyToModeResolver,
@@ -231,6 +232,7 @@ function toSdkChatChannelMeta(params: {
if (!label) {
throw new Error(`Missing label for bundled chat channel "${params.id}"`);
}
const exposure = resolveChannelExposure(params.channel);
return {
id: params.id,
label,
@@ -258,9 +260,7 @@ function toSdkChatChannelMeta(params: {
...(params.channel.markdownCapable !== undefined
? { markdownCapable: params.channel.markdownCapable }
: {}),
...(params.channel.showConfigured !== undefined
? { showConfigured: params.channel.showConfigured }
: {}),
exposure,
...(params.channel.quickstartAllowFrom !== undefined
? { quickstartAllowFrom: params.channel.quickstartAllowFrom }
: {}),

View File

@@ -428,7 +428,13 @@ export type PluginPackageChannel = {
selectionDocsOmitLabel?: boolean;
selectionExtras?: readonly string[];
markdownCapable?: boolean;
exposure?: {
configured?: boolean;
setup?: boolean;
docs?: boolean;
};
showConfigured?: boolean;
showInSetup?: boolean;
quickstartAllowFrom?: boolean;
forceAccountBinding?: boolean;
preferSessionLookupForAnnounceTarget?: boolean;