upgrade openclaw to 3.23 (#652)

Co-authored-by: Felix <24791380+vcfgv@users.noreply.github.com>
This commit is contained in:
paisley
2026-03-26 16:58:04 +08:00
committed by GitHub
Unverified
parent b786b773f1
commit ba5947e2cb
22 changed files with 2927 additions and 4739 deletions

View File

@@ -53,22 +53,21 @@ import {
listDiscordDirectoryGroupsFromConfig,
listDiscordDirectoryPeersFromConfig,
normalizeDiscordMessagingTarget,
} from 'openclaw/plugin-sdk/discord';
import {
listTelegramDirectoryGroupsFromConfig,
listTelegramDirectoryPeersFromConfig,
normalizeTelegramMessagingTarget,
} from 'openclaw/plugin-sdk/telegram';
import {
listSlackDirectoryGroupsFromConfig,
listSlackDirectoryPeersFromConfig,
normalizeSlackMessagingTarget,
} from 'openclaw/plugin-sdk/slack';
import {
listWhatsAppDirectoryGroupsFromConfig,
listWhatsAppDirectoryPeersFromConfig,
normalizeWhatsAppMessagingTarget,
} from 'openclaw/plugin-sdk/whatsapp';
} from '../../utils/openclaw-sdk';
// listWhatsAppDirectory*FromConfig were removed from openclaw's public exports
// in 2026.3.23-1. No-op stubs; WhatsApp target picker uses session discovery.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function listWhatsAppDirectoryGroupsFromConfig(_params: any): Promise<any[]> { return []; }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function listWhatsAppDirectoryPeersFromConfig(_params: any): Promise<any[]> { return []; }
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
@@ -196,7 +195,13 @@ function scheduleGatewayChannelRestart(ctx: HostApiContext, reason: string): voi
// Plugin-based channels require a full Gateway process restart to properly
// initialize / tear-down plugin connections. SIGUSR1 in-process reload is
// not sufficient for channel plugins (see restartGatewayForAgentDeletion).
const FORCE_RESTART_CHANNELS = new Set(['dingtalk', 'wecom', 'whatsapp', 'feishu', 'qqbot', OPENCLAW_WECHAT_CHANNEL_TYPE]);
// OpenClaw 3.23+ does not reliably support in-process channel reload for any
// channel type. All channel config saves must trigger a full Gateway process
// restart to ensure the channel adapter properly initializes with the new config.
const FORCE_RESTART_CHANNELS = new Set([
'dingtalk', 'wecom', 'whatsapp', 'feishu', 'qqbot', OPENCLAW_WECHAT_CHANNEL_TYPE,
'discord', 'telegram', 'signal', 'imessage', 'matrix', 'line', 'msteams', 'googlechat', 'mattermost',
]);
function scheduleGatewayChannelSaveRefresh(
ctx: HostApiContext,

View File

@@ -47,10 +47,32 @@ const CHANNEL_PLUGIN_MAP: Record<string, { dirName: string; npmName: string }> =
dingtalk: { dirName: 'dingtalk', npmName: '@soimy/dingtalk' },
wecom: { dirName: 'wecom', npmName: '@wecom/wecom-openclaw-plugin' },
feishu: { dirName: 'feishu-openclaw-plugin', npmName: '@larksuite/openclaw-lark' },
qqbot: { dirName: 'qqbot', npmName: '@sliverp/qqbot' },
qqbot: { dirName: 'qqbot', npmName: '@tencent-connect/openclaw-qqbot' },
'openclaw-weixin': { dirName: 'openclaw-weixin', npmName: '@tencent-weixin/openclaw-weixin' },
};
/**
* OpenClaw 3.22+ ships Discord, Telegram, and other channels as built-in
* extensions. If a previous ClawX version copied one of these into
* ~/.openclaw/extensions/, the broken copy overrides the working built-in
* plugin and must be removed.
*/
const BUILTIN_CHANNEL_EXTENSIONS = ['discord', 'telegram'];
function cleanupStaleBuiltInExtensions(): void {
for (const ext of BUILTIN_CHANNEL_EXTENSIONS) {
const extDir = join(homedir(), '.openclaw', 'extensions', ext);
if (existsSync(fsPath(extDir))) {
logger.info(`[plugin] Removing stale built-in extension copy: ${ext}`);
try {
rmSync(fsPath(extDir), { recursive: true, force: true });
} catch (err) {
logger.warn(`[plugin] Failed to remove stale extension ${ext}:`, err);
}
}
}
}
function readPluginVersion(pkgJsonPath: string): string | null {
try {
const raw = readFileSync(fsPath(pkgJsonPath), 'utf-8');
@@ -151,6 +173,14 @@ export async function syncGatewayConfigBeforeLaunch(
logger.warn('Failed to clean dangling WeChat plugin state before launch:', err);
}
// Remove stale copies of built-in extensions (Discord, Telegram) that
// override OpenClaw's working built-in plugins and break channel loading.
try {
cleanupStaleBuiltInExtensions();
} catch (err) {
logger.warn('Failed to clean stale built-in extensions:', err);
}
// Auto-upgrade installed plugins before Gateway starts so that
// the plugin manifest ID matches what sanitize wrote to the config.
try {

View File

@@ -33,7 +33,21 @@ const LEGACY_WECHAT_CREDENTIALS_DIR = join(OPENCLAW_DIR, 'credentials', WECHAT_P
const LEGACY_WECHAT_SYNC_DIR = join(OPENCLAW_DIR, 'agents', 'default', 'sessions', '.openclaw-weixin-sync');
// Channels that are managed as plugins (config goes under plugins.entries, not channels)
const PLUGIN_CHANNELS = ['whatsapp'];
const PLUGIN_CHANNELS: string[] = [];
const LEGACY_BUILTIN_CHANNEL_PLUGIN_IDS = new Set(['whatsapp']);
const BUILTIN_CHANNEL_IDS = new Set([
'discord',
'telegram',
'whatsapp',
'slack',
'signal',
'imessage',
'matrix',
'line',
'msteams',
'googlechat',
'mattermost',
]);
// Unique credential key per channel type used for duplicate bot detection.
// Maps each channel type to the field that uniquely identifies a bot/account.
@@ -193,6 +207,101 @@ function channelHasConfiguredAccounts(channelSection: ChannelConfigData | undefi
return Object.keys(channelSection).some((key) => !CHANNEL_TOP_LEVEL_KEYS_TO_KEEP.has(key));
}
function ensurePluginRegistration(currentConfig: OpenClawConfig, pluginId: string): void {
if (!currentConfig.plugins) {
currentConfig.plugins = {
allow: [pluginId],
enabled: true,
entries: {
[pluginId]: { enabled: true },
},
};
return;
}
currentConfig.plugins.enabled = true;
const allow = Array.isArray(currentConfig.plugins.allow)
? currentConfig.plugins.allow as string[]
: [];
if (!allow.includes(pluginId)) {
currentConfig.plugins.allow = [...allow, pluginId];
}
if (!currentConfig.plugins.entries) {
currentConfig.plugins.entries = {};
}
if (!currentConfig.plugins.entries[pluginId]) {
currentConfig.plugins.entries[pluginId] = {};
}
currentConfig.plugins.entries[pluginId].enabled = true;
}
function cleanupLegacyBuiltInChannelPluginRegistration(
currentConfig: OpenClawConfig,
channelType: string,
): boolean {
if (!LEGACY_BUILTIN_CHANNEL_PLUGIN_IDS.has(channelType)) {
return false;
}
return removePluginRegistration(currentConfig, channelType);
}
function isBuiltinChannelId(channelId: string): boolean {
return BUILTIN_CHANNEL_IDS.has(channelId);
}
function listConfiguredBuiltinChannels(
currentConfig: OpenClawConfig,
additionalChannelIds: string[] = [],
): string[] {
const configured = new Set<string>();
const channels = currentConfig.channels ?? {};
for (const [channelId, section] of Object.entries(channels)) {
if (!isBuiltinChannelId(channelId)) continue;
if (!section || section.enabled === false) continue;
if (channelHasAnyAccount(section) || Object.keys(section).length > 0) {
configured.add(channelId);
}
}
for (const channelId of additionalChannelIds) {
if (isBuiltinChannelId(channelId)) {
configured.add(channelId);
}
}
return Array.from(configured);
}
function syncBuiltinChannelsWithPluginAllowlist(
currentConfig: OpenClawConfig,
additionalBuiltinChannelIds: string[] = [],
): void {
const plugins = currentConfig.plugins;
if (!plugins || !Array.isArray(plugins.allow)) {
return;
}
const configuredBuiltins = new Set(listConfiguredBuiltinChannels(currentConfig, additionalBuiltinChannelIds));
const existingAllow = plugins.allow as string[];
const externalPluginIds = existingAllow.filter((pluginId) => !isBuiltinChannelId(pluginId));
let nextAllow = [...externalPluginIds];
if (externalPluginIds.length > 0) {
nextAllow = [
...nextAllow,
...Array.from(configuredBuiltins).filter((channelId) => !nextAllow.includes(channelId)),
];
}
if (nextAllow.length > 0) {
plugins.allow = nextAllow;
} else {
delete plugins.allow;
}
}
// ── Types ────────────────────────────────────────────────────────
export interface ChannelConfigData {
@@ -262,6 +371,10 @@ export async function writeOpenClawConfig(config: OpenClawConfig): Promise<void>
// ── Channel operations ───────────────────────────────────────────
async function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType: string): Promise<void> {
if (PLUGIN_CHANNELS.includes(channelType)) {
ensurePluginRegistration(currentConfig, channelType);
}
if (channelType === 'feishu') {
const feishuPluginId = await resolveFeishuPluginId();
if (!currentConfig.plugins) {
@@ -582,18 +695,15 @@ export async function saveChannelConfig(
const currentConfig = await readOpenClawConfig();
const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID;
cleanupLegacyBuiltInChannelPluginRegistration(currentConfig, resolvedChannelType);
await ensurePluginAllowlist(currentConfig, resolvedChannelType);
syncBuiltinChannelsWithPluginAllowlist(currentConfig, [resolvedChannelType]);
// Plugin-based channels (e.g. WhatsApp) go under plugins.entries, not channels
if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
if (!currentConfig.plugins) {
currentConfig.plugins = {};
}
if (!currentConfig.plugins.entries) {
currentConfig.plugins.entries = {};
}
currentConfig.plugins.entries[resolvedChannelType] = {
...currentConfig.plugins.entries[resolvedChannelType],
ensurePluginRegistration(currentConfig, resolvedChannelType);
currentConfig.plugins!.entries![resolvedChannelType] = {
...currentConfig.plugins!.entries![resolvedChannelType],
enabled: config.enabled ?? true,
};
await writeOpenClawConfig(currentConfig);
@@ -802,6 +912,7 @@ export async function deleteChannelAccountConfig(channelType: string, accountId:
}
}
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
await writeOpenClawConfig(currentConfig);
if (isWechatChannelType(resolvedChannelType)) {
await deleteWeChatAccountState(accountId);
@@ -815,31 +926,29 @@ export async function deleteChannelConfig(channelType: string): Promise<void> {
return withConfigLock(async () => {
const resolvedChannelType = resolveStoredChannelType(channelType);
const currentConfig = await readOpenClawConfig();
cleanupLegacyBuiltInChannelPluginRegistration(currentConfig, resolvedChannelType);
if (currentConfig.channels?.[resolvedChannelType]) {
delete currentConfig.channels[resolvedChannelType];
if (isWechatChannelType(resolvedChannelType)) {
removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
}
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
await writeOpenClawConfig(currentConfig);
if (isWechatChannelType(resolvedChannelType)) {
await deleteWeChatState();
}
console.log(`Deleted channel config for ${resolvedChannelType}`);
} else if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
if (currentConfig.plugins?.entries?.[resolvedChannelType]) {
delete currentConfig.plugins.entries[resolvedChannelType];
if (Object.keys(currentConfig.plugins.entries).length === 0) {
delete currentConfig.plugins.entries;
}
if (currentConfig.plugins && Object.keys(currentConfig.plugins).length === 0) {
delete currentConfig.plugins;
}
if (currentConfig.plugins?.entries?.[resolvedChannelType] || currentConfig.plugins?.allow?.includes(resolvedChannelType)) {
removePluginRegistration(currentConfig, resolvedChannelType);
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
await writeOpenClawConfig(currentConfig);
console.log(`Deleted plugin channel config for ${resolvedChannelType}`);
}
} else if (isWechatChannelType(resolvedChannelType)) {
removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
await writeOpenClawConfig(currentConfig);
await deleteWeChatState();
}
@@ -1049,6 +1158,7 @@ export async function setChannelEnabled(channelType: string, enabled: boolean):
return withConfigLock(async () => {
const resolvedChannelType = resolveStoredChannelType(channelType);
const currentConfig = await readOpenClawConfig();
cleanupLegacyBuiltInChannelPluginRegistration(currentConfig, resolvedChannelType);
if (isWechatChannelType(resolvedChannelType)) {
if (enabled) {
@@ -1059,10 +1169,15 @@ export async function setChannelEnabled(channelType: string, enabled: boolean):
}
if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
if (!currentConfig.plugins) currentConfig.plugins = {};
if (!currentConfig.plugins.entries) currentConfig.plugins.entries = {};
if (!currentConfig.plugins.entries[resolvedChannelType]) currentConfig.plugins.entries[resolvedChannelType] = {};
if (enabled) {
ensurePluginRegistration(currentConfig, resolvedChannelType);
} else {
if (!currentConfig.plugins) currentConfig.plugins = {};
if (!currentConfig.plugins.entries) currentConfig.plugins.entries = {};
if (!currentConfig.plugins.entries[resolvedChannelType]) currentConfig.plugins.entries[resolvedChannelType] = {};
}
currentConfig.plugins.entries[resolvedChannelType].enabled = enabled;
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
await writeOpenClawConfig(currentConfig);
console.log(`Set plugin channel ${resolvedChannelType} enabled: ${enabled}`);
return;
@@ -1071,6 +1186,7 @@ export async function setChannelEnabled(channelType: string, enabled: boolean):
if (!currentConfig.channels) currentConfig.channels = {};
if (!currentConfig.channels[resolvedChannelType]) currentConfig.channels[resolvedChannelType] = {};
currentConfig.channels[resolvedChannelType].enabled = enabled;
syncBuiltinChannelsWithPluginAllowlist(currentConfig, enabled ? [resolvedChannelType] : []);
await writeOpenClawConfig(currentConfig);
console.log(`Set channel ${resolvedChannelType} enabled: ${enabled}`);
});

View File

@@ -1,18 +1,18 @@
/**
* Device OAuth Manager
*
* Delegates MiniMax and Qwen OAuth to the OpenClaw extension oauth.ts functions
* imported directly from the bundled openclaw package at build time.
* Manages Device Code OAuth flows for MiniMax and Qwen providers.
*
* The OAuth protocol implementations are fully self-contained in:
* - ./minimax-oauth.ts (MiniMax Device Code + PKCE)
* - ./qwen-oauth.ts (Qwen Device Code + PKCE)
*
* This approach:
* - Avoids hardcoding client_id (lives in openclaw extension)
* - Avoids duplicating HTTP OAuth logic
* - Avoids spawning CLI process (which requires interactive TTY)
* - Hardcodes client_id and endpoints (same as openai-codex-oauth.ts)
* - Implements OAuth flows locally with zero openclaw dependency
* - Survives openclaw package upgrades without breakage
* - Works identically on macOS, Windows, and Linux
*
* The extension oauth.ts files only use `node:crypto` and global `fetch` —
* they are pure Node.js HTTP functions, no TTY, no prompter needed.
*
* We provide our own callbacks (openUrl/note/progress) that hook into
* the Electron IPC system to display UI in the ClawX frontend.
*/
@@ -21,21 +21,15 @@ import { BrowserWindow, shell } from 'electron';
import { logger } from './logger';
import { saveProvider, getProvider, ProviderConfig } from './secure-storage';
import { getProviderDefaultModel } from './provider-registry';
import { isOpenClawPresent } from './paths';
import { proxyAwareFetch } from './proxy-fetch';
import {
loginMiniMaxPortalOAuth,
type MiniMaxOAuthToken,
type MiniMaxRegion,
} from '../../node_modules/openclaw/extensions/minimax-portal-auth/oauth';
import {
loginQwenPortalOAuth,
type QwenOAuthToken,
} from '../../node_modules/openclaw/extensions/qwen-portal-auth/oauth';
import { saveOAuthTokenToOpenClaw, setOpenClawDefaultModelWithOverride } from './openclaw-auth';
import { loginMiniMaxPortalOAuth, type MiniMaxOAuthToken, type MiniMaxRegion } from './minimax-oauth';
import { loginQwenPortalOAuth, type QwenOAuthToken } from './qwen-oauth';
export type OAuthProviderType = 'minimax-portal' | 'minimax-portal-cn' | 'qwen-portal';
export type { MiniMaxRegion };
// Re-export types for consumers
export type { MiniMaxRegion, MiniMaxOAuthToken, QwenOAuthToken };
// ─────────────────────────────────────────────────────────────
// DeviceOAuthManager
@@ -116,21 +110,18 @@ class DeviceOAuthManager extends EventEmitter {
// ─────────────────────────────────────────────────────────
private async runMiniMaxFlow(region?: MiniMaxRegion, providerType: OAuthProviderType = 'minimax-portal'): Promise<void> {
if (!isOpenClawPresent()) {
throw new Error('OpenClaw package not found');
}
const provider = this.activeProvider!;
const token: MiniMaxOAuthToken = await this.runWithProxyAwareFetch(() => loginMiniMaxPortalOAuth({
region,
openUrl: async (url) => {
openUrl: async (url: string) => {
logger.info(`[DeviceOAuth] MiniMax opening browser: ${url}`);
// Open the authorization URL in the system browser
shell.openExternal(url).catch((err) =>
shell.openExternal(url).catch((err: unknown) =>
logger.warn(`[DeviceOAuth] Failed to open browser:`, err)
);
},
note: async (message, _title) => {
note: async (message: string, _title?: string) => {
if (!this.active) return;
// The extension calls note() with a message containing
// the user_code and verification_uri — parse them for the UI
@@ -142,8 +133,8 @@ class DeviceOAuthManager extends EventEmitter {
}
},
progress: {
update: (msg) => logger.info(`[DeviceOAuth] MiniMax progress: ${msg}`),
stop: (msg) => logger.info(`[DeviceOAuth] MiniMax progress done: ${msg ?? ''}`),
update: (msg: string) => logger.info(`[DeviceOAuth] MiniMax progress: ${msg}`),
stop: (msg?: string) => logger.info(`[DeviceOAuth] MiniMax progress done: ${msg ?? ''}`),
},
}));
@@ -166,19 +157,16 @@ class DeviceOAuthManager extends EventEmitter {
// ─────────────────────────────────────────────────────────
private async runQwenFlow(): Promise<void> {
if (!isOpenClawPresent()) {
throw new Error('OpenClaw package not found');
}
const provider = this.activeProvider!;
const token: QwenOAuthToken = await this.runWithProxyAwareFetch(() => loginQwenPortalOAuth({
openUrl: async (url) => {
openUrl: async (url: string) => {
logger.info(`[DeviceOAuth] Qwen opening browser: ${url}`);
shell.openExternal(url).catch((err) =>
shell.openExternal(url).catch((err: unknown) =>
logger.warn(`[DeviceOAuth] Failed to open browser:`, err)
);
},
note: async (message, _title) => {
note: async (message: string, _title?: string) => {
if (!this.active) return;
const { verificationUri, userCode } = this.parseNote(message);
if (verificationUri && userCode) {
@@ -188,8 +176,8 @@ class DeviceOAuthManager extends EventEmitter {
}
},
progress: {
update: (msg) => logger.info(`[DeviceOAuth] Qwen progress: ${msg}`),
stop: (msg) => logger.info(`[DeviceOAuth] Qwen progress done: ${msg ?? ''}`),
update: (msg: string) => logger.info(`[DeviceOAuth] Qwen progress: ${msg}`),
stop: (msg?: string) => logger.info(`[DeviceOAuth] Qwen progress done: ${msg ?? ''}`),
},
}));

View File

@@ -0,0 +1,256 @@
/**
* Self-contained MiniMax Device Code OAuth flow.
*
* Implements RFC 8628 (Device Authorization Grant) with PKCE for MiniMax API.
* Zero dependency on openclaw extension modules — survives openclaw upgrades.
*
* Protocol:
* 1. POST /oauth/code → get user_code, verification_uri
* 2. Open verification_uri in browser
* 3. Poll POST /oauth/token with user_code until approved
* 4. Return { access, refresh, expires, resourceUrl }
*/
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { proxyAwareFetch } from './proxy-fetch';
// ── Constants ────────────────────────────────────────────────
export type MiniMaxRegion = 'cn' | 'global';
const MINIMAX_OAUTH_CONFIG = {
cn: {
baseUrl: 'https://api.minimaxi.com',
clientId: '78257093-7e40-4613-99e0-527b14b39113',
},
global: {
baseUrl: 'https://api.minimax.io',
clientId: '78257093-7e40-4613-99e0-527b14b39113',
},
} as const;
const MINIMAX_OAUTH_SCOPE = 'group_id profile model.completion';
const MINIMAX_OAUTH_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:user_code';
function getOAuthEndpoints(region: MiniMaxRegion) {
const config = MINIMAX_OAUTH_CONFIG[region];
return {
codeEndpoint: `${config.baseUrl}/oauth/code`,
tokenEndpoint: `${config.baseUrl}/oauth/token`,
clientId: config.clientId,
baseUrl: config.baseUrl,
};
}
// ── Types ────────────────────────────────────────────────────
export interface MiniMaxOAuthToken {
access: string;
refresh: string;
expires: number;
resourceUrl?: string;
notification_message?: string;
}
interface MiniMaxOAuthAuthorization {
user_code: string;
verification_uri: string;
expired_in: number;
interval?: number;
state: string;
}
type TokenResult =
| { status: 'success'; token: MiniMaxOAuthToken }
| { status: 'pending'; message?: string }
| { status: 'error'; message: string };
export interface MiniMaxOAuthOptions {
openUrl: (url: string) => Promise<void>;
note: (message: string, title?: string) => Promise<void>;
progress: { update: (message: string) => void; stop: (message?: string) => void };
region?: MiniMaxRegion;
}
// ── PKCE helpers (self-contained, no openclaw dependency) ────
function generatePkce(): { verifier: string; challenge: string; state: string } {
const verifier = randomBytes(32).toString('base64url');
const challenge = createHash('sha256').update(verifier).digest('base64url');
const state = randomBytes(16).toString('base64url');
return { verifier, challenge, state };
}
function toFormUrlEncoded(params: Record<string, string>): string {
return new URLSearchParams(params).toString();
}
// ── OAuth flow steps ─────────────────────────────────────────
async function requestOAuthCode(params: {
challenge: string;
state: string;
region: MiniMaxRegion;
}): Promise<MiniMaxOAuthAuthorization> {
const endpoints = getOAuthEndpoints(params.region);
const response = await proxyAwareFetch(endpoints.codeEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
'x-request-id': randomUUID(),
},
body: toFormUrlEncoded({
response_type: 'code',
client_id: endpoints.clientId,
scope: MINIMAX_OAUTH_SCOPE,
code_challenge: params.challenge,
code_challenge_method: 'S256',
state: params.state,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`MiniMax OAuth authorization failed: ${text || response.statusText}`);
}
const payload = (await response.json()) as MiniMaxOAuthAuthorization & { error?: string };
if (!payload.user_code || !payload.verification_uri) {
throw new Error(
payload.error ??
'MiniMax OAuth authorization returned an incomplete payload (missing user_code or verification_uri).',
);
}
if (payload.state !== params.state) {
throw new Error('MiniMax OAuth state mismatch: possible CSRF attack or session corruption.');
}
return payload;
}
async function pollOAuthToken(params: {
userCode: string;
verifier: string;
region: MiniMaxRegion;
}): Promise<TokenResult> {
const endpoints = getOAuthEndpoints(params.region);
const response = await proxyAwareFetch(endpoints.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: toFormUrlEncoded({
grant_type: MINIMAX_OAUTH_GRANT_TYPE,
client_id: endpoints.clientId,
user_code: params.userCode,
code_verifier: params.verifier,
}),
});
const text = await response.text();
let payload:
| {
status?: string;
base_resp?: { status_code?: number; status_msg?: string };
}
| undefined;
if (text) {
try {
payload = JSON.parse(text) as typeof payload;
} catch {
payload = undefined;
}
}
if (!response.ok) {
return {
status: 'error',
message:
(payload?.base_resp?.status_msg ?? text) || 'MiniMax OAuth failed to parse response.',
};
}
if (!payload) {
return { status: 'error', message: 'MiniMax OAuth failed to parse response.' };
}
const tokenPayload = payload as {
status: string;
access_token?: string | null;
refresh_token?: string | null;
expired_in?: number | null;
token_type?: string;
resource_url?: string;
notification_message?: string;
};
if (tokenPayload.status === 'error') {
return { status: 'error', message: 'An error occurred. Please try again later' };
}
if (tokenPayload.status !== 'success') {
return { status: 'pending', message: 'current user code is not authorized' };
}
if (!tokenPayload.access_token || !tokenPayload.refresh_token || !tokenPayload.expired_in) {
return { status: 'error', message: 'MiniMax OAuth returned incomplete token payload.' };
}
return {
status: 'success',
token: {
access: tokenPayload.access_token,
refresh: tokenPayload.refresh_token,
expires: tokenPayload.expired_in,
resourceUrl: tokenPayload.resource_url,
notification_message: tokenPayload.notification_message,
},
};
}
// ── Public API ───────────────────────────────────────────────
export async function loginMiniMaxPortalOAuth(params: MiniMaxOAuthOptions): Promise<MiniMaxOAuthToken> {
const region = params.region ?? 'global';
const { verifier, challenge, state } = generatePkce();
const oauth = await requestOAuthCode({ challenge, state, region });
const verificationUrl = oauth.verification_uri;
const noteLines = [
`Open ${verificationUrl} to approve access.`,
`If prompted, enter the code ${oauth.user_code}.`,
`Interval: ${oauth.interval ?? 'default (2000ms)'}, Expires at: ${oauth.expired_in} unix timestamp`,
];
await params.note(noteLines.join('\n'), 'MiniMax OAuth');
try {
await params.openUrl(verificationUrl);
} catch {
// Fall back to manual copy/paste if browser open fails.
}
let pollIntervalMs = oauth.interval ? oauth.interval : 2000;
const expireTimeMs = oauth.expired_in;
while (Date.now() < expireTimeMs) {
params.progress.update('Waiting for MiniMax OAuth approval…');
const result = await pollOAuthToken({
userCode: oauth.user_code,
verifier,
region,
});
if (result.status === 'success') {
return result.token;
}
if (result.status === 'error') {
throw new Error(result.message);
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
pollIntervalMs = Math.max(pollIntervalMs, 2000);
}
throw new Error('MiniMax OAuth timed out before authorization completed.');
}

View File

@@ -133,6 +133,19 @@ async function discoverAgentIds(): Promise<string[]> {
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
const FEISHU_PLUGIN_ID_CANDIDATES = ['openclaw-lark', 'feishu-openclaw-plugin'] as const;
const VALID_COMPACTION_MODES = new Set(['default', 'safeguard']);
const BUILTIN_CHANNEL_IDS = new Set([
'discord',
'telegram',
'whatsapp',
'slack',
'signal',
'imessage',
'matrix',
'line',
'msteams',
'googlechat',
'mattermost',
]);
async function readOpenClawJson(): Promise<Record<string, unknown>> {
return (await readJsonFile<Record<string, unknown>>(OPENCLAW_CONFIG_PATH)) ?? {};
@@ -1310,6 +1323,67 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
}
}
}
// ── Reconcile built-in channels with restrictive plugin allowlists ──
// If plugins.allow is active because an external plugin is configured,
// configured built-in channels must also be present or they will be
// blocked on restart. If the allowlist only contains built-ins, drop it.
const configuredBuiltIns = new Set<string>();
const channelsObj = config.channels as Record<string, Record<string, unknown>> | undefined;
if (channelsObj && typeof channelsObj === 'object') {
for (const [channelId, section] of Object.entries(channelsObj)) {
if (!BUILTIN_CHANNEL_IDS.has(channelId)) continue;
if (!section || section.enabled === false) continue;
if (Object.keys(section).length > 0) {
configuredBuiltIns.add(channelId);
}
}
}
if (pEntries.whatsapp) {
delete pEntries.whatsapp;
console.log('[sanitize] Removed legacy plugins.entries.whatsapp for built-in channel');
modified = true;
}
const externalPluginIds = allowArr2.filter((pluginId) => !BUILTIN_CHANNEL_IDS.has(pluginId));
let nextAllow = [...externalPluginIds];
if (externalPluginIds.length > 0) {
for (const channelId of configuredBuiltIns) {
if (!nextAllow.includes(channelId)) {
nextAllow.push(channelId);
modified = true;
console.log(`[sanitize] Added configured built-in channel "${channelId}" to plugins.allow`);
}
}
}
if (JSON.stringify(nextAllow) !== JSON.stringify(allowArr2)) {
if (nextAllow.length > 0) {
pluginsObj.allow = nextAllow;
} else {
delete pluginsObj.allow;
}
modified = true;
}
if (Array.isArray(pluginsObj.allow) && pluginsObj.allow.length === 0) {
delete pluginsObj.allow;
modified = true;
}
if (pluginsObj.entries && Object.keys(pEntries).length === 0) {
delete pluginsObj.entries;
modified = true;
}
const pluginKeysExcludingEnabled = Object.keys(pluginsObj).filter((key) => key !== 'enabled');
if (pluginsObj.enabled === true && pluginKeysExcludingEnabled.length === 0) {
delete pluginsObj.enabled;
modified = true;
}
if (Object.keys(pluginsObj).length === 0) {
delete config.plugins;
modified = true;
}
}
// ── channels default-account migration ─────────────────────────

View File

@@ -0,0 +1,74 @@
/**
* Dynamic imports for openclaw plugin-sdk subpath exports.
*
* openclaw is NOT in the asar's node_modules — it lives at resources/openclaw/
* (extraResources). Static `import ... from 'openclaw/plugin-sdk/...'` would
* produce a runtime require() that fails inside the asar.
*
* Instead, we create a require context from the openclaw directory itself.
* Node.js package self-referencing allows a package to require its own exports
* by name, so `openclawRequire('openclaw/plugin-sdk/discord')` resolves via the
* exports map in openclaw's package.json.
*
* In dev mode (pnpm), the resolved path is in the pnpm virtual store where
* self-referencing also works. The projectRequire fallback covers edge cases.
*/
import { createRequire } from 'module';
import { join } from 'node:path';
import { getOpenClawDir, getOpenClawResolvedDir } from './paths';
const _openclawPath = getOpenClawDir();
const _openclawResolvedPath = getOpenClawResolvedDir();
const _openclawSdkRequire = createRequire(join(_openclawResolvedPath, 'package.json'));
const _projectSdkRequire = createRequire(join(_openclawPath, 'package.json'));
function requireOpenClawSdk(subpath: string): Record<string, unknown> {
try {
return _openclawSdkRequire(subpath);
} catch {
return _projectSdkRequire(subpath);
}
}
// --- Channel SDK dynamic imports ---
const _discordSdk = requireOpenClawSdk('openclaw/plugin-sdk/discord') as {
listDiscordDirectoryGroupsFromConfig: (...args: unknown[]) => Promise<unknown[]>;
listDiscordDirectoryPeersFromConfig: (...args: unknown[]) => Promise<unknown[]>;
normalizeDiscordMessagingTarget: (target: string) => string | undefined;
};
const _telegramSdk = requireOpenClawSdk('openclaw/plugin-sdk/telegram') as {
listTelegramDirectoryGroupsFromConfig: (...args: unknown[]) => Promise<unknown[]>;
listTelegramDirectoryPeersFromConfig: (...args: unknown[]) => Promise<unknown[]>;
normalizeTelegramMessagingTarget: (target: string) => string | undefined;
};
const _slackSdk = requireOpenClawSdk('openclaw/plugin-sdk/slack') as {
listSlackDirectoryGroupsFromConfig: (...args: unknown[]) => Promise<unknown[]>;
listSlackDirectoryPeersFromConfig: (...args: unknown[]) => Promise<unknown[]>;
normalizeSlackMessagingTarget: (target: string) => string | undefined;
};
const _whatsappSdk = requireOpenClawSdk('openclaw/plugin-sdk/whatsapp-shared') as {
normalizeWhatsAppMessagingTarget: (target: string) => string | undefined;
};
export const {
listDiscordDirectoryGroupsFromConfig,
listDiscordDirectoryPeersFromConfig,
normalizeDiscordMessagingTarget,
} = _discordSdk;
export const {
listTelegramDirectoryGroupsFromConfig,
listTelegramDirectoryPeersFromConfig,
normalizeTelegramMessagingTarget,
} = _telegramSdk;
export const {
listSlackDirectoryGroupsFromConfig,
listSlackDirectoryPeersFromConfig,
normalizeSlackMessagingTarget,
} = _slackSdk;
export const { normalizeWhatsAppMessagingTarget } = _whatsappSdk;

View File

@@ -170,7 +170,7 @@ const PLUGIN_NPM_NAMES: Record<string, string> = {
dingtalk: '@soimy/dingtalk',
wecom: '@wecom/wecom-openclaw-plugin',
'feishu-openclaw-plugin': '@larksuite/openclaw-lark',
qqbot: '@sliverp/qqbot',
qqbot: '@tencent-connect/openclaw-qqbot',
'openclaw-weixin': '@tencent-weixin/openclaw-weixin',
};

View File

@@ -0,0 +1,211 @@
/**
* Self-contained Qwen Device Code OAuth flow.
*
* Implements RFC 8628 (Device Authorization Grant) with PKCE for Qwen API.
* Zero dependency on openclaw extension modules — survives openclaw upgrades.
*
* Protocol:
* 1. POST /api/v1/oauth2/device/code → get device_code, user_code, verification_uri
* 2. Open verification_uri in browser
* 3. Poll POST /api/v1/oauth2/token with device_code until approved
* 4. Return { access, refresh, expires, resourceUrl }
*/
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { proxyAwareFetch } from './proxy-fetch';
// ── Constants ────────────────────────────────────────────────
const QWEN_OAUTH_BASE_URL = 'https://chat.qwen.ai';
const QWEN_OAUTH_DEVICE_CODE_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/device/code`;
const QWEN_OAUTH_TOKEN_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token`;
const QWEN_OAUTH_CLIENT_ID = 'f0304373b74a44d2b584a3fb70ca9e56';
const QWEN_OAUTH_SCOPE = 'openid profile email model.completion';
const QWEN_OAUTH_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
// ── Types ────────────────────────────────────────────────────
export interface QwenOAuthToken {
access: string;
refresh: string;
expires: number;
resourceUrl?: string;
}
interface QwenDeviceAuthorization {
device_code: string;
user_code: string;
verification_uri: string;
verification_uri_complete?: string;
expires_in: number;
interval?: number;
}
type DeviceTokenResult =
| { status: 'success'; token: QwenOAuthToken }
| { status: 'pending'; slowDown?: boolean }
| { status: 'error'; message: string };
export interface QwenOAuthOptions {
openUrl: (url: string) => Promise<void>;
note: (message: string, title?: string) => Promise<void>;
progress: { update: (message: string) => void; stop: (message?: string) => void };
}
// ── PKCE helpers (self-contained, no openclaw dependency) ────
function generatePkce(): { verifier: string; challenge: string } {
const verifier = randomBytes(32).toString('base64url');
const challenge = createHash('sha256').update(verifier).digest('base64url');
return { verifier, challenge };
}
function toFormUrlEncoded(params: Record<string, string>): string {
return new URLSearchParams(params).toString();
}
// ── OAuth flow steps ─────────────────────────────────────────
async function requestDeviceCode(params: { challenge: string }): Promise<QwenDeviceAuthorization> {
const response = await proxyAwareFetch(QWEN_OAUTH_DEVICE_CODE_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
'x-request-id': randomUUID(),
},
body: toFormUrlEncoded({
client_id: QWEN_OAUTH_CLIENT_ID,
scope: QWEN_OAUTH_SCOPE,
code_challenge: params.challenge,
code_challenge_method: 'S256',
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Qwen device authorization failed: ${text || response.statusText}`);
}
const payload = (await response.json()) as QwenDeviceAuthorization & { error?: string };
if (!payload.device_code || !payload.user_code || !payload.verification_uri) {
throw new Error(
payload.error ??
'Qwen device authorization returned an incomplete payload (missing user_code or verification_uri).',
);
}
return payload;
}
async function pollDeviceToken(params: {
deviceCode: string;
verifier: string;
}): Promise<DeviceTokenResult> {
const response = await proxyAwareFetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: toFormUrlEncoded({
grant_type: QWEN_OAUTH_GRANT_TYPE,
client_id: QWEN_OAUTH_CLIENT_ID,
device_code: params.deviceCode,
code_verifier: params.verifier,
}),
});
if (!response.ok) {
let payload: { error?: string; error_description?: string } | undefined;
try {
payload = (await response.json()) as { error?: string; error_description?: string };
} catch {
const text = await response.text();
return { status: 'error', message: text || response.statusText };
}
if (payload?.error === 'authorization_pending') {
return { status: 'pending' };
}
if (payload?.error === 'slow_down') {
return { status: 'pending', slowDown: true };
}
return {
status: 'error',
message: payload?.error_description || payload?.error || response.statusText,
};
}
const tokenPayload = (await response.json()) as {
access_token?: string | null;
refresh_token?: string | null;
expires_in?: number | null;
token_type?: string;
resource_url?: string;
};
if (!tokenPayload.access_token || !tokenPayload.refresh_token || !tokenPayload.expires_in) {
return { status: 'error', message: 'Qwen OAuth returned incomplete token payload.' };
}
return {
status: 'success',
token: {
access: tokenPayload.access_token,
refresh: tokenPayload.refresh_token,
expires: Date.now() + tokenPayload.expires_in * 1000,
resourceUrl: tokenPayload.resource_url,
},
};
}
// ── Public API ───────────────────────────────────────────────
export async function loginQwenPortalOAuth(params: QwenOAuthOptions): Promise<QwenOAuthToken> {
const { verifier, challenge } = generatePkce();
const device = await requestDeviceCode({ challenge });
const verificationUrl = device.verification_uri_complete || device.verification_uri;
await params.note(
[
`Open ${verificationUrl} to approve access.`,
`If prompted, enter the code ${device.user_code}.`,
].join('\n'),
'Qwen OAuth',
);
try {
await params.openUrl(verificationUrl);
} catch {
// Fall back to manual copy/paste if browser open fails.
}
const start = Date.now();
let pollIntervalMs = device.interval ? device.interval * 1000 : 2000;
const timeoutMs = device.expires_in * 1000;
while (Date.now() - start < timeoutMs) {
params.progress.update('Waiting for Qwen OAuth approval…');
const result = await pollDeviceToken({
deviceCode: device.device_code,
verifier,
});
if (result.status === 'success') {
return result.token;
}
if (result.status === 'error') {
throw new Error(`Qwen OAuth failed: ${result.message}`);
}
if (result.status === 'pending' && result.slowDown) {
pollIntervalMs = Math.min(pollIntervalMs * 1.5, 10000);
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
throw new Error('Qwen OAuth timed out waiting for authorization.');
}

View File

@@ -11,12 +11,24 @@ const require = createRequire(import.meta.url);
// Resolve dependencies from OpenClaw package context (pnpm-safe)
const openclawPath = getOpenClawDir();
const openclawResolvedPath = getOpenClawResolvedDir();
// Primary: resolves from openclaw's real (dereferenced) path in pnpm store.
// In packaged builds this is the flat `resources/openclaw/node_modules/`.
const openclawRequire = createRequire(join(openclawResolvedPath, 'package.json'));
// Fallback: resolves from the symlink path (`node_modules/openclaw`).
// In dev mode, Node walks UP from here to `<project>/node_modules/`, which
// contains ClawX's own devDependencies — packages that are NOT deps of openclaw
// (e.g. @whiskeysockets/baileys) become resolvable through pnpm hoisting.
const projectRequire = createRequire(join(openclawPath, 'package.json'));
function resolveOpenClawPackageJson(packageName: string): string {
const specifier = `${packageName}/package.json`;
// 1. Try openclaw's own deps (works in packaged mode + openclaw transitive deps)
try {
return openclawRequire.resolve(specifier);
} catch { /* fall through */ }
// 2. Fallback to project-level deps (works in dev mode for ClawX devDependencies)
try {
return projectRequire.resolve(specifier);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
throw new Error(