mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-20 02:11:38 +00:00
* fix(core): make indexed access explicit in auto-reply, infra, and config Part 1/3 of the src NUIA phase-3b burn-down (#104600): iteration and destructuring over index reads, boundary guards on parsed input, and named invariants. Config path walkers bind the path head once; SQLite migration key handling is hoisted without query-shape changes. * fix(core): make indexed access explicit in cli, gateway, commands, security, shared Part 2/3: argv/token selection restructured, gateway event/attachment invariants named, security parsers stay fail-closed (invariant violations throw), edit-distance matrices access checked entries. * fix(core): make indexed access explicit across remaining src surfaces Part 3/3: channels, plugins, process, cron, plugin-sdk, media, logging, tui, hooks, daemon, and small directories. Latent bug fixed: a tailnet resolver could leak undefined through a string|null contract and now fails with a descriptive local error. * fix(core): keep optional boundaries optional after per-commit review Review findings: expectDefined misused where absence is a legitimate state. CLI --profile/route-args missing next tokens take their existing miss paths; help normalization compares --help against the last positional again; first-time plugin install spreads absent cfg.plugins; denylist scan iterates manifest dependency entries instead of throwing on omitted sections; tailnet resolver returns a guaranteed string at the source instead of a caller-side undefined throw. * refactor(core): closed-key provider labels and honest optional passthroughs PROVIDER_LABELS becomes a satisfies-typed closed record (static reads provably defined; dynamic lookups go through providerUsageLabel with honest string|undefined). Status-scan overview passes its optional params through unchanged instead of asserting them. * fix(channels): make getChatChannelMeta honestly optional The original signature claimed ChatChannelMeta while leaking undefined on bundled channel id metadata drift; three of four callers already handled absence. The return type now says so, and the one assuming caller falls back to the raw channel label. * fix(core): index-safety for post-rebase main drift Covers the sqlite-sessions flip and auth-source-plan code that landed mid-phase, plus the channel-validation test consuming the now honestly optional getChatChannelMeta. * refactor(channels): split chat-meta accessors along the SDK contract getChatChannelMeta keeps its shipped plugin-SDK signature (defined for bundled ids, fail-loud on impossible misses); new findChatChannelMeta carries the drift-tolerant optional contract for core auto-enable and formatting paths. * fix(qa-channel): own channel metadata instead of a guaranteed-undefined catalog lookup qa-channel spread getChatChannelMeta over an id that is never in the bundled catalog, shipping an empty setup meta by accident; the fail-loud SDK accessor exposed it. The channel now declares its metadata once. * fix(gateway): heartbeat projection lookahead is optional at the transcript tail expectDefined wrapped messages[i + 1] whose absence on the final message is the normal case; the adjacent ternary already handled it. Restores the plain optional read with an explicit guard in the pair condition. * fix(plugin-sdk): channel plugin factory tolerates non-bundled channel ids again createChannelPluginBase spreads bundled catalog meta for ANY channel id, where absence is the normal case for external plugins; the resolver is honestly optional again while the exported bundled-id accessor keeps the fail-loud contract. * fix(core): spreads of optional config sections stay optional Fresh-setup and first-install paths (crestodian setup inference, hook installs, agent config base, target agent models) legitimately lack the section being rebuilt; spreading undefined is the shipped {} semantics. Removes the remaining gratuitous assertion wraps found by tree audit.
315 lines
9.9 KiB
TypeScript
315 lines
9.9 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 };
|
|
}
|
|
|
|
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) {
|
|
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,
|
|
});
|
|
}
|