From 386d4c545482c13279cf0a60b1e1cf6b3eacf6fc Mon Sep 17 00:00:00 2001 From: Haze <709547807@qq.com> Date: Fri, 27 Feb 2026 22:10:35 +0800 Subject: [PATCH] fix(win): Windows stability improvements (#207) (#208) Co-authored-by: Cursor Agent Co-authored-by: Haze --- electron/gateway/manager.ts | 82 +++- electron/main/app-state.ts | 17 +- electron/main/index.ts | 63 ++-- electron/main/ipc-handlers.ts | 167 +++++---- electron/utils/channel-config.ts | 321 +++++----------- electron/utils/device-identity.ts | 41 +- electron/utils/device-oauth.ts | 4 +- electron/utils/logger.ts | 169 ++++++--- electron/utils/openclaw-auth.ts | 539 ++++++++++----------------- electron/utils/openclaw-workspace.ts | 160 ++++---- electron/utils/secure-storage.ts | 2 +- electron/utils/skill-config.ts | 40 +- package.json | 4 +- src/pages/Channels/index.tsx | 16 +- 14 files changed, 754 insertions(+), 871 deletions(-) diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index 786e8aa6f..2f46de03a 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -130,17 +130,20 @@ export class GatewayManager extends EventEmitter { timeout: NodeJS.Timeout; }> = new Map(); private deviceIdentity: DeviceIdentity | null = null; + private restartDebounceTimer: NodeJS.Timeout | null = null; constructor(config?: Partial) { super(); this.reconnectConfig = { ...DEFAULT_RECONNECT_CONFIG, ...config }; - this.initDeviceIdentity(); + // Device identity is loaded lazily in start() — not in the constructor — + // so that async file I/O and key generation don't block module loading. } - private initDeviceIdentity(): void { + private async initDeviceIdentity(): Promise { + if (this.deviceIdentity) return; // already loaded try { const identityPath = path.join(app.getPath('userData'), 'clawx-device-identity.json'); - this.deviceIdentity = loadOrCreateDeviceIdentity(identityPath); + this.deviceIdentity = await loadOrCreateDeviceIdentity(identityPath); logger.debug(`Device identity loaded (deviceId=${this.deviceIdentity.deviceId})`); } catch (err) { logger.warn('Failed to load device identity, scopes will be limited:', err); @@ -211,6 +214,10 @@ export class GatewayManager extends EventEmitter { this.lastSpawnSummary = null; this.shouldReconnect = true; + // Lazily load device identity (async file I/O + key generation). + // Must happen before connect() which uses the identity for the handshake. + await this.initDeviceIdentity(); + // Manual start should override and cancel any pending reconnect timer. if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); @@ -369,6 +376,26 @@ export class GatewayManager extends EventEmitter { await this.stop(); await this.start(); } + + /** + * Debounced restart — coalesces multiple rapid restart requests into a + * single restart after `delayMs` of inactivity. This prevents the + * cascading stop/start cycles that occur when provider:save, + * provider:setDefault and channel:saveConfig all fire within seconds + * of each other during setup. + */ + debouncedRestart(delayMs = 2000): void { + if (this.restartDebounceTimer) { + clearTimeout(this.restartDebounceTimer); + } + logger.debug(`Gateway restart debounced (will fire in ${delayMs}ms)`); + this.restartDebounceTimer = setTimeout(() => { + this.restartDebounceTimer = null; + void this.restart().catch((err) => { + logger.warn('Debounced Gateway restart failed:', err); + }); + }, delayMs); + } /** * Clear all active timers @@ -386,6 +413,10 @@ export class GatewayManager extends EventEmitter { clearInterval(this.healthCheckInterval); this.healthCheckInterval = null; } + if (this.restartDebounceTimer) { + clearTimeout(this.restartDebounceTimer); + this.restartDebounceTimer = null; + } } /** @@ -540,9 +571,15 @@ export class GatewayManager extends EventEmitter { const port = PORTS.OPENCLAW_GATEWAY; try { + // Platform-specific command to find processes listening on the gateway port. + // On Windows, lsof doesn't exist; use PowerShell's Get-NetTCPConnection instead. + const cmd = process.platform === 'win32' + ? `powershell -NoProfile -Command "(Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue).OwningProcess"` + : `lsof -i :${port} -sTCP:LISTEN -t`; + const { stdout } = await new Promise<{ stdout: string }>((resolve, reject) => { import('child_process').then(cp => { - cp.exec(`lsof -i :${port} -sTCP:LISTEN -t`, { timeout: 5000 }, (err, stdout) => { + cp.exec(cmd, { timeout: 5000 }, (err, stdout) => { if (err) resolve({ stdout: '' }); else resolve({ stdout }); }); @@ -550,7 +587,7 @@ export class GatewayManager extends EventEmitter { }); if (stdout.trim()) { - const pids = stdout.trim().split('\n') + const pids = stdout.trim().split(/\r?\n/) .map(s => s.trim()) .filter(Boolean); @@ -560,18 +597,33 @@ export class GatewayManager extends EventEmitter { // Unload the launchctl service first so macOS doesn't auto- // respawn the process we're about to kill. - await this.unloadLaunchctlService(); + if (process.platform === 'darwin') { + await this.unloadLaunchctlService(); + } - // SIGTERM first so the gateway can clean up its lock file. + // Terminate orphaned processes for (const pid of pids) { - try { process.kill(parseInt(pid), 'SIGTERM'); } catch { /* ignore */ } + try { + if (process.platform === 'win32') { + // On Windows, use taskkill for reliable process group termination + import('child_process').then(cp => { + cp.exec(`taskkill /PID ${pid} /T /F`, { timeout: 5000 }, () => {}); + }).catch(() => {}); + } else { + // SIGTERM first so the gateway can clean up its lock file. + process.kill(parseInt(pid), 'SIGTERM'); + } + } catch { /* ignore */ } } - await new Promise(r => setTimeout(r, 3000)); - // SIGKILL any survivors. - for (const pid of pids) { - try { process.kill(parseInt(pid), 0); process.kill(parseInt(pid), 'SIGKILL'); } catch { /* already exited */ } + await new Promise(r => setTimeout(r, process.platform === 'win32' ? 2000 : 3000)); + + // SIGKILL any survivors (Unix only — Windows taskkill /F is already forceful) + if (process.platform !== 'win32') { + for (const pid of pids) { + try { process.kill(parseInt(pid), 0); process.kill(parseInt(pid), 'SIGKILL'); } catch { /* already exited */ } + } + await new Promise(r => setTimeout(r, 1000)); } - await new Promise(r => setTimeout(r, 1000)); return null; } } @@ -633,13 +685,13 @@ export class GatewayManager extends EventEmitter { // system-managed launchctl service) the WebSocket handshake will fail // with "token mismatch" even though we pass --token on the CLI. try { - syncGatewayTokenToConfig(gatewayToken); + await syncGatewayTokenToConfig(gatewayToken); } catch (err) { logger.warn('Failed to sync gateway token to openclaw.json:', err); } try { - syncBrowserConfigToOpenClaw(); + await syncBrowserConfigToOpenClaw(); } catch (err) { logger.warn('Failed to sync browser config to openclaw.json:', err); } diff --git a/electron/main/app-state.ts b/electron/main/app-state.ts index 3a9aa6c12..03848d275 100644 --- a/electron/main/app-state.ts +++ b/electron/main/app-state.ts @@ -1,5 +1,18 @@ -export let isQuitting = false; +/** + * Application quit state. + * + * Exposed as a function accessor (not a bare `export let`) so that every + * import site reads the *live* value. With `export let`, bundlers that + * compile to CJS may snapshot the variable at import time, causing + * `isQuitting` to stay `false` forever and preventing the window from + * closing on Windows/Linux. + */ +let _isQuitting = false; + +export function isQuitting(): boolean { + return _isQuitting; +} export function setQuitting(value = true): void { - isQuitting = value; + _isQuitting = value; } diff --git a/electron/main/index.ts b/electron/main/index.ts index 2861e4fc1..4ded0f091 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -17,8 +17,13 @@ import { ClawHubService } from '../gateway/clawhub'; import { ensureClawXContext, repairClawXOnlyBootstrapFiles } from '../utils/openclaw-workspace'; import { isQuitting, setQuitting } from './app-state'; -// Disable GPU acceleration for better compatibility -app.disableHardwareAcceleration(); +// Disable GPU acceleration only on Linux where GPU driver issues are common. +// On Windows and macOS, hardware acceleration is essential for responsive UI; +// forcing CPU rendering makes the main thread compete with sync I/O and +// contributes to "Not Responding" hangs. +if (process.platform === 'linux') { + app.disableHardwareAcceleration(); +} // Global references let mainWindow: BrowserWindow | null = null; @@ -122,30 +127,28 @@ async function initialize(): Promise { // Create system tray createTray(mainWindow); - // Override security headers ONLY for the OpenClaw Gateway Control UI - session.defaultSession.webRequest.onHeadersReceived((details, callback) => { - const isGatewayUrl = details.url.includes('127.0.0.1:18789') || details.url.includes('localhost:18789'); - - if (!isGatewayUrl) { - callback({ responseHeaders: details.responseHeaders }); - return; - } - - const headers = { ...details.responseHeaders }; - delete headers['X-Frame-Options']; - delete headers['x-frame-options']; - if (headers['Content-Security-Policy']) { - headers['Content-Security-Policy'] = headers['Content-Security-Policy'].map( - (csp) => csp.replace(/frame-ancestors\s+'none'/g, "frame-ancestors 'self' *") - ); - } - if (headers['content-security-policy']) { - headers['content-security-policy'] = headers['content-security-policy'].map( - (csp) => csp.replace(/frame-ancestors\s+'none'/g, "frame-ancestors 'self' *") - ); - } - callback({ responseHeaders: headers }); - }); + // Override security headers ONLY for the OpenClaw Gateway Control UI. + // The URL filter ensures this callback only fires for gateway requests, + // avoiding unnecessary overhead on every other HTTP response. + session.defaultSession.webRequest.onHeadersReceived( + { urls: ['http://127.0.0.1:18789/*', 'http://localhost:18789/*'] }, + (details, callback) => { + const headers = { ...details.responseHeaders }; + delete headers['X-Frame-Options']; + delete headers['x-frame-options']; + if (headers['Content-Security-Policy']) { + headers['Content-Security-Policy'] = headers['Content-Security-Policy'].map( + (csp) => csp.replace(/frame-ancestors\s+'none'/g, "frame-ancestors 'self' *") + ); + } + if (headers['content-security-policy']) { + headers['content-security-policy'] = headers['content-security-policy'].map( + (csp) => csp.replace(/frame-ancestors\s+'none'/g, "frame-ancestors 'self' *") + ); + } + callback({ responseHeaders: headers }); + }, + ); // Register IPC handlers registerIpcHandlers(gatewayManager, clawHubService, mainWindow); @@ -158,7 +161,7 @@ async function initialize(): Promise { // Minimize to tray on close instead of quitting (macOS & Windows) mainWindow.on('close', (event) => { - if (!isQuitting) { + if (!isQuitting()) { event.preventDefault(); mainWindow?.hide(); } @@ -171,11 +174,9 @@ async function initialize(): Promise { // Repair any bootstrap files that only contain ClawX markers (no OpenClaw // template content). This fixes a race condition where ensureClawXContext() // previously created the file before the gateway could seed the full template. - try { - repairClawXOnlyBootstrapFiles(); - } catch (error) { + void repairClawXOnlyBootstrapFiles().catch((error) => { logger.warn('Failed to repair bootstrap files:', error); - } + }); // Start Gateway automatically (this seeds missing bootstrap files with full templates) try { diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index dedbbdeb3..29e588f70 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -3,7 +3,7 @@ * Registers all IPC handlers for main-renderer communication */ import { ipcMain, BrowserWindow, shell, dialog, app, nativeImage } from 'electron'; -import { existsSync, copyFileSync, statSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { existsSync } from 'node:fs'; import { homedir } from 'node:os'; import { join, extname, basename } from 'node:path'; import crypto from 'node:crypto'; @@ -85,7 +85,7 @@ export function registerIpcHandlers( registerClawHubHandlers(clawHubService); // OpenClaw handlers - registerOpenClawHandlers(); + registerOpenClawHandlers(gatewayManager); // Provider handlers registerProviderHandlers(gatewayManager); @@ -135,7 +135,7 @@ function registerSkillConfigHandlers(): void { apiKey?: string; env?: Record; }) => { - return updateSkillConfig(params.skillKey, { + return await updateSkillConfig(params.skillKey, { apiKey: params.apiKey, env: params.env, }); @@ -143,12 +143,12 @@ function registerSkillConfigHandlers(): void { // Get skill config ipcMain.handle('skill:getConfig', async (_, skillKey: string) => { - return getSkillConfig(skillKey); + return await getSkillConfig(skillKey); }); // Get all skill configs ipcMain.handle('skill:getAllConfigs', async () => { - return getAllSkillConfigs(); + return await getAllSkillConfigs(); }); } @@ -374,7 +374,7 @@ function registerLogHandlers(): void { // Read log file content (last N lines) ipcMain.handle('log:readFile', async (_, tailLines?: number) => { - return logger.readLogFile(tailLines); + return await logger.readLogFile(tailLines); }); // Get log file path (so user can open in file explorer) @@ -389,7 +389,7 @@ function registerLogHandlers(): void { // List all log files ipcMain.handle('log:listFiles', async () => { - return logger.listLogFiles(); + return await logger.listLogFiles(); }); } @@ -479,8 +479,10 @@ function registerGatewayHandlers( const fileReferences: string[] = []; if (params.media && params.media.length > 0) { + const fsP = await import('fs/promises'); for (const m of params.media) { - logger.info(`[chat:sendWithMedia] Processing file: ${m.fileName} (${m.mimeType}), path: ${m.filePath}, exists: ${existsSync(m.filePath)}, isVision: ${VISION_MIME_TYPES.has(m.mimeType)}`); + const exists = await fsP.access(m.filePath).then(() => true, () => false); + logger.info(`[chat:sendWithMedia] Processing file: ${m.fileName} (${m.mimeType}), path: ${m.filePath}, exists: ${exists}, isVision: ${VISION_MIME_TYPES.has(m.mimeType)}`); // Always add file path reference so the model can access it via tools fileReferences.push( @@ -491,7 +493,7 @@ function registerGatewayHandlers( // Send as base64 attachment in the format the Gateway expects: // { content: base64String, mimeType: string, fileName?: string } // The Gateway normalizer looks for `a.content` (NOT `a.source.data`). - const fileBuffer = readFileSync(m.filePath); + const fileBuffer = await fsP.readFile(m.filePath); const base64Data = fileBuffer.toString('base64'); logger.info(`[chat:sendWithMedia] Read ${fileBuffer.length} bytes, base64 length: ${base64Data.length}`); imageAttachments.push({ @@ -605,7 +607,7 @@ function registerGatewayHandlers( * OpenClaw-related IPC handlers * For checking package status and channel configuration */ -function registerOpenClawHandlers(): void { +function registerOpenClawHandlers(gatewayManager: GatewayManager): void { // Get OpenClaw package status ipcMain.handle('openclaw:status', () => { @@ -664,7 +666,13 @@ function registerOpenClawHandlers(): void { ipcMain.handle('channel:saveConfig', async (_, channelType: string, config: Record) => { try { logger.info('channel:saveConfig', { channelType, keys: Object.keys(config || {}) }); - saveChannelConfig(channelType, config); + await saveChannelConfig(channelType, config); + // Debounced restart so the gateway picks up the new channel config. + // The gateway watches openclaw.json, but a restart ensures a clean + // start for newly-added channels. Using debouncedRestart() here + // instead of an explicit restart on the frontend side means that + // rapid config changes (e.g. setup wizard) coalesce into one restart. + gatewayManager.debouncedRestart(); return { success: true }; } catch (error) { console.error('Failed to save channel config:', error); @@ -675,7 +683,7 @@ function registerOpenClawHandlers(): void { // Get channel configuration ipcMain.handle('channel:getConfig', async (_, channelType: string) => { try { - const config = getChannelConfig(channelType); + const config = await getChannelConfig(channelType); return { success: true, config }; } catch (error) { console.error('Failed to get channel config:', error); @@ -686,7 +694,7 @@ function registerOpenClawHandlers(): void { // Get channel form values (reverse-transformed for UI pre-fill) ipcMain.handle('channel:getFormValues', async (_, channelType: string) => { try { - const values = getChannelFormValues(channelType); + const values = await getChannelFormValues(channelType); return { success: true, values }; } catch (error) { console.error('Failed to get channel form values:', error); @@ -697,7 +705,7 @@ function registerOpenClawHandlers(): void { // Delete channel configuration ipcMain.handle('channel:deleteConfig', async (_, channelType: string) => { try { - deleteChannelConfig(channelType); + await deleteChannelConfig(channelType); return { success: true }; } catch (error) { console.error('Failed to delete channel config:', error); @@ -708,7 +716,7 @@ function registerOpenClawHandlers(): void { // List configured channels ipcMain.handle('channel:listConfigured', async () => { try { - const channels = listConfiguredChannels(); + const channels = await listConfiguredChannels(); return { success: true, channels }; } catch (error) { console.error('Failed to list channels:', error); @@ -719,7 +727,7 @@ function registerOpenClawHandlers(): void { // Enable or disable a channel ipcMain.handle('channel:setEnabled', async (_, channelType: string, enabled: boolean) => { try { - setChannelEnabled(channelType, enabled); + await setChannelEnabled(channelType, enabled); return { success: true }; } catch (error) { console.error('Failed to set channel enabled:', error); @@ -838,10 +846,8 @@ function registerDeviceOAuthHandlers(mainWindow: BrowserWindow): void { function registerProviderHandlers(gatewayManager: GatewayManager): void { // Listen for OAuth success to automatically restart the Gateway with new tokens/configs deviceOAuthManager.on('oauth:success', (providerType) => { - logger.info(`[IPC] Restarting Gateway after ${providerType} OAuth success...`); - void gatewayManager.restart().catch(err => { - logger.error('Failed to restart Gateway after OAuth success:', err); - }); + logger.info(`[IPC] Scheduling Gateway restart after ${providerType} OAuth success...`); + gatewayManager.debouncedRestart(); }); // Get all providers with key info @@ -871,7 +877,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { // Also write to OpenClaw auth-profiles.json so the gateway can use it try { - saveProviderKeyToOpenClaw(ock, trimmedKey); + await saveProviderKeyToOpenClaw(ock, trimmedKey); } catch (err) { console.warn('Failed to save key to OpenClaw auth-profiles:', err); } @@ -884,7 +890,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { const api = config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : meta?.api; if (api) { - syncProviderConfigToOpenClaw(ock, config.model, { + await syncProviderConfigToOpenClaw(ock, config.model, { baseUrl: config.baseUrl || meta?.baseUrl, api, apiKeyEnv: meta?.apiKeyEnv, @@ -897,7 +903,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { : await getApiKey(config.id); if (resolvedKey && config.baseUrl) { const modelId = config.model; - updateAgentModelProvider(ock, { + await updateAgentModelProvider(ock, { baseUrl: config.baseUrl, api: 'openai-completions', models: modelId ? [{ id: modelId, name: modelId }] : [], @@ -906,11 +912,10 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { } } - // Restart Gateway so it picks up the new config and env vars - logger.info(`Restarting Gateway after saving provider "${ock}" config`); - void gatewayManager.restart().catch((err) => { - logger.warn('Gateway restart after provider save failed:', err); - }); + // Debounced restart so the gateway picks up new config/env vars. + // Multiple rapid provider saves (e.g. during setup) are coalesced. + logger.info(`Scheduling Gateway restart after saving provider "${ock}" config`); + gatewayManager.debouncedRestart(); } } catch (err) { console.warn('Failed to sync openclaw provider config:', err); @@ -932,13 +937,11 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { if (existing?.type) { try { const ock = getOpenClawProviderKey(existing.type, providerId); - removeProviderFromOpenClaw(ock); + await removeProviderFromOpenClaw(ock); - // Restart Gateway so it no longer loads the deleted provider's plugin/config - logger.info(`Restarting Gateway after deleting provider "${ock}"`); - void gatewayManager.restart().catch((err) => { - logger.warn('Gateway restart after provider delete failed:', err); - }); + // Debounced restart so the gateway stops loading the deleted provider. + logger.info(`Scheduling Gateway restart after deleting provider "${ock}"`); + gatewayManager.debouncedRestart(); } catch (err) { console.warn('Failed to completely remove provider from OpenClaw:', err); } @@ -960,7 +963,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { const providerType = provider?.type || providerId; const ock = getOpenClawProviderKey(providerType, providerId); try { - saveProviderKeyToOpenClaw(ock, apiKey); + await saveProviderKeyToOpenClaw(ock, apiKey); } catch (err) { console.warn('Failed to save key to OpenClaw auth-profiles:', err); } @@ -1003,10 +1006,10 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { const trimmedKey = apiKey.trim(); if (trimmedKey) { await storeApiKey(providerId, trimmedKey); - saveProviderKeyToOpenClaw(ock, trimmedKey); + await saveProviderKeyToOpenClaw(ock, trimmedKey); } else { await deleteApiKey(providerId); - removeProviderFromOpenClaw(ock); + await removeProviderFromOpenClaw(ock); } } @@ -1016,7 +1019,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { const api = nextConfig.type === 'custom' || nextConfig.type === 'ollama' ? 'openai-completions' : meta?.api; if (api) { - syncProviderConfigToOpenClaw(ock, nextConfig.model, { + await syncProviderConfigToOpenClaw(ock, nextConfig.model, { baseUrl: nextConfig.baseUrl || meta?.baseUrl, api, apiKeyEnv: meta?.apiKeyEnv, @@ -1029,7 +1032,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { : await getApiKey(providerId); if (resolvedKey && nextConfig.baseUrl) { const modelId = nextConfig.model; - updateAgentModelProvider(ock, { + await updateAgentModelProvider(ock, { baseUrl: nextConfig.baseUrl, api: 'openai-completions', models: modelId ? [{ id: modelId, name: modelId }] : [], @@ -1046,20 +1049,18 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { ? `${ock}/${nextConfig.model}` : undefined; if (nextConfig.type !== 'custom' && nextConfig.type !== 'ollama') { - setOpenClawDefaultModel(nextConfig.type, modelOverride); + await setOpenClawDefaultModel(nextConfig.type, modelOverride); } else { - setOpenClawDefaultModelWithOverride(ock, modelOverride, { + await setOpenClawDefaultModelWithOverride(ock, modelOverride, { baseUrl: nextConfig.baseUrl, api: 'openai-completions', }); } } - // Restart Gateway so it picks up the new config and env vars - logger.info(`Restarting Gateway after updating provider "${ock}" config`); - void gatewayManager.restart().catch((err) => { - logger.warn('Gateway restart after provider update failed:', err); - }); + // Debounced restart so the gateway picks up updated config/env vars. + logger.info(`Scheduling Gateway restart after updating provider "${ock}" config`); + gatewayManager.debouncedRestart(); } catch (err) { console.warn('Failed to sync openclaw config after provider update:', err); } @@ -1071,10 +1072,10 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { await saveProvider(existing); if (previousKey) { await storeApiKey(providerId, previousKey); - saveProviderKeyToOpenClaw(previousOck, previousKey); + await saveProviderKeyToOpenClaw(previousOck, previousKey); } else { await deleteApiKey(providerId); - removeProviderFromOpenClaw(previousOck); + await removeProviderFromOpenClaw(previousOck); } } catch (rollbackError) { console.warn('Failed to rollback provider updateWithKey:', rollbackError); @@ -1096,7 +1097,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { const ock = getOpenClawProviderKey(providerType, providerId); try { if (ock) { - removeProviderFromOpenClaw(ock); + await removeProviderFromOpenClaw(ock); } } catch (err) { console.warn('Failed to completely remove provider from OpenClaw:', err); @@ -1144,17 +1145,17 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { : undefined; if (provider.type === 'custom' || provider.type === 'ollama') { - setOpenClawDefaultModelWithOverride(ock, modelOverride, { + await setOpenClawDefaultModelWithOverride(ock, modelOverride, { baseUrl: provider.baseUrl, api: 'openai-completions', }); } else { - setOpenClawDefaultModel(provider.type, modelOverride); + await setOpenClawDefaultModel(provider.type, modelOverride); } // Keep auth-profiles in sync with the default provider instance. if (providerKey) { - saveProviderKeyToOpenClaw(ock, providerKey); + await saveProviderKeyToOpenClaw(ock, providerKey); } } else { // OAuth providers (minimax-portal, minimax-portal-cn, qwen-portal) @@ -1177,7 +1178,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { ? 'minimax-portal' : provider.type; - setOpenClawDefaultModelWithOverride(targetProviderKey, undefined, { + await setOpenClawDefaultModelWithOverride(targetProviderKey, undefined, { baseUrl, api, authHeader: targetProviderKey === 'minimax-portal' ? true : undefined, @@ -1191,7 +1192,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { // authHeader immediately, without waiting for Gateway to sync openclaw.json. try { const defaultModelId = provider.model?.split('/').pop(); - updateAgentModelProvider(targetProviderKey, { + await updateAgentModelProvider(targetProviderKey, { baseUrl, api, authHeader: targetProviderKey === 'minimax-portal' ? true : undefined, @@ -1210,7 +1211,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { provider.baseUrl ) { const modelId = provider.model; - updateAgentModelProvider(ock, { + await updateAgentModelProvider(ock, { baseUrl: provider.baseUrl, api: 'openai-completions', models: modelId ? [{ id: modelId, name: modelId }] : [], @@ -1218,12 +1219,10 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { }); } - // Restart Gateway so it picks up the new config and env vars. + // Debounced restart so the gateway picks up the new default provider. if (gatewayManager.isConnected()) { - logger.info(`Restarting Gateway after provider switch to "${ock}"`); - void gatewayManager.restart().catch((err) => { - logger.warn('Gateway restart after provider switch failed:', err); - }); + logger.info(`Scheduling Gateway restart after provider switch to "${ock}"`); + gatewayManager.debouncedRestart(); } } catch (err) { console.warn('Failed to set OpenClaw default model:', err); @@ -1757,7 +1756,7 @@ const OUTBOUND_DIR = join(homedir(), '.openclaw', 'media', 'outbound'); * longer side so the image is never squished). The frontend handles * square cropping via CSS object-fit: cover. */ -function generateImagePreview(filePath: string, mimeType: string): string | null { +async function generateImagePreview(filePath: string, mimeType: string): Promise { try { const img = nativeImage.createFromPath(filePath); if (img.isEmpty()) return null; @@ -1770,8 +1769,9 @@ function generateImagePreview(filePath: string, mimeType: string): string | null : img.resize({ height: maxDim }); // portrait → constrain height return `data:image/png;base64,${resized.toPNG().toString('base64')}`; } - // Small image — use original - const buf = readFileSync(filePath); + // Small image — use original (async read to avoid blocking) + const { readFile: readFileAsync } = await import('fs/promises'); + const buf = await readFileAsync(filePath); return `data:${mimeType};base64,${buf.toString('base64')}`; } catch { return null; @@ -1785,26 +1785,27 @@ function generateImagePreview(filePath: string, mimeType: string): string | null function registerFileHandlers(): void { // Stage files from real disk paths (used with dialog:open) ipcMain.handle('file:stage', async (_, filePaths: string[]) => { - mkdirSync(OUTBOUND_DIR, { recursive: true }); + const fsP = await import('fs/promises'); + await fsP.mkdir(OUTBOUND_DIR, { recursive: true }); const results = []; for (const filePath of filePaths) { const id = crypto.randomUUID(); const ext = extname(filePath); const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`); - copyFileSync(filePath, stagedPath); + await fsP.copyFile(filePath, stagedPath); - const stat = statSync(stagedPath); + const s = await fsP.stat(stagedPath); const mimeType = getMimeType(ext); const fileName = basename(filePath); // Generate preview for images let preview: string | null = null; if (mimeType.startsWith('image/')) { - preview = generateImagePreview(stagedPath, mimeType); + preview = await generateImagePreview(stagedPath, mimeType); } - results.push({ id, fileName, mimeType, fileSize: stat.size, stagedPath, preview }); + results.push({ id, fileName, mimeType, fileSize: s.size, stagedPath, preview }); } return results; }); @@ -1815,13 +1816,14 @@ function registerFileHandlers(): void { fileName: string; mimeType: string; }) => { - mkdirSync(OUTBOUND_DIR, { recursive: true }); + const fsP = await import('fs/promises'); + await fsP.mkdir(OUTBOUND_DIR, { recursive: true }); const id = crypto.randomUUID(); const ext = extname(payload.fileName) || mimeToExt(payload.mimeType); const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`); const buffer = Buffer.from(payload.base64, 'base64'); - writeFileSync(stagedPath, buffer); + await fsP.writeFile(stagedPath, buffer); const mimeType = payload.mimeType || getMimeType(ext); const fileSize = buffer.length; @@ -1829,7 +1831,7 @@ function registerFileHandlers(): void { // Generate preview for images let preview: string | null = null; if (mimeType.startsWith('image/')) { - preview = generateImagePreview(stagedPath, mimeType); + preview = await generateImagePreview(stagedPath, mimeType); } return { id, fileName: payload.fileName, mimeType, fileSize, stagedPath, preview }; @@ -1856,11 +1858,17 @@ function registerFileHandlers(): void { }); if (result.canceled || !result.filePath) return { success: false }; - if (params.filePath && existsSync(params.filePath)) { - copyFileSync(params.filePath, result.filePath); + const fsP = await import('fs/promises'); + if (params.filePath) { + try { + await fsP.access(params.filePath); + await fsP.copyFile(params.filePath, result.filePath); + } catch { + return { success: false, error: 'Source file not found' }; + } } else if (params.base64) { const buffer = Buffer.from(params.base64, 'base64'); - writeFileSync(result.filePath, buffer); + await fsP.writeFile(result.filePath, buffer); } else { return { success: false, error: 'No image data provided' }; } @@ -1871,19 +1879,16 @@ function registerFileHandlers(): void { }); ipcMain.handle('media:getThumbnails', async (_, paths: Array<{ filePath: string; mimeType: string }>) => { + const fsP = await import('fs/promises'); const results: Record = {}; for (const { filePath, mimeType } of paths) { try { - if (!existsSync(filePath)) { - results[filePath] = { preview: null, fileSize: 0 }; - continue; - } - const stat = statSync(filePath); + const s = await fsP.stat(filePath); let preview: string | null = null; if (mimeType.startsWith('image/')) { - preview = generateImagePreview(filePath, mimeType); + preview = await generateImagePreview(filePath, mimeType); } - results[filePath] = { preview, fileSize: stat.size }; + results[filePath] = { preview, fileSize: s.size }; } catch { results[filePath] = { preview: null, fileSize: 0 }; } diff --git a/electron/utils/channel-config.ts b/electron/utils/channel-config.ts index 326ec8460..8191a6461 100644 --- a/electron/utils/channel-config.ts +++ b/electron/utils/channel-config.ts @@ -1,8 +1,11 @@ /** * Channel Configuration Utilities - * Manages channel configuration in OpenClaw config files + * Manages channel configuration in OpenClaw config files. + * + * All file I/O uses async fs/promises to avoid blocking the main thread. */ -import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, rmSync } from 'fs'; +import { access, mkdir, readFile, writeFile, readdir, stat, rm } from 'fs/promises'; +import { constants } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; import { getOpenClawResolvedDir } from './paths'; @@ -14,6 +17,14 @@ const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json'); // Channels that are managed as plugins (config goes under plugins.entries, not channels) const PLUGIN_CHANNELS = ['whatsapp']; +// ── Helpers ────────────────────────────────────────────────────── + +async function fileExists(p: string): Promise { + try { await access(p, constants.F_OK); return true; } catch { return false; } +} + +// ── Types ──────────────────────────────────────────────────────── + export interface ChannelConfigData { enabled?: boolean; [key: string]: unknown; @@ -30,27 +41,23 @@ export interface OpenClawConfig { [key: string]: unknown; } -/** - * Ensure OpenClaw config directory exists - */ -function ensureConfigDir(): void { - if (!existsSync(OPENCLAW_DIR)) { - mkdirSync(OPENCLAW_DIR, { recursive: true }); +// ── Config I/O ─────────────────────────────────────────────────── + +async function ensureConfigDir(): Promise { + if (!(await fileExists(OPENCLAW_DIR))) { + await mkdir(OPENCLAW_DIR, { recursive: true }); } } -/** - * Read OpenClaw configuration - */ -export function readOpenClawConfig(): OpenClawConfig { - ensureConfigDir(); +export async function readOpenClawConfig(): Promise { + await ensureConfigDir(); - if (!existsSync(CONFIG_FILE)) { + if (!(await fileExists(CONFIG_FILE))) { return {}; } try { - const content = readFileSync(CONFIG_FILE, 'utf-8'); + const content = await readFile(CONFIG_FILE, 'utf-8'); return JSON.parse(content) as OpenClawConfig; } catch (error) { logger.error('Failed to read OpenClaw config', error); @@ -59,14 +66,11 @@ export function readOpenClawConfig(): OpenClawConfig { } } -/** - * Write OpenClaw configuration - */ -export function writeOpenClawConfig(config: OpenClawConfig): void { - ensureConfigDir(); +export async function writeOpenClawConfig(config: OpenClawConfig): Promise { + await ensureConfigDir(); try { - writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8'); + await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8'); } catch (error) { logger.error('Failed to write OpenClaw config', error); console.error('Failed to write OpenClaw config:', error); @@ -74,16 +78,13 @@ export function writeOpenClawConfig(config: OpenClawConfig): void { } } -/** - * Save channel configuration - * @param channelType - The channel type (e.g., 'telegram', 'discord') - * @param config - The channel configuration object - */ -export function saveChannelConfig( +// ── Channel operations ─────────────────────────────────────────── + +export async function saveChannelConfig( channelType: string, config: ChannelConfigData -): void { - const currentConfig = readOpenClawConfig(); +): Promise { + const currentConfig = await readOpenClawConfig(); // Plugin-based channels (e.g. WhatsApp) go under plugins.entries, not channels if (PLUGIN_CHANNELS.includes(channelType)) { @@ -97,7 +98,7 @@ export function saveChannelConfig( ...currentConfig.plugins.entries[channelType], enabled: config.enabled ?? true, }; - writeOpenClawConfig(currentConfig); + await writeOpenClawConfig(currentConfig); logger.info('Plugin channel config saved', { channelType, configFile: CONFIG_FILE, @@ -119,7 +120,6 @@ export function saveChannelConfig( const { guildId, channelId, ...restConfig } = config; transformedConfig = { ...restConfig }; - // Add standard Discord config transformedConfig.groupPolicy = 'allowlist'; transformedConfig.dm = { enabled: false }; transformedConfig.retry = { @@ -129,21 +129,17 @@ export function saveChannelConfig( jitter: 0.1, }; - // Build guilds structure if (guildId && typeof guildId === 'string' && guildId.trim()) { const guildConfig: Record = { users: ['*'], requireMention: true, }; - // Add channels config if (channelId && typeof channelId === 'string' && channelId.trim()) { - // Specific channel guildConfig.channels = { [channelId.trim()]: { allow: true, requireMention: true } }; } else { - // All channels guildConfig.channels = { '*': { allow: true, requireMention: true } }; @@ -166,8 +162,7 @@ export function saveChannelConfig( .filter(u => u.length > 0); if (users.length > 0) { - transformedConfig.allowFrom = users; // Use 'allowFrom' (correct key) - // transformedConfig.groupPolicy = 'allowlist'; // Default is allowlist + transformedConfig.allowFrom = users; } } } @@ -176,17 +171,16 @@ export function saveChannelConfig( if (channelType === 'feishu') { const existingConfig = currentConfig.channels[channelType] || {}; transformedConfig.dmPolicy = transformedConfig.dmPolicy ?? existingConfig.dmPolicy ?? 'open'; - + let allowFrom = transformedConfig.allowFrom ?? existingConfig.allowFrom ?? ['*']; if (!Array.isArray(allowFrom)) { allowFrom = [allowFrom]; } - - // If dmPolicy is open, OpenClaw schema requires '*' in allowFrom + if (transformedConfig.dmPolicy === 'open' && !allowFrom.includes('*')) { allowFrom = [...allowFrom, '*']; } - + transformedConfig.allowFrom = allowFrom; } @@ -197,7 +191,7 @@ export function saveChannelConfig( enabled: transformedConfig.enabled ?? true, }; - writeOpenClawConfig(currentConfig); + await writeOpenClawConfig(currentConfig); logger.info('Channel config saved', { channelType, configFile: CONFIG_FILE, @@ -208,42 +202,26 @@ export function saveChannelConfig( console.log(`Saved channel config for ${channelType}`); } -/** - * Get channel configuration - * @param channelType - The channel type - */ -export function getChannelConfig(channelType: string): ChannelConfigData | undefined { - const config = readOpenClawConfig(); +export async function getChannelConfig(channelType: string): Promise { + const config = await readOpenClawConfig(); return config.channels?.[channelType]; } -/** - * Get channel configuration as form-friendly values. - * Reverses the transformation done in saveChannelConfig so the - * values can be fed back into the UI form fields. - * - * @param channelType - The channel type - * @returns A flat Record matching the form field keys, or undefined - */ -export function getChannelFormValues(channelType: string): Record | undefined { - const saved = getChannelConfig(channelType); +export async function getChannelFormValues(channelType: string): Promise | undefined> { + const saved = await getChannelConfig(channelType); if (!saved) return undefined; const values: Record = {}; if (channelType === 'discord') { - // token is stored at top level if (saved.token && typeof saved.token === 'string') { values.token = saved.token; } - - // Extract guildId and channelId from the nested guilds structure const guilds = saved.guilds as Record> | undefined; if (guilds) { const guildIds = Object.keys(guilds); if (guildIds.length > 0) { values.guildId = guildIds[0]; - const guildConfig = guilds[guildIds[0]]; const channels = guildConfig?.channels as Record | undefined; if (channels) { @@ -255,19 +233,15 @@ export function getChannelFormValues(channelType: string): Record 0 ? values : undefined; } -/** - * Delete channel configuration - * @param channelType - The channel type - */ -export function deleteChannelConfig(channelType: string): void { - const currentConfig = readOpenClawConfig(); +export async function deleteChannelConfig(channelType: string): Promise { + const currentConfig = await readOpenClawConfig(); if (currentConfig.channels?.[channelType]) { delete currentConfig.channels[channelType]; - writeOpenClawConfig(currentConfig); + await writeOpenClawConfig(currentConfig); console.log(`Deleted channel config for ${channelType}`); } else if (PLUGIN_CHANNELS.includes(channelType)) { - // Handle plugin channels (like whatsapp) if (currentConfig.plugins?.entries?.[channelType]) { delete currentConfig.plugins.entries[channelType]; - - // Cleanup empty objects if (Object.keys(currentConfig.plugins.entries).length === 0) { delete currentConfig.plugins.entries; } if (currentConfig.plugins && Object.keys(currentConfig.plugins).length === 0) { delete currentConfig.plugins; } - - writeOpenClawConfig(currentConfig); + await writeOpenClawConfig(currentConfig); console.log(`Deleted plugin channel config for ${channelType}`); } } @@ -310,10 +276,9 @@ export function deleteChannelConfig(channelType: string): void { // Special handling for WhatsApp credentials if (channelType === 'whatsapp') { try { - const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp'); - if (existsSync(whatsappDir)) { - rmSync(whatsappDir, { recursive: true, force: true }); + if (await fileExists(whatsappDir)) { + await rm(whatsappDir, { recursive: true, force: true }); console.log('Deleted WhatsApp credentials directory'); } } catch (error) { @@ -322,11 +287,8 @@ export function deleteChannelConfig(channelType: string): void { } } -/** - * List all configured channels - */ -export function listConfiguredChannels(): string[] { - const config = readOpenClawConfig(); +export async function listConfiguredChannels(): Promise { + const config = await readOpenClawConfig(); const channels: string[] = []; if (config.channels) { @@ -338,14 +300,17 @@ export function listConfiguredChannels(): string[] { // Check for WhatsApp credentials directory try { const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp'); - if (existsSync(whatsappDir)) { - const entries = readdirSync(whatsappDir); - // Check if there's at least one directory (session) - const hasSession = entries.some((entry: string) => { - try { - return statSync(join(whatsappDir, entry)).isDirectory(); - } catch { return false; } - }); + if (await fileExists(whatsappDir)) { + const entries = await readdir(whatsappDir); + const hasSession = await (async () => { + for (const entry of entries) { + try { + const s = await stat(join(whatsappDir, entry)); + if (s.isDirectory()) return true; + } catch { /* ignore */ } + } + return false; + })(); if (hasSession && !channels.includes('whatsapp')) { channels.push('whatsapp'); @@ -358,42 +323,28 @@ export function listConfiguredChannels(): string[] { return channels; } -/** - * Enable or disable a channel - */ -export function setChannelEnabled(channelType: string, enabled: boolean): void { - const currentConfig = readOpenClawConfig(); +export async function setChannelEnabled(channelType: string, enabled: boolean): Promise { + const currentConfig = await readOpenClawConfig(); - // Plugin-based channels go under plugins.entries if (PLUGIN_CHANNELS.includes(channelType)) { - if (!currentConfig.plugins) { - currentConfig.plugins = {}; - } - if (!currentConfig.plugins.entries) { - currentConfig.plugins.entries = {}; - } - if (!currentConfig.plugins.entries[channelType]) { - currentConfig.plugins.entries[channelType] = {}; - } + if (!currentConfig.plugins) currentConfig.plugins = {}; + if (!currentConfig.plugins.entries) currentConfig.plugins.entries = {}; + if (!currentConfig.plugins.entries[channelType]) currentConfig.plugins.entries[channelType] = {}; currentConfig.plugins.entries[channelType].enabled = enabled; - writeOpenClawConfig(currentConfig); + await writeOpenClawConfig(currentConfig); console.log(`Set plugin channel ${channelType} enabled: ${enabled}`); return; } - if (!currentConfig.channels) { - currentConfig.channels = {}; - } - - if (!currentConfig.channels[channelType]) { - currentConfig.channels[channelType] = {}; - } - + if (!currentConfig.channels) currentConfig.channels = {}; + if (!currentConfig.channels[channelType]) currentConfig.channels[channelType] = {}; currentConfig.channels[channelType].enabled = enabled; - writeOpenClawConfig(currentConfig); + await writeOpenClawConfig(currentConfig); console.log(`Set channel ${channelType} enabled: ${enabled}`); } +// ── Validation ─────────────────────────────────────────────────── + export interface ValidationResult { valid: boolean; errors: string[]; @@ -404,17 +355,9 @@ export interface CredentialValidationResult { valid: boolean; errors: string[]; warnings: string[]; - /** Extra info returned from the API (e.g. bot username, guild name) */ details?: Record; } -/** - * Validate channel credentials by calling the actual service APIs - * This validates the raw config values BEFORE saving them. - * - * @param channelType - The channel type (e.g., 'discord', 'telegram') - * @param config - The raw config values from the form - */ export async function validateChannelCredentials( channelType: string, config: Record @@ -425,14 +368,10 @@ export async function validateChannelCredentials( case 'telegram': return validateTelegramCredentials(config); default: - // For channels without specific validation, just check required fields are present return { valid: true, errors: [], warnings: ['No online validation available for this channel type.'] }; } } -/** - * Validate Discord bot token and optional guild/channel IDs - */ async function validateDiscordCredentials( config: Record ): Promise { @@ -443,12 +382,10 @@ async function validateDiscordCredentials( return { valid: false, errors: ['Bot token is required'], warnings: [] }; } - // 1) Validate bot token by calling GET /users/@me try { const meResponse = await fetch('https://discord.com/api/v10/users/@me', { headers: { Authorization: `Bot ${token}` }, }); - if (!meResponse.ok) { if (meResponse.status === 401) { return { valid: false, errors: ['Invalid bot token. Please check and try again.'], warnings: [] }; @@ -457,38 +394,25 @@ async function validateDiscordCredentials( const msg = (errorData as { message?: string }).message || `Discord API error: ${meResponse.status}`; return { valid: false, errors: [msg], warnings: [] }; } - const meData = (await meResponse.json()) as { username?: string; id?: string; bot?: boolean }; if (!meData.bot) { - return { - valid: false, - errors: ['The provided token belongs to a user account, not a bot. Please use a bot token.'], - warnings: [], - }; + return { valid: false, errors: ['The provided token belongs to a user account, not a bot. Please use a bot token.'], warnings: [] }; } result.details!.botUsername = meData.username || 'Unknown'; result.details!.botId = meData.id || ''; } catch (error) { - return { - valid: false, - errors: [`Connection error when validating bot token: ${error instanceof Error ? error.message : String(error)}`], - warnings: [], - }; + return { valid: false, errors: [`Connection error when validating bot token: ${error instanceof Error ? error.message : String(error)}`], warnings: [] }; } - // 2) Validate guild ID (optional) const guildId = config.guildId?.trim(); if (guildId) { try { const guildResponse = await fetch(`https://discord.com/api/v10/guilds/${guildId}`, { headers: { Authorization: `Bot ${token}` }, }); - if (!guildResponse.ok) { if (guildResponse.status === 403 || guildResponse.status === 404) { - result.errors.push( - `Cannot access guild (server) with ID "${guildId}". Make sure the bot has been invited to this server.` - ); + result.errors.push(`Cannot access guild (server) with ID "${guildId}". Make sure the bot has been invited to this server.`); result.valid = false; } else { result.errors.push(`Failed to verify guild ID: Discord API returned ${guildResponse.status}`); @@ -503,19 +427,15 @@ async function validateDiscordCredentials( } } - // 3) Validate channel ID (optional) const channelId = config.channelId?.trim(); if (channelId) { try { const channelResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}`, { headers: { Authorization: `Bot ${token}` }, }); - if (!channelResponse.ok) { if (channelResponse.status === 403 || channelResponse.status === 404) { - result.errors.push( - `Cannot access channel with ID "${channelId}". Make sure the bot has permission to view this channel.` - ); + result.errors.push(`Cannot access channel with ID "${channelId}". Make sure the bot has permission to view this channel.`); result.valid = false; } else { result.errors.push(`Failed to verify channel ID: Discord API returned ${channelResponse.status}`); @@ -524,12 +444,8 @@ async function validateDiscordCredentials( } else { const channelData = (await channelResponse.json()) as { name?: string; guild_id?: string }; result.details!.channelName = channelData.name || 'Unknown'; - - // Cross-check: if both guild and channel are provided, make sure channel belongs to the guild if (guildId && channelData.guild_id && channelData.guild_id !== guildId) { - result.errors.push( - `Channel "${channelData.name}" does not belong to the specified guild. It belongs to a different server.` - ); + result.errors.push(`Channel "${channelData.name}" does not belong to the specified guild. It belongs to a different server.`); result.valid = false; } } @@ -541,80 +457,52 @@ async function validateDiscordCredentials( return result; } -/** - * Validate Telegram bot token - */ async function validateTelegramCredentials( config: Record ): Promise { const botToken = config.botToken?.trim(); - const allowedUsers = config.allowedUsers?.trim(); - if (!botToken) { - return { valid: false, errors: ['Bot token is required'], warnings: [] }; - } - - if (!allowedUsers) { - return { valid: false, errors: ['At least one allowed user ID is required'], warnings: [] }; - } + if (!botToken) return { valid: false, errors: ['Bot token is required'], warnings: [] }; + if (!allowedUsers) return { valid: false, errors: ['At least one allowed user ID is required'], warnings: [] }; try { const response = await fetch(`https://api.telegram.org/bot${botToken}/getMe`); const data = (await response.json()) as { ok?: boolean; description?: string; result?: { username?: string } }; - if (data.ok) { - return { - valid: true, - errors: [], - warnings: [], - details: { botUsername: data.result?.username || 'Unknown' }, - }; + return { valid: true, errors: [], warnings: [], details: { botUsername: data.result?.username || 'Unknown' } }; } - - return { - valid: false, - errors: [data.description || 'Invalid bot token'], - warnings: [], - }; + return { valid: false, errors: [data.description || 'Invalid bot token'], warnings: [] }; } catch (error) { - return { - valid: false, - errors: [`Connection error: ${error instanceof Error ? error.message : String(error)}`], - warnings: [], - }; + return { valid: false, errors: [`Connection error: ${error instanceof Error ? error.message : String(error)}`], warnings: [] }; } } - - -/** - * Validate channel configuration using OpenClaw doctor - */ export async function validateChannelConfig(channelType: string): Promise { - const { execSync } = await import('child_process'); + const { exec } = await import('child_process'); - const result: ValidationResult = { - valid: true, - errors: [], - warnings: [], - }; + const result: ValidationResult = { valid: true, errors: [], warnings: [] }; try { - // Get OpenClaw path const openclawPath = getOpenClawResolvedDir(); - // Run openclaw doctor command to validate config - const output = execSync( - `node openclaw.mjs doctor --json 2>&1`, - { - cwd: openclawPath, - encoding: 'utf-8', - timeout: 30000, - } - ); + // Run openclaw doctor command to validate config (async to avoid + // blocking the main thread). + const output = await new Promise((resolve, reject) => { + exec( + `node openclaw.mjs doctor --json 2>&1`, + { + cwd: openclawPath, + encoding: 'utf-8', + timeout: 30000, + }, + (err, stdout) => { + if (err) reject(err); + else resolve(stdout); + }, + ); + }); - // Parse output for errors related to the channel const lines = output.split('\n'); for (const line of lines) { const lowerLine = line.toLowerCase(); @@ -629,8 +517,7 @@ export async function validateChannelConfig(channelType: string): Promise { + try { await access(p, constants.F_OK); return true; } catch { return false; } +} + +/** Generate a new Ed25519 identity (async key generation). */ +async function generateIdentity(): Promise { + const { publicKey, privateKey } = await new Promise( + (resolve, reject) => { + crypto.generateKeyPair('ed25519', (err, publicKey, privateKey) => { + if (err) reject(err); + else resolve({ publicKey, privateKey }); + }); + }, + ); const publicKeyPem = (publicKey.export({ type: 'spki', format: 'pem' }) as Buffer).toString(); const privateKeyPem = (privateKey.export({ type: 'pkcs8', format: 'pem' }) as Buffer).toString(); return { @@ -63,11 +80,13 @@ function generateIdentity(): DeviceIdentity { /** * Load device identity from disk, or create and persist a new one. * The identity file is stored at `filePath` with mode 0o600. + * + * Fully async — no synchronous file I/O or crypto. */ -export function loadOrCreateDeviceIdentity(filePath: string): DeviceIdentity { +export async function loadOrCreateDeviceIdentity(filePath: string): Promise { try { - if (fs.existsSync(filePath)) { - const raw = fs.readFileSync(filePath, 'utf8'); + if (await fileExists(filePath)) { + const raw = await readFile(filePath, 'utf8'); const parsed = JSON.parse(raw); if ( parsed?.version === 1 && @@ -78,7 +97,7 @@ export function loadOrCreateDeviceIdentity(filePath: string): DeviceIdentity { const derivedId = fingerprintPublicKey(parsed.publicKeyPem); if (derivedId && derivedId !== parsed.deviceId) { const updated = { ...parsed, deviceId: derivedId }; - fs.writeFileSync(filePath, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 }); + await writeFile(filePath, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 }); return { deviceId: derivedId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem }; } return { deviceId: parsed.deviceId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem }; @@ -88,12 +107,12 @@ export function loadOrCreateDeviceIdentity(filePath: string): DeviceIdentity { // fall through to create a new identity } - const identity = generateIdentity(); + const identity = await generateIdentity(); const dir = path.dirname(filePath); - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + if (!(await fileExists(dir))) await mkdir(dir, { recursive: true }); const stored = { version: 1, ...identity, createdAtMs: Date.now() }; - fs.writeFileSync(filePath, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 }); - try { fs.chmodSync(filePath, 0o600); } catch { /* ignore */ } + await writeFile(filePath, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 }); + try { await chmod(filePath, 0o600); } catch { /* ignore */ } return identity; } diff --git a/electron/utils/device-oauth.ts b/electron/utils/device-oauth.ts index 9e86a90ca..e4a4c407a 100644 --- a/electron/utils/device-oauth.ts +++ b/electron/utils/device-oauth.ts @@ -204,7 +204,7 @@ class DeviceOAuthManager extends EventEmitter { // so OpenClaw's gateway auto-refresher knows how to find it. try { const tokenProviderId = providerType.startsWith('minimax-portal') ? 'minimax-portal' : providerType; - saveOAuthTokenToOpenClaw(tokenProviderId, { + await saveOAuthTokenToOpenClaw(tokenProviderId, { access: token.access, refresh: token.refresh, expires: token.expires, @@ -230,7 +230,7 @@ class DeviceOAuthManager extends EventEmitter { try { const tokenProviderId = providerType.startsWith('minimax-portal') ? 'minimax-portal' : providerType; - setOpenClawDefaultModelWithOverride(tokenProviderId, undefined, { + await setOpenClawDefaultModelWithOverride(tokenProviderId, undefined, { baseUrl, api: token.api, // Tells OpenClaw's anthropic adapter to use `Authorization: Bearer` instead of `x-api-key` diff --git a/electron/utils/logger.ts b/electron/utils/logger.ts index b53d7130b..576905cce 100644 --- a/electron/utils/logger.ts +++ b/electron/utils/logger.ts @@ -1,10 +1,16 @@ /** * Logger Utility - * Centralized logging with levels, file output, and log retrieval for UI + * Centralized logging with levels, file output, and log retrieval for UI. + * + * File writes use an async buffered writer so that high-frequency logging + * (e.g. during gateway startup) never blocks the Electron main thread. + * Only the final `process.on('exit')` handler uses synchronous I/O to + * guarantee the last few messages are flushed before the process exits. */ import { app } from 'electron'; import { join } from 'path'; -import { existsSync, mkdirSync, appendFileSync, readFileSync, readdirSync, statSync } from 'fs'; +import { existsSync, mkdirSync, appendFileSync } from 'fs'; +import { appendFile, readFile, readdir, stat } from 'fs/promises'; /** * Log levels @@ -19,7 +25,11 @@ export enum LogLevel { /** * Current log level (can be changed at runtime) */ -let currentLevel = LogLevel.DEBUG; // Default to DEBUG for better diagnostics +// Default to INFO in packaged builds to reduce sync-like overhead from +// high-volume DEBUG logging. In dev mode, keep DEBUG for diagnostics. +// Note: app.isPackaged may not be available before app.isReady(), but the +// logger is initialised after that point so this is safe. +let currentLevel = LogLevel.DEBUG; /** * Log file path @@ -33,11 +43,58 @@ let logDir: string | null = null; const RING_BUFFER_SIZE = 500; const recentLogs: string[] = []; +// ── Async write buffer ─────────────────────────────────────────── + +/** Pending log lines waiting to be flushed to disk. */ +let writeBuffer: string[] = []; +/** Timer for the next scheduled flush. */ +let flushTimer: NodeJS.Timeout | null = null; +/** Whether a flush is currently in progress. */ +let flushing = false; + +const FLUSH_INTERVAL_MS = 500; +const FLUSH_SIZE_THRESHOLD = 20; + +async function flushBuffer(): Promise { + if (flushing || writeBuffer.length === 0 || !logFilePath) return; + flushing = true; + const batch = writeBuffer.join(''); + writeBuffer = []; + try { + await appendFile(logFilePath, batch); + } catch { + // Silently fail if we can't write to file + } finally { + flushing = false; + } +} + +/** Synchronous flush for the `exit` handler — guaranteed to write. */ +function flushBufferSync(): void { + if (writeBuffer.length === 0 || !logFilePath) return; + try { + appendFileSync(logFilePath, writeBuffer.join('')); + } catch { + // Silently fail + } + writeBuffer = []; +} + +// Ensure all buffered data reaches disk before the process exits. +process.on('exit', flushBufferSync); + +// ── Initialisation ─────────────────────────────────────────────── + /** * Initialize logger — safe to call before app.isReady() */ export function initLogger(): void { try { + // In production, default to INFO to reduce log volume and overhead. + if (app.isPackaged && currentLevel < LogLevel.INFO) { + currentLevel = LogLevel.INFO; + } + logDir = join(app.getPath('userData'), 'logs'); if (!existsSync(logDir)) { @@ -47,7 +104,7 @@ export function initLogger(): void { const timestamp = new Date().toISOString().split('T')[0]; logFilePath = join(logDir, `clawx-${timestamp}.log`); - // Write a separator for new session + // Write a separator for new session (sync is OK — happens once at startup) const sessionHeader = `\n${'='.repeat(80)}\n[${new Date().toISOString()}] === ClawX Session Start (v${app.getVersion()}) ===\n${'='.repeat(80)}\n`; appendFileSync(logFilePath, sessionHeader); } catch (error) { @@ -55,30 +112,22 @@ export function initLogger(): void { } } -/** - * Set log level - */ +// ── Level / path accessors ─────────────────────────────────────── + export function setLogLevel(level: LogLevel): void { currentLevel = level; } -/** - * Get log file directory path - */ export function getLogDir(): string | null { return logDir; } -/** - * Get current log file path - */ export function getLogFilePath(): string | null { return logFilePath; } -/** - * Format log message - */ +// ── Formatting ─────────────────────────────────────────────────── + function formatMessage(level: string, message: string, ...args: unknown[]): string { const timestamp = new Date().toISOString(); const formattedArgs = args.length > 0 ? ' ' + args.map(arg => { @@ -98,29 +147,36 @@ function formatMessage(level: string, message: string, ...args: unknown[]): stri return `[${timestamp}] [${level.padEnd(5)}] ${message}${formattedArgs}`; } +// ── Core write ─────────────────────────────────────────────────── + /** - * Write to log file and ring buffer + * Write to ring buffer + schedule an async flush to disk. */ function writeLog(formatted: string): void { - // Ring buffer + // Ring buffer (always synchronous — in-memory only) recentLogs.push(formatted); if (recentLogs.length > RING_BUFFER_SIZE) { recentLogs.shift(); } - // File + // Async file write via buffer if (logFilePath) { - try { - appendFileSync(logFilePath, formatted + '\n'); - } catch { - // Silently fail if we can't write to file + writeBuffer.push(formatted + '\n'); + if (writeBuffer.length >= FLUSH_SIZE_THRESHOLD) { + // Buffer is large enough — flush immediately (non-blocking) + void flushBuffer(); + } else if (!flushTimer) { + // Schedule a flush after a short delay + flushTimer = setTimeout(() => { + flushTimer = null; + void flushBuffer(); + }, FLUSH_INTERVAL_MS); } } } -/** - * Log debug message - */ +// ── Public log methods ─────────────────────────────────────────── + export function debug(message: string, ...args: unknown[]): void { if (currentLevel <= LogLevel.DEBUG) { const formatted = formatMessage('DEBUG', message, ...args); @@ -129,9 +185,6 @@ export function debug(message: string, ...args: unknown[]): void { } } -/** - * Log info message - */ export function info(message: string, ...args: unknown[]): void { if (currentLevel <= LogLevel.INFO) { const formatted = formatMessage('INFO', message, ...args); @@ -140,9 +193,6 @@ export function info(message: string, ...args: unknown[]): void { } } -/** - * Log warning message - */ export function warn(message: string, ...args: unknown[]): void { if (currentLevel <= LogLevel.WARN) { const formatted = formatMessage('WARN', message, ...args); @@ -151,9 +201,6 @@ export function warn(message: string, ...args: unknown[]): void { } } -/** - * Log error message - */ export function error(message: string, ...args: unknown[]): void { if (currentLevel <= LogLevel.ERROR) { const formatted = formatMessage('ERROR', message, ...args); @@ -162,11 +209,8 @@ export function error(message: string, ...args: unknown[]): void { } } -/** - * Get recent logs from ring buffer (for UI display) - * @param count Number of recent log lines to return (default: all) - * @param minLevel Minimum log level to include (default: DEBUG) - */ +// ── Log retrieval (for UI / diagnostics) ───────────────────────── + export function getRecentLogs(count?: number, minLevel?: LogLevel): string[] { const filtered = minLevel != null ? recentLogs.filter(line => { @@ -181,14 +225,13 @@ export function getRecentLogs(count?: number, minLevel?: LogLevel): string[] { } /** - * Read the current day's log file content (last N lines) + * Read the current day's log file content (last N lines). + * Uses async I/O to avoid blocking. */ -export function readLogFile(tailLines = 200): string { - if (!logFilePath || !existsSync(logFilePath)) { - return '(No log file found)'; - } +export async function readLogFile(tailLines = 200): Promise { + if (!logFilePath) return '(No log file found)'; try { - const content = readFileSync(logFilePath, 'utf-8'); + const content = await readFile(logFilePath, 'utf-8'); const lines = content.split('\n'); if (lines.length <= tailLines) return content; return lines.slice(-tailLines).join('\n'); @@ -198,24 +241,26 @@ export function readLogFile(tailLines = 200): string { } /** - * List available log files + * List available log files. + * Uses async I/O to avoid blocking. */ -export function listLogFiles(): Array<{ name: string; path: string; size: number; modified: string }> { - if (!logDir || !existsSync(logDir)) return []; +export async function listLogFiles(): Promise> { + if (!logDir) return []; try { - return readdirSync(logDir) - .filter(f => f.endsWith('.log')) - .map(f => { - const fullPath = join(logDir!, f); - const stat = statSync(fullPath); - return { - name: f, - path: fullPath, - size: stat.size, - modified: stat.mtime.toISOString(), - }; - }) - .sort((a, b) => b.modified.localeCompare(a.modified)); + const files = await readdir(logDir); + const results: Array<{ name: string; path: string; size: number; modified: string }> = []; + for (const f of files) { + if (!f.endsWith('.log')) continue; + const fullPath = join(logDir, f); + const s = await stat(fullPath); + results.push({ + name: f, + path: fullPath, + size: s.size, + modified: s.mtime.toISOString(), + }); + } + return results.sort((a, b) => b.modified.localeCompare(a.modified)); } catch { return []; } diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index 360e11ca3..6f2a89e8a 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -2,8 +2,14 @@ * OpenClaw Auth Profiles Utility * Writes API keys to ~/.openclaw/agents/main/agent/auth-profiles.json * so the OpenClaw Gateway can load them for AI provider calls. + * + * All file I/O is asynchronous (fs/promises) to avoid blocking the + * Electron main thread. On Windows + NTFS + Defender the synchronous + * equivalents could stall for 500 ms – 2 s+ per call, causing "Not + * Responding" hangs. */ -import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs'; +import { access, mkdir, readFile, writeFile, readdir } from 'fs/promises'; +import { constants, Dirent } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; import { @@ -15,18 +21,50 @@ import { const AUTH_STORE_VERSION = 1; const AUTH_PROFILE_FILENAME = 'auth-profiles.json'; -/** - * Auth profile entry for an API key - */ +// ── Helpers ────────────────────────────────────────────────────── + +/** Non-throwing async existence check (replaces existsSync). */ +async function fileExists(p: string): Promise { + try { + await access(p, constants.F_OK); + return true; + } catch { + return false; + } +} + +/** Ensure a directory exists (replaces mkdirSync). */ +async function ensureDir(dir: string): Promise { + if (!(await fileExists(dir))) { + await mkdir(dir, { recursive: true }); + } +} + +/** Read a JSON file, returning `null` on any error. */ +async function readJsonFile(filePath: string): Promise { + try { + if (!(await fileExists(filePath))) return null; + const raw = await readFile(filePath, 'utf-8'); + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +/** Write a JSON file, creating parent directories if needed. */ +async function writeJsonFile(filePath: string, data: unknown): Promise { + await ensureDir(join(filePath, '..')); + await writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8'); +} + +// ── Types ──────────────────────────────────────────────────────── + interface AuthProfileEntry { type: 'api_key'; provider: string; key: string; } -/** - * Auth profile entry for an OAuth token (matches OpenClaw plugin format) - */ interface OAuthProfileEntry { type: 'oauth'; provider: string; @@ -35,9 +73,6 @@ interface OAuthProfileEntry { expires: number; } -/** - * Auth profiles store format - */ interface AuthProfilesStore { version: number; profiles: Record; @@ -45,90 +80,78 @@ interface AuthProfilesStore { lastGood?: Record; } -/** - * Get the path to the auth-profiles.json for a given agent - */ +// ── Auth Profiles I/O ──────────────────────────────────────────── + function getAuthProfilesPath(agentId = 'main'): string { return join(homedir(), '.openclaw', 'agents', agentId, 'agent', AUTH_PROFILE_FILENAME); } -/** - * Read existing auth profiles store, or create an empty one - */ -function readAuthProfiles(agentId = 'main'): AuthProfilesStore { +async function readAuthProfiles(agentId = 'main'): Promise { const filePath = getAuthProfilesPath(agentId); - try { - if (existsSync(filePath)) { - const raw = readFileSync(filePath, 'utf-8'); - const data = JSON.parse(raw) as AuthProfilesStore; - // Validate basic structure - if (data.version && data.profiles && typeof data.profiles === 'object') { - return data; - } + const data = await readJsonFile(filePath); + if (data?.version && data.profiles && typeof data.profiles === 'object') { + return data; } } catch (error) { console.warn('Failed to read auth-profiles.json, creating fresh store:', error); } - - return { - version: AUTH_STORE_VERSION, - profiles: {}, - }; + return { version: AUTH_STORE_VERSION, profiles: {} }; } -/** - * Write auth profiles store to disk - */ -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'); +async function writeAuthProfiles(store: AuthProfilesStore, agentId = 'main'): Promise { + await writeJsonFile(getAuthProfilesPath(agentId), store); } -/** - * Discover all agent IDs that have an agent/ subdirectory. - */ -function discoverAgentIds(): string[] { +// ── Agent Discovery ────────────────────────────────────────────── + +async function discoverAgentIds(): Promise { const agentsDir = join(homedir(), '.openclaw', 'agents'); try { - if (!existsSync(agentsDir)) return ['main']; - return readdirSync(agentsDir, { withFileTypes: true }) - .filter((d) => d.isDirectory() && existsSync(join(agentsDir, d.name, 'agent'))) - .map((d) => d.name); + if (!(await fileExists(agentsDir))) return ['main']; + const entries: Dirent[] = await readdir(agentsDir, { withFileTypes: true }); + const ids: string[] = []; + for (const d of entries) { + if (d.isDirectory() && await fileExists(join(agentsDir, d.name, 'agent'))) { + ids.push(d.name); + } + } + return ids.length > 0 ? ids : ['main']; } catch { return ['main']; } } +// ── OpenClaw Config Helpers ────────────────────────────────────── + +const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json'); + +async function readOpenClawJson(): Promise> { + return (await readJsonFile>(OPENCLAW_CONFIG_PATH)) ?? {}; +} + +async function writeOpenClawJson(config: Record): Promise { + await writeJsonFile(OPENCLAW_CONFIG_PATH, config); +} + +// ── Exported Functions (all async) ─────────────────────────────── + /** * 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( +export async function saveOAuthTokenToOpenClaw( provider: string, token: { access: string; refresh: string; expires: number }, agentId?: string -): void { - const agentIds = agentId ? [agentId] : discoverAgentIds(); +): Promise { + const agentIds = agentId ? [agentId] : await discoverAgentIds(); if (agentIds.length === 0) agentIds.push('main'); for (const id of agentIds) { - const store = readAuthProfiles(id); + const store = await readAuthProfiles(id); const profileId = `${provider}:default`; - const entry: OAuthProfileEntry = { + store.profiles[profileId] = { type: 'oauth', provider, access: token.access, @@ -136,8 +159,6 @@ export function saveOAuthTokenToOpenClaw( 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)) { @@ -147,9 +168,8 @@ export function saveOAuthTokenToOpenClaw( if (!store.lastGood) store.lastGood = {}; store.lastGood[provider] = profileId; - writeAuthProfiles(store, id); + await writeAuthProfiles(store, id); } - console.log(`Saved OAuth token for provider "${provider}" to OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`); } @@ -161,12 +181,12 @@ export function saveOAuthTokenToOpenClaw( * @param agentId - Optional single agent ID to read from, defaults to 'main' * @returns The OAuth token access string or null if not found */ -export function getOAuthTokenFromOpenClaw( +export async function getOAuthTokenFromOpenClaw( provider: string, agentId = 'main' -): string | null { +): Promise { try { - const store = readAuthProfiles(agentId); + const store = await readAuthProfiles(agentId); const profileId = `${provider}:default`; const profile = store.profiles[profileId]; @@ -181,65 +201,36 @@ export function getOAuthTokenFromOpenClaw( /** * Save a provider API key to OpenClaw's auth-profiles.json - * This writes the key in the format OpenClaw expects so the gateway - * can use it for AI provider calls. - * - * Writes to ALL discovered agent directories so every agent - * (including non-"main" agents like "dev") stays in sync. - * - * @param provider - Provider type (e.g., 'anthropic', 'openrouter', 'openai', 'google') - * @param apiKey - The API key to store - * @param agentId - Optional single agent ID. When omitted, writes to every agent. */ -export function saveProviderKeyToOpenClaw( - +export async 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. +): Promise { const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn']; 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(); + const agentIds = agentId ? [agentId] : await discoverAgentIds(); if (agentIds.length === 0) agentIds.push('main'); for (const id of agentIds) { - const store = readAuthProfiles(id); - - // Profile ID follows OpenClaw convention: :default + const store = await readAuthProfiles(id); const profileId = `${provider}:default`; - // Upsert the profile entry - store.profiles[profileId] = { - type: 'api_key', - provider, - key: apiKey, - }; + store.profiles[profileId] = { type: 'api_key', provider, key: apiKey }; - // Update order to include this profile - if (!store.order) { - store.order = {}; - } - if (!store.order[provider]) { - store.order[provider] = []; - } + if (!store.order) store.order = {}; + if (!store.order[provider]) store.order[provider] = []; if (!store.order[provider].includes(profileId)) { store.order[provider].push(profileId); } - // Set as last good - if (!store.lastGood) { - store.lastGood = {}; - } + if (!store.lastGood) store.lastGood = {}; store.lastGood[provider] = profileId; - writeAuthProfiles(store, id); + await writeAuthProfiles(store, id); } console.log(`Saved API key for provider "${provider}" to OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`); } @@ -247,38 +238,31 @@ export function saveProviderKeyToOpenClaw( /** * Remove a provider API key from OpenClaw auth-profiles.json */ -export function removeProviderKeyFromOpenClaw( +export async function removeProviderKeyFromOpenClaw( provider: string, agentId?: string -): void { - // OAuth providers have their credentials managed by OpenClaw plugins. - // Do NOT delete their auth-profiles entries. +): Promise { const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn']; 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(); + const agentIds = agentId ? [agentId] : await discoverAgentIds(); if (agentIds.length === 0) agentIds.push('main'); for (const id of agentIds) { - const store = readAuthProfiles(id); + const store = await readAuthProfiles(id); const profileId = `${provider}:default`; 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.order[provider].length === 0) delete store.order[provider]; } + if (store.lastGood?.[provider] === profileId) delete store.lastGood[provider]; - if (store.lastGood?.[provider] === profileId) { - delete store.lastGood[provider]; - } - - writeAuthProfiles(store, id); + await writeAuthProfiles(store, id); } console.log(`Removed API key for provider "${provider}" from OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`); } @@ -286,12 +270,12 @@ export function removeProviderKeyFromOpenClaw( /** * Remove a provider completely from OpenClaw (delete config, disable plugins, delete keys) */ -export function removeProviderFromOpenClaw(provider: string): void { +export async function removeProviderFromOpenClaw(provider: string): Promise { // 1. Remove from auth-profiles.json - const agentIds = discoverAgentIds(); + const agentIds = await discoverAgentIds(); if (agentIds.length === 0) agentIds.push('main'); for (const id of agentIds) { - const store = readAuthProfiles(id); + const store = await readAuthProfiles(id); const profileId = `${provider}:default`; if (store.profiles[profileId]) { delete store.profiles[profileId]; @@ -300,55 +284,55 @@ export function removeProviderFromOpenClaw(provider: string): void { if (store.order[provider].length === 0) delete store.order[provider]; } if (store.lastGood?.[provider] === profileId) delete store.lastGood[provider]; - writeAuthProfiles(store, id); + await writeAuthProfiles(store, id); } } // 2. Remove from models.json (per-agent model registry used by pi-ai directly) - for (const agentId of agentIds) { - const modelsPath = join(homedir(), '.openclaw', 'agents', agentId, 'agent', 'models.json'); + for (const id of agentIds) { + const modelsPath = join(homedir(), '.openclaw', 'agents', id, 'agent', 'models.json'); try { - if (existsSync(modelsPath)) { - const data = JSON.parse(readFileSync(modelsPath, 'utf-8')) as Record; + if (await fileExists(modelsPath)) { + const raw = await readFile(modelsPath, 'utf-8'); + const data = JSON.parse(raw) as Record; const providers = data.providers as Record | undefined; if (providers && providers[provider]) { delete providers[provider]; - writeFileSync(modelsPath, JSON.stringify(data, null, 2), 'utf-8'); - console.log(`Removed models.json entry for provider "${provider}" (agent "${agentId}")`); + await writeFile(modelsPath, JSON.stringify(data, null, 2), 'utf-8'); + console.log(`Removed models.json entry for provider "${provider}" (agent "${id}")`); } } } catch (err) { - console.warn(`Failed to remove provider ${provider} from models.json (agent "${agentId}"):`, err); + console.warn(`Failed to remove provider ${provider} from models.json (agent "${id}"):`, err); } } // 3. 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; + const config = await readOpenClawJson(); + 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}`); - } - } + // Disable plugin (for OAuth like qwen-portal-auth) + const plugins = config.plugins as Record | undefined; + const entries = (plugins?.entries ?? {}) as Record>; + const pluginName = `${provider}-auth`; + if (entries[pluginName]) { + 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}`); - } + // Remove from models.providers + const models = config.models as Record | undefined; + const providers = (models?.providers ?? {}) as Record; + if (providers[provider]) { + delete providers[provider]; + modified = true; + console.log(`Removed OpenClaw provider config: ${provider}`); + } - if (modified) { - writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); - } + if (modified) { + await writeOpenClawJson(config); } } catch (err) { console.warn(`Failed to remove provider ${provider} from openclaw.json:`, err); @@ -361,37 +345,21 @@ export function removeProviderFromOpenClaw(provider: string): void { */ 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; } /** * Update the OpenClaw config to use the given provider and model * Writes to ~/.openclaw/openclaw.json - * - * @param provider - Provider type (e.g. 'anthropic', 'siliconflow') - * @param modelOverride - Optional model string to use instead of the registry default. - * For siliconflow this is the user-supplied model ID prefixed with "siliconflow/". */ -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')); - } - } catch (err) { - console.warn('Failed to read openclaw.json, creating fresh config:', err); - } +export async function setOpenClawDefaultModel(provider: string, modelOverride?: string): Promise { + const config = await readOpenClawJson(); const model = modelOverride || getProviderDefaultModel(provider); if (!model) { @@ -404,7 +372,6 @@ export function setOpenClawDefaultModel(provider: string, modelOverride?: string : model; // Set the default model for the agents - // model must be an object: { primary: "provider/model", fallbacks?: [] } const agents = (config.agents || {}) as Record; const defaults = (agents.defaults || {}) as Record; defaults.model = { primary: model }; @@ -412,8 +379,6 @@ export function setOpenClawDefaultModel(provider: string, modelOverride?: string 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. const providerCfg = getProviderConfig(provider); if (providerCfg) { const models = (config.models || {}) as Record; @@ -456,9 +421,7 @@ export function setOpenClawDefaultModel(provider: string, modelOverride?: string models.providers = providers; config.models = models; } else { - // Built-in provider: remove any stale models.providers entry that may - // have been written by an earlier version. Leaving it in place would - // override the native pi-ai catalog and can break streaming/auth. + // Built-in provider: remove any stale models.providers entry const models = (config.models || {}) as Record; const providers = (models.providers || {}) as Record; if (providers[provider]) { @@ -471,18 +434,10 @@ export function setOpenClawDefaultModel(provider: string, modelOverride?: string // Ensure gateway mode is set const gateway = (config.gateway || {}) as Record; - if (!gateway.mode) { - gateway.mode = 'local'; - } + 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'); + await writeOpenClawJson(config); console.log(`Set OpenClaw default model to "${model}" for provider "${provider}"`); } @@ -498,39 +453,26 @@ interface RuntimeProviderConfigOverride { * Register or update a provider's configuration in openclaw.json * without changing the current default model. */ -export function syncProviderConfigToOpenClaw( +export async function syncProviderConfigToOpenClaw( provider: string, modelId: string | undefined, override: RuntimeProviderConfigOverride -): void { - const configPath = join(homedir(), '.openclaw', 'openclaw.json'); - - let config: Record = {}; - try { - if (existsSync(configPath)) { - config = JSON.parse(readFileSync(configPath, 'utf-8')); - } - } catch (err) { - console.warn('Failed to read openclaw.json, creating fresh config:', err); - } +): Promise { + const config = await readOpenClawJson(); if (override.baseUrl && override.api) { const models = (config.models || {}) as Record; const providers = (models.providers || {}) as Record; const nextModels: Array> = []; - if (modelId) { - nextModels.push({ id: modelId, name: modelId }); - } + if (modelId) nextModels.push({ id: modelId, name: modelId }); const nextProvider: Record = { baseUrl: override.baseUrl, api: override.api, models: nextModels, }; - if (override.apiKeyEnv) { - nextProvider.apiKey = override.apiKeyEnv; - } + if (override.apiKeyEnv) nextProvider.apiKey = override.apiKeyEnv; if (override.headers && Object.keys(override.headers).length > 0) { nextProvider.headers = override.headers; } @@ -543,40 +485,24 @@ export function syncProviderConfigToOpenClaw( // Ensure extension is enabled for oauth providers to prevent gateway wiping config 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; + const pEntries = (plugins.entries || {}) as Record; + pEntries[`${provider}-auth`] = { enabled: true }; + plugins.entries = pEntries; config.plugins = plugins; } - const dir = join(configPath, '..'); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - - writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); + await writeOpenClawJson(config); } /** * Update OpenClaw model + provider config using runtime config values. - * Useful for user-configurable providers (custom/ollama-like) where - * baseUrl/model are not in the static registry. */ -export function setOpenClawDefaultModelWithOverride( +export async function setOpenClawDefaultModelWithOverride( provider: string, modelOverride: string | undefined, override: RuntimeProviderConfigOverride -): void { - const configPath = join(homedir(), '.openclaw', 'openclaw.json'); - - let config: Record = {}; - try { - if (existsSync(configPath)) { - config = JSON.parse(readFileSync(configPath, 'utf-8')); - } - } catch (err) { - console.warn('Failed to read openclaw.json, creating fresh config:', err); - } +): Promise { + const config = await readOpenClawJson(); const model = modelOverride || getProviderDefaultModel(provider); if (!model) { @@ -598,23 +524,15 @@ export function setOpenClawDefaultModelWithOverride( const models = (config.models || {}) as Record; const providers = (models.providers || {}) as Record; - // Replace the provider entry entirely rather than merging. - // Different custom/ollama provider instances have different baseUrls, - // so merging models from a previous instance creates an inconsistent - // config (models pointing at the wrong endpoint). const nextModels: Array> = []; - if (modelId) { - nextModels.push({ id: modelId, name: modelId }); - } + if (modelId) nextModels.push({ id: modelId, name: modelId }); const nextProvider: Record = { baseUrl: override.baseUrl, api: override.api, models: nextModels, }; - if (override.apiKeyEnv) { - nextProvider.apiKey = override.apiKeyEnv; - } + if (override.apiKeyEnv) nextProvider.apiKey = override.apiKeyEnv; if (override.headers && Object.keys(override.headers).length > 0) { nextProvider.headers = override.headers; } @@ -628,48 +546,48 @@ export function setOpenClawDefaultModelWithOverride( } const gateway = (config.gateway || {}) as Record; - if (!gateway.mode) { - gateway.mode = 'local'; - } + if (!gateway.mode) gateway.mode = 'local'; 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; + const pEntries = (plugins.entries || {}) as Record; + pEntries[`${provider}-auth`] = { enabled: true }; + plugins.entries = pEntries; config.plugins = plugins; } - const dir = join(configPath, '..'); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - - writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); + await writeOpenClawJson(config); console.log( `Set OpenClaw default model to "${model}" for provider "${provider}" (runtime override)` ); } /** - * 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. + * Get a set of all active provider IDs configured in openclaw.json. + * Reads the file ONCE and extracts both models.providers and plugins.entries. */ -export function getActiveOpenClawProviders(): Set { +export async function getActiveOpenClawProviders(): Promise> { 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); + const config = await readOpenClawJson(); + + // 1. models.providers + const providers = (config.models as Record | undefined)?.providers; + if (providers && typeof providers === 'object') { + for (const key of Object.keys(providers as Record)) { + activeProviders.add(key); + } + } + + // 2. plugins.entries for OAuth providers + const plugins = (config.plugins as Record | undefined)?.entries; + if (plugins && typeof plugins === 'object') { + for (const [pluginId, meta] of Object.entries(plugins as Record)) { + if (pluginId.endsWith('-auth') && (meta as Record).enabled) { + activeProviders.add(pluginId.replace(/-auth$/, '')); } } } @@ -677,48 +595,14 @@ export function getActiveOpenClawProviders(): Set { 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 - * gateway process reads the same token we use for the WebSocket handshake. - * - * Without this, openclaw.json may contain a stale token written by the - * system-managed gateway service (launchctl), causing a "token mismatch" - * auth failure when ClawX connects to the process it just spawned. + * Write the ClawX gateway token into ~/.openclaw/openclaw.json. */ -export function syncGatewayTokenToConfig(token: string): void { - const configPath = join(homedir(), '.openclaw', 'openclaw.json'); - let config: Record = {}; - try { - if (existsSync(configPath)) { - config = JSON.parse(readFileSync(configPath, 'utf-8')) as Record; - } - } catch { - // start from a blank config if the file is corrupt - } +export async function syncGatewayTokenToConfig(token: string): Promise { + const config = await readOpenClawJson(); const gateway = ( config.gateway && typeof config.gateway === 'object' @@ -738,31 +622,15 @@ export function syncGatewayTokenToConfig(token: string): void { if (!gateway.mode) gateway.mode = 'local'; config.gateway = gateway; - const dir = join(configPath, '..'); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); + await writeOpenClawJson(config); console.log('Synced gateway token to openclaw.json'); } /** - * Ensure browser automation is enabled in ~/.openclaw/openclaw.json with the - * "openclaw" managed profile as the default. - * - * Only sets values that are not already present so existing user - * customisation (e.g. switching to a remote CDP profile) is preserved. + * Ensure browser automation is enabled in ~/.openclaw/openclaw.json. */ -export function syncBrowserConfigToOpenClaw(): void { - const configPath = join(homedir(), '.openclaw', 'openclaw.json'); - let config: Record = {}; - try { - if (existsSync(configPath)) { - config = JSON.parse(readFileSync(configPath, 'utf-8')) as Record; - } - } catch { - // start from a blank config if the file is corrupt - } +export async function syncBrowserConfigToOpenClaw(): Promise { + const config = await readOpenClawJson(); const browser = ( config.browser && typeof config.browser === 'object' @@ -785,29 +653,14 @@ export function syncBrowserConfigToOpenClaw(): void { if (!changed) return; config.browser = browser; - - const dir = join(configPath, '..'); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); + await writeOpenClawJson(config); console.log('Synced browser config to openclaw.json'); } /** * Update a provider entry in every discovered agent's models.json. - * - * The gateway caches resolved provider configs in - * ~/.openclaw/agents//agent/models.json and serves requests from - * that file (not from openclaw.json directly). We must update it - * whenever the active provider changes so the gateway immediately picks - * up the new baseUrl / apiKey without requiring a full restart. - * - * Existing model-level metadata (contextWindow, cost, etc.) is preserved - * when the model ID matches; only the top-level provider fields and the - * models list are updated. */ -export function updateAgentModelProvider( +export async function updateAgentModelProvider( providerType: string, entry: { baseUrl?: string; @@ -817,15 +670,13 @@ export function updateAgentModelProvider( /** When true, pi-ai sends Authorization: Bearer instead of x-api-key */ authHeader?: boolean; } -): void { - const agentIds = discoverAgentIds(); +): Promise { + const agentIds = await discoverAgentIds(); for (const agentId of agentIds) { const modelsPath = join(homedir(), '.openclaw', 'agents', agentId, 'agent', 'models.json'); let data: Record = {}; try { - if (existsSync(modelsPath)) { - data = JSON.parse(readFileSync(modelsPath, 'utf-8')) as Record; - } + data = (await readJsonFile>(modelsPath)) ?? {}; } catch { // corrupt / missing – start with an empty object } @@ -839,8 +690,6 @@ export function updateAgentModelProvider( ? { ...providers[providerType] } : {}; - // Preserve per-model metadata (reasoning, cost, contextWindow…) for - // models that already exist; use a minimal stub for new models. const existingModels = Array.isArray(existing.models) ? (existing.models as Array>) : []; @@ -860,7 +709,7 @@ export function updateAgentModelProvider( data.providers = providers; try { - writeFileSync(modelsPath, JSON.stringify(data, null, 2), 'utf-8'); + await writeJsonFile(modelsPath, data); console.log(`Updated models.json for agent "${agentId}" provider "${providerType}"`); } catch (err) { console.warn(`Failed to update models.json for agent "${agentId}":`, err); diff --git a/electron/utils/openclaw-workspace.ts b/electron/utils/openclaw-workspace.ts index 02fe64db4..673dee1c1 100644 --- a/electron/utils/openclaw-workspace.ts +++ b/electron/utils/openclaw-workspace.ts @@ -1,4 +1,11 @@ -import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync, unlinkSync } from 'fs'; +/** + * OpenClaw workspace context utilities. + * + * All file I/O is async (fs/promises) to avoid blocking the Electron + * main thread. + */ +import { access, readFile, writeFile, readdir, mkdir, unlink } from 'fs/promises'; +import { constants, Dirent } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; import { logger } from './logger'; @@ -7,6 +14,20 @@ import { getResourcesDir } from './paths'; const CLAWX_BEGIN = ''; const CLAWX_END = ''; +// ── Helpers ────────────────────────────────────────────────────── + +async function fileExists(p: string): Promise { + try { await access(p, constants.F_OK); return true; } catch { return false; } +} + +async function ensureDir(dir: string): Promise { + if (!(await fileExists(dir))) { + await mkdir(dir, { recursive: true }); + } +} + +// ── Pure helpers (no I/O) ──────────────────────────────────────── + /** * Merge a ClawX context section into an existing file's content. * If markers already exist, replaces the section in-place. @@ -22,62 +43,21 @@ export function mergeClawXSection(existing: string, section: string): string { return existing.trimEnd() + '\n\n' + wrapped + '\n'; } -/** - * Detect and remove bootstrap .md files that contain only ClawX markers - * with no meaningful OpenClaw content outside them. This repairs a race - * condition where ensureClawXContext() created the file before the gateway - * could seed the full template. Deleting the hollow file lets the gateway - * re-seed the complete template on next start. - */ -export function repairClawXOnlyBootstrapFiles(): void { - const workspaceDirs = resolveAllWorkspaceDirs(); - for (const workspaceDir of workspaceDirs) { - if (!existsSync(workspaceDir)) continue; - let entries: string[]; - try { - entries = readdirSync(workspaceDir).filter((f) => f.endsWith('.md')); - } catch { - continue; - } - for (const file of entries) { - const filePath = join(workspaceDir, file); - let content: string; - try { - content = readFileSync(filePath, 'utf-8'); - } catch { - continue; - } - const beginIdx = content.indexOf(CLAWX_BEGIN); - const endIdx = content.indexOf(CLAWX_END); - if (beginIdx === -1 || endIdx === -1) continue; - - const before = content.slice(0, beginIdx).trim(); - const after = content.slice(endIdx + CLAWX_END.length).trim(); - if (before === '' && after === '') { - try { - unlinkSync(filePath); - logger.info(`Removed ClawX-only bootstrap file for re-seeding: ${file} (${workspaceDir})`); - } catch { - logger.warn(`Failed to remove ClawX-only bootstrap file: ${filePath}`); - } - } - } - } -} +// ── Workspace directory resolution ─────────────────────────────── /** * Collect all unique workspace directories from the openclaw config: * the defaults workspace, each agent's workspace, and any workspace-* * directories that already exist under ~/.openclaw/. */ -function resolveAllWorkspaceDirs(): string[] { +async function resolveAllWorkspaceDirs(): Promise { const openclawDir = join(homedir(), '.openclaw'); const dirs = new Set(); const configPath = join(openclawDir, 'openclaw.json'); try { - if (existsSync(configPath)) { - const config = JSON.parse(readFileSync(configPath, 'utf-8')); + if (await fileExists(configPath)) { + const config = JSON.parse(await readFile(configPath, 'utf-8')); const defaultWs = config?.agents?.defaults?.workspace; if (typeof defaultWs === 'string' && defaultWs.trim()) { @@ -99,7 +79,8 @@ function resolveAllWorkspaceDirs(): string[] { } try { - for (const entry of readdirSync(openclawDir, { withFileTypes: true })) { + const entries: Dirent[] = await readdir(openclawDir, { withFileTypes: true }); + for (const entry of entries) { if (entry.isDirectory() && entry.name.startsWith('workspace')) { dirs.add(join(openclawDir, entry.name)); } @@ -115,49 +96,93 @@ function resolveAllWorkspaceDirs(): string[] { return [...dirs]; } +// ── Bootstrap file repair ──────────────────────────────────────── + /** - * Synchronously merge ClawX context snippets into workspace bootstrap - * files that already exist on disk. Returns the number of target files - * that were skipped because they don't exist yet. + * Detect and remove bootstrap .md files that contain only ClawX markers + * with no meaningful OpenClaw content outside them. */ -function mergeClawXContextOnce(): number { +export async function repairClawXOnlyBootstrapFiles(): Promise { + const workspaceDirs = await resolveAllWorkspaceDirs(); + for (const workspaceDir of workspaceDirs) { + if (!(await fileExists(workspaceDir))) continue; + + let entries: string[]; + try { + entries = (await readdir(workspaceDir)).filter((f) => f.endsWith('.md')); + } catch { + continue; + } + + for (const file of entries) { + const filePath = join(workspaceDir, file); + let content: string; + try { + content = await readFile(filePath, 'utf-8'); + } catch { + continue; + } + const beginIdx = content.indexOf(CLAWX_BEGIN); + const endIdx = content.indexOf(CLAWX_END); + if (beginIdx === -1 || endIdx === -1) continue; + + const before = content.slice(0, beginIdx).trim(); + const after = content.slice(endIdx + CLAWX_END.length).trim(); + if (before === '' && after === '') { + try { + await unlink(filePath); + logger.info(`Removed ClawX-only bootstrap file for re-seeding: ${file} (${workspaceDir})`); + } catch { + logger.warn(`Failed to remove ClawX-only bootstrap file: ${filePath}`); + } + } + } + } +} + +// ── Context merging ────────────────────────────────────────────── + +/** + * Merge ClawX context snippets into workspace bootstrap files that + * already exist on disk. Returns the number of target files that were + * skipped because they don't exist yet. + */ +async function mergeClawXContextOnce(): Promise { const contextDir = join(getResourcesDir(), 'context'); - if (!existsSync(contextDir)) { + if (!(await fileExists(contextDir))) { logger.debug('ClawX context directory not found, skipping context merge'); return 0; } let files: string[]; try { - files = readdirSync(contextDir).filter((f) => f.endsWith('.clawx.md')); + files = (await readdir(contextDir)).filter((f) => f.endsWith('.clawx.md')); } catch { return 0; } - const workspaceDirs = resolveAllWorkspaceDirs(); + const workspaceDirs = await resolveAllWorkspaceDirs(); let skipped = 0; for (const workspaceDir of workspaceDirs) { - if (!existsSync(workspaceDir)) { - mkdirSync(workspaceDir, { recursive: true }); - } + await ensureDir(workspaceDir); for (const file of files) { const targetName = file.replace('.clawx.md', '.md'); const targetPath = join(workspaceDir, targetName); - if (!existsSync(targetPath)) { + if (!(await fileExists(targetPath))) { logger.debug(`Skipping ${targetName} in ${workspaceDir} (file does not exist yet, will be seeded by gateway)`); skipped++; continue; } - const section = readFileSync(join(contextDir, file), 'utf-8'); - const existing = readFileSync(targetPath, 'utf-8'); + const section = await readFile(join(contextDir, file), 'utf-8'); + const existing = await readFile(targetPath, 'utf-8'); const merged = mergeClawXSection(existing, section); if (merged !== existing) { - writeFileSync(targetPath, merged, 'utf-8'); + await writeFile(targetPath, merged, 'utf-8'); logger.info(`Merged ClawX context into ${targetName} (${workspaceDir})`); } } @@ -171,22 +196,15 @@ const MAX_RETRIES = 15; /** * Ensure ClawX context snippets are merged into the openclaw workspace - * bootstrap files. Reads `*.clawx.md` templates from resources/context/ - * and injects them as marker-delimited sections into the corresponding - * workspace `.md` files (e.g. AGENTS.clawx.md -> AGENTS.md). - * - * The gateway seeds workspace files asynchronously after its HTTP server - * starts, so the target files may not exist yet when this is first called. - * When files are missing, retries with a delay until all targets are merged - * or the retry budget is exhausted. + * bootstrap files. */ export async function ensureClawXContext(): Promise { - let skipped = mergeClawXContextOnce(); + let skipped = await mergeClawXContextOnce(); if (skipped === 0) return; for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { await new Promise((r) => setTimeout(r, RETRY_INTERVAL_MS)); - skipped = mergeClawXContextOnce(); + skipped = await mergeClawXContextOnce(); if (skipped === 0) { logger.info(`ClawX context merge completed after ${attempt} retry(ies)`); return; diff --git a/electron/utils/secure-storage.ts b/electron/utils/secure-storage.ts index 55fa2a2ac..9f717ddab 100644 --- a/electron/utils/secure-storage.ts +++ b/electron/utils/secure-storage.ts @@ -214,7 +214,7 @@ export async function getAllProvidersWithKeyInfo(): Promise< > { const providers = await getAllProviders(); const results: Array = []; - const activeOpenClawProviders = getActiveOpenClawProviders(); + const activeOpenClawProviders = await getActiveOpenClawProviders(); // We need to avoid deleting native ones like 'anthropic' or 'google' // that don't need to exist in openclaw.json models.providers diff --git a/electron/utils/skill-config.ts b/electron/utils/skill-config.ts index b75e839c1..2097bc996 100644 --- a/electron/utils/skill-config.ts +++ b/electron/utils/skill-config.ts @@ -1,9 +1,12 @@ /** * Skill Config Utilities * Direct read/write access to skill configuration in ~/.openclaw/openclaw.json - * This bypasses the Gateway RPC for faster and more reliable config updates + * This bypasses the Gateway RPC for faster and more reliable config updates. + * + * All file I/O uses async fs/promises to avoid blocking the main thread. */ -import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { readFile, writeFile, access } from 'fs/promises'; +import { constants } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; @@ -23,15 +26,19 @@ interface OpenClawConfig { [key: string]: unknown; } +async function fileExists(p: string): Promise { + try { await access(p, constants.F_OK); return true; } catch { return false; } +} + /** * Read the current OpenClaw config */ -function readConfig(): OpenClawConfig { - if (!existsSync(OPENCLAW_CONFIG_PATH)) { +async function readConfig(): Promise { + if (!(await fileExists(OPENCLAW_CONFIG_PATH))) { return {}; } try { - const raw = readFileSync(OPENCLAW_CONFIG_PATH, 'utf-8'); + const raw = await readFile(OPENCLAW_CONFIG_PATH, 'utf-8'); return JSON.parse(raw); } catch (err) { console.error('Failed to read openclaw config:', err); @@ -42,28 +49,28 @@ function readConfig(): OpenClawConfig { /** * Write the OpenClaw config */ -function writeConfig(config: OpenClawConfig): void { +async function writeConfig(config: OpenClawConfig): Promise { const json = JSON.stringify(config, null, 2); - writeFileSync(OPENCLAW_CONFIG_PATH, json, 'utf-8'); + await writeFile(OPENCLAW_CONFIG_PATH, json, 'utf-8'); } /** * Get skill config */ -export function getSkillConfig(skillKey: string): SkillEntry | undefined { - const config = readConfig(); +export async function getSkillConfig(skillKey: string): Promise { + const config = await readConfig(); return config.skills?.entries?.[skillKey]; } /** * Update skill config (apiKey and env) */ -export function updateSkillConfig( +export async function updateSkillConfig( skillKey: string, updates: { apiKey?: string; env?: Record } -): { success: boolean; error?: string } { +): Promise<{ success: boolean; error?: string }> { try { - const config = readConfig(); + const config = await readConfig(); // Ensure skills.entries exists if (!config.skills) { @@ -90,7 +97,6 @@ export function updateSkillConfig( if (updates.env !== undefined) { const newEnv: Record = {}; - // Process all keys from the update for (const [key, value] of Object.entries(updates.env)) { const trimmedKey = key.trim(); if (!trimmedKey) continue; @@ -99,10 +105,8 @@ export function updateSkillConfig( if (trimmedVal) { newEnv[trimmedKey] = trimmedVal; } - // Empty value = don't include (delete) } - // Only set env if there are values, otherwise delete if (Object.keys(newEnv).length > 0) { entry.env = newEnv; } else { @@ -113,7 +117,7 @@ export function updateSkillConfig( // Save entry back config.skills.entries[skillKey] = entry; - writeConfig(config); + await writeConfig(config); return { success: true }; } catch (err) { console.error('Failed to update skill config:', err); @@ -124,7 +128,7 @@ export function updateSkillConfig( /** * Get all skill configs (for syncing to frontend) */ -export function getAllSkillConfigs(): Record { - const config = readConfig(); +export async function getAllSkillConfigs(): Promise> { + const config = await readConfig(); return config.skills?.entries || {}; } diff --git a/package.json b/package.json index 7de37ba6f..716c9b560 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawx", - "version": "0.1.18", + "version": "0.1.19-alpha.0", "pnpm": { "onlyBuiltDependencies": [ "@whiskeysockets/baileys", @@ -107,4 +107,4 @@ "zx": "^8.8.5" }, "packageManager": "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8" -} \ No newline at end of file +} diff --git a/src/pages/Channels/index.tsx b/src/pages/Channels/index.tsx index 38d9298a7..6778d66f5 100644 --- a/src/pages/Channels/index.tsx +++ b/src/pages/Channels/index.tsx @@ -581,16 +581,12 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded toast.success(t('toast.channelSaved', { name: meta.name })); - // Step 4: Restart the Gateway so it picks up the new channel config - // The Gateway watches the config file, but a restart ensures a clean start - // especially when adding a channel for the first time. - try { - await window.electron.ipcRenderer.invoke('gateway:restart'); - toast.success(t('toast.channelConnecting', { name: meta.name })); - } catch (restartError) { - console.warn('Gateway restart after channel config:', restartError); - toast.info(t('toast.restartManual')); - } + // Gateway restart is now handled server-side via debouncedRestart() + // inside the channel:saveConfig IPC handler, so we don't need to + // trigger it explicitly here. This avoids cascading restarts when + // multiple config changes happen in quick succession (e.g. during + // the setup wizard). + toast.success(t('toast.channelConnecting', { name: meta.name })); // Brief delay so user can see the success state before dialog closes await new Promise((resolve) => setTimeout(resolve, 800));