mirror of https://github.com/openclaw/openclaw.git
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:
parent
5cb8e33a31
commit
c8f4b8533d
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue