From c8f4b8533d8b0258bc8a2112a6fc43fd904e0aba Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 23 Mar 2026 19:54:46 -0500 Subject: [PATCH] fix(cli): auto-select login-capable auth channels (#53254) thanks @BunsDev Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com> Co-authored-by: Nova --- CHANGELOG.md | 1 + src/cli/channel-auth.test.ts | 77 +++++++++++++++++++++++++----------- src/cli/channel-auth.ts | 66 +++++++++++++++++++++++++------ 3 files changed, 111 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec4daff0af2..e9d33192179 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- CLI/channel auth: auto-select the single login-capable configured channel for `channels login`/`logout` instead of relying on the outbound message-channel resolver, so env-only or non-auth channels no longer cause false ambiguity errors. (#53254) Thanks @BunsDev. - Control UI/auth: preserve operator scopes through the device-auth bypass path, ignore cached under-scoped operator tokens, and show a clear `operator.read` fallback message when a connection really lacks read scope, so operator sessions stop failing or blanking on read-backed pages. (#53110) Thanks @BunsDev. - Plugins/uninstall: accept installed `clawhub:` specs and versionless ClawHub package names as uninstall targets, so `openclaw plugins uninstall clawhub:` works again even when the recorded install was pinned to a version. - Auth/OpenAI tokens: stop live gateway auth-profile writes from reverting freshly saved credentials back to stale in-memory values, and make `models auth paste-token` write to the resolved agent store, so Configure, Onboard, and token-paste flows stop snapping back to expired OpenAI tokens. Fixes #53207. Related to #45516. diff --git a/src/cli/channel-auth.test.ts b/src/cli/channel-auth.test.ts index 952f5e0038b..171f60f9d82 100644 --- a/src/cli/channel-auth.test.ts +++ b/src/cli/channel-auth.test.ts @@ -7,10 +7,10 @@ const mocks = vi.hoisted(() => ({ getChannelPluginCatalogEntry: vi.fn(), resolveChannelDefaultAccountId: vi.fn(), getChannelPlugin: vi.fn(), + listChannelPlugins: vi.fn(), normalizeChannelId: vi.fn(), loadConfig: vi.fn(), writeConfigFile: vi.fn(), - resolveMessageChannelSelection: vi.fn(), setVerbose: vi.fn(), createClackPrompter: vi.fn(), ensureChannelSetupPluginInstalled: vi.fn(), @@ -35,6 +35,7 @@ vi.mock("../channels/plugins/helpers.js", () => ({ vi.mock("../channels/plugins/index.js", () => ({ getChannelPlugin: mocks.getChannelPlugin, + listChannelPlugins: mocks.listChannelPlugins, normalizeChannelId: mocks.normalizeChannelId, })); @@ -43,10 +44,6 @@ vi.mock("../config/config.js", () => ({ writeConfigFile: mocks.writeConfigFile, })); -vi.mock("../infra/outbound/channel-selection.js", () => ({ - resolveMessageChannelSelection: mocks.resolveMessageChannelSelection, -})); - vi.mock("../globals.js", () => ({ setVerbose: mocks.setVerbose, })); @@ -67,7 +64,10 @@ describe("channel-auth", () => { id: "whatsapp", auth: { login: mocks.login }, gateway: { logoutAccount: mocks.logoutAccount }, - config: { resolveAccount: mocks.resolveAccount }, + config: { + listAccountIds: vi.fn().mockReturnValue(["default"]), + resolveAccount: mocks.resolveAccount, + }, }; beforeEach(() => { @@ -75,18 +75,15 @@ describe("channel-auth", () => { mocks.normalizeChannelId.mockReturnValue("whatsapp"); mocks.getChannelPlugin.mockReturnValue(plugin); mocks.getChannelPluginCatalogEntry.mockReturnValue(undefined); - mocks.loadConfig.mockReturnValue({ channels: {} }); + mocks.loadConfig.mockReturnValue({ channels: { whatsapp: {} } }); mocks.writeConfigFile.mockResolvedValue(undefined); - mocks.resolveMessageChannelSelection.mockResolvedValue({ - channel: "whatsapp", - configured: ["whatsapp"], - }); + mocks.listChannelPlugins.mockReturnValue([plugin]); mocks.resolveDefaultAgentId.mockReturnValue("main"); mocks.resolveAgentWorkspaceDir.mockReturnValue("/tmp/workspace"); mocks.resolveChannelDefaultAccountId.mockReturnValue("default-account"); mocks.createClackPrompter.mockReturnValue({} as object); mocks.ensureChannelSetupPluginInstalled.mockResolvedValue({ - cfg: { channels: {} }, + cfg: { channels: { whatsapp: {} } }, installed: true, pluginId: "whatsapp", }); @@ -106,7 +103,7 @@ describe("channel-auth", () => { expect(mocks.resolveChannelDefaultAccountId).not.toHaveBeenCalled(); expect(mocks.login).toHaveBeenCalledWith( expect.objectContaining({ - cfg: { channels: {} }, + cfg: { channels: { whatsapp: {} } }, accountId: "acct-1", runtime, verbose: true, @@ -115,10 +112,9 @@ describe("channel-auth", () => { ); }); - it("auto-picks the single configured channel when opts are empty", async () => { + it("auto-picks the single configured channel that supports login when opts are empty", async () => { await runChannelLogin({}, runtime); - expect(mocks.resolveMessageChannelSelection).toHaveBeenCalledWith({ cfg: { channels: {} } }); expect(mocks.normalizeChannelId).toHaveBeenCalledWith("whatsapp"); expect(mocks.login).toHaveBeenCalledWith( expect.objectContaining({ @@ -127,12 +123,49 @@ describe("channel-auth", () => { ); }); - it("propagates channel ambiguity when channel is omitted", async () => { - mocks.resolveMessageChannelSelection.mockRejectedValueOnce( - new Error("Channel is required when multiple channels are configured: telegram, slack"), + it("ignores configured channels that do not support login when channel is omitted", async () => { + const telegramPlugin = { + id: "telegram", + auth: {}, + gateway: {}, + config: { + listAccountIds: vi.fn().mockReturnValue(["default"]), + resolveAccount: vi.fn().mockReturnValue({ enabled: true }), + }, + }; + mocks.loadConfig.mockReturnValue({ channels: { whatsapp: {}, telegram: {} } }); + mocks.listChannelPlugins.mockReturnValue([telegramPlugin, plugin]); + + await runChannelLogin({}, runtime); + + expect(mocks.normalizeChannelId).toHaveBeenCalledWith("whatsapp"); + expect(mocks.login).toHaveBeenCalled(); + }); + + it("propagates auth-channel ambiguity when multiple configured channels support login", async () => { + const zaloPlugin = { + id: "zalouser", + auth: { login: vi.fn() }, + gateway: {}, + config: { + listAccountIds: vi.fn().mockReturnValue(["default"]), + resolveAccount: vi.fn().mockReturnValue({ enabled: true }), + }, + }; + mocks.loadConfig.mockReturnValue({ channels: { whatsapp: {}, zalouser: {} } }); + mocks.listChannelPlugins.mockReturnValue([plugin, zaloPlugin]); + mocks.normalizeChannelId.mockImplementation((value) => value); + mocks.getChannelPlugin.mockImplementation((value) => + value === "whatsapp" + ? plugin + : value === "zalouser" + ? (zaloPlugin as typeof plugin) + : undefined, ); - await expect(runChannelLogin({}, runtime)).rejects.toThrow("Channel is required"); + await expect(runChannelLogin({}, runtime)).rejects.toThrow( + "multiple configured channels support login: whatsapp, zalouser", + ); expect(mocks.login).not.toHaveBeenCalled(); }); @@ -199,16 +232,16 @@ describe("channel-auth", () => { workspaceDir: "/tmp/workspace", }), ); - expect(mocks.writeConfigFile).toHaveBeenCalledWith({ channels: {} }); + expect(mocks.writeConfigFile).toHaveBeenCalledWith({ channels: { whatsapp: {} } }); expect(mocks.login).toHaveBeenCalled(); }); it("runs logout with resolved account and explicit account id", async () => { await runChannelLogout({ channel: "whatsapp", account: " acct-2 " }, runtime); - expect(mocks.resolveAccount).toHaveBeenCalledWith({ channels: {} }, "acct-2"); + expect(mocks.resolveAccount).toHaveBeenCalledWith({ channels: { whatsapp: {} } }, "acct-2"); expect(mocks.logoutAccount).toHaveBeenCalledWith({ - cfg: { channels: {} }, + cfg: { channels: { whatsapp: {} } }, accountId: "acct-2", account: { id: "resolved-account" }, runtime, diff --git a/src/cli/channel-auth.ts b/src/cli/channel-auth.ts index 46954c2ff13..71dac264b56 100644 --- a/src/cli/channel-auth.ts +++ b/src/cli/channel-auth.ts @@ -1,9 +1,12 @@ import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; -import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js"; +import { + getChannelPlugin, + listChannelPlugins, + normalizeChannelId, +} from "../channels/plugins/index.js"; import { resolveInstallableChannelPlugin } from "../commands/channel-setup/channel-plugin-resolution.js"; import { loadConfig, writeConfigFile, type OpenClawConfig } from "../config/config.js"; import { setVerbose } from "../globals.js"; -import { resolveMessageChannelSelection } from "../infra/outbound/channel-selection.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; type ChannelAuthOptions = { @@ -15,6 +18,52 @@ type ChannelAuthOptions = { type ChannelPlugin = NonNullable>; type ChannelAuthMode = "login" | "logout"; +function supportsChannelAuthMode(plugin: ChannelPlugin, mode: ChannelAuthMode): boolean { + return mode === "login" ? Boolean(plugin.auth?.login) : Boolean(plugin.gateway?.logoutAccount); +} + +function isConfiguredAuthPlugin(plugin: ChannelPlugin, cfg: OpenClawConfig): boolean { + const channelCfg = cfg.channels?.[plugin.id as keyof NonNullable]; + if (!channelCfg || typeof channelCfg !== "object") { + return false; + } + + for (const accountId of plugin.config.listAccountIds(cfg)) { + try { + const account = plugin.config.resolveAccount(cfg, accountId); + const enabled = plugin.config.isEnabled + ? plugin.config.isEnabled(account, cfg) + : account && typeof account === "object" + ? ((account as { enabled?: boolean }).enabled ?? true) + : true; + if (enabled) { + return true; + } + } catch { + continue; + } + } + + return false; +} + +function resolveConfiguredAuthChannelInput(cfg: OpenClawConfig, mode: ChannelAuthMode): string { + const configured = listChannelPlugins() + .filter((plugin): plugin is ChannelPlugin => supportsChannelAuthMode(plugin, mode)) + .filter((plugin) => isConfiguredAuthPlugin(plugin, cfg)) + .map((plugin) => plugin.id); + + if (configured.length === 1) { + return configured[0]; + } + if (configured.length === 0) { + throw new Error(`Channel is required (no configured channels support ${mode}).`); + } + throw new Error( + `Channel is required when multiple configured channels support ${mode}: ${configured.join(", ")}`, + ); +} + async function resolveChannelPluginForMode( opts: ChannelAuthOptions, mode: ChannelAuthMode, @@ -28,9 +77,7 @@ async function resolveChannelPluginForMode( plugin: ChannelPlugin; }> { const explicitChannel = opts.channel?.trim(); - const channelInput = explicitChannel - ? explicitChannel - : (await resolveMessageChannelSelection({ cfg })).channel; + const channelInput = explicitChannel || resolveConfiguredAuthChannelInput(cfg, mode); const channelId = normalizeChannelId(channelInput); if (!channelId) { throw new Error(`Unsupported channel: ${channelInput}`); @@ -41,13 +88,10 @@ async function resolveChannelPluginForMode( runtime, channelId, allowInstall: true, - supports: (candidate) => - mode === "login" ? Boolean(candidate.auth?.login) : Boolean(candidate.gateway?.logoutAccount), + supports: (candidate) => supportsChannelAuthMode(candidate, mode), }); const plugin = resolved.plugin; - const supportsMode = - mode === "login" ? Boolean(plugin?.auth?.login) : Boolean(plugin?.gateway?.logoutAccount); - if (!supportsMode) { + if (!plugin || !supportsChannelAuthMode(plugin, mode)) { throw new Error(`Channel ${channelId} does not support ${mode}`); } return { @@ -55,7 +99,7 @@ async function resolveChannelPluginForMode( configChanged: resolved.configChanged, channelInput, channelId, - plugin: plugin as ChannelPlugin, + plugin, }; }