From 7b16b6af144ffa48c6cc84a72eb751baba7393e0 Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Thu, 26 Feb 2026 15:11:37 +0800 Subject: [PATCH] feat: support OAuth & API key for Qwen/MiniMax providers (#177) --- electron/main/ipc-handlers.ts | 130 +++++-- electron/preload/index.ts | 8 + electron/utils/device-oauth.ts | 322 ++++++++++++++++++ electron/utils/openclaw-auth.ts | 216 +++++++++++- electron/utils/provider-registry.ts | 8 + electron/utils/secure-storage.ts | 24 +- src/components/settings/ProvidersSettings.tsx | 297 +++++++++++++--- src/i18n/locales/en/settings.json | 280 ++++++++------- src/i18n/locales/ja/settings.json | 280 ++++++++------- src/i18n/locales/zh/settings.json | 280 ++++++++------- src/lib/providers.ts | 8 + src/pages/Setup/index.tsx | 207 ++++++++++- 12 files changed, 1581 insertions(+), 479 deletions(-) create mode 100644 electron/utils/device-oauth.ts diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 01aac9df5..b0250c1db 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -27,7 +27,7 @@ import { getOpenClawCliCommand, installOpenClawCliMac } from '../utils/openclaw- import { getSetting } from '../utils/store'; import { saveProviderKeyToOpenClaw, - removeProviderKeyFromOpenClaw, + removeProviderFromOpenClaw, setOpenClawDefaultModel, setOpenClawDefaultModelWithOverride, updateAgentModelProvider, @@ -47,6 +47,7 @@ import { checkUvInstalled, installUv, setupManagedPython } from '../utils/uv-set import { updateSkillConfig, getSkillConfig, getAllSkillConfigs } from '../utils/skill-config'; import { whatsAppLoginManager } from '../utils/whatsapp-login'; import { getProviderConfig } from '../utils/provider-registry'; +import { deviceOAuthManager, OAuthProviderType } from '../utils/device-oauth'; /** * Register all IPC handlers @@ -95,6 +96,9 @@ export function registerIpcHandlers( // WhatsApp handlers registerWhatsAppHandlers(mainWindow); + // Device OAuth handlers (Code Plan) + registerDeviceOAuthHandlers(mainWindow); + // File staging handlers (upload/send separation) registerFileHandlers(); } @@ -777,6 +781,35 @@ function registerWhatsAppHandlers(mainWindow: BrowserWindow): void { }); } +/** + * Device OAuth Handlers (Code Plan) + */ +function registerDeviceOAuthHandlers(mainWindow: BrowserWindow): void { + deviceOAuthManager.setWindow(mainWindow); + + // Request Provider OAuth initialization + ipcMain.handle('provider:requestOAuth', async (_, provider: OAuthProviderType, region?: 'global' | 'cn') => { + try { + logger.info(`provider:requestOAuth for ${provider}`); + await deviceOAuthManager.startFlow(provider, region); + return { success: true }; + } catch (error) { + logger.error('provider:requestOAuth failed', error); + return { success: false, error: String(error) }; + } + }); + + // Cancel Provider OAuth + ipcMain.handle('provider:cancelOAuth', async () => { + try { + await deviceOAuthManager.stopFlow(); + return { success: true }; + } catch (error) { + logger.error('provider:cancelOAuth failed', error); + return { success: false, error: String(error) }; + } + }); +} /** * Provider-related IPC handlers @@ -822,12 +855,12 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { const existing = await getProvider(providerId); await deleteProvider(providerId); - // Best-effort cleanup in OpenClaw auth profiles + // Best-effort cleanup in OpenClaw auth profiles & openclaw.json config if (existing?.type) { try { - removeProviderKeyFromOpenClaw(existing.type); + removeProviderFromOpenClaw(existing.type); } catch (err) { - console.warn('Failed to remove key from OpenClaw auth-profiles:', err); + console.warn('Failed to completely remove provider from OpenClaw:', err); } } @@ -891,7 +924,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { saveProviderKeyToOpenClaw(nextConfig.type, trimmedKey); } else { await deleteApiKey(providerId); - removeProviderKeyFromOpenClaw(nextConfig.type); + removeProviderFromOpenClaw(nextConfig.type); } } @@ -942,7 +975,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { saveProviderKeyToOpenClaw(previousProviderType, previousKey); } else { await deleteApiKey(providerId); - removeProviderKeyFromOpenClaw(previousProviderType); + removeProviderFromOpenClaw(previousProviderType); } } catch (rollbackError) { console.warn('Failed to rollback provider updateWithKey:', rollbackError); @@ -962,9 +995,11 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { const provider = await getProvider(providerId); const providerType = provider?.type || providerId; try { - removeProviderKeyFromOpenClaw(providerType); + if (providerType) { + removeProviderFromOpenClaw(providerType); + } } catch (err) { - console.warn('Failed to remove key from OpenClaw auth-profiles:', err); + console.warn('Failed to completely remove provider from OpenClaw:', err); } return { success: true }; @@ -992,29 +1027,66 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { const provider = await getProvider(providerId); if (provider) { try { - // If the provider has a user-specified model (e.g. siliconflow), - // build the full model string: "providerType/modelId" - const modelOverride = provider.model - ? `${provider.type}/${provider.model}` - : undefined; + // OAuth providers (qwen-portal, minimax-portal) have their openclaw.json + // model config already written by `openclaw models auth login --set-default`. + // Non-OAuth providers need us to write it here. + const OAUTH_PROVIDER_TYPES = ['qwen-portal', 'minimax-portal']; + const isOAuthProvider = OAUTH_PROVIDER_TYPES.includes(provider.type); - if (provider.type === 'custom' || provider.type === 'ollama') { - // For runtime-configured providers, use user-entered base URL/api. - // Do NOT set apiKeyEnv — the OpenClaw gateway resolves custom - // provider keys via auth-profiles, not the config apiKey field. - setOpenClawDefaultModelWithOverride(provider.type, modelOverride, { - baseUrl: provider.baseUrl, - api: 'openai-completions', - }); + if (!isOAuthProvider) { + // If the provider has a user-specified model (e.g. siliconflow), + // build the full model string: "providerType/modelId" + // Guard against double-prefixing: provider.model may already + // include the provider type (e.g. "siliconflow/DeepSeek-V3"). + const modelOverride = provider.model + ? (provider.model.startsWith(`${provider.type}/`) + ? provider.model + : `${provider.type}/${provider.model}`) + : undefined; + + if (provider.type === 'custom' || provider.type === 'ollama') { + // For runtime-configured providers, use user-entered base URL/api. + // Do NOT set apiKeyEnv — the OpenClaw gateway resolves custom + // provider keys via auth-profiles, not the config apiKey field. + setOpenClawDefaultModelWithOverride(provider.type, modelOverride, { + baseUrl: provider.baseUrl, + api: 'openai-completions', + }); + } else { + setOpenClawDefaultModel(provider.type, modelOverride); + } + + // Keep auth-profiles in sync with the default provider instance. + // This is especially important when multiple custom providers exist. + const providerKey = await getApiKey(providerId); + if (providerKey) { + saveProviderKeyToOpenClaw(provider.type, providerKey); + } } else { - setOpenClawDefaultModel(provider.type, modelOverride); - } + // OAuth providers (minimax-portal, qwen-portal): write the provider config + // using the model and baseUrl stored by device-oauth.ts at login time. + // These providers use their own API format (not standard OpenAI completions). + const defaultBaseUrl = provider.type === 'minimax-portal' + ? 'https://api.minimax.io/anthropic' + : 'https://portal.qwen.ai/v1'; + const api: 'anthropic-messages' | 'openai-completions' = provider.type === 'minimax-portal' + ? 'anthropic-messages' + : 'openai-completions'; - // Keep auth-profiles in sync with the default provider instance. - // This is especially important when multiple custom providers exist. - const providerKey = await getApiKey(providerId); - if (providerKey) { - saveProviderKeyToOpenClaw(provider.type, providerKey); + let baseUrl = provider.baseUrl || defaultBaseUrl; + if (provider.type === 'minimax-portal' && baseUrl && !baseUrl.endsWith('/anthropic')) { + baseUrl = baseUrl.replace(/\/$/, '') + '/anthropic'; + } + + setOpenClawDefaultModelWithOverride(provider.type, undefined, { + baseUrl, + api, + // OAuth placeholder — Gateway uses this to look up OAuth credentials + // from auth-profiles.json instead of a static API key. + apiKeyEnv: provider.type === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth', + }); + + logger.info(`Configured openclaw.json for OAuth provider "${provider.type}"`); } // For custom/ollama providers, also update the per-agent models.json @@ -1056,6 +1128,8 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { } }); + + // Get default provider ipcMain.handle('provider:getDefault', async () => { return await getDefaultProvider(); diff --git a/electron/preload/index.ts b/electron/preload/index.ts index f4a6ab0f1..f75f3d01d 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -77,6 +77,8 @@ const electronAPI = { 'provider:setDefault', 'provider:getDefault', 'provider:validateKey', + 'provider:requestOAuth', + 'provider:cancelOAuth', // Cron 'cron:list', 'cron:create', @@ -163,6 +165,9 @@ const electronAPI = { 'update:error', 'update:auto-install-countdown', 'cron:updated', + 'oauth:code', + 'oauth:success', + 'oauth:error', ]; if (validChannels.includes(channel)) { @@ -202,6 +207,9 @@ const electronAPI = { 'update:downloaded', 'update:error', 'update:auto-install-countdown', + 'oauth:code', + 'oauth:success', + 'oauth:error', ]; if (validChannels.includes(channel)) { diff --git a/electron/utils/device-oauth.ts b/electron/utils/device-oauth.ts new file mode 100644 index 000000000..bf2bf0a6d --- /dev/null +++ b/electron/utils/device-oauth.ts @@ -0,0 +1,322 @@ +/** + * 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. + * + * This approach: + * - Avoids hardcoding client_id (lives in openclaw extension) + * - Avoids duplicating HTTP OAuth logic + * - Avoids spawning CLI process (which requires interactive TTY) + * - 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. + */ +import { EventEmitter } from 'events'; +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 { + 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'; + +export type OAuthProviderType = 'minimax-portal' | 'qwen-portal'; +export type { MiniMaxRegion }; + +// ───────────────────────────────────────────────────────────── +// DeviceOAuthManager +// ───────────────────────────────────────────────────────────── + +class DeviceOAuthManager extends EventEmitter { + private activeProvider: OAuthProviderType | null = null; + private active: boolean = false; + private mainWindow: BrowserWindow | null = null; + + setWindow(window: BrowserWindow) { + this.mainWindow = window; + } + + async startFlow(provider: OAuthProviderType, region: MiniMaxRegion = 'global'): Promise { + if (this.active) { + await this.stopFlow(); + } + + this.active = true; + this.activeProvider = provider; + + try { + if (provider === 'minimax-portal') { + await this.runMiniMaxFlow(region); + } else if (provider === 'qwen-portal') { + await this.runQwenFlow(); + } else { + throw new Error(`Unsupported OAuth provider: ${provider}`); + } + return true; + } catch (error) { + if (!this.active) { + // Flow was cancelled — not an error + return false; + } + logger.error(`[DeviceOAuth] Flow error for ${provider}:`, error); + this.emitError(error instanceof Error ? error.message : String(error)); + this.active = false; + this.activeProvider = null; + return false; + } + } + + async stopFlow(): Promise { + this.active = false; + this.activeProvider = null; + logger.info('[DeviceOAuth] Flow explicitly stopped'); + } + + // ───────────────────────────────────────────────────────── + // MiniMax flow + // ───────────────────────────────────────────────────────── + + private async runMiniMaxFlow(region: MiniMaxRegion): Promise { + if (!isOpenClawPresent()) { + throw new Error('OpenClaw package not found'); + } + const provider = this.activeProvider!; + + const token: MiniMaxOAuthToken = await loginMiniMaxPortalOAuth({ + region, + openUrl: async (url) => { + logger.info(`[DeviceOAuth] MiniMax opening browser: ${url}`); + // Open the authorization URL in the system browser + shell.openExternal(url).catch((err) => + logger.warn(`[DeviceOAuth] Failed to open browser:`, err) + ); + }, + note: async (message, _title) => { + if (!this.active) return; + // The extension calls note() with a message containing + // the user_code and verification_uri — parse them for the UI + const { verificationUri, userCode } = this.parseNote(message); + if (verificationUri && userCode) { + this.emitCode({ provider, verificationUri, userCode, expiresIn: 300 }); + } else { + logger.info(`[DeviceOAuth] MiniMax note: ${message}`); + } + }, + progress: { + update: (msg) => logger.info(`[DeviceOAuth] MiniMax progress: ${msg}`), + stop: (msg) => logger.info(`[DeviceOAuth] MiniMax progress done: ${msg ?? ''}`), + }, + }); + + if (!this.active) return; + + await this.onSuccess('minimax-portal', { + access: token.access, + refresh: token.refresh, + expires: token.expires, + // MiniMax returns a per-account resourceUrl as the API base URL + resourceUrl: token.resourceUrl, + // MiniMax uses Anthropic Messages API format + api: 'anthropic-messages', + }); + } + + // ───────────────────────────────────────────────────────── + // Qwen flow + // ───────────────────────────────────────────────────────── + + private async runQwenFlow(): Promise { + if (!isOpenClawPresent()) { + throw new Error('OpenClaw package not found'); + } + const provider = this.activeProvider!; + + const token: QwenOAuthToken = await loginQwenPortalOAuth({ + openUrl: async (url) => { + logger.info(`[DeviceOAuth] Qwen opening browser: ${url}`); + shell.openExternal(url).catch((err) => + logger.warn(`[DeviceOAuth] Failed to open browser:`, err) + ); + }, + note: async (message, _title) => { + if (!this.active) return; + const { verificationUri, userCode } = this.parseNote(message); + if (verificationUri && userCode) { + this.emitCode({ provider, verificationUri, userCode, expiresIn: 300 }); + } else { + logger.info(`[DeviceOAuth] Qwen note: ${message}`); + } + }, + progress: { + update: (msg) => logger.info(`[DeviceOAuth] Qwen progress: ${msg}`), + stop: (msg) => logger.info(`[DeviceOAuth] Qwen progress done: ${msg ?? ''}`), + }, + }); + + if (!this.active) return; + + await this.onSuccess('qwen-portal', { + access: token.access, + refresh: token.refresh, + expires: token.expires, + // Qwen returns a per-account resourceUrl as the API base URL + resourceUrl: token.resourceUrl, + // Qwen uses OpenAI Completions API format + api: 'openai-completions', + }); + } + + // ───────────────────────────────────────────────────────── + // Success handler + // ───────────────────────────────────────────────────────── + + private async onSuccess(providerType: OAuthProviderType, token: { + access: string; + refresh: string; + expires: number; + resourceUrl?: string; + api: 'anthropic-messages' | 'openai-completions'; + }) { + this.active = false; + this.activeProvider = null; + logger.info(`[DeviceOAuth] Successfully completed OAuth for ${providerType}`); + + // 1. Write OAuth token to OpenClaw's auth-profiles.json in native OAuth format + // (matches what `openclaw models auth login` → upsertAuthProfile writes) + try { + saveOAuthTokenToOpenClaw(providerType, { + access: token.access, + refresh: token.refresh, + expires: token.expires, + }); + } catch (err) { + logger.warn(`[DeviceOAuth] Failed to save OAuth token to OpenClaw:`, err); + } + + // 2. Write openclaw.json: set default model + provider config (baseUrl/api/models) + // This mirrors what the OpenClaw plugin's configPatch does after CLI login. + // The baseUrl comes from token.resourceUrl (per-account URL from the OAuth server) + // or falls back to the provider's default public endpoint. + // Note: MiniMax Anthropic-compatible API requires the /anthropic suffix. + const defaultBaseUrl = providerType === 'minimax-portal' + ? 'https://api.minimax.io/anthropic' + : 'https://portal.qwen.ai/v1'; + + let baseUrl = token.resourceUrl || defaultBaseUrl; + + // If MiniMax returned a resourceUrl (e.g. https://api.minimax.io) but no /anthropic suffix, + // we must append it because we use the 'anthropic-messages' API mode + if (providerType === 'minimax-portal' && baseUrl && !baseUrl.endsWith('/anthropic')) { + baseUrl = baseUrl.replace(/\/$/, '') + '/anthropic'; + } + + try { + setOpenClawDefaultModelWithOverride(providerType, undefined, { + baseUrl, + api: token.api, + // OAuth placeholder — tells Gateway to resolve credentials + // from auth-profiles.json (type: 'oauth') instead of a static API key. + // This matches what the OpenClaw plugin's configPatch writes: + // minimax-portal → apiKey: 'minimax-oauth' + // qwen-portal → apiKey: 'qwen-oauth' + apiKeyEnv: providerType === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth', + }); + } catch (err) { + logger.warn(`[DeviceOAuth] Failed to configure openclaw models:`, err); + } + + // 3. Save provider record in ClawX's own store so UI shows it as configured + const existing = await getProvider(providerType); + const providerConfig: ProviderConfig = { + id: providerType, + name: providerType === 'minimax-portal' ? 'MiniMax' : 'Qwen', + type: providerType, + enabled: existing?.enabled ?? true, + baseUrl: existing?.baseUrl, + model: existing?.model || getProviderDefaultModel(providerType), + createdAt: existing?.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + await saveProvider(providerConfig); + + // 4. Emit success to frontend + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + this.mainWindow.webContents.send('oauth:success', { provider: providerType, success: true }); + } + } + + + // ───────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────── + + /** + * Parse user_code and verification_uri from the note message sent by + * the OpenClaw extension's loginXxxPortalOAuth function. + * + * Note format (minimax-portal-auth/oauth.ts): + * "Open https://platform.minimax.io/oauth-authorize?user_code=dyMj_wOhpK&client=... to approve access.\n" + * "If prompted, enter the code dyMj_wOhpK.\n" + * ... + * + * user_code format: mixed-case alphanumeric with underscore, e.g. "dyMj_wOhpK" + */ + private parseNote(message: string): { verificationUri?: string; userCode?: string } { + // Primary: extract URL (everything between "Open " and " to") + const urlMatch = message.match(/Open\s+(https?:\/\/\S+?)\s+to/i); + const verificationUri = urlMatch?.[1]; + + let userCode: string | undefined; + + // Method 1: extract user_code from URL query param (most reliable) + if (verificationUri) { + try { + const parsed = new URL(verificationUri); + const qp = parsed.searchParams.get('user_code'); + if (qp) userCode = qp; + } catch { + // fall through to text-based extraction + } + } + + // Method 2: text-based extraction — matches mixed-case alnum + underscore/hyphen codes + if (!userCode) { + const codeMatch = message.match(/enter.*?code\s+([A-Za-z0-9][A-Za-z0-9_-]{3,})/i); + if (codeMatch?.[1]) userCode = codeMatch[1].replace(/\.$/, ''); // strip trailing period + } + + return { verificationUri, userCode }; + } + + private emitCode(data: { + provider: string; + verificationUri: string; + userCode: string; + expiresIn: number; + }) { + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + this.mainWindow.webContents.send('oauth:code', data); + } + } + + private emitError(message: string) { + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + this.mainWindow.webContents.send('oauth:error', { message }); + } + } +} + +export const deviceOAuthManager = new DeviceOAuthManager(); diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index 15bef5cff..b354495de 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -24,12 +24,23 @@ interface AuthProfileEntry { key: string; } +/** + * Auth profile entry for an OAuth token (matches OpenClaw plugin format) + */ +interface OAuthProfileEntry { + type: 'oauth'; + provider: string; + access: string; + refresh: string; + expires: number; +} + /** * Auth profiles store format */ interface AuthProfilesStore { version: number; - profiles: Record; + profiles: Record; order?: Record; lastGood?: Record; } @@ -46,7 +57,7 @@ function getAuthProfilesPath(agentId = 'main'): string { */ function readAuthProfiles(agentId = 'main'): AuthProfilesStore { const filePath = getAuthProfilesPath(agentId); - + try { if (existsSync(filePath)) { const raw = readFileSync(filePath, 'utf-8'); @@ -59,7 +70,7 @@ function readAuthProfiles(agentId = 'main'): AuthProfilesStore { } catch (error) { console.warn('Failed to read auth-profiles.json, creating fresh store:', error); } - + return { version: AUTH_STORE_VERSION, profiles: {}, @@ -72,12 +83,12 @@ function readAuthProfiles(agentId = 'main'): AuthProfilesStore { function writeAuthProfiles(store: AuthProfilesStore, agentId = 'main'): void { const filePath = getAuthProfilesPath(agentId); const dir = join(filePath, '..'); - + // Ensure directory exists if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } - + writeFileSync(filePath, JSON.stringify(store, null, 2), 'utf-8'); } @@ -96,6 +107,52 @@ function discoverAgentIds(): string[] { } } +/** + * Save an OAuth token to OpenClaw's auth-profiles.json. + * Writes in OpenClaw's native OAuth credential format (type: 'oauth'), + * matching exactly what `openclaw models auth login` (upsertAuthProfile) writes. + * + * @param provider - Provider type (e.g. 'minimax-portal', 'qwen-portal') + * @param token - OAuth token from the provider's login function + * @param agentId - Optional single agent ID. When omitted, writes to every agent. + */ +export function saveOAuthTokenToOpenClaw( + provider: string, + token: { access: string; refresh: string; expires: number }, + agentId?: string +): void { + const agentIds = agentId ? [agentId] : discoverAgentIds(); + if (agentIds.length === 0) agentIds.push('main'); + + for (const id of agentIds) { + const store = readAuthProfiles(id); + const profileId = `${provider}:default`; + + const entry: OAuthProfileEntry = { + type: 'oauth', + provider, + access: token.access, + refresh: token.refresh, + expires: token.expires, + }; + + store.profiles[profileId] = entry; + + if (!store.order) store.order = {}; + if (!store.order[provider]) store.order[provider] = []; + if (!store.order[provider].includes(profileId)) { + store.order[provider].push(profileId); + } + + if (!store.lastGood) store.lastGood = {}; + store.lastGood[provider] = profileId; + + writeAuthProfiles(store, id); + } + + console.log(`Saved OAuth token for provider "${provider}" to OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`); +} + /** * Save a provider API key to OpenClaw's auth-profiles.json * This writes the key in the format OpenClaw expects so the gateway @@ -109,10 +166,20 @@ function discoverAgentIds(): string[] { * @param agentId - Optional single agent ID. When omitted, writes to every agent. */ export function saveProviderKeyToOpenClaw( + provider: string, apiKey: string, agentId?: string ): void { + // OAuth providers (qwen-portal, minimax-portal) typically have their credentials + // managed by OpenClaw plugins via `openclaw models auth login`. + // Skip only if there's no explicit API key — meaning the user is using OAuth. + // If the user provided an actual API key, write it normally. + const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal']; + if (OAUTH_PROVIDERS.includes(provider) && !apiKey) { + console.log(`Skipping auth-profiles write for OAuth provider "${provider}" (no API key provided, using OAuth)`); + return; + } const agentIds = agentId ? [agentId] : discoverAgentIds(); if (agentIds.length === 0) agentIds.push('main'); @@ -158,6 +225,13 @@ export function removeProviderKeyFromOpenClaw( provider: string, agentId?: string ): void { + // OAuth providers have their credentials managed by OpenClaw plugins. + // Do NOT delete their auth-profiles entries. + const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal']; + if (OAUTH_PROVIDERS.includes(provider)) { + console.log(`Skipping auth-profiles removal for OAuth provider "${provider}" (managed by OpenClaw plugin)`); + return; + } const agentIds = agentId ? [agentId] : discoverAgentIds(); if (agentIds.length === 0) agentIds.push('main'); @@ -183,20 +257,74 @@ export function removeProviderKeyFromOpenClaw( console.log(`Removed API key for provider "${provider}" from OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`); } +/** + * Remove a provider completely from OpenClaw (delete config, disable plugins, delete keys) + */ +export function removeProviderFromOpenClaw(provider: string): void { + // 1. Remove from auth-profiles.json + const agentIds = discoverAgentIds(); + if (agentIds.length === 0) agentIds.push('main'); + for (const id of agentIds) { + const store = readAuthProfiles(id); + const profileId = `${provider}:default`; + if (store.profiles[profileId]) { + delete store.profiles[profileId]; + if (store.order?.[provider]) { + store.order[provider] = store.order[provider].filter((aid) => aid !== profileId); + if (store.order[provider].length === 0) delete store.order[provider]; + } + if (store.lastGood?.[provider] === profileId) delete store.lastGood[provider]; + writeAuthProfiles(store, id); + } + } + + // 2. Remove from openclaw.json + const configPath = join(homedir(), '.openclaw', 'openclaw.json'); + try { + if (existsSync(configPath)) { + const config = JSON.parse(readFileSync(configPath, 'utf-8')); + let modified = false; + + // Disable plugin (for OAuth like qwen-portal-auth) + if (config.plugins?.entries) { + const pluginName = `${provider}-auth`; + if (config.plugins.entries[pluginName]) { + config.plugins.entries[pluginName].enabled = false; + modified = true; + console.log(`Disabled OpenClaw plugin: ${pluginName}`); + } + } + + // Remove from models.providers + if (config.models?.providers?.[provider]) { + delete config.models.providers[provider]; + modified = true; + console.log(`Removed OpenClaw provider config: ${provider}`); + } + + if (modified) { + writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); + } + } + } catch (err) { + console.warn(`Failed to remove provider ${provider} from openclaw.json:`, err); + } +} + /** * Build environment variables object with all stored API keys * for passing to the Gateway process */ export function buildProviderEnvVars(providers: Array<{ type: string; apiKey: string }>): Record { const env: Record = {}; - + for (const { type, apiKey } of providers) { const envVar = getProviderEnvVar(type); if (envVar && apiKey) { env[envVar] = apiKey; } } - + return env; } @@ -210,9 +338,9 @@ export function buildProviderEnvVars(providers: Array<{ type: string; apiKey: st */ export function setOpenClawDefaultModel(provider: string, modelOverride?: string): void { const configPath = join(homedir(), '.openclaw', 'openclaw.json'); - + let config: Record = {}; - + try { if (existsSync(configPath)) { config = JSON.parse(readFileSync(configPath, 'utf-8')); @@ -220,7 +348,7 @@ export function setOpenClawDefaultModel(provider: string, modelOverride?: string } catch (err) { console.warn('Failed to read openclaw.json, creating fresh config:', err); } - + const model = modelOverride || getProviderDefaultModel(provider); if (!model) { console.warn(`No default model mapping for provider "${provider}"`); @@ -230,7 +358,7 @@ export function setOpenClawDefaultModel(provider: string, modelOverride?: string const modelId = model.startsWith(`${provider}/`) ? model.slice(provider.length + 1) : model; - + // Set the default model for the agents // model must be an object: { primary: "provider/model", fallbacks?: [] } const agents = (config.agents || {}) as Record; @@ -238,7 +366,7 @@ export function setOpenClawDefaultModel(provider: string, modelOverride?: string defaults.model = { primary: model }; agents.defaults = defaults; config.agents = agents; - + // Configure models.providers for providers that need explicit registration. // Built-in providers (anthropic, google) are part of OpenClaw's pi-ai catalog // and must NOT have a models.providers entry — it would override the built-in. @@ -276,7 +404,7 @@ export function setOpenClawDefaultModel(provider: string, modelOverride?: string models: mergedModels, }; console.log(`Configured models.providers.${provider} with baseUrl=${providerCfg.baseUrl}, model=${modelId}`); - + models.providers = providers; config.models = models; } else { @@ -292,20 +420,20 @@ export function setOpenClawDefaultModel(provider: string, modelOverride?: string config.models = models; } } - + // Ensure gateway mode is set const gateway = (config.gateway || {}) as Record; if (!gateway.mode) { gateway.mode = 'local'; } config.gateway = gateway; - + // Ensure directory exists const dir = join(configPath, '..'); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } - + writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); console.log(`Set OpenClaw default model to "${model}" for provider "${provider}"`); } @@ -386,6 +514,16 @@ export function setOpenClawDefaultModelWithOverride( } config.gateway = gateway; + // Ensure the extension plugin is marked as enabled in openclaw.json + // Without this, the OpenClaw Gateway will silently wipe the provider config on startup + if (provider === 'minimax-portal' || provider === 'qwen-portal') { + const plugins = (config.plugins || {}) as Record; + const entries = (plugins.entries || {}) as Record; + entries[`${provider}-auth`] = { enabled: true }; + plugins.entries = entries; + config.plugins = plugins; + } + const dir = join(configPath, '..'); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); @@ -397,6 +535,52 @@ export function setOpenClawDefaultModelWithOverride( ); } +/** + * Get a set of all active provider IDs configured in openclaw.json and auth-profiles.json. + * This is used to sync ClawX's local provider list with the actual OpenClaw engine state. + */ +export function getActiveOpenClawProviders(): Set { + const activeProviders = new Set(); + const configPath = join(homedir(), '.openclaw', 'openclaw.json'); + + // 1. Read openclaw.json models.providers + try { + if (existsSync(configPath)) { + const config = JSON.parse(readFileSync(configPath, 'utf-8')); + const providers = config.models?.providers; + if (providers && typeof providers === 'object') { + for (const key of Object.keys(providers)) { + activeProviders.add(key); + } + } + } + } catch (err) { + console.warn('Failed to read openclaw.json for active providers:', err); + } + + // 2. Read openclaw.json plugins.entries for OAuth providers + try { + if (existsSync(configPath)) { + const config = JSON.parse(readFileSync(configPath, 'utf-8')); + const plugins = config.plugins?.entries; + if (plugins && typeof plugins === 'object') { + for (const [pluginId, meta] of Object.entries(plugins)) { + // If the plugin ends with -auth and is enabled, it's an OAuth provider + // e.g. 'qwen-portal-auth' implies provider 'qwen-portal' + if (pluginId.endsWith('-auth') && (meta as Record).enabled) { + const providerId = pluginId.replace(/-auth$/, ''); + activeProviders.add(providerId); + } + } + } + } + } catch (err) { + console.warn('Failed to read openclaw.json for active plugins:', err); + } + + return activeProviders; +} + // Re-export for backwards compatibility /** * Write the ClawX gateway token into ~/.openclaw/openclaw.json so the diff --git a/electron/utils/provider-registry.ts b/electron/utils/provider-registry.ts index c746e63fd..4b19270f4 100644 --- a/electron/utils/provider-registry.ts +++ b/electron/utils/provider-registry.ts @@ -12,6 +12,8 @@ export const BUILTIN_PROVIDER_TYPES = [ 'openrouter', 'moonshot', 'siliconflow', + 'minimax-portal', + 'qwen-portal', 'ollama', ] as const; export type BuiltinProviderType = (typeof BUILTIN_PROVIDER_TYPES)[number]; @@ -94,6 +96,12 @@ const REGISTRY: Record = { apiKeyEnv: 'SILICONFLOW_API_KEY', }, }, + 'minimax-portal': { + defaultModel: 'minimax-portal/MiniMax-M2.1', + }, + 'qwen-portal': { + defaultModel: 'qwen-portal/coder-model', + }, custom: { envVar: 'CUSTOM_API_KEY', }, diff --git a/electron/utils/secure-storage.ts b/electron/utils/secure-storage.ts index cc6f0089b..c89483671 100644 --- a/electron/utils/secure-storage.ts +++ b/electron/utils/secure-storage.ts @@ -4,6 +4,9 @@ * Keys are stored in plain text alongside provider configs in a single electron-store. */ +import type { ProviderType } from './provider-registry'; +import { getActiveOpenClawProviders } from './openclaw-auth'; + // Lazy-load electron-store (ESM module) // eslint-disable-next-line @typescript-eslint/no-explicit-any let providerStore: any = null; @@ -29,7 +32,7 @@ async function getProviderStore() { export interface ProviderConfig { id: string; name: string; - type: 'anthropic' | 'openai' | 'google' | 'openrouter' | 'moonshot' | 'siliconflow' | 'ollama' | 'custom'; + type: ProviderType; baseUrl?: string; model?: string; enabled: boolean; @@ -204,14 +207,32 @@ export async function getProviderWithKeyInfo( /** * Get all providers with key info (for UI display) + * Also synchronizes ClawX local provider list with OpenClaw's actual config. */ export async function getAllProvidersWithKeyInfo(): Promise< Array > { const providers = await getAllProviders(); const results: Array = []; + const activeOpenClawProviders = getActiveOpenClawProviders(); + + // We need to avoid deleting native ones like 'anthropic' or 'google' + // that don't need to exist in openclaw.json models.providers + const OpenClawBuiltinList = [ + 'anthropic', 'openai', 'google', 'moonshot', 'siliconflow', 'ollama' + ]; for (const provider of providers) { + // Sync check: If it's a custom/OAuth provider and it no longer exists in OpenClaw config + // (e.g. wiped by Gateway due to missing plugin, or manually deleted by user) + // we should remove it from ClawX UI to stay consistent. + const isBuiltin = OpenClawBuiltinList.includes(provider.type); + if (!isBuiltin && !activeOpenClawProviders.has(provider.type) && !activeOpenClawProviders.has(provider.id)) { + console.log(`[Sync] Provider ${provider.id} (${provider.type}) missing from OpenClaw, dropping from ClawX UI`); + await deleteProvider(provider.id); + continue; + } + const apiKey = await getApiKey(provider.id); let keyMasked: string | null = null; @@ -232,3 +253,4 @@ export async function getAllProvidersWithKeyInfo(): Promise< return results; } + diff --git a/src/components/settings/ProvidersSettings.tsx b/src/components/settings/ProvidersSettings.tsx index bc59826e0..2e39d4f3a 100644 --- a/src/components/settings/ProvidersSettings.tsx +++ b/src/components/settings/ProvidersSettings.tsx @@ -2,7 +2,7 @@ * Providers Settings Component * Manage AI provider configurations and API keys */ -import { useState, useEffect } from 'react'; +import React, { useState, useEffect } from 'react'; import { Plus, Trash2, @@ -14,6 +14,9 @@ import { Loader2, Star, Key, + ExternalLink, + Copy, + XCircle, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -76,8 +79,8 @@ export function ProvidersSettings() { apiKey.trim() || undefined ); - // Auto-set as default if this is the first provider - if (providers.length === 0) { + // Auto-set as default if no default is currently configured + if (!defaultProviderId) { await setDefaultProvider(id); } @@ -370,16 +373,25 @@ function ProviderCard({ ) : (
- - - {provider.hasKey - ? (provider.keyMasked && provider.keyMasked.length > 12 - ? `${provider.keyMasked.substring(0, 4)}...${provider.keyMasked.substring(provider.keyMasked.length - 4)}` - : provider.keyMasked) - : t('aiProviders.card.noKey')} - - {provider.hasKey && ( - {t('aiProviders.card.configured')} + {typeInfo?.isOAuth ? ( + <> + + {t('aiProviders.card.configured')} + + ) : ( + <> + + + {provider.hasKey + ? (provider.keyMasked && provider.keyMasked.length > 12 + ? `${provider.keyMasked.substring(0, 4)}...${provider.keyMasked.substring(provider.keyMasked.length - 4)}` + : provider.keyMasked) + : t('aiProviders.card.noKey')} + + {provider.hasKey && ( + {t('aiProviders.card.configured')} + )} + )}
@@ -441,7 +453,96 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add const [saving, setSaving] = useState(false); const [validationError, setValidationError] = useState(null); + // OAuth Flow State + const [oauthFlowing, setOauthFlowing] = useState(false); + const [oauthData, setOauthData] = useState<{ + verificationUri: string; + userCode: string; + expiresIn: number; + } | null>(null); + const [oauthError, setOauthError] = useState(null); + // For providers that support both OAuth and API key, let the user choose + const [authMode, setAuthMode] = useState<'oauth' | 'apikey'>('oauth'); + const typeInfo = PROVIDER_TYPE_INFO.find((t) => t.id === selectedType); + const isOAuth = typeInfo?.isOAuth ?? false; + const supportsApiKey = typeInfo?.supportsApiKey ?? false; + // Effective OAuth mode: pure OAuth providers, or dual-mode with oauth selected + const useOAuthFlow = isOAuth && (!supportsApiKey || authMode === 'oauth'); + + // Keep a ref to the latest values so the effect closure can access them + const latestRef = React.useRef({ selectedType, typeInfo, onAdd, onClose, t }); + useEffect(() => { + latestRef.current = { selectedType, typeInfo, onAdd, onClose, t }; + }); + + // Manage OAuth events + useEffect(() => { + const handleCode = (data: unknown) => { + setOauthData(data as { verificationUri: string; userCode: string; expiresIn: number }); + setOauthError(null); + }; + + const handleSuccess = async () => { + setOauthFlowing(false); + setOauthData(null); + setValidationError(null); + const { selectedType: type, typeInfo: info, onAdd: add, onClose: close, t: translate } = latestRef.current; + // Save the provider to the store so the list refreshes automatically + if (type && add) { + try { + await add( + type, + info?.name || type, + '', // OAuth providers don't use a plain API key + { model: info?.defaultModelId } + ); + } catch { + // provider may already exist; ignore duplicate errors + } + } + close(); + toast.success(translate('aiProviders.toast.added')); + }; + + const handleError = (data: unknown) => { + setOauthError((data as { message: string }).message); + setOauthData(null); + }; + + window.electron.ipcRenderer.on('oauth:code', handleCode); + window.electron.ipcRenderer.on('oauth:success', handleSuccess); + window.electron.ipcRenderer.on('oauth:error', handleError); + + return () => { + if (typeof window.electron.ipcRenderer.off === 'function') { + window.electron.ipcRenderer.off('oauth:code', handleCode); + window.electron.ipcRenderer.off('oauth:success', handleSuccess); + window.electron.ipcRenderer.off('oauth:error', handleError); + } + }; + }, []); + + const handleStartOAuth = async () => { + if (!selectedType) return; + setOauthFlowing(true); + setOauthData(null); + setOauthError(null); + + try { + await window.electron.ipcRenderer.invoke('provider:requestOAuth', selectedType, 'global'); + } catch (e) { + setOauthError(String(e)); + setOauthFlowing(false); + } + }; + + const handleCancelOAuth = async () => { + setOauthFlowing(false); + setOauthData(null); + setOauthError(null); + await window.electron.ipcRenderer.invoke('provider:cancelOAuth'); + }; // Only custom can be added multiple times. const availableTypes = PROVIDER_TYPE_INFO.filter( @@ -562,35 +663,62 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add />
-
- -
- { - setApiKey(e.target.value); - setValidationError(null); - }} - className="pr-10" - /> + {/* Auth mode toggle for providers supporting both */} + {isOAuth && supportsApiKey && ( +
+
- {validationError && ( -

{validationError}

- )} -

- {t('aiProviders.dialog.apiKeyStored')} -

-
+ )} + + {/* API Key input — shown for non-OAuth providers or when apikey mode is selected */} + {(!isOAuth || (supportsApiKey && authMode === 'apikey')) && ( +
+ +
+ { + setApiKey(e.target.value); + setValidationError(null); + }} + className="pr-10" + /> + +
+ {validationError && ( +

{validationError}

+ )} +

+ {t('aiProviders.dialog.apiKeyStored')} +

+
+ )} {typeInfo?.showBaseUrl && (
@@ -618,6 +746,98 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add />
)} + {/* Device OAuth Trigger — only shown when in OAuth mode */} + {useOAuthFlow && ( +
+
+

+ {t('aiProviders.oauth.loginPrompt')} +

+ +
+ + {/* OAuth Active State Modal / Inline View */} + {oauthFlowing && ( +
+ {/* Background pulse effect */} +
+ +
+ {oauthError ? ( +
+ +

{t('aiProviders.oauth.authFailed')}

+

{oauthError}

+ +
+ ) : !oauthData ? ( +
+ +

{t('aiProviders.oauth.requestingCode')}

+
+ ) : ( +
+
+

{t('aiProviders.oauth.approveLogin')}

+
+

1. {t('aiProviders.oauth.step1')}

+

2. {t('aiProviders.oauth.step2')}

+

3. {t('aiProviders.oauth.step3')}

+
+
+ +
+ + {oauthData.userCode} + + +
+ + + +
+ + {t('aiProviders.oauth.waitingApproval')} +
+ + +
+ )} +
+
+ )} +
+ )}
)} @@ -629,6 +849,7 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
)} + {/* Auth mode toggle for providers supporting both */} + {isOAuth && supportsApiKey && ( +
+ + +
+ )} + {/* API Key field (hidden for ollama) */} - {requiresKey && ( + {(!isOAuth || (supportsApiKey && authMode === 'apikey')) && requiresKey && (
@@ -1076,11 +1184,104 @@ function ProviderContent({
)} + {/* Device OAuth Trigger */} + {useOAuthFlow && ( +
+
+

+ This provider requires signing in via your browser. +

+ +
+ + {/* OAuth Active State Modal / Inline View */} + {oauthFlowing && ( +
+ {/* Background pulse effect */} +
+ +
+ {oauthError ? ( +
+ +

Authentication Failed

+

{oauthError}

+ +
+ ) : !oauthData ? ( +
+ +

Requesting secure login code...

+
+ ) : ( +
+
+

Approve Login

+
+

1. Copy the authorization code below.

+

2. Open the login page in your browser.

+

3. Paste the code to approve access.

+
+
+ +
+ + {oauthData.userCode} + + +
+ + + +
+ + Waiting for approval in browser... +
+ + +
+ )} +
+
+ )} +
+ )} + {/* Validate & Save */}