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 <nova@openknot.ai>
This commit is contained in:
Val Alexander
2026-03-23 19:54:46 -05:00
parent 5cb8e33a31
commit c8f4b8533d
3 changed files with 111 additions and 33 deletions

View File

@@ -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:<package>` 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.

View File

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

View File

@@ -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<ReturnType<typeof getChannelPlugin>>;
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<typeof cfg.channels>];
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,
};
}