upgrade openclaw to 3.23 (#652)
Co-authored-by: Felix <24791380+vcfgv@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
b786b773f1
commit
ba5947e2cb
@@ -38,6 +38,10 @@ afterPack: ./scripts/after-pack.cjs
|
||||
asar: true
|
||||
asarUnpack:
|
||||
- "**/*.node"
|
||||
# lru-cache CJS/ESM interop: older CJS versions (v5, v6, v7) don't export
|
||||
# `LRUCache` as a named property, breaking `import { LRUCache }` in Node.js
|
||||
# 22+ (Electron 40+). Unpacking lets afterPack patch them in place.
|
||||
- "**/node_modules/lru-cache/**"
|
||||
|
||||
# Disable native module rebuilding.
|
||||
# The Electron renderer/main process has no native (.node) dependencies.
|
||||
@@ -115,8 +119,6 @@ win:
|
||||
target:
|
||||
- target: nsis
|
||||
arch: x64
|
||||
- target: nsis
|
||||
arch: arm64
|
||||
|
||||
nsis:
|
||||
oneClick: false
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
|
||||
@@ -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 ?? ''}`),
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
256
electron/utils/minimax-oauth.ts
Normal file
256
electron/utils/minimax-oauth.ts
Normal 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.');
|
||||
}
|
||||
@@ -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 ─────────────────────────
|
||||
|
||||
74
electron/utils/openclaw-sdk.ts
Normal file
74
electron/utils/openclaw-sdk.ts
Normal 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;
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
211
electron/utils/qwen-oauth.ts
Normal file
211
electron/utils/qwen-oauth.ts
Normal 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.');
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
18
package.json
18
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawx",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1-alpha.0",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@discordjs/opus",
|
||||
@@ -68,6 +68,7 @@
|
||||
"clawhub": "^0.5.0",
|
||||
"electron-store": "^11.0.2",
|
||||
"electron-updater": "^6.8.3",
|
||||
"lru-cache": "^11.2.6",
|
||||
"ms": "^2.1.3",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"posthog-node": "^5.28.0",
|
||||
@@ -75,7 +76,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@larksuite/openclaw-lark": "2026.3.17",
|
||||
"@larksuite/openclaw-lark": "2026.3.25",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
@@ -88,9 +89,9 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sliverp/qqbot": "^1.6.1",
|
||||
"@tencent-weixin/openclaw-weixin": "^1.0.2",
|
||||
"@soimy/dingtalk": "^3.3.0",
|
||||
"@soimy/dingtalk": "^3.4.2",
|
||||
"@tencent-connect/openclaw-qqbot": "^1.6.5",
|
||||
"@tencent-weixin/openclaw-weixin": "^2.0.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^25.3.0",
|
||||
@@ -100,7 +101,8 @@
|
||||
"@typescript-eslint/eslint-plugin": "^8.56.0",
|
||||
"@typescript-eslint/parser": "^8.56.0",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@wecom/wecom-openclaw-plugin": "^2026.3.20",
|
||||
"@wecom/wecom-openclaw-plugin": "^2026.3.24",
|
||||
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -114,7 +116,7 @@
|
||||
"i18next": "^25.8.11",
|
||||
"jsdom": "^28.1.0",
|
||||
"lucide-react": "^0.563.0",
|
||||
"openclaw": "2026.3.13",
|
||||
"openclaw": "2026.3.24",
|
||||
"png2icons": "^2.0.1",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.2.4",
|
||||
@@ -138,4 +140,4 @@
|
||||
"zx": "^8.8.5"
|
||||
},
|
||||
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268"
|
||||
}
|
||||
}
|
||||
|
||||
6260
pnpm-lock.yaml
generated
6260
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
|
||||
const { cpSync, existsSync, readdirSync, rmSync, statSync, mkdirSync, realpathSync } = require('fs');
|
||||
const { join, dirname, basename } = require('path');
|
||||
const { join, dirname, basename, relative } = require('path');
|
||||
|
||||
// On Windows, paths in pnpm's virtual store can exceed the default MAX_PATH
|
||||
// limit (260 chars). Node.js 18.17+ respects the system LongPathsEnabled
|
||||
@@ -287,6 +287,84 @@ function patchBrokenModules(nodeModulesDir) {
|
||||
}
|
||||
}
|
||||
|
||||
// lru-cache CJS/ESM interop fix (recursive):
|
||||
// Multiple versions of lru-cache may exist in the output tree — not just
|
||||
// at node_modules/lru-cache/ but also nested inside other packages.
|
||||
// Older CJS versions (v5, v6) export the class via `module.exports = LRUCache`
|
||||
// without a named `LRUCache` property, so `import { LRUCache } from 'lru-cache'`
|
||||
// fails in Node.js 22+ ESM interop (used by Electron 40+).
|
||||
// We recursively scan the entire output for ALL lru-cache installations and
|
||||
// patch each CJS entry to ensure `exports.LRUCache` always exists.
|
||||
function patchAllLruCacheInstances(rootDir) {
|
||||
let lruCount = 0;
|
||||
const stack = [rootDir];
|
||||
while (stack.length > 0) {
|
||||
const dir = stack.pop();
|
||||
let entries;
|
||||
try { entries = readdirSync(normWin(dir), { withFileTypes: true }); } catch { continue; }
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
let isDirectory = entry.isDirectory();
|
||||
if (!isDirectory) {
|
||||
// pnpm layout may contain symlink/junction directories on Windows.
|
||||
try { isDirectory = statSync(normWin(fullPath)).isDirectory(); } catch { isDirectory = false; }
|
||||
}
|
||||
if (!isDirectory) continue;
|
||||
if (entry.name === 'lru-cache') {
|
||||
const pkgPath = join(fullPath, 'package.json');
|
||||
if (!existsSync(normWin(pkgPath))) { stack.push(fullPath); continue; }
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(normWin(pkgPath), 'utf8'));
|
||||
if (pkg.type === 'module') continue; // ESM version — already has named exports
|
||||
const mainFile = pkg.main || 'index.js';
|
||||
const entryFile = join(fullPath, mainFile);
|
||||
if (!existsSync(normWin(entryFile))) continue;
|
||||
const original = readFileSync(normWin(entryFile), 'utf8');
|
||||
if (!original.includes('exports.LRUCache')) {
|
||||
const patched = [
|
||||
original,
|
||||
'',
|
||||
'// ClawX patch: add LRUCache named export for Node.js 22+ ESM interop',
|
||||
'if (typeof module.exports === "function" && !module.exports.LRUCache) {',
|
||||
' module.exports.LRUCache = module.exports;',
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
writeFileSync(normWin(entryFile), patched, 'utf8');
|
||||
lruCount++;
|
||||
console.log(`[after-pack] 🩹 Patched lru-cache CJS (v${pkg.version}) at ${relative(rootDir, fullPath)}`);
|
||||
}
|
||||
|
||||
// lru-cache v7 ESM entry exports default only; add named export.
|
||||
const moduleFile = typeof pkg.module === 'string' ? pkg.module : null;
|
||||
if (moduleFile) {
|
||||
const esmEntry = join(fullPath, moduleFile);
|
||||
if (existsSync(normWin(esmEntry))) {
|
||||
const esmOriginal = readFileSync(normWin(esmEntry), 'utf8');
|
||||
if (
|
||||
esmOriginal.includes('export default LRUCache') &&
|
||||
!esmOriginal.includes('export { LRUCache')
|
||||
) {
|
||||
const esmPatched = [esmOriginal, '', 'export { LRUCache }', ''].join('\n');
|
||||
writeFileSync(normWin(esmEntry), esmPatched, 'utf8');
|
||||
lruCount++;
|
||||
console.log(`[after-pack] 🩹 Patched lru-cache ESM (v${pkg.version}) at ${relative(rootDir, fullPath)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[after-pack] ⚠️ Failed to patch lru-cache at ${fullPath}:`, err.message);
|
||||
}
|
||||
} else {
|
||||
stack.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
return lruCount;
|
||||
}
|
||||
const lruPatched = patchAllLruCacheInstances(nodeModulesDir);
|
||||
count += lruPatched;
|
||||
|
||||
if (count > 0) {
|
||||
console.log(`[after-pack] 🩹 Patched ${count} broken module(s) in ${nodeModulesDir}`);
|
||||
}
|
||||
@@ -497,7 +575,7 @@ exports.default = async function afterPack(context) {
|
||||
const BUNDLED_PLUGINS = [
|
||||
{ npmName: '@soimy/dingtalk', pluginId: 'dingtalk' },
|
||||
{ npmName: '@wecom/wecom-openclaw-plugin', pluginId: 'wecom' },
|
||||
{ npmName: '@sliverp/qqbot', pluginId: 'qqbot' },
|
||||
{ npmName: '@tencent-connect/openclaw-qqbot', pluginId: 'qqbot' },
|
||||
{ npmName: '@larksuite/openclaw-lark', pluginId: 'feishu-openclaw-plugin' },
|
||||
{ npmName: '@tencent-weixin/openclaw-weixin', pluginId: 'openclaw-weixin' },
|
||||
];
|
||||
@@ -535,4 +613,86 @@ exports.default = async function afterPack(context) {
|
||||
if (nativeRemoved > 0) {
|
||||
console.log(`[after-pack] ✅ Removed ${nativeRemoved} non-target native platform packages.`);
|
||||
}
|
||||
|
||||
// 5. Patch lru-cache in app.asar.unpacked
|
||||
//
|
||||
// Production dependencies (electron-updater → semver → lru-cache@6,
|
||||
// posthog-node → proxy agents → lru-cache@7, etc.) end up inside app.asar.
|
||||
// Older CJS versions lack the `LRUCache` named export, breaking
|
||||
// `import { LRUCache }` in Electron 40+ (Node.js 22+ ESM interop).
|
||||
//
|
||||
// electron-builder.yml lists `**/node_modules/lru-cache/**` in asarUnpack,
|
||||
// which extracts those files to app.asar.unpacked/. We patch them here so
|
||||
// Electron's transparent asar fs layer serves the fixed version at runtime.
|
||||
const asarUnpackedDir = join(resourcesDir, 'app.asar.unpacked');
|
||||
if (existsSync(asarUnpackedDir)) {
|
||||
const { readFileSync: readFS, writeFileSync: writeFS } = require('fs');
|
||||
let asarLruCount = 0;
|
||||
const lruStack = [asarUnpackedDir];
|
||||
while (lruStack.length > 0) {
|
||||
const dir = lruStack.pop();
|
||||
let entries;
|
||||
try { entries = readdirSync(normWin(dir), { withFileTypes: true }); } catch { continue; }
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
let isDirectory = entry.isDirectory();
|
||||
if (!isDirectory) {
|
||||
// pnpm layout may contain symlink/junction directories on Windows.
|
||||
try { isDirectory = statSync(normWin(fullPath)).isDirectory(); } catch { isDirectory = false; }
|
||||
}
|
||||
if (!isDirectory) continue;
|
||||
if (entry.name === 'lru-cache') {
|
||||
const pkgPath = join(fullPath, 'package.json');
|
||||
if (!existsSync(normWin(pkgPath))) { lruStack.push(fullPath); continue; }
|
||||
try {
|
||||
const pkg = JSON.parse(readFS(normWin(pkgPath), 'utf8'));
|
||||
if (pkg.type === 'module') continue; // ESM — already exports LRUCache
|
||||
const mainFile = pkg.main || 'index.js';
|
||||
const entryFile = join(fullPath, mainFile);
|
||||
if (!existsSync(normWin(entryFile))) continue;
|
||||
const original = readFS(normWin(entryFile), 'utf8');
|
||||
if (!original.includes('exports.LRUCache')) {
|
||||
const patched = [
|
||||
original,
|
||||
'',
|
||||
'// ClawX patch: add LRUCache named export for Node.js 22+ ESM interop',
|
||||
'if (typeof module.exports === "function" && !module.exports.LRUCache) {',
|
||||
' module.exports.LRUCache = module.exports;',
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
writeFS(normWin(entryFile), patched, 'utf8');
|
||||
asarLruCount++;
|
||||
console.log(`[after-pack] 🩹 Patched lru-cache CJS (v${pkg.version}) in app.asar.unpacked at ${relative(asarUnpackedDir, fullPath)}`);
|
||||
}
|
||||
|
||||
// lru-cache v7 ESM entry exports default only; add named export.
|
||||
const moduleFile = typeof pkg.module === 'string' ? pkg.module : null;
|
||||
if (moduleFile) {
|
||||
const esmEntry = join(fullPath, moduleFile);
|
||||
if (existsSync(normWin(esmEntry))) {
|
||||
const esmOriginal = readFS(normWin(esmEntry), 'utf8');
|
||||
if (
|
||||
esmOriginal.includes('export default LRUCache') &&
|
||||
!esmOriginal.includes('export { LRUCache')
|
||||
) {
|
||||
const esmPatched = [esmOriginal, '', 'export { LRUCache }', ''].join('\n');
|
||||
writeFS(normWin(esmEntry), esmPatched, 'utf8');
|
||||
asarLruCount++;
|
||||
console.log(`[after-pack] 🩹 Patched lru-cache ESM (v${pkg.version}) in app.asar.unpacked at ${relative(asarUnpackedDir, fullPath)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[after-pack] ⚠️ Failed to patch lru-cache in asar.unpacked at ${fullPath}:`, err.message);
|
||||
}
|
||||
} else {
|
||||
lruStack.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (asarLruCount > 0) {
|
||||
console.log(`[after-pack] 🩹 Patched ${asarLruCount} lru-cache instance(s) in app.asar.unpacked`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -38,7 +38,7 @@ function normWin(p) {
|
||||
const PLUGINS = [
|
||||
{ npmName: '@soimy/dingtalk', pluginId: 'dingtalk' },
|
||||
{ npmName: '@wecom/wecom-openclaw-plugin', pluginId: 'wecom' },
|
||||
{ npmName: '@sliverp/qqbot', pluginId: 'qqbot' },
|
||||
{ npmName: '@tencent-connect/openclaw-qqbot', pluginId: 'qqbot' },
|
||||
{ npmName: '@larksuite/openclaw-lark', pluginId: 'feishu-openclaw-plugin' },
|
||||
{ npmName: '@tencent-weixin/openclaw-weixin', pluginId: 'openclaw-weixin' },
|
||||
];
|
||||
|
||||
@@ -179,6 +179,61 @@ while (queue.length > 0) {
|
||||
echo` Found ${collected.size} total packages (direct + transitive)`;
|
||||
echo` Skipped ${skippedDevCount} dev-only package references`;
|
||||
|
||||
// 4b. Collect extra packages required by ClawX's Electron main process that are
|
||||
// NOT deps of openclaw. These are resolved from openclaw's context at runtime
|
||||
// (via createRequire from the openclaw directory) so they must live in the
|
||||
// bundled openclaw/node_modules/.
|
||||
//
|
||||
// For each package we resolve it from the workspace's own node_modules,
|
||||
// then BFS its transitive deps exactly like we did for openclaw above.
|
||||
const EXTRA_BUNDLED_PACKAGES = [
|
||||
'@whiskeysockets/baileys', // WhatsApp channel (was a dep of old clawdbot, not openclaw)
|
||||
];
|
||||
|
||||
let extraCount = 0;
|
||||
for (const pkgName of EXTRA_BUNDLED_PACKAGES) {
|
||||
const pkgLink = path.join(NODE_MODULES, ...pkgName.split('/'));
|
||||
if (!fs.existsSync(pkgLink)) {
|
||||
echo` ⚠️ Extra package ${pkgName} not found in workspace node_modules, skipping.`;
|
||||
continue;
|
||||
}
|
||||
|
||||
let pkgReal;
|
||||
try { pkgReal = fs.realpathSync(pkgLink); } catch { continue; }
|
||||
|
||||
if (!collected.has(pkgReal)) {
|
||||
collected.set(pkgReal, pkgName);
|
||||
extraCount++;
|
||||
|
||||
// BFS this package's own transitive deps
|
||||
const depVirtualNM = getVirtualStoreNodeModules(pkgReal);
|
||||
if (depVirtualNM) {
|
||||
const extraQueue = [{ nodeModulesDir: depVirtualNM, skipPkg: pkgName }];
|
||||
while (extraQueue.length > 0) {
|
||||
const { nodeModulesDir, skipPkg } = extraQueue.shift();
|
||||
const packages = listPackages(nodeModulesDir);
|
||||
for (const { name, fullPath } of packages) {
|
||||
if (name === skipPkg) continue;
|
||||
if (SKIP_PACKAGES.has(name) || SKIP_SCOPES.some(s => name.startsWith(s))) continue;
|
||||
let realPath;
|
||||
try { realPath = fs.realpathSync(fullPath); } catch { continue; }
|
||||
if (collected.has(realPath)) continue;
|
||||
collected.set(realPath, name);
|
||||
extraCount++;
|
||||
const innerVirtualNM = getVirtualStoreNodeModules(realPath);
|
||||
if (innerVirtualNM && innerVirtualNM !== nodeModulesDir) {
|
||||
extraQueue.push({ nodeModulesDir: innerVirtualNM, skipPkg: name });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (extraCount > 0) {
|
||||
echo` Added ${extraCount} extra packages (+ transitive deps) for Electron main process`;
|
||||
}
|
||||
|
||||
// 5. Copy all collected packages into OUTPUT/node_modules/ (flat structure)
|
||||
//
|
||||
// IMPORTANT: BFS guarantees direct deps are encountered before transitive deps.
|
||||
@@ -457,6 +512,84 @@ function patchBrokenModules(nodeModulesDir) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
// lru-cache CJS/ESM interop fix (recursive):
|
||||
// Multiple versions of lru-cache may exist in the output tree — not just
|
||||
// at node_modules/lru-cache/ but also nested inside other packages.
|
||||
// Older CJS versions (v5, v6) export the class via `module.exports = LRUCache`
|
||||
// without a named `LRUCache` property, so `import { LRUCache } from 'lru-cache'`
|
||||
// fails in Node.js 22+ ESM interop (used by Electron 40+).
|
||||
// We recursively scan the entire output for ALL lru-cache installations and
|
||||
// patch each CJS entry to ensure `exports.LRUCache` always exists.
|
||||
function patchAllLruCacheInstances(rootDir) {
|
||||
let lruCount = 0;
|
||||
const stack = [rootDir];
|
||||
while (stack.length > 0) {
|
||||
const dir = stack.pop();
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(normWin(dir), { withFileTypes: true }); } catch { continue; }
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
let isDirectory = entry.isDirectory();
|
||||
if (!isDirectory) {
|
||||
// pnpm layout may contain symlink/junction directories on Windows.
|
||||
try { isDirectory = fs.statSync(normWin(fullPath)).isDirectory(); } catch { isDirectory = false; }
|
||||
}
|
||||
if (!isDirectory) continue;
|
||||
if (entry.name === 'lru-cache') {
|
||||
const pkgPath = path.join(fullPath, 'package.json');
|
||||
if (!fs.existsSync(normWin(pkgPath))) { stack.push(fullPath); continue; }
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(normWin(pkgPath), 'utf8'));
|
||||
if (pkg.type === 'module') continue; // ESM version — already has named exports
|
||||
const mainFile = pkg.main || 'index.js';
|
||||
const entryFile = path.join(fullPath, mainFile);
|
||||
if (!fs.existsSync(normWin(entryFile))) continue;
|
||||
const original = fs.readFileSync(normWin(entryFile), 'utf8');
|
||||
if (!original.includes('exports.LRUCache')) {
|
||||
const patched = [
|
||||
original,
|
||||
'',
|
||||
'// ClawX patch: add LRUCache named export for Node.js 22+ ESM interop',
|
||||
'if (typeof module.exports === "function" && !module.exports.LRUCache) {',
|
||||
' module.exports.LRUCache = module.exports;',
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(normWin(entryFile), patched, 'utf8');
|
||||
lruCount++;
|
||||
echo` 🩹 Patched lru-cache CJS (v${pkg.version}) at ${path.relative(rootDir, fullPath)}`;
|
||||
}
|
||||
|
||||
// lru-cache v7 ESM entry exports default only; add named export.
|
||||
const moduleFile = typeof pkg.module === 'string' ? pkg.module : null;
|
||||
if (moduleFile) {
|
||||
const esmEntry = path.join(fullPath, moduleFile);
|
||||
if (fs.existsSync(normWin(esmEntry))) {
|
||||
const esmOriginal = fs.readFileSync(normWin(esmEntry), 'utf8');
|
||||
if (
|
||||
esmOriginal.includes('export default LRUCache') &&
|
||||
!esmOriginal.includes('export { LRUCache')
|
||||
) {
|
||||
const esmPatched = [esmOriginal, '', 'export { LRUCache }', ''].join('\n');
|
||||
fs.writeFileSync(normWin(esmEntry), esmPatched, 'utf8');
|
||||
lruCount++;
|
||||
echo` 🩹 Patched lru-cache ESM (v${pkg.version}) at ${path.relative(rootDir, fullPath)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
echo` ⚠️ Failed to patch lru-cache at ${fullPath}: ${err.message}`;
|
||||
}
|
||||
} else {
|
||||
stack.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
return lruCount;
|
||||
}
|
||||
const lruPatched = patchAllLruCacheInstances(nodeModulesDir);
|
||||
count += lruPatched;
|
||||
|
||||
if (count > 0) {
|
||||
echo` 🩹 Patched ${count} broken module(s) in node_modules`;
|
||||
}
|
||||
|
||||
@@ -368,7 +368,7 @@
|
||||
]
|
||||
},
|
||||
"qqbot": {
|
||||
"description": "Connect QQ Bot via @sliverp/qqbot plugin",
|
||||
"description": "Connect QQ Bot via @tencent-connect/openclaw-qqbot plugin",
|
||||
"docsUrl": "https://icnnp7d0dymg.feishu.cn/wiki/KPIJwlyiGiupMrkiS9ice39Zn2c",
|
||||
"fields": {
|
||||
"appId": {
|
||||
|
||||
@@ -368,7 +368,7 @@
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/mattermost"
|
||||
},
|
||||
"qqbot": {
|
||||
"description": "@sliverp/qqbot プラグイン経由で QQ ボットに接続します",
|
||||
"description": "@tencent-connect/openclaw-qqbot プラグイン経由で QQ ボットに接続します",
|
||||
"fields": {
|
||||
"appId": {
|
||||
"label": "App ID",
|
||||
|
||||
@@ -369,7 +369,7 @@
|
||||
]
|
||||
},
|
||||
"qqbot": {
|
||||
"description": "通过 @sliverp/qqbot 插件连接 QQ 机器人",
|
||||
"description": "通过 @tencent-connect/openclaw-qqbot 插件连接 QQ 机器人",
|
||||
"docsUrl": "https://icnnp7d0dymg.feishu.cn/wiki/KPIJwlyiGiupMrkiS9ice39Zn2c",
|
||||
"fields": {
|
||||
"appId": {
|
||||
|
||||
@@ -158,6 +158,54 @@ describe('WeCom plugin configuration', () => {
|
||||
expect(plugins.allow).toContain('wecom');
|
||||
expect(plugins.entries['wecom'].enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('saves whatsapp as a built-in channel instead of a plugin', async () => {
|
||||
const { saveChannelConfig } = await import('@electron/utils/channel-config');
|
||||
|
||||
await saveChannelConfig('whatsapp', { enabled: true }, 'default');
|
||||
|
||||
const config = await readOpenClawJson();
|
||||
const channels = config.channels as Record<string, { enabled?: boolean; defaultAccount?: string; accounts?: Record<string, { enabled?: boolean }> }>;
|
||||
|
||||
expect(channels.whatsapp.enabled).toBe(true);
|
||||
expect(channels.whatsapp.defaultAccount).toBe('default');
|
||||
expect(channels.whatsapp.accounts?.default?.enabled).toBe(true);
|
||||
expect(config.plugins).toBeUndefined();
|
||||
});
|
||||
|
||||
it('cleans up stale whatsapp plugin registration when saving built-in config', async () => {
|
||||
const { saveChannelConfig, writeOpenClawConfig } = await import('@electron/utils/channel-config');
|
||||
|
||||
await writeOpenClawConfig({
|
||||
plugins: {
|
||||
enabled: true,
|
||||
allow: ['whatsapp'],
|
||||
entries: {
|
||||
whatsapp: { enabled: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await saveChannelConfig('whatsapp', { enabled: true }, 'default');
|
||||
|
||||
const config = await readOpenClawJson();
|
||||
expect(config.plugins).toBeUndefined();
|
||||
const channels = config.channels as Record<string, { enabled?: boolean }>;
|
||||
expect(channels.whatsapp.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps configured built-in channels in plugins.allow when a plugin-backed channel is enabled', async () => {
|
||||
const { saveChannelConfig } = await import('@electron/utils/channel-config');
|
||||
|
||||
await saveChannelConfig('discord', { token: 'discord-token' }, 'default');
|
||||
await saveChannelConfig('whatsapp', { enabled: true }, 'default');
|
||||
await saveChannelConfig('qqbot', { appId: 'qq-app', token: 'qq-token', appSecret: 'qq-secret' }, 'default');
|
||||
|
||||
const config = await readOpenClawJson();
|
||||
const plugins = config.plugins as { allow: string[] };
|
||||
|
||||
expect(plugins.allow).toEqual(expect.arrayContaining(['qqbot', 'discord', 'whatsapp']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('WeChat dangling plugin cleanup', () => {
|
||||
|
||||
@@ -63,12 +63,29 @@ vi.mock('@electron/api/route-utils', () => ({
|
||||
|
||||
vi.mock('@electron/utils/paths', () => ({
|
||||
getOpenClawConfigDir: () => testOpenClawConfigDir,
|
||||
getOpenClawDir: () => testOpenClawConfigDir,
|
||||
getOpenClawResolvedDir: () => testOpenClawConfigDir,
|
||||
}));
|
||||
|
||||
vi.mock('@electron/utils/proxy-fetch', () => ({
|
||||
proxyAwareFetch: (...args: unknown[]) => proxyAwareFetchMock(...args),
|
||||
}));
|
||||
|
||||
// Stub openclaw SDK functions that are dynamically loaded via createRequire
|
||||
// in the real code — the extracted utility module is easy to mock.
|
||||
vi.mock('@electron/utils/openclaw-sdk', () => ({
|
||||
listDiscordDirectoryGroupsFromConfig: vi.fn().mockResolvedValue([]),
|
||||
listDiscordDirectoryPeersFromConfig: vi.fn().mockResolvedValue([]),
|
||||
normalizeDiscordMessagingTarget: vi.fn().mockReturnValue(undefined),
|
||||
listTelegramDirectoryGroupsFromConfig: vi.fn().mockResolvedValue([]),
|
||||
listTelegramDirectoryPeersFromConfig: vi.fn().mockResolvedValue([]),
|
||||
normalizeTelegramMessagingTarget: vi.fn().mockReturnValue(undefined),
|
||||
listSlackDirectoryGroupsFromConfig: vi.fn().mockResolvedValue([]),
|
||||
listSlackDirectoryPeersFromConfig: vi.fn().mockResolvedValue([]),
|
||||
normalizeSlackMessagingTarget: vi.fn().mockReturnValue(undefined),
|
||||
normalizeWhatsAppMessagingTarget: vi.fn().mockReturnValue(undefined),
|
||||
}));
|
||||
|
||||
describe('handleChannelRoutes', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
@@ -40,6 +40,19 @@ async function sanitizeConfig(filePath: string): Promise<boolean> {
|
||||
|
||||
const config = JSON.parse(raw) as Record<string, unknown>;
|
||||
let modified = false;
|
||||
const BUILTIN_CHANNEL_IDS = new Set([
|
||||
'discord',
|
||||
'telegram',
|
||||
'whatsapp',
|
||||
'slack',
|
||||
'signal',
|
||||
'imessage',
|
||||
'matrix',
|
||||
'line',
|
||||
'msteams',
|
||||
'googlechat',
|
||||
'mattermost',
|
||||
]);
|
||||
|
||||
/** Non-throwing async existence check. */
|
||||
async function fileExists(p: string): Promise<boolean> {
|
||||
@@ -104,6 +117,68 @@ async function sanitizeConfig(filePath: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allow = Array.isArray(pluginsObj.allow) ? [...pluginsObj.allow as string[]] : [];
|
||||
const entries = (
|
||||
pluginsObj.entries && typeof pluginsObj.entries === 'object' && !Array.isArray(pluginsObj.entries)
|
||||
? { ...(pluginsObj.entries as Record<string, unknown>) }
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
|
||||
if ('whatsapp' in entries) {
|
||||
delete entries.whatsapp;
|
||||
pluginsObj.entries = entries;
|
||||
modified = true;
|
||||
}
|
||||
|
||||
const configuredBuiltIns = new Set<string>();
|
||||
const channels = config.channels;
|
||||
if (channels && typeof channels === 'object' && !Array.isArray(channels)) {
|
||||
for (const [channelId, section] of Object.entries(channels as Record<string, Record<string, unknown>>)) {
|
||||
if (!BUILTIN_CHANNEL_IDS.has(channelId)) continue;
|
||||
if (!section || section.enabled === false) continue;
|
||||
if (Object.keys(section).length > 0) {
|
||||
configuredBuiltIns.add(channelId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const externalPluginIds = allow.filter((id) => !BUILTIN_CHANNEL_IDS.has(id));
|
||||
const nextAllow = [...externalPluginIds];
|
||||
if (externalPluginIds.length > 0) {
|
||||
for (const channelId of configuredBuiltIns) {
|
||||
if (!nextAllow.includes(channelId)) {
|
||||
nextAllow.push(channelId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (JSON.stringify(nextAllow) !== JSON.stringify(allow)) {
|
||||
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(entries).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;
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror: remove stale tools.web.search.kimi.apiKey when moonshot provider exists.
|
||||
@@ -275,7 +350,7 @@ describe('sanitizeOpenClawConfig (blocklist approach)', () => {
|
||||
await writeConfig({
|
||||
skills: { enabled: true, entries: {} },
|
||||
channels: { discord: { token: 'abc', enabled: true } },
|
||||
plugins: { entries: { whatsapp: { enabled: true } } },
|
||||
plugins: { entries: { customPlugin: { enabled: true } } },
|
||||
gateway: { mode: 'local', auth: { token: 'xyz' } },
|
||||
agents: { defaults: { model: { primary: 'gpt-4' } } },
|
||||
models: { providers: { openai: { baseUrl: 'https://api.openai.com' } } },
|
||||
@@ -289,7 +364,7 @@ describe('sanitizeOpenClawConfig (blocklist approach)', () => {
|
||||
expect(result.skills).not.toHaveProperty('enabled');
|
||||
// All other sections unchanged
|
||||
expect(result.channels).toEqual({ discord: { token: 'abc', enabled: true } });
|
||||
expect(result.plugins).toEqual({ entries: { whatsapp: { enabled: true } } });
|
||||
expect(result.plugins).toEqual({ entries: { customPlugin: { enabled: true } } });
|
||||
expect(result.gateway).toEqual({ mode: 'local', auth: { token: 'xyz' } });
|
||||
expect(result.agents).toEqual({ defaults: { model: { primary: 'gpt-4' } } });
|
||||
});
|
||||
@@ -359,7 +434,7 @@ describe('sanitizeOpenClawConfig (blocklist approach)', () => {
|
||||
'/another/missing/plugin/dir',
|
||||
],
|
||||
},
|
||||
entries: { whatsapp: { enabled: true } },
|
||||
entries: { customPlugin: { enabled: true } },
|
||||
},
|
||||
gateway: { mode: 'local' },
|
||||
});
|
||||
@@ -372,11 +447,40 @@ describe('sanitizeOpenClawConfig (blocklist approach)', () => {
|
||||
const load = plugins.load as Record<string, unknown>;
|
||||
expect(load.paths).toEqual([]);
|
||||
// Other plugin config is preserved
|
||||
expect(plugins.entries).toEqual({ whatsapp: { enabled: true } });
|
||||
expect(plugins.entries).toEqual({ customPlugin: { enabled: true } });
|
||||
// Other top-level sections untouched
|
||||
expect(result.gateway).toEqual({ mode: 'local' });
|
||||
});
|
||||
|
||||
it('keeps configured built-in channels in plugins.allow when external plugins are enabled', async () => {
|
||||
await writeConfig({
|
||||
plugins: {
|
||||
enabled: true,
|
||||
allow: ['whatsapp', 'customPlugin'],
|
||||
entries: {
|
||||
whatsapp: { enabled: true },
|
||||
customPlugin: { enabled: true },
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
discord: { enabled: true, token: 'abc' },
|
||||
},
|
||||
});
|
||||
|
||||
const modified = await sanitizeConfig(configPath);
|
||||
expect(modified).toBe(true);
|
||||
|
||||
const result = await readConfig();
|
||||
expect(result.channels).toEqual({ discord: { enabled: true, token: 'abc' } });
|
||||
expect(result.plugins).toEqual({
|
||||
enabled: true,
|
||||
allow: ['customPlugin', 'discord'],
|
||||
entries: {
|
||||
customPlugin: { enabled: true },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('removes bundled node_modules paths from plugins.load.paths', async () => {
|
||||
await writeConfig({
|
||||
plugins: {
|
||||
|
||||
Reference in New Issue
Block a user