// Channel setup tests cover setup flow prompts and config output. import { expectDefined } from "@openclaw/normalization-core/expect"; import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { applyAccountNameToChannelSection } from "../channels/plugins/setup-helpers.js"; import type { ChannelOnboardingPostWriteHook } from "../channels/plugins/setup-wizard-types.js"; import { createTestRuntime } from "../commands/test-runtime-config-helpers.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { withPluginMetadataSnapshotScope } from "../plugins/current-plugin-metadata-snapshot.js"; import { createPluginCache, getPluginCache, withPluginCache } from "../plugins/plugin-cache.js"; import { PluginInstance } from "../plugins/plugin-instance.js"; import { hasPluginLifecycleLease } from "../plugins/plugin-lifecycle-lease.js"; import * as pluginMetadata from "../plugins/plugin-metadata-snapshot.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { createInstallAccountPolicyFixture } from "../plugins/test-helpers/install-account-policy.test-support.js"; import { resolveChannelAccountEntry } from "../routing/account-lookup.js"; import { WizardCancelledError, WizardNavigationError } from "../wizard/prompts.js"; import { makeCatalogEntry, makeChannelSetupEntries, makeMeta, } from "./channel-setup.test-helpers.js"; type ChannelSetupPlugin = import("../channels/plugins/setup-wizard-types.js").ChannelSetupPlugin; type ChannelSetupWizardAdapter = import("../channels/plugins/setup-wizard-types.js").ChannelSetupWizardAdapter; type ResolveChannelSetupEntries = typeof import("../commands/channel-setup/discovery.js").resolveChannelSetupEntries; type CollectChannelStatus = typeof import("./channel-setup.status.js").collectChannelStatus; type EnsureChannelSetupPluginInstalled = typeof import("../commands/channel-setup/plugin-install.js").ensureChannelSetupPluginInstalled; type LoadChannelSetupPluginRegistrySnapshotForChannel = typeof import("../commands/channel-setup/plugin-install.js").loadChannelSetupPluginRegistrySnapshotForChannel; type PluginRegistry = ReturnType; function makeSetupPlugin(params: { id: string; label: string; setupWizard?: ChannelSetupPlugin["setupWizard"]; }): ChannelSetupPlugin { return { id: params.id as ChannelSetupPlugin["id"], meta: makeMeta(params.id, params.label), capabilities: { chatTypes: [] }, config: { resolveAccount: vi.fn(() => ({})), } as unknown as ChannelSetupPlugin["config"], ...(params.setupWizard ? { setupWizard: params.setupWizard } : {}), }; } function makeExternalChatSetupPlugin( setupWizard: Pick & Partial, ): ChannelSetupPlugin { return makeSetupPlugin({ id: "external-chat", label: "External Chat", setupWizard: { channel: "external-chat", getStatus: vi.fn(async () => ({ channel: "external-chat", configured: false, statusLines: [], })), ...setupWizard, }, }); } function externalChatSetupEntries(overrides: Partial> = {}) { return makeChannelSetupEntries({ entries: [ { id: "external-chat", meta: makeMeta("external-chat", "External Chat"), }, ], ...overrides, }); } function makePluginRegistry(overrides: Partial = {}): PluginRegistry { const registry = createEmptyPluginRegistry(); for (const key of Object.keys(overrides) as Array) { const value = overrides[key]; if (value !== undefined) { Object.assign(registry, { [key]: value }); } } return registry; } function callArg(mock: { mock: { calls: unknown[][] } }, index = 0, _type?: (value: T) => T): T { const call = expectDefined(mock.mock.calls[index], `mock call ${index}`); return call[0] as T; } function expectExternalCatalogInstallCall(index = 0) { const input = callArg<{ entry?: { id?: string; install?: { npmSpec?: string } }; autoConfirmSingleSource?: boolean; }>(ensureChannelSetupPluginInstalled, index); expect(input.entry?.id).toBe("external-chat"); expect(input.entry?.install?.npmSpec).toBe("@vendor/external-chat-plugin"); expect(input.autoConfirmSingleSource).toBe(true); } const resolveAgentWorkspaceDir = vi.hoisted(() => vi.fn((_cfg?: unknown, _agentId?: unknown) => "/tmp/openclaw-workspace"), ); const resolveDefaultAgentId = vi.hoisted(() => vi.fn((_cfg?: unknown) => "default")); const listTrustedChannelPluginCatalogEntries = vi.hoisted(() => vi.fn((_params?: unknown): unknown[] => []), ); const getTrustedChannelPluginCatalogEntry = vi.hoisted(() => vi.fn((_channelId: string, _params?: unknown): unknown => undefined), ); const getChannelSetupPlugin = vi.hoisted(() => vi.fn((_channel?: unknown) => undefined)); const listChannelSetupPlugins = vi.hoisted(() => vi.fn((): unknown[] => [])); const listActiveChannelSetupPlugins = vi.hoisted(() => vi.fn((): unknown[] => [])); const loadChannelSetupPluginRegistrySnapshotForChannel = vi.hoisted(() => vi.fn((_params: Parameters[0]) => makePluginRegistry(), ), ); const ensureChannelSetupPluginInstalled = vi.hoisted(() => vi.fn(async ({ cfg, entry }: Parameters[0]) => ({ cfg, installed: true, pluginId: entry?.pluginId, status: "installed", })), ); const resolveChannelSetupEntries = vi.hoisted(() => vi.fn( ( _params: Parameters[0], ): ReturnType => ({ entries: [], installedCatalogEntries: [], installableCatalogEntries: [], installedCatalogById: new Map(), installableCatalogById: new Map(), }), ), ); const collectChannelStatus = vi.hoisted(() => vi.fn(async (_params: Parameters[0]) => ({ installedPlugins: [], catalogEntries: [], installedCatalogEntries: [], statusByChannel: new Map(), statusLines: [], })), ); const resolveChannelSetupWorkspaceDir = vi.hoisted(() => vi.fn((_cfg?: unknown) => "/tmp/openclaw-workspace"), ); const isChannelConfigured = vi.hoisted(() => vi.fn((_cfg?: unknown, _channel?: unknown) => true)); vi.mock("../agents/agent-scope.js", () => ({ resolveAgentWorkspaceDir: (cfg?: unknown, agentId?: unknown) => resolveAgentWorkspaceDir(cfg, agentId), resolveDefaultAgentId: (cfg?: unknown) => resolveDefaultAgentId(cfg), })); vi.mock("../channels/plugins/setup-registry.js", () => ({ getChannelSetupPlugin: (channel?: unknown) => getChannelSetupPlugin(channel), listActiveChannelSetupPlugins: () => listActiveChannelSetupPlugins(), listChannelSetupPlugins: () => listChannelSetupPlugins(), })); vi.mock("../channels/registry.js", () => ({ getChatChannelMeta: (channelId: string) => ({ id: channelId, label: channelId }), listChatChannels: () => [], normalizeAnyChannelId: (channelId?: unknown) => typeof channelId === "string" ? channelId.trim().toLowerCase() || null : null, normalizeChatChannelId: (channelId?: unknown) => typeof channelId === "string" ? channelId.trim().toLowerCase() || null : null, })); vi.mock("../commands/channel-setup/discovery.js", () => ({ resolveChannelSetupEntries: (params: Parameters[0]) => resolveChannelSetupEntries(params), shouldShowChannelInSetup: () => true, })); vi.mock("../commands/channel-setup/plugin-install.js", () => ({ ensureChannelSetupPluginInstalled: (params: Parameters[0]) => ensureChannelSetupPluginInstalled(params), loadChannelSetupPluginRegistrySnapshotForChannel: ( params: Parameters[0], ) => loadChannelSetupPluginRegistrySnapshotForChannel(params), })); vi.mock("../commands/channel-setup/registry.js", () => ({ resolveChannelSetupWizardAdapterForPlugin: (plugin?: { setupWizard?: unknown }) => plugin?.setupWizard, })); vi.mock("../commands/channel-setup/trusted-catalog.js", () => ({ listTrustedChannelPluginCatalogEntries: (params?: unknown) => listTrustedChannelPluginCatalogEntries(params), getTrustedChannelPluginCatalogEntry: (channelId: string, params?: unknown) => getTrustedChannelPluginCatalogEntry(channelId, params), })); vi.mock("../config/channel-configured.js", () => ({ isChannelConfigured: (cfg?: unknown, channel?: unknown) => isChannelConfigured(cfg, channel), })); vi.mock("./channel-setup.prompts.js", () => ({ maybeConfigureCommandOwner: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => cfg), maybeConfigureDmPolicies: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => cfg), promptConfiguredAction: vi.fn(), promptRemovalAccountId: vi.fn(), formatAccountLabel: vi.fn(), })); vi.mock("./channel-setup.status.js", () => ({ collectChannelStatus: (params: Parameters[0]) => collectChannelStatus(params), findBundledSourceForCatalogChannel: vi.fn(() => undefined), noteChannelPrimer: vi.fn(), noteChannelStatus: vi.fn(), resolveCatalogChannelSelectionHint: vi.fn(() => "download from "), resolveChannelSelectionNoteLines: vi.fn(() => []), resolveChannelSetupSelectionContributions: vi.fn(() => []), resolveChannelSetupWorkspaceDir: (cfg?: unknown) => resolveChannelSetupWorkspaceDir(cfg), resolveQuickstartDefault: vi.fn(() => undefined), })); import { setupChannels } from "./channel-setup.js"; const DEFERRED_CHANNEL_SETUP_OPTIONS = { deferStatusUntilSelection: true, skipConfirm: true, skipDmPolicyPrompt: true, } satisfies NonNullable[3]>; const QUICKSTART_CHANNEL_SETUP_OPTIONS = { quickstartDefaults: true, skipConfirm: true, skipDmPolicyPrompt: true, } satisfies NonNullable[3]>; const TARGETED_CHANNEL_SETUP_OPTIONS = { initialSelection: ["external-chat"], finishAfterInitialSelection: true, deferStatusUntilSelection: true, skipDmPolicyPrompt: true, } satisfies NonNullable[3]>; function runChannelSetup( cfg: OpenClawConfig, prompter: Record, options?: Parameters[3], ) { return setupChannels( cfg, {} as never, { confirm: vi.fn(async () => true), note: vi.fn(async () => undefined), ...prompter, } as never, options, ); } describe("setupChannels workspace shadow exclusion", () => { const tempDirs = useAutoCleanupTempDirTracker(afterEach); beforeEach(() => { vi.clearAllMocks(); resolveAgentWorkspaceDir.mockReturnValue("/tmp/openclaw-workspace"); resolveDefaultAgentId.mockReturnValue("default"); resolveChannelSetupWorkspaceDir.mockReturnValue("/tmp/openclaw-workspace"); listTrustedChannelPluginCatalogEntries.mockReturnValue([ { id: "external-chat", pluginId: "@vendor/external-chat-plugin", origin: "bundled", }, ]); getTrustedChannelPluginCatalogEntry.mockReturnValue(undefined); getChannelSetupPlugin.mockReturnValue(undefined); listActiveChannelSetupPlugins.mockReturnValue([]); listChannelSetupPlugins.mockReturnValue([]); loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue(makePluginRegistry()); ensureChannelSetupPluginInstalled.mockImplementation(async ({ cfg, entry }) => ({ cfg, installed: true, pluginId: entry?.pluginId, status: "installed", })); resolveChannelSetupEntries.mockReturnValue(makeChannelSetupEntries()); collectChannelStatus.mockResolvedValue({ installedPlugins: [], catalogEntries: [], installedCatalogEntries: [], statusByChannel: new Map(), statusLines: [], }); isChannelConfigured.mockReturnValue(true); }); it("preloads configured external plugins from the trusted catalog boundary", async () => { await runChannelSetup({}, { confirm: vi.fn(async () => false) }); const trustedInput = callArg<{ cfg?: unknown; workspaceDir?: string }>( listTrustedChannelPluginCatalogEntries, ); expect(trustedInput.cfg).toEqual({}); expect(trustedInput.workspaceDir).toBe("/tmp/openclaw-workspace"); const registryInput = callArg<{ channel?: string; pluginId?: string; workspaceDir?: string; }>(loadChannelSetupPluginRegistrySnapshotForChannel); expect(registryInput.channel).toBe("external-chat"); expect(registryInput.pluginId).toBe("@vendor/external-chat-plugin"); expect(registryInput.workspaceDir).toBe("/tmp/openclaw-workspace"); }); it("resolves plugin discovery through the channel setup workspace owner", async () => { const cfg = { agents: { ownership: "explicit", defaults: { systemAgent: { agentId: "main" } }, entries: { main: {}, helper: {}, third: {} }, }, } as unknown as OpenClawConfig; resolveDefaultAgentId.mockImplementationOnce(() => { throw new Error("legacy default resolver must not own channel setup"); }); await runChannelSetup(cfg, { confirm: vi.fn(async () => false) }); expect(resolveChannelSetupWorkspaceDir).toHaveBeenCalledWith(cfg); }); it("does not load or probe channels when optional deferred setup is declined", async () => { const cfg = { agents: { ownership: "explicit" as const, entries: { alpha: {}, beta: {} } } }; const result = await runChannelSetup( cfg, { confirm: vi.fn(async () => false) }, { workspaceDir: "/tmp/beta-workspace", deferStatusUntilSelection: true }, ); expect(result).toBe(cfg); expect(collectChannelStatus).not.toHaveBeenCalled(); expect(listTrustedChannelPluginCatalogEntries).not.toHaveBeenCalled(); expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled(); }); it("keeps trusted workspace overrides eligible during preload", async () => { listTrustedChannelPluginCatalogEntries.mockReturnValue([ { id: "external-chat", pluginId: "trusted-external-chat-shadow", origin: "workspace" }, ]); await runChannelSetup( { plugins: { enabled: true, allow: ["trusted-external-chat-shadow"], }, } as never, { confirm: vi.fn(async () => false) }, ); const registryInput = callArg<{ channel?: string; pluginId?: string; workspaceDir?: string; }>(loadChannelSetupPluginRegistrySnapshotForChannel); expect(registryInput.channel).toBe("external-chat"); expect(registryInput.pluginId).toBe("trusted-external-chat-shadow"); expect(registryInput.workspaceDir).toBe("/tmp/openclaw-workspace"); }); it("defers status and setup-plugin loads until a channel is selected", async () => { resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); const select = vi.fn(async () => "__done__"); await runChannelSetup( {}, { select }, { deferStatusUntilSelection: true, skipConfirm: true, }, ); expect(callArg<{ message?: string }>(select).message).toBe("Select a channel"); expect(collectChannelStatus).not.toHaveBeenCalled(); expect(listTrustedChannelPluginCatalogEntries).not.toHaveBeenCalled(); expect(listChannelSetupPlugins).not.toHaveBeenCalled(); expect(getChannelSetupPlugin).not.toHaveBeenCalled(); expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled(); }); it("puts skip first and selected by default in QuickStart channel selection", async () => { resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); const select = vi.fn(async () => "__skip__"); await runChannelSetup( {}, { select }, { deferStatusUntilSelection: true, quickstartDefaults: true, skipConfirm: true, }, ); const prompt = callArg<{ message?: string; options?: Array<{ value: string; label: string }>; initialValue?: string; searchable?: boolean; }>(select); expect(prompt.message).toBe("Select channel (QuickStart)"); expect(prompt.options?.[0]).toEqual( expect.objectContaining({ value: "__skip__", label: "Skip for now", }), ); expect(prompt.initialValue).toBe("__skip__"); expect(prompt.searchable).toBe(true); }); it("keeps already-active setup plugins in the deferred picker without registry fallback", async () => { const activePlugin = { ...makeSetupPlugin({ id: "custom-chat", label: "Custom Chat" }), }; listActiveChannelSetupPlugins.mockReturnValue([activePlugin]); resolveChannelSetupEntries.mockImplementation(() => ({ entries: [], installedCatalogEntries: [], installableCatalogEntries: [], installedCatalogById: new Map(), installableCatalogById: new Map(), })); const select = vi.fn(async () => "__done__"); await runChannelSetup( {}, { select }, { deferStatusUntilSelection: true, skipConfirm: true, }, ); expect( callArg<{ installedPlugins?: unknown[] }>(resolveChannelSetupEntries).installedPlugins, ).toEqual([activePlugin]); expect(listChannelSetupPlugins).not.toHaveBeenCalled(); expect(collectChannelStatus).not.toHaveBeenCalled(); }); it("enables an active deferred setup plugin when explicitly selected", async () => { const setupWizard = { channel: "custom-chat", getStatus: vi.fn(async () => ({ channel: "custom-chat", configured: false, statusLines: [], })), configure: vi.fn(async ({ cfg }: { cfg: Record }) => ({ cfg: { ...cfg, channels: { "custom-chat": { token: "secret" }, }, }, })), }; const activePlugin = makeSetupPlugin({ id: "custom-chat", label: "Custom Chat", setupWizard, }); listActiveChannelSetupPlugins.mockReturnValue([activePlugin]); resolveChannelSetupEntries.mockReturnValue( makeChannelSetupEntries({ entries: [ { id: "custom-chat", meta: makeMeta("custom-chat", "Custom Chat"), }, ], installedCatalogEntries: [], installableCatalogEntries: [], installedCatalogById: new Map(), installableCatalogById: new Map(), }), ); const select = vi.fn().mockResolvedValueOnce("custom-chat").mockResolvedValueOnce("__done__"); const next = await runChannelSetup({}, { select }, DEFERRED_CHANNEL_SETUP_OPTIONS); expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled(); expect(callArg<{ cfg?: unknown }>(setupWizard.configure).cfg).toEqual({ plugins: { entries: { "custom-chat": { enabled: true }, }, }, }); expect(next).toEqual({ plugins: { entries: { "custom-chat": { enabled: true }, }, }, channels: { "custom-chat": { token: "secret" }, }, }); }); it("normalizes official external compatibility output from interactive setup", async () => { const setupWizard = { channel: "qqbot", getStatus: vi.fn(async () => ({ channel: "qqbot", configured: false, statusLines: [], })), configure: vi.fn(async () => ({ cfg: { channels: { qqbot: { appId: "app-id", clientSecret: "secret", allowFrom: ["*"], }, }, }, })), }; const activePlugin = makeSetupPlugin({ id: "qqbot", label: "QQ Bot", setupWizard }); listActiveChannelSetupPlugins.mockReturnValue([activePlugin]); resolveChannelSetupEntries.mockReturnValue( makeChannelSetupEntries({ entries: [{ id: "qqbot", meta: makeMeta("qqbot", "QQ Bot") }], }), ); const select = vi.fn().mockResolvedValueOnce("qqbot").mockResolvedValueOnce("__done__"); const next = await runChannelSetup({}, { select }, DEFERRED_CHANNEL_SETUP_OPTIONS); expect(next.channels?.qqbot).toMatchObject({ dmPolicy: "open", allowFrom: ["openclaw:approval-disabled"], }); }); it("allowlists ClickClack when it is explicitly selected for setup", async () => { const setupWizard = { channel: "clickclack", getStatus: vi.fn(async () => ({ channel: "clickclack", configured: false, statusLines: [], })), configure: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ cfg: { ...cfg, channels: { ...cfg.channels, clickclack: { ...cfg.channels?.clickclack, token: "secret", }, }, }, })), }; const clickClackPlugin = makeSetupPlugin({ id: "clickclack", label: "ClickClack", setupWizard, }); resolveChannelSetupEntries.mockReturnValue( makeChannelSetupEntries({ entries: [ { id: "clickclack", meta: makeMeta("clickclack", "ClickClack"), }, ], }), ); loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue( makePluginRegistry({ channelSetups: [ { pluginId: "clickclack", source: "bundled", enabled: true, plugin: clickClackPlugin, }, ], }), ); const select = vi.fn().mockResolvedValueOnce("clickclack").mockResolvedValueOnce("__done__"); const next = await runChannelSetup( { plugins: { allow: ["memory-core"], }, } as never, { select }, DEFERRED_CHANNEL_SETUP_OPTIONS, ); expect(next.plugins?.allow).toEqual(["memory-core", "clickclack"]); expect(next.plugins?.entries?.clickclack?.enabled).toBe(true); expect(next.channels?.clickclack).toEqual({ enabled: true, token: "secret", }); }); it("loads the selected bundled catalog plugin without writing explicit plugin enablement", async () => { const configure = vi.fn(async ({ cfg }: { cfg: Record }) => ({ cfg: { ...cfg, channels: { "external-chat": { token: "secret" }, }, } as never, })); const setupWizard = { channel: "external-chat", getStatus: vi.fn(async () => ({ channel: "external-chat", configured: false, statusLines: [], })), configure, } as ChannelSetupPlugin["setupWizard"]; const externalChatPlugin = makeSetupPlugin({ id: "external-chat", label: "External Chat", setupWizard, }); const installedCatalogEntry = makeCatalogEntry("external-chat", "External Chat", { pluginId: "external-chat", origin: "bundled", }); resolveChannelSetupEntries.mockReturnValue( externalChatSetupEntries({ installedCatalogEntries: [installedCatalogEntry], installedCatalogById: new Map([["external-chat", installedCatalogEntry]]), }), ); loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue( makePluginRegistry({ channels: [ { pluginId: "external-chat", source: "bundled", plugin: externalChatPlugin, }, ], }), ); const select = vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__"); const next = await runChannelSetup({}, { select }, DEFERRED_CHANNEL_SETUP_OPTIONS); expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledTimes(2); const firstRegistryInput = callArg<{ channel?: string; pluginId?: string; workspaceDir?: string; forceSetupOnlyChannelPlugins?: boolean; }>(loadChannelSetupPluginRegistrySnapshotForChannel, 0); expect(firstRegistryInput.channel).toBe("external-chat"); expect(firstRegistryInput.pluginId).toBe("external-chat"); expect(firstRegistryInput.workspaceDir).toBe("/tmp/openclaw-workspace"); expect(firstRegistryInput.forceSetupOnlyChannelPlugins).toBe(true); const secondRegistryInput = callArg<{ channel?: string; workspaceDir?: string; forceSetupOnlyChannelPlugins?: boolean; }>(loadChannelSetupPluginRegistrySnapshotForChannel, 1); expect(secondRegistryInput.channel).toBe("external-chat"); expect(secondRegistryInput.workspaceDir).toBe("/tmp/openclaw-workspace"); expect(secondRegistryInput.forceSetupOnlyChannelPlugins).toBe(true); expect(getChannelSetupPlugin).not.toHaveBeenCalled(); expect(collectChannelStatus).not.toHaveBeenCalled(); expect(callArg<{ cfg?: unknown }>(configure).cfg).toEqual({}); expect(next).toEqual({ channels: { "external-chat": { token: "secret" }, }, }); }); it("returns to quickstart selection when install-on-demand is skipped", async () => { const configure = vi.fn(async ({ cfg }: { cfg: Record }) => ({ cfg })); const externalChatPlugin = makeExternalChatSetupPlugin({ configure }); const installableCatalogEntry = makeCatalogEntry("external-chat", "External Chat", { pluginId: "@vendor/external-chat-plugin", }); resolveChannelSetupEntries.mockReturnValue( externalChatSetupEntries({ installableCatalogEntries: [installableCatalogEntry], installableCatalogById: new Map([["external-chat", installableCatalogEntry]]), }), ); ensureChannelSetupPluginInstalled .mockResolvedValueOnce({ cfg: {}, installed: false, pluginId: "@vendor/external-chat-plugin", status: "skipped", }) .mockResolvedValueOnce({ cfg: {}, installed: true, pluginId: "@vendor/external-chat-plugin", status: "installed", }); loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue( makePluginRegistry({ channelSetups: [ { pluginId: "@vendor/external-chat-plugin", source: "global", enabled: true, plugin: externalChatPlugin, }, ], }), ); let quickstartSelectionCount = 0; const select = vi.fn(async ({ message }: { message: string }) => { if (message === "Select channel (QuickStart)") { quickstartSelectionCount += 1; return "external-chat"; } return "__done__"; }); await runChannelSetup({}, { select }, QUICKSTART_CHANNEL_SETUP_OPTIONS); expect(quickstartSelectionCount).toBe(2); expect(ensureChannelSetupPluginInstalled).toHaveBeenCalledTimes(2); expect(configure).toHaveBeenCalledTimes(1); }); it("returns an external install confirmation to channel selection before installing", async () => { const installableCatalogEntry = makeCatalogEntry("external-chat", "External Chat", { pluginId: "@vendor/external-chat-plugin", }); resolveChannelSetupEntries.mockReturnValue( externalChatSetupEntries({ installableCatalogEntries: [installableCatalogEntry], installableCatalogById: new Map([["external-chat", installableCatalogEntry]]), }), ); ensureChannelSetupPluginInstalled.mockImplementationOnce(async ({ cfg, prompter }) => { await prompter.confirm({ message: "Install External Chat?", initialValue: true, }); return { cfg, installed: true, pluginId: "@vendor/external-chat-plugin", status: "installed", }; }); const select = vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__"); const confirm = vi.fn(async () => { throw new WizardNavigationError("back"); }); const cfg = { channels: { telegram: { botToken: "keep" } } } as OpenClawConfig; const result = await runChannelSetup(cfg, { confirm, select }, DEFERRED_CHANNEL_SETUP_OPTIONS); expect(confirm).toHaveBeenCalledWith( expect.objectContaining({ message: "Install External Chat?", navigation: { canGoBack: true, canGoForward: false }, }), ); expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled(); expect(result).toEqual(cfg); }); it.each(["installed-catalog", "trusted-catalog-fallback"] as const)( "returns the %s reinstall confirmation to channel selection", async (source) => { const catalogEntry = makeCatalogEntry("external-chat", "External Chat", { pluginId: "@vendor/external-chat-plugin", install: { npmSpec: "@vendor/external-chat-plugin" }, }); resolveChannelSetupEntries.mockReturnValue( source === "installed-catalog" ? externalChatSetupEntries({ installedCatalogEntries: [catalogEntry], installedCatalogById: new Map([["external-chat", catalogEntry]]), }) : externalChatSetupEntries(), ); if (source === "trusted-catalog-fallback") { getTrustedChannelPluginCatalogEntry.mockReturnValue(catalogEntry); } loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue(makePluginRegistry()); ensureChannelSetupPluginInstalled.mockImplementationOnce(async ({ cfg, prompter }) => { await prompter.confirm({ message: "Reinstall External Chat?", initialValue: true, }); return { cfg, installed: true, pluginId: "@vendor/external-chat-plugin", status: "installed", }; }); const confirm = vi.fn(async () => { throw new WizardNavigationError("back"); }); const cfg = { channels: { "external-chat": { token: "keep" } } } as OpenClawConfig; const result = await runChannelSetup( cfg, { confirm, select: vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__"), }, DEFERRED_CHANNEL_SETUP_OPTIONS, ); expect(confirm).toHaveBeenCalledWith( expect.objectContaining({ message: "Reinstall External Chat?", navigation: { canGoBack: true, canGoForward: false }, }), ); expect(result).toEqual(cfg); }, ); it("enters an explicitly targeted channel before the generic setup prompts", async () => { const promptOrder: string[] = []; const configureInteractive = vi.fn(async ({ cfg }) => { promptOrder.push("channel setup"); return { cfg: { ...cfg, channels: { ...cfg.channels, "external-chat": { token: "configured" } }, }, accountId: "external-account", }; }); const externalChatPlugin = makeExternalChatSetupPlugin({ configure: vi.fn(), configureInteractive, }); resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); const confirm = vi.fn(async () => { promptOrder.push("setup confirmation"); return true; }); const select = vi.fn(async () => { promptOrder.push("channel picker"); return "__done__"; }); const result = await runChannelSetup({}, { confirm, select }, TARGETED_CHANNEL_SETUP_OPTIONS); expect(promptOrder).toEqual(["channel setup"]); expect(confirm).not.toHaveBeenCalled(); expect(configureInteractive).toHaveBeenCalledTimes(1); expect(result).toMatchObject({ channels: { "external-chat": { token: "configured" } }, plugins: { entries: { "external-chat": { enabled: true } } }, }); }); it("fails visibly when configured channel setup has no post-write hook sink", async () => { const externalChatPlugin = makeExternalChatSetupPlugin({ configure: vi.fn(), configureInteractive: vi.fn(async ({ cfg }) => ({ cfg: { ...cfg, channels: { ...cfg.channels, "external-chat": { token: "configured" } }, }, accountId: "external-account", })), afterConfigWritten: vi.fn(async () => {}), }); resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); await expect( setupChannels( {} as OpenClawConfig, {} as never, { confirm: vi.fn(async () => true), note: vi.fn(async () => undefined), select: vi.fn(async () => "__done__"), } as never, { initialSelection: ["external-chat"], finishAfterInitialSelection: true, deferStatusUntilSelection: true, skipDmPolicyPrompt: true, }, ), ).rejects.toThrow(/post-write hook.*transaction sink/i); }); it("keeps completed setup when the follow-up status refresh fails", async () => { const getStatus = vi .fn() .mockResolvedValueOnce({ channel: "external-chat", configured: false, statusLines: [], }) .mockRejectedValue(new Error("controlled status failure")); const externalChatPlugin = makeExternalChatSetupPlugin({ getStatus, configure: vi.fn(async ({ cfg }) => ({ cfg: { ...cfg, channels: { ...cfg.channels, "external-chat": { token: "configured" } }, }, accountId: "external-account", })), }); resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); const note = vi.fn(async () => undefined); const result = await runChannelSetup( {}, { note, select: vi.fn(async () => "__done__") }, TARGETED_CHANNEL_SETUP_OPTIONS, ); expect(result).toMatchObject({ channels: { "external-chat": { token: "configured" } }, }); expect(note).toHaveBeenCalledWith( "Status unavailable (controlled status failure).\n" + "Retry: openclaw channels status --channel external-chat", "Channel status", ); }); it("returns targeted channel setup Back navigation to the channel picker", async () => { const promptOrder: string[] = []; const configureInteractive = vi.fn(async ({ prompter }) => { promptOrder.push("channel setup"); await prompter.text({ message: "External Chat token" }); return { cfg: { channels: { "external-chat": { token: "should-not-apply" } }, } as OpenClawConfig, accountId: "external-account", }; }); const externalChatPlugin = makeExternalChatSetupPlugin({ configure: vi.fn(), configureInteractive, }); resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); const select = vi.fn(async () => { promptOrder.push("channel picker"); return "__done__"; }); const cfg = { channels: { telegram: { botToken: "keep" } } } as OpenClawConfig; const result = await runChannelSetup( cfg, { select, text: vi.fn(async () => { throw new WizardNavigationError("back"); }), }, TARGETED_CHANNEL_SETUP_OPTIONS, ); expect(promptOrder).toEqual(["channel setup", "channel picker"]); expect(select).toHaveBeenCalledWith( expect.objectContaining({ message: "Select a channel", }), ); expect(result).toEqual(cfg); }); it("returns custom channel setup to channel selection when its first prompt goes back", async () => { const configureInteractive = vi.fn(async ({ prompter }) => { await prompter.text({ message: "Custom channel token" }); return { cfg: { channels: { "external-chat": { token: "should-not-apply" } }, } as OpenClawConfig, accountId: "custom-account", }; }); const externalChatPlugin = makeExternalChatSetupPlugin({ configure: vi.fn(), configureInteractive, }); resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); const select = vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__"); const text = vi.fn(async () => { throw new WizardNavigationError("back"); }); const result = await runChannelSetup( { channels: { telegram: { botToken: "keep" } } } as OpenClawConfig, { select, text }, DEFERRED_CHANNEL_SETUP_OPTIONS, ); expect(configureInteractive).toHaveBeenCalledTimes(1); expect(text).toHaveBeenCalledWith( expect.objectContaining({ message: "Custom channel token", navigation: { canGoBack: true, canGoForward: false }, }), ); expect(select).toHaveBeenCalledTimes(2); expect(result).toEqual({ channels: { telegram: { botToken: "keep" } } }); }); it("returns declarative channel setup to channel selection when its first prompt goes back", async () => { const configure = vi.fn(async ({ cfg, prompter }) => { await prompter.text({ message: "Declarative channel token" }); return { cfg: { ...cfg, channels: { ...cfg.channels, "external-chat": { token: "should-not-apply" } }, }, accountId: "declarative-account", }; }); const externalChatPlugin = makeExternalChatSetupPlugin({ configure }); resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); const select = vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__"); const text = vi.fn(async () => { throw new WizardNavigationError("back"); }); const result = await runChannelSetup( { channels: { telegram: { botToken: "keep" } } } as OpenClawConfig, { select, text }, DEFERRED_CHANNEL_SETUP_OPTIONS, ); expect(configure).toHaveBeenCalledTimes(1); expect(text).toHaveBeenCalledWith( expect.objectContaining({ message: "Declarative channel token", navigation: { canGoBack: true, canGoForward: false }, }), ); expect(select).toHaveBeenCalledTimes(2); expect(result).toEqual({ channels: { telegram: { botToken: "keep" } } }); }); it("returns configured channel actions to selection when their first prompt goes back", async () => { const configureWhenConfigured = vi.fn(async ({ cfg, prompter }) => { await prompter.select({ message: "Configured channel action", options: [{ value: "update", label: "Update" }], }); return { cfg: { ...cfg, channels: { ...cfg.channels, "external-chat": { token: "should-not-apply" } }, }, }; }); const externalChatPlugin = makeExternalChatSetupPlugin({ getStatus: vi.fn(async () => ({ channel: "external-chat", configured: true, statusLines: [], })), configure: vi.fn(), configureWhenConfigured, }); resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); const select = vi .fn() .mockResolvedValueOnce("external-chat") .mockRejectedValueOnce(new WizardNavigationError("back")) .mockResolvedValueOnce("__done__"); const cfg = { channels: { "external-chat": { token: "keep" } }, } as OpenClawConfig; const result = await runChannelSetup(cfg, { select }, DEFERRED_CHANNEL_SETUP_OPTIONS); expect(configureWhenConfigured).toHaveBeenCalledTimes(1); expect(select).toHaveBeenNthCalledWith( 2, expect.objectContaining({ message: "Configured channel action", navigation: { canGoBack: true, canGoForward: false }, }), ); expect(result).toEqual(cfg); }); it("rolls back reversible plugin enablement when channel setup goes back", async () => { const configure = vi.fn(async ({ cfg, prompter }) => { await prompter.text({ message: "ClickClack token" }); return { cfg }; }); const clickClackPlugin = makeSetupPlugin({ id: "clickclack", label: "ClickClack", setupWizard: { channel: "clickclack", getStatus: vi.fn(async () => ({ channel: "clickclack", configured: false, statusLines: [], })), configure, } as ChannelSetupPlugin["setupWizard"], }); resolveChannelSetupEntries.mockReturnValue( makeChannelSetupEntries({ entries: [{ id: "clickclack", meta: makeMeta("clickclack", "ClickClack") }], }), ); loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue( makePluginRegistry({ channelSetups: [ { pluginId: "clickclack", source: "bundled", enabled: true, plugin: clickClackPlugin, }, ], }), ); const select = vi.fn().mockResolvedValueOnce("clickclack").mockResolvedValueOnce("__done__"); const text = vi.fn(async () => { throw new WizardNavigationError("back"); }); const cfg = { plugins: { allow: ["memory-core"] } } as OpenClawConfig; const result = await runChannelSetup(cfg, { select, text }, DEFERRED_CHANNEL_SETUP_OPTIONS); expect(result).toEqual(cfg); expect(result.plugins?.allow).toEqual(["memory-core"]); expect(result.plugins?.entries?.clickclack).toBeUndefined(); }); it("preserves accepted external install metadata when later channel setup goes back", async () => { const configure = vi.fn(async ({ cfg, prompter }) => { await prompter.text({ message: "External Chat token" }); return { cfg, accountId: "external-account" }; }); const externalChatPlugin = makeExternalChatSetupPlugin({ configure }); const installableCatalogEntry = makeCatalogEntry("external-chat", "External Chat", { pluginId: "@vendor/external-chat-plugin", }); resolveChannelSetupEntries.mockReturnValue( externalChatSetupEntries({ installableCatalogEntries: [installableCatalogEntry], installableCatalogById: new Map([["external-chat", installableCatalogEntry]]), }), ); loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue( makePluginRegistry({ channelSetups: [ { pluginId: "@vendor/external-chat-plugin", source: "global", enabled: true, plugin: externalChatPlugin, }, ], }), ); ensureChannelSetupPluginInstalled.mockImplementationOnce(async (params) => { await params.beforePersistentEffect?.(); return { cfg: { ...params.cfg, plugins: { ...params.cfg.plugins, entries: { ...params.cfg.plugins?.entries, "@vendor/external-chat-plugin": { enabled: true }, }, installs: { ...params.cfg.plugins?.installs, "@vendor/external-chat-plugin": { source: "npm", spec: "@vendor/external-chat-plugin@1.0.0", }, }, }, }, installed: true, pluginId: "@vendor/external-chat-plugin", status: "installed", }; }); const select = vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__"); const beforePersistentEffect = vi.fn(async () => undefined); const result = await runChannelSetup( { channels: { telegram: { botToken: "keep" } } } as OpenClawConfig, { select, text: vi.fn(async () => { throw new WizardNavigationError("back"); }), }, { beforePersistentEffect, ...DEFERRED_CHANNEL_SETUP_OPTIONS, }, ); expect(result.channels).toEqual({ telegram: { botToken: "keep" } }); expect(result.plugins?.entries?.["@vendor/external-chat-plugin"]?.enabled).toBe(true); expect(result.plugins?.installs?.["@vendor/external-chat-plugin"]?.spec).toBe( "@vendor/external-chat-plugin@1.0.0", ); expect(beforePersistentEffect).toHaveBeenCalledTimes(1); }); it("replays earlier prompts when Back is used inside channel setup", async () => { const configure = vi.fn(async ({ cfg, prompter }) => { const username = await prompter.text({ message: "Username" }); const token = await prompter.text({ message: "Token" }); return { cfg: { ...cfg, channels: { ...cfg.channels, "external-chat": { username, token } } }, }; }); const externalChatPlugin = makeExternalChatSetupPlugin({ configure }); resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); const text = vi .fn() .mockResolvedValueOnce("old-name") .mockRejectedValueOnce(new WizardNavigationError("back")) .mockResolvedValueOnce("new-name") .mockResolvedValueOnce("secret-token"); const result = await runChannelSetup( {}, { select: vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__"), text, }, DEFERRED_CHANNEL_SETUP_OPTIONS, ); expect(configure).toHaveBeenCalledTimes(2); expect(result.channels?.["external-chat"]).toEqual({ username: "new-name", token: "secret-token", }); expect(expectDefined(text.mock.calls[2], "third text prompt call")[0]).toEqual( expect.objectContaining({ initialValue: "old-name", navigation: { canGoBack: true, canGoForward: true }, }), ); }); it("disables Back after a channel declares a persistent effect boundary", async () => { const configure = vi.fn(async ({ cfg, prompter, options }) => { await prompter.text({ message: "Before effect" }); await options.beforePersistentEffect?.(); await prompter.text({ message: "After effect" }); return { cfg }; }); const externalChatPlugin = makeExternalChatSetupPlugin({ configure }); resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); const navigationError = new WizardNavigationError("back"); const text = vi.fn().mockResolvedValueOnce("ready").mockRejectedValueOnce(navigationError); const beforePersistentEffect = vi.fn(async () => undefined); await expect( setupChannels( {} as OpenClawConfig, {} as never, { confirm: vi.fn(async () => true), note: vi.fn(async () => undefined), select: vi.fn().mockResolvedValueOnce("external-chat"), text, } as never, { beforePersistentEffect, deferStatusUntilSelection: true, skipConfirm: true, skipDmPolicyPrompt: true, }, ), ).rejects.toBe(navigationError); expect(beforePersistentEffect).toHaveBeenCalledTimes(1); expect(expectDefined(text.mock.calls[1], "second text prompt call")[0]).toEqual( expect.objectContaining({ navigation: { canGoBack: false, canGoForward: false }, }), ); }); it("propagates Ctrl-C cancellation from channel setup", async () => { const cancelled = new WizardCancelledError(); const configure = vi.fn(async ({ prompter }) => { await prompter.text({ message: "Token" }); return { cfg: {} as OpenClawConfig }; }); const externalChatPlugin = makeExternalChatSetupPlugin({ configure }); resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); await expect( setupChannels( {} as OpenClawConfig, {} as never, { confirm: vi.fn(async () => true), note: vi.fn(async () => undefined), select: vi.fn().mockResolvedValueOnce("external-chat"), text: vi.fn(async () => { throw cancelled; }), } as never, { deferStatusUntilSelection: true, skipConfirm: true, skipDmPolicyPrompt: true, }, ), ).rejects.toBe(cancelled); }); it("keeps an explicitly disabled channel unchanged when setup resume is declined", async () => { const setupWizard = { channel: "external-chat", getStatus: vi.fn(async () => ({ channel: "external-chat", configured: true, statusLines: [], })), configure: vi.fn(), }; const externalChatPlugin = makeSetupPlugin({ id: "external-chat", label: "External Chat", setupWizard: setupWizard as ChannelSetupPlugin["setupWizard"], }); resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); const select = vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__"); const confirm = vi.fn(async () => false); const cfg = { channels: { "external-chat": { enabled: false, token: "secret" }, }, }; const next = await runChannelSetup( cfg as never, { confirm, select }, DEFERRED_CHANNEL_SETUP_OPTIONS, ); expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled(); expect(confirm).toHaveBeenCalledWith({ message: "external-chat is disabled. Enable it and continue setup now?", initialValue: true, }); expect(setupWizard.configure).not.toHaveBeenCalled(); expect(next).toEqual({ channels: { "external-chat": { enabled: false, token: "secret" }, }, }); }); it("resumes an explicitly selected disabled channel without replacing its config", async () => { const setupWizard = { channel: "external-chat", getStatus: vi.fn(async () => ({ channel: "external-chat", configured: true, statusLines: [], })), configure: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ cfg, accountId: "default", })), }; const externalChatPlugin = makeSetupPlugin({ id: "external-chat", label: "External Chat", setupWizard: setupWizard as ChannelSetupPlugin["setupWizard"], }); resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); const onSelection = vi.fn(); const cfg = { channels: { "external-chat": { enabled: false, token: "saved-secret" }, }, }; const next = await runChannelSetup( cfg as never, { select: vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__") }, { ...DEFERRED_CHANNEL_SETUP_OPTIONS, onSelection }, ); expect(setupWizard.configure).toHaveBeenCalledWith( expect.objectContaining({ cfg: { channels: { "external-chat": { enabled: true, token: "saved-secret" }, }, plugins: { entries: { "external-chat": { enabled: true }, }, }, }, }), ); expect(onSelection).toHaveBeenCalledWith(["external-chat"]); expect(next).toEqual({ channels: { "external-chat": { enabled: true, token: "saved-secret" }, }, plugins: { entries: { "external-chat": { enabled: true }, }, }, }); }); it("enables an explicitly selected disabled plugin and continues setup", async () => { const setupWizard = { channel: "external-chat", getStatus: vi.fn(async () => ({ channel: "external-chat", configured: true, statusLines: [], })), configure: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ cfg, accountId: "default", })), }; const externalChatPlugin = makeSetupPlugin({ id: "external-chat", label: "External Chat", setupWizard: setupWizard as ChannelSetupPlugin["setupWizard"], }); resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); const confirm = vi.fn(async () => true); const next = await runChannelSetup( { channels: { "external-chat": { enabled: true, token: "saved-secret" }, }, plugins: { entries: { "external-chat": { enabled: false }, }, }, } as never, { confirm, select: vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__"), }, DEFERRED_CHANNEL_SETUP_OPTIONS, ); expect(confirm).toHaveBeenCalledWith({ message: "external-chat plugin is disabled. Enable it and continue setup now?", initialValue: true, }); expect(setupWizard.configure).toHaveBeenCalledOnce(); expect(next.plugins?.entries?.["external-chat"]?.enabled).toBe(true); expect(next.channels?.["external-chat"]?.enabled).toBe(true); }); it("returns paused setup state without marking the channel configured", async () => { const pausedConfig = { channels: { "external-chat": { enabled: false, relayUrl: "wss://relay.example.com", privateKey: "saved-secret", }, }, }; const setupWizard = { channel: "external-chat", getStatus: vi.fn(async () => ({ channel: "external-chat", configured: false, statusLines: [], })), configure: vi.fn(async () => ({ cfg: pausedConfig as OpenClawConfig, completion: "paused" as const, })), }; const externalChatPlugin = makeSetupPlugin({ id: "external-chat", label: "External Chat", setupWizard: setupWizard as ChannelSetupPlugin["setupWizard"], }); resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); const select = vi.fn().mockResolvedValue("external-chat"); const onSelection = vi.fn(); const next = await runChannelSetup( {}, { select }, { ...DEFERRED_CHANNEL_SETUP_OPTIONS, onSelection }, ); expect(select).toHaveBeenCalledOnce(); expect(onSelection).toHaveBeenCalledWith([]); expect(next).toBe(pausedConfig); }); it("honors global plugin disablement before lazy channel setup loads plugins", async () => { resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); const select = vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__"); const note = vi.fn(async () => undefined); const cfg = { plugins: { enabled: false }, channels: { "external-chat": { enabled: true, token: "secret" }, }, }; await runChannelSetup(cfg as never, { note, select }, DEFERRED_CHANNEL_SETUP_OPTIONS); expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled(); expect(note).toHaveBeenCalledWith( "external-chat cannot be configured while plugins disabled. Enable it before setup.", "Channel setup", ); }); it( "setupChannels reinstalls the external plugin via catalog when a stale channel config " + "declares an already-installed plugin whose runtime cannot be loaded", async () => { // Regression: users who uninstalled an externalized channel plugin // (qqbot / imessage / discord / ...) while a non-empty // `channels.` entry remained in their config got dead-ended with // " plugin not available" because the installed-catalog // branch did not fall back to the catalog install flow. const fixture = createInstallAccountPolicyFixture( tempDirs.make("openclaw-wizard-install-policy-"), "external-chat", ); const cfg = fixture.config; const beforeInstall = fixture.readMetadata(); const resolveMetadata = vi .spyOn(pluginMetadata, "resolvePluginMetadataSnapshot") .mockImplementation(({ config, workspaceDir }) => fixture.readMetadata(config, workspaceDir), ); const readAccount = (config: OpenClawConfig) => resolveChannelAccountEntry( asOptionalRecord(asOptionalRecord(config.channels?.["external-chat"])?.accounts), "work-phone", "external-chat", ); const statusAccounts: unknown[] = []; const postWriteAccounts: unknown[] = []; const hooks: ChannelOnboardingPostWriteHook[] = []; const configure = vi.fn(async ({ cfg: current }: { cfg: OpenClawConfig }) => ({ cfg: applyAccountNameToChannelSection({ cfg: current, channelKey: "external-chat", accountId: "work-phone", name: "Work calls", }), accountId: "work-phone", })); const installedCatalogEntry = makeCatalogEntry("external-chat", "External Chat", { pluginId: "@vendor/external-chat-plugin", install: { npmSpec: "@vendor/external-chat-plugin" }, }); resolveChannelSetupEntries.mockReturnValue( externalChatSetupEntries({ installedCatalogEntries: [installedCatalogEntry], installedCatalogById: new Map([["external-chat", installedCatalogEntry]]), }), ); // First snapshot (pre-install) is empty — plugin runtime is gone. // After `ensureChannelSetupPluginInstalled` runs, subsequent snapshots // resolve the plugin as expected. loadChannelSetupPluginRegistrySnapshotForChannel .mockReturnValueOnce(makePluginRegistry()) .mockImplementation(() => { const instance = new PluginInstance("installed-channel"); getPluginCache().instances.add(instance); const externalChatPlugin = makeExternalChatSetupPlugin({ configure, configureInteractive: instance.wrap(configure), getStatus: instance.wrap(async ({ cfg: current }) => { const account = readAccount(current); statusAccounts.push(account); return { channel: "external-chat", configured: Boolean(account), statusLines: [] }; }), afterConfigWritten: instance.wrap(({ cfg: current }) => { expect(hasPluginLifecycleLease()).toBe(false); postWriteAccounts.push(readAccount(current)); }), }); return makePluginRegistry({ channels: [ { pluginId: "@vendor/external-chat-plugin", source: "global", plugin: externalChatPlugin, }, ], }); }); ensureChannelSetupPluginInstalled.mockImplementationOnce(async () => { expect(hasPluginLifecycleLease()).toBe(true); fixture.installPolicy(); return { cfg, installed: true, pluginId: "@vendor/external-chat-plugin", status: "installed", }; }); isChannelConfigured.mockReturnValue(false); const note = vi.fn(async () => undefined); const select = vi .fn() .mockResolvedValueOnce("external-chat") .mockResolvedValueOnce("__done__"); try { await using cache = createPluginCache(); await withPluginCache(cache, async () => { const next = await withPluginMetadataSnapshotScope(beforeInstall, () => { expect(readAccount(cfg)).toBeUndefined(); return runChannelSetup( cfg, { note, select }, { ...DEFERRED_CHANNEL_SETUP_OPTIONS, onPostWriteHook: (hook) => hooks.push(hook), }, ); }); const account = { account: "+12025550123", name: "Work calls" }; expect(next.channels?.["external-chat"]).toEqual({ accounts: { "Work Phone": account } }); expect(statusAccounts.at(-1)).toEqual(account); expect(hooks).toHaveLength(1); await withPluginMetadataSnapshotScope(fixture.readMetadata(next), () => expectDefined(hooks[0], "collected setup hook").run({ cfg: next, runtime: createTestRuntime(), }), ); expect(postWriteAccounts).toEqual([account]); }); expect(ensureChannelSetupPluginInstalled).toHaveBeenCalledTimes(1); expectExternalCatalogInstallCall(); expect(note).not.toHaveBeenCalledWith( "external-chat plugin not available.", "Channel setup", ); expect(configure).toHaveBeenCalledTimes(1); } finally { resolveMetadata.mockRestore(); } }, ); it( "returns to channel selection when catalog-fallback install is declined " + "from the installed-catalog branch", async () => { const installedCatalogEntry = makeCatalogEntry("external-chat", "External Chat", { pluginId: "@vendor/external-chat-plugin", install: { npmSpec: "@vendor/external-chat-plugin" }, }); resolveChannelSetupEntries.mockReturnValue( externalChatSetupEntries({ installedCatalogEntries: [installedCatalogEntry], installedCatalogById: new Map([["external-chat", installedCatalogEntry]]), }), ); loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue(makePluginRegistry()); ensureChannelSetupPluginInstalled.mockResolvedValueOnce({ cfg: {}, installed: false, pluginId: "@vendor/external-chat-plugin", status: "skipped", }); isChannelConfigured.mockReturnValue(false); let quickstartSelectionCount = 0; const select = vi.fn(async ({ message }: { message: string }) => { if (message === "Select channel (QuickStart)") { quickstartSelectionCount += 1; if (quickstartSelectionCount === 1) { return "external-chat"; } } return "__skip__"; }); const note = vi.fn(async () => undefined); await runChannelSetup({}, { note, select }, QUICKSTART_CHANNEL_SETUP_OPTIONS); // Install prompt ran once, was declined; user returned to channel // selection (quickstartSelectionCount === 2) rather than being // dead-ended with a "plugin not available" note. expect(ensureChannelSetupPluginInstalled).toHaveBeenCalledTimes(1); expect(quickstartSelectionCount).toBe(2); expect(note).not.toHaveBeenCalledWith("external-chat plugin not available.", "Channel setup"); }, ); it( "auto-installs external plugin from catalog when both discovery buckets " + "are empty due to a stale `channels.` config entry", async () => { // Regression test for the real-world repro: `channels.qqbot` has stale // fields (appId/secret) from an earlier install, so // `isStaticallyChannelConfigured` drops qqbot from // `installableCatalogEntries`; qqbot isn't on disk either, so // `manifestInstalledIds` doesn't include it. Both discovery buckets // come back empty, but the channel is still selectable (entries list // does not apply the static-config filter). Before the fix, onboard // fell through to `enableBundledPluginForSetup` which just printed // "qqbot plugin not available." and exited the flow. The fix consults // the catalog directly and drives `ensureChannelSetupPluginInstalled`. const configure = vi.fn(async ({ cfg }: { cfg: Record }) => ({ cfg: { ...cfg, channels: { "external-chat": { token: "secret" } } }, })); const externalChatPlugin = makeExternalChatSetupPlugin({ configure }); // Entries list exposes the channel in the menu, but BOTH discovery // buckets are empty — faithfully reproducing the observed bug. resolveChannelSetupEntries.mockReturnValue( externalChatSetupEntries({ installedCatalogEntries: [], installableCatalogEntries: [], installedCatalogById: new Map(), installableCatalogById: new Map(), }), ); const fallbackCatalogEntry = makeCatalogEntry("external-chat", "External Chat", { pluginId: "@vendor/external-chat-plugin", install: { npmSpec: "@vendor/external-chat-plugin" }, }); getTrustedChannelPluginCatalogEntry.mockReturnValue(fallbackCatalogEntry); ensureChannelSetupPluginInstalled.mockResolvedValueOnce({ cfg: {}, installed: true, pluginId: "@vendor/external-chat-plugin", status: "installed", }); loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue( makePluginRegistry({ channels: [ { pluginId: "@vendor/external-chat-plugin", source: "global", plugin: externalChatPlugin, }, ], }), ); isChannelConfigured.mockReturnValue(false); const note = vi.fn(async () => undefined); const select = vi .fn() .mockResolvedValueOnce("external-chat") .mockResolvedValueOnce("__done__"); await runChannelSetup({}, { note, select }, DEFERRED_CHANNEL_SETUP_OPTIONS); const catalogLookupCall = expectDefined( getTrustedChannelPluginCatalogEntry.mock.calls[0], "catalog lookup call", ) as [string, { workspaceDir?: string } | undefined]; expect(catalogLookupCall[0]).toBe("external-chat"); expect(catalogLookupCall[1]?.workspaceDir).toBe("/tmp/openclaw-workspace"); expect(ensureChannelSetupPluginInstalled).toHaveBeenCalledTimes(1); expectExternalCatalogInstallCall(); expect(note).not.toHaveBeenCalledWith("external-chat plugin not available.", "Channel setup"); expect(configure).toHaveBeenCalledTimes(1); }, ); it( "returns to channel selection when the catalog-fallback install is " + "declined from the bundled-enable branch", async () => { resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); const fallbackCatalogEntry = makeCatalogEntry("external-chat", "External Chat", { pluginId: "@vendor/external-chat-plugin", install: { npmSpec: "@vendor/external-chat-plugin" }, }); getTrustedChannelPluginCatalogEntry.mockReturnValue(fallbackCatalogEntry); ensureChannelSetupPluginInstalled.mockResolvedValueOnce({ cfg: {}, installed: false, pluginId: "@vendor/external-chat-plugin", status: "skipped", }); isChannelConfigured.mockReturnValue(false); let quickstartSelectionCount = 0; const select = vi.fn(async ({ message }: { message: string }) => { if (message === "Select channel (QuickStart)") { quickstartSelectionCount += 1; if (quickstartSelectionCount === 1) { return "external-chat"; } } return "__skip__"; }); const note = vi.fn(async () => undefined); await runChannelSetup({}, { note, select }, QUICKSTART_CHANNEL_SETUP_OPTIONS); expect(ensureChannelSetupPluginInstalled).toHaveBeenCalledTimes(1); expect(quickstartSelectionCount).toBe(2); expect(note).not.toHaveBeenCalledWith("external-chat plugin not available.", "Channel setup"); }, ); it("fails closed when the catalog-fallback install guard rejects", async () => { resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); const fallbackCatalogEntry = makeCatalogEntry("external-chat", "External Chat", { pluginId: "@vendor/external-chat-plugin", install: { npmSpec: "@vendor/external-chat-plugin" }, }); getTrustedChannelPluginCatalogEntry.mockReturnValue(fallbackCatalogEntry); isChannelConfigured.mockReturnValue(false); const guardError = new Error("verified inference owner changed"); const beforePersistentEffect = vi.fn(async () => { throw guardError; }); ensureChannelSetupPluginInstalled.mockImplementationOnce(async (params) => { await params.beforePersistentEffect?.(); return { cfg: params.cfg, installed: true, pluginId: params.entry.pluginId, status: "installed", }; }); const select = vi.fn().mockResolvedValueOnce("external-chat"); await expect( setupChannels( {} as never, {} as never, { confirm: vi.fn(async () => true), note: vi.fn(async () => undefined), select, } as never, { deferStatusUntilSelection: true, skipConfirm: true, skipDmPolicyPrompt: true, beforePersistentEffect, }, ), ).rejects.toBe(guardError); expect(ensureChannelSetupPluginInstalled).toHaveBeenCalledTimes(1); expect(beforePersistentEffect).toHaveBeenCalledTimes(1); }); it( "refuses catalog-fallback install from empty discovery buckets when the " + "channel is explicitly disabled in config", async () => { // Review-note regression: the bundled-enable `else` branch used to rely // on `enableBundledPluginForSetup`'s own disabled-config guard. The // new catalog fallback runs BEFORE that helper, so it must re-apply // the same `resolveConfigDisabledHint` check — otherwise an operator- // disabled channel with a stale `channels.` entry could be // reinstalled/re-enabled silently. // // We intentionally do NOT pass `deferStatusUntilSelection` here so the // top-level `deferredDisabledHint` guard in `handleChannelChoice` is // bypassed. That isolates the guard newly added inside the catalog // fallback; without it, the test would pass against an unguarded // fallback because the QuickStart path's early guard would catch the // disabled state first. resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); const fallbackCatalogEntry = makeCatalogEntry("external-chat", "External Chat", { pluginId: "@vendor/external-chat-plugin", install: { npmSpec: "@vendor/external-chat-plugin" }, }); getTrustedChannelPluginCatalogEntry.mockReturnValue(fallbackCatalogEntry); const select = vi .fn() .mockResolvedValueOnce("external-chat") .mockResolvedValueOnce("__done__"); const note = vi.fn(async () => undefined); // Operator has explicitly disabled the plugin while a stale // `channels.` entry lingers in config. const cfg = { plugins: { entries: { "external-chat": { enabled: false } } }, channels: { "external-chat": { enabled: true, appId: "999999", clientSecret: "stale", }, }, }; await runChannelSetup( cfg as never, { note, select }, { skipConfirm: true, skipDmPolicyPrompt: true, }, ); // The new catalog fallback must NOT drive an install. expect(ensureChannelSetupPluginInstalled).not.toHaveBeenCalled(); // Instead, the same "Enable it before setup." note used by // `enableBundledPluginForSetup` should be shown. expect(note).toHaveBeenCalledWith( "external-chat cannot be configured while plugin disabled. Enable it before setup.", "Channel setup", ); }, ); it( "refuses the installed-catalog install fallback when the channel is " + "explicitly disabled in config", async () => { // Symmetric guard for the `installedCatalogEntry` fallback path. When // `loadScopedChannelPlugin` returns null and the catalog entry carries // `install.npmSpec`, the fix reaches for the catalog install flow — // but must first respect an operator-level disable, matching the // guard inside `enableBundledPluginForSetup`. // // As in the sibling test, we omit `deferStatusUntilSelection` to skip // the top-level guard and isolate the new inline guard. const installedCatalogEntry = makeCatalogEntry("external-chat", "External Chat", { pluginId: "@vendor/external-chat-plugin", install: { npmSpec: "@vendor/external-chat-plugin" }, }); resolveChannelSetupEntries.mockReturnValue( externalChatSetupEntries({ installedCatalogEntries: [installedCatalogEntry], installedCatalogById: new Map([["external-chat", installedCatalogEntry]]), }), ); loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue(makePluginRegistry()); isChannelConfigured.mockReturnValue(false); const select = vi .fn() .mockResolvedValueOnce("external-chat") .mockResolvedValueOnce("__done__"); const note = vi.fn(async () => undefined); const cfg = { plugins: { entries: { "external-chat": { enabled: false } } }, channels: { "external-chat": { enabled: true, appId: "999999", clientSecret: "stale", }, }, }; await runChannelSetup( cfg as never, { note, select }, { skipConfirm: true, skipDmPolicyPrompt: true, }, ); expect(ensureChannelSetupPluginInstalled).not.toHaveBeenCalled(); expect(note).toHaveBeenCalledWith( "external-chat cannot be configured while plugin disabled. Enable it before setup.", "Channel setup", ); }, ); }); /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */