Files
openclaw/src/cli/channel-auth.ts
OfflynAI def7dd2401 fix(channels): restart gateway when channels.start fails for unloaded plugin (#90779)
* fix(channels): restart gateway when channels.start fails for unloaded plugin

After channels login, the CLI calls channels.start to reconcile the running
gateway. If the channel plugin was installed or enabled after the gateway
started, the plugin is not in the runtime registry and channels.start rejects
with 'invalid channels.start channel'. Detect this error and fall back to
gateway.restart.request so the gateway reloads plugins and starts the channel
automatically.

Fixes #90296

* ci: retrigger checks (update proof section format)

Co-authored-by: Cursor <cursoragent@cursor.com>

* ci: retrigger proof check (real behavior evidence)

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(channels): narrow unloaded-plugin restart

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-16 20:21:06 -07:00

345 lines
11 KiB
TypeScript

import { expectDefined } from "@openclaw/normalization-core";
// Channel login/logout command helpers for local config and gateway reconciliation.
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { sanitizeForLog } from "../../packages/terminal-core/src/ansi.js";
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import {
getChannelPlugin,
listChannelPlugins,
normalizeChannelId,
} from "../channels/plugins/index.js";
import { resolveInstallableChannelPlugin } from "../commands/channel-setup/channel-plugin-resolution.js";
import { getRuntimeConfig, readConfigFileSnapshot, type OpenClawConfig } from "../config/config.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import { callGateway } from "../gateway/call.js";
import { setVerbose } from "../globals.js";
import { formatErrorMessage } from "../infra/errors.js";
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
import { commitConfigWithPendingPluginInstalls } from "../plugins/install-record-commit.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { formatCliCommand } from "./command-format.js";
import { formatUnsupportedChannelActionMessage } from "./error-format.js";
type ChannelAuthOptions = {
channel?: string;
account?: string;
verbose?: boolean;
};
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 key = plugin.id;
if (isBlockedObjectKey(key)) {
return false;
}
const channelCfg = (cfg.channels as Record<string, unknown> | undefined)?.[key];
if (
channelCfg &&
typeof channelCfg === "object" &&
"enabled" in channelCfg &&
(channelCfg as { enabled?: unknown }).enabled === false
) {
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 expectDefined(configured[0], "configured entry at 0");
}
if (configured.length === 0) {
throw new Error(
`No configured channel supports ${mode}. Run ${formatCliCommand("openclaw channels status")} to inspect channels or ${formatCliCommand("openclaw channels add --channel <channel>")} to add one.`,
);
}
const safeIds = configured.map(sanitizeForLog);
throw new Error(
`Multiple configured channels support ${mode}: ${safeIds.join(", ")}. Choose one with --channel <channel>.`,
);
}
async function resolveChannelPluginForMode(
opts: ChannelAuthOptions,
mode: ChannelAuthMode,
cfg: OpenClawConfig,
runtime: RuntimeEnv,
): Promise<{
cfg: OpenClawConfig;
configChanged: boolean;
channelInput: string;
channelId: string;
plugin: ChannelPlugin;
}> {
const explicitChannel = opts.channel?.trim();
const channelInput = explicitChannel || resolveConfiguredAuthChannelInput(cfg, mode);
const normalizedChannelId = normalizeChannelId(channelInput);
const resolved = await resolveInstallableChannelPlugin({
cfg,
runtime,
rawChannel: channelInput,
...(normalizedChannelId ? { channelId: normalizedChannelId } : {}),
allowInstall: true,
supports: (candidate) => supportsChannelAuthMode(candidate, mode),
});
const channelId = resolved.channelId ?? normalizedChannelId;
if (!channelId) {
throw new Error(
`Unsupported channel "${channelInput}". Run ${formatCliCommand("openclaw channels list")} to see available channels.`,
);
}
const plugin = resolved.plugin;
if (!plugin || !supportsChannelAuthMode(plugin, mode)) {
throw new Error(
formatUnsupportedChannelActionMessage({
channel: channelId,
action: mode,
inspectCommand: "openclaw channels status --channel " + channelId,
}),
);
}
return {
cfg: resolved.cfg,
configChanged: resolved.configChanged,
channelInput,
channelId,
plugin,
};
}
function resolveAccountContext(
plugin: ChannelPlugin,
opts: ChannelAuthOptions,
cfg: OpenClawConfig,
) {
const accountId =
normalizeOptionalString(opts.account) || resolveChannelDefaultAccountId({ plugin, cfg });
return { accountId };
}
function isChannelMissingFromGatewayRegistry(error: unknown): error is Error {
const requestError = error as (Error & { gatewayCode?: unknown }) | undefined;
return (
requestError instanceof Error &&
requestError.name === "GatewayClientRequestError" &&
requestError.gatewayCode === "INVALID_REQUEST" &&
requestError.message === "invalid channels.start channel"
);
}
async function reconcileGatewayRuntimeAfterLocalLogin(params: {
cfg: OpenClawConfig;
plugin: ChannelPlugin;
channelId: string;
accountId: string;
runtime: RuntimeEnv;
}) {
// Local auth writes are durable even when the gateway restart hook is unavailable or remote.
if (!params.plugin.gateway?.startAccount) {
return;
}
if (params.cfg.gateway?.mode === "remote") {
params.runtime.log(
`Gateway is in remote mode; local login saved auth for ${params.channelId}/${params.accountId} but did not start the remote runtime.`,
);
return;
}
try {
await callGateway({
config: params.cfg,
method: "channels.start",
params: {
channel: params.channelId,
accountId: params.accountId,
},
mode: GATEWAY_CLIENT_MODES.BACKEND,
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
deviceIdentity: null,
});
} catch (error) {
// A plugin installed or enabled after Gateway startup is absent from its
// process-stable registry. Restart only for that exact RPC rejection.
if (isChannelMissingFromGatewayRegistry(error)) {
try {
await callGateway({
config: params.cfg,
method: "gateway.restart.request",
params: { reason: `channel login: load ${params.channelId}` },
mode: GATEWAY_CLIENT_MODES.BACKEND,
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
deviceIdentity: null,
});
params.runtime.log(
`Gateway restart requested to load ${params.channelId}; the channel will start after restart.`,
);
return;
} catch {
// Fall through to the generic warning if the restart request also fails.
}
}
params.runtime.log(
`Local login saved auth for ${params.channelId}/${params.accountId}, but the running gateway did not restart it: ${formatErrorMessage(error)}`,
);
}
}
async function logoutViaGatewayRuntime(params: {
cfg: OpenClawConfig;
channelId: string;
accountId: string;
runtime: RuntimeEnv;
}): Promise<boolean> {
try {
await callGateway({
config: params.cfg,
method: "channels.logout",
params: {
channel: params.channelId,
accountId: params.accountId,
},
mode: GATEWAY_CLIENT_MODES.BACKEND,
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
deviceIdentity: null,
});
return true;
} catch (error) {
if (params.cfg.gateway?.mode === "remote") {
throw error;
}
params.runtime.log(
`Local logout will clear auth for ${params.channelId}/${params.accountId}, but the running gateway did not stop it: ${formatErrorMessage(error)}`,
);
return false;
}
}
export async function runChannelLogin(
opts: ChannelAuthOptions,
runtime: RuntimeEnv = defaultRuntime,
) {
const sourceSnapshotPromise = readConfigFileSnapshot().catch(() => null);
const autoEnabled = applyPluginAutoEnable({
config: getRuntimeConfig(),
env: process.env,
});
const loadedCfg = autoEnabled.config;
const resolvedChannel = await resolveChannelPluginForMode(opts, "login", loadedCfg, runtime);
let cfg = resolvedChannel.cfg;
const { configChanged, channelInput, plugin } = resolvedChannel;
if (autoEnabled.changes.length > 0 || configChanged) {
const committed = await commitConfigWithPendingPluginInstalls({
nextConfig: cfg,
baseHash: (await sourceSnapshotPromise)?.hash,
});
cfg = committed.config;
}
const login = plugin.auth?.login;
if (!login) {
throw new Error(
formatUnsupportedChannelActionMessage({
channel: channelInput,
action: "login",
inspectCommand: "openclaw channels status --channel " + channelInput,
}),
);
}
// Auth-only flow: do not mutate channel config here.
setVerbose(Boolean(opts.verbose));
const { accountId } = resolveAccountContext(plugin, opts, cfg);
await login({
cfg,
accountId,
runtime,
verbose: Boolean(opts.verbose),
channelInput,
});
await reconcileGatewayRuntimeAfterLocalLogin({
cfg,
plugin,
channelId: plugin.id,
accountId,
runtime,
});
}
export async function runChannelLogout(
opts: ChannelAuthOptions,
runtime: RuntimeEnv = defaultRuntime,
) {
const sourceSnapshotPromise = readConfigFileSnapshot().catch(() => null);
const autoEnabled = applyPluginAutoEnable({
config: getRuntimeConfig(),
env: process.env,
});
const loadedCfg = autoEnabled.config;
const resolvedChannel = await resolveChannelPluginForMode(opts, "logout", loadedCfg, runtime);
let cfg = resolvedChannel.cfg;
const { configChanged, channelInput, plugin } = resolvedChannel;
if (autoEnabled.changes.length > 0 || configChanged) {
const committed = await commitConfigWithPendingPluginInstalls({
nextConfig: cfg,
baseHash: (await sourceSnapshotPromise)?.hash,
});
cfg = committed.config;
}
const logoutAccount = plugin.gateway?.logoutAccount;
if (!logoutAccount) {
throw new Error(
formatUnsupportedChannelActionMessage({
channel: channelInput,
action: "logout",
inspectCommand: "openclaw channels status --channel " + channelInput,
}),
);
}
// Prefer the live gateway so logout also stops any active channel runtime.
const { accountId } = resolveAccountContext(plugin, opts, cfg);
if (
await logoutViaGatewayRuntime({
cfg,
channelId: plugin.id,
accountId,
runtime,
})
) {
return;
}
const account = plugin.config.resolveAccount(cfg, accountId);
await logoutAccount({
cfg,
accountId,
account,
runtime,
});
}